markov-coil
Version:
Markov chain optimized for large bodies of text.
176 lines (155 loc) • 5.37 kB
text/typescript
export class MarkovNode {
children = new Map<number, MarkovNode>();
weight = 0;
}
interface MarkovVocab {
tokens: string[];
indexes: Map<string, number>;
}
export class MarkovCoil {
depth: number;
vocab: MarkovVocab = {
tokens: [],
indexes: new Map<string, number>(),
};
root: MarkovNode = new MarkovNode();
/**
* Create new markov chain for token prediction.
* @param {string[]} tokens Tokens to generate chain with.
* @param {number} [depth=3] Depth of the chain. A depth of n will use n tokens in its search for a prediction (n-gram).
*/
constructor(tokens: string[], depth = 3) {
this.depth = depth;
this.createChain(tokens);
}
private getIndex(token: string) {
return this.vocab.indexes.get(token);
}
private getToken(index: number) {
return this.vocab.tokens[index];
}
private addTokenToVocab(token: string) {
const index = this.getIndex(token);
if (index === undefined) {
this.vocab.tokens.push(token);
this.vocab.indexes.set(token, this.vocab.tokens.length - 1);
}
}
private addSequenceToChain(sequence: string[]) {
let current = this.root;
sequence.forEach((token) => {
current.weight += 1;
const index = this.getIndex(token)!;
if (current.children.has(index) === false) {
current.children.set(index, new MarkovNode());
}
current = current.children.get(index)!;
});
current.weight += 1;
}
private createChain(tokens: string[]) {
tokens.forEach((token) => {
this.addTokenToVocab(token);
});
for (let i = 0; i < tokens.length; i += 1) {
const sequence = tokens.slice(i, i + this.depth + 1);
this.addSequenceToChain(sequence);
}
}
/**
* Predict next possible values for the given sequence of tokens.
* @param {string[]} context An array of tokens.
* @returns A mapping of possible tokens to their probability of occuring.
*/
predictions(context: string[]): Record<string, number> {
const indexes = context
.slice(-1 * this.depth)
.map((token) => this.getIndex(token));
let current = this.root;
indexes.forEach((index) => {
if (index === undefined || current.children.has(index) === false) {
current = this.root;
return;
}
current = current.children.get(index)!;
});
const result: Record<string, number> = {};
current.children.forEach((node, index) => {
result[this.getToken(index)] = node.weight / current.weight;
});
return result;
}
private weightedChoice(predictions: Record<string, number>): string | null {
const tokens = Object.keys(predictions);
if (tokens.length === 0) return null;
// Accumulate weights
const weights = tokens.map((token) => predictions[token]);
for (let i = 1; i < weights.length; i += 1) {
weights[i] += weights[i - 1];
}
const random = Math.random() * weights[weights.length - 1];
// Could probably optimize this better with binary search, since weights are now ordered
for (let i = 0; i < tokens.length; i += 1) {
if (weights[i] > random) {
return tokens[i];
}
}
// Fallback (should not reach)
return null;
}
/**
* Predict the next token given a starting sequence.
* @param {string[]} sequence The starting sequence of tokens.
* @param {boolean} [weighted=true] If true, will use weighted random choice from all possible predictions. If false, will use random choice.
* @returns {string} The predicted token.
*/
predict(sequence: string[], weighted = true): string | null {
const predictions = this.predictions(sequence);
const tokens = Object.keys(predictions);
if (tokens.length === 0) return null;
if (!weighted) return tokens[Math.floor(Math.random() * tokens.length)];
return this.weightedChoice(predictions);
}
/**
* Predicts a sequence of tokens that could follow the starting sequence.
* @param {token[]} sequence The starting sequence of tokens.
* @param {number} length The length of the predicted sequence.
* @param {boolean} [weighted=true] If true, will use weighted random choice from all possible predictions. If false, will use random choice.
* @returns {string[]} A sequence of predicted tokens.
*/
predictSequence(
sequence: string[],
length: number,
weighted = true,
): string[] {
if (length === 0) {
return [];
}
const prediction = this.predict(sequence, weighted);
if (prediction === null) {
return this.predictSequence([], length - 1, weighted);
}
const nextSequence = sequence.slice(1).concat(prediction);
return [prediction].concat(
this.predictSequence(nextSequence, length - 1, weighted),
);
}
/** Prints tabulated trie structure to console. Useful for debugging. */
prettyPrint(useVocab=false) {
console.log(`Root (${this.root.weight})`);
const prettyPrintHelper = (node: MarkovNode, spaces = 2) => {
node.children.forEach((child, index) => {
let line = " ".repeat(spaces);
if (useVocab) {
line += this.getToken(index);
} else {
line += index
}
line += ` (${child.weight})`;
console.log(line);
prettyPrintHelper(child, spaces + 2);
});
};
prettyPrintHelper(this.root);
}
}