decidekit 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/LICENSE +21 -0
- package/README.md +253 -2
- package/dist/browser.d.ts +25 -0
- package/dist/browser.js +43 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +114 -0
- package/dist/describe.d.ts +14 -0
- package/dist/describe.js +30 -0
- package/dist/fallback.d.ts +75 -0
- package/dist/fallback.js +68 -0
- package/dist/head-io.d.ts +4 -0
- package/dist/head-io.js +16 -0
- package/dist/head.d.ts +14 -0
- package/dist/head.js +155 -0
- package/dist/index.d.ts +121 -0
- package/dist/index.js +137 -0
- package/dist/react.d.ts +39 -0
- package/dist/react.js +55 -0
- package/dist/switch.d.ts +103 -0
- package/dist/switch.js +94 -0
- package/dist/typesafe.d.ts +36 -0
- package/dist/typesafe.js +47 -0
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +26 -0
- package/package.json +93 -6
package/dist/fallback.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** The label `withFallback()` learns `ask`'s `null` answers as. It shows in `route.head.labels` and `route.learned`. */
|
|
2
|
+
export const NONE = "none_of_these";
|
|
3
|
+
const isHead = (c) => typeof c === "object" && c !== null && "weights" in c;
|
|
4
|
+
/**
|
|
5
|
+
* Routes each text to the local decision, or to `ask` when its confidence is below `minConfidence`.
|
|
6
|
+
*
|
|
7
|
+
* - With a trained head and its `examples`, answers from `ask` are kept as new examples.
|
|
8
|
+
* - With labels (`["refund", "bug"]` or described labels), it starts zero-shot and always learns: `retrain()` (or
|
|
9
|
+
* `retrainEvery`) trains the first head from the answers. Labels with no answers yet start from their names.
|
|
10
|
+
*/
|
|
11
|
+
export function withFallback(decider, using, options) {
|
|
12
|
+
const { minConfidence = 0.9, ask, retrainEvery, trainOptions, learnNone = true, noneBelow = 0.6 } = options;
|
|
13
|
+
// A head saved from an earlier session may carry NONE; it isn't one of the labels `ask` chooses from.
|
|
14
|
+
const labels = (isHead(using) ? using.labels : Array.isArray(using) ? using : Object.keys(using)).filter((l) => l !== NONE);
|
|
15
|
+
const zeroShotChoices = isHead(using) ? labels : using;
|
|
16
|
+
const examples = options.examples ?? (isHead(using) ? undefined : []);
|
|
17
|
+
const learned = [...(options.restore?.learned ?? [])];
|
|
18
|
+
let current = using;
|
|
19
|
+
let retraining;
|
|
20
|
+
let sinceRetrain = 0;
|
|
21
|
+
// Zero-shot's confident answers: right about 93% of the time on BANKING77, and enough to calibrate on (eval/long-stream.ts).
|
|
22
|
+
// 4 per label, or more with few labels so the set can reach 50 (12 labels capped at 4 never did).
|
|
23
|
+
const confident = [...(options.restore?.confident ?? [])], perLabel = new Map(), perLabelCap = Math.max(4, Math.ceil(50 / labels.length));
|
|
24
|
+
for (const e of confident)
|
|
25
|
+
perLabel.set(e.label, (perLabel.get(e.label) ?? 0) + 1);
|
|
26
|
+
function retrain() {
|
|
27
|
+
if (!examples)
|
|
28
|
+
return Promise.reject(new Error("decidekit: withFallback() needs `examples` to retrain a head"));
|
|
29
|
+
if (!examples.length && !learned.length)
|
|
30
|
+
return Promise.reject(new Error("decidekit: nothing to train on yet: no answers from `ask`"));
|
|
31
|
+
const calibrateOn = trainOptions?.calibrateOn ?? (confident.length >= 50 ? confident : undefined);
|
|
32
|
+
const trainLabels = learned.some((e) => e.label === NONE) ? [...labels, NONE] : labels;
|
|
33
|
+
retraining ??= decider.train([...examples, ...learned], { ...trainOptions, labels: trainLabels, calibrateOn }).then((h) => {
|
|
34
|
+
current = h;
|
|
35
|
+
return h;
|
|
36
|
+
}).finally(() => (retraining = undefined));
|
|
37
|
+
return retraining;
|
|
38
|
+
}
|
|
39
|
+
async function route(text) {
|
|
40
|
+
const local = await decider.decide(text, current);
|
|
41
|
+
if (local.confidence >= minConfidence) {
|
|
42
|
+
if (!isHead(current) && (perLabel.get(local.choice) ?? 0) < perLabelCap) {
|
|
43
|
+
perLabel.set(local.choice, (perLabel.get(local.choice) ?? 0) + 1);
|
|
44
|
+
confident.push({ text, label: local.choice });
|
|
45
|
+
}
|
|
46
|
+
return local.choice === NONE ? { ...local, choice: null, source: "local" } : { ...local, source: "local" };
|
|
47
|
+
}
|
|
48
|
+
const answer = await ask(text, labels, local);
|
|
49
|
+
const choice = answer !== null && labels.includes(answer) ? answer : null;
|
|
50
|
+
const learnAsNone = examples && answer === null && learnNone && (!noneBelow || ((await decider.decide(text, zeroShotChoices)).similarity ?? 0) < noneBelow);
|
|
51
|
+
if (examples && (choice !== null || learnAsNone)) {
|
|
52
|
+
learned.push({ text, label: choice ?? NONE });
|
|
53
|
+
if (retrainEvery && ++sinceRetrain >= retrainEvery) {
|
|
54
|
+
sinceRetrain = 0;
|
|
55
|
+
// A failed background retrain keeps the current head; call retrain() yourself to see the error.
|
|
56
|
+
retrain().catch(() => { });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { ...local, choice, source: "fallback" };
|
|
60
|
+
}
|
|
61
|
+
// defineProperties, not Object.assign: assign would copy the getters' values once.
|
|
62
|
+
return Object.defineProperties(route, {
|
|
63
|
+
retrain: { value: retrain },
|
|
64
|
+
saved: { value: () => ({ learned: [...learned], confident: [...confident] }) },
|
|
65
|
+
head: { get: () => (isHead(current) ? current : null) },
|
|
66
|
+
learned: { get: () => learned },
|
|
67
|
+
});
|
|
68
|
+
}
|
package/dist/head-io.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Serialises a head to JSON with base64 weights, for a static file next to the app. */
|
|
2
|
+
export function saveHead(head) {
|
|
3
|
+
const b64 = (a) => {
|
|
4
|
+
const bytes = new Uint8Array(a.buffer, a.byteOffset, a.byteLength);
|
|
5
|
+
let s = "";
|
|
6
|
+
for (let i = 0; i < bytes.length; i += 0x8000)
|
|
7
|
+
s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
8
|
+
return btoa(s);
|
|
9
|
+
};
|
|
10
|
+
return JSON.stringify({ ...head, weights: b64(head.weights), bias: b64(head.bias) });
|
|
11
|
+
}
|
|
12
|
+
export function loadHead(json) {
|
|
13
|
+
const raw = JSON.parse(json);
|
|
14
|
+
const f32 = (s) => new Float32Array(Uint8Array.from(atob(s), (c) => c.charCodeAt(0)).buffer);
|
|
15
|
+
return { ...raw, weights: f32(raw.weights), bias: f32(raw.bias) };
|
|
16
|
+
}
|
package/dist/head.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Head, TrainOptions } from "./index.ts";
|
|
2
|
+
export declare function softmax(z: ArrayLike<number>, t: number): number[];
|
|
3
|
+
export declare function logits(head: Pick<Head, "labels" | "dim" | "weights" | "bias">, x: ArrayLike<number>): Float64Array;
|
|
4
|
+
/** Fits weights, biases and temperature to embeddings `X` with label indices `y`. */
|
|
5
|
+
export declare function fitHead<L extends string>(X: ArrayLike<number>[], y: number[], labels: L[], opts?: TrainOptions, priors?: ArrayLike<number>[], priorWeight?: number, centroidScale?: number, smallFloor?: number, calibration?: {
|
|
6
|
+
X: ArrayLike<number>[];
|
|
7
|
+
y: number[];
|
|
8
|
+
}): Promise<{
|
|
9
|
+
labels: L[];
|
|
10
|
+
dim: number;
|
|
11
|
+
weights: Float32Array<ArrayBuffer>;
|
|
12
|
+
bias: Float32Array<ArrayBuffer>;
|
|
13
|
+
temperature: number;
|
|
14
|
+
}>;
|
package/dist/head.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
const BATCH = 64;
|
|
2
|
+
const CENTROID_SCALE = 20;
|
|
3
|
+
// Lets a tab paint and handle input between training batches. A message-channel task, unlike setTimeout, isn't
|
|
4
|
+
// throttled to once a second (or minute) when the tab is in the background.
|
|
5
|
+
function yieldToPage() {
|
|
6
|
+
return new Promise((resolve) => {
|
|
7
|
+
const { port1, port2 } = new MessageChannel();
|
|
8
|
+
port1.onmessage = () => { port1.close(); resolve(); };
|
|
9
|
+
port2.postMessage(null);
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
export function softmax(z, t) {
|
|
13
|
+
let max = -Infinity;
|
|
14
|
+
for (let i = 0; i < z.length; i++)
|
|
15
|
+
max = Math.max(max, z[i]);
|
|
16
|
+
const e = Array.from(z, (v) => Math.exp((v - max) / t));
|
|
17
|
+
const sum = e.reduce((a, b) => a + b, 0);
|
|
18
|
+
return e.map((v) => v / sum);
|
|
19
|
+
}
|
|
20
|
+
export function logits(head, x) {
|
|
21
|
+
const { labels, dim, weights, bias } = head;
|
|
22
|
+
const z = new Float64Array(labels.length);
|
|
23
|
+
for (let c = 0; c < labels.length; c++) {
|
|
24
|
+
let s = bias[c];
|
|
25
|
+
const row = c * dim;
|
|
26
|
+
for (let j = 0; j < dim; j++)
|
|
27
|
+
s += weights[row + j] * x[j];
|
|
28
|
+
z[c] = s;
|
|
29
|
+
}
|
|
30
|
+
return z;
|
|
31
|
+
}
|
|
32
|
+
// Sums the softmax cross-entropy gradient over one batch into gW and gb. A plain function, not inline in the async
|
|
33
|
+
// training loop: inline it took 777 s on the hill harness, here 340 s (the previous loop: 448 s).
|
|
34
|
+
function batchGradient(ids, X, y, W, B, gW, gb, z, C, dim) {
|
|
35
|
+
for (const i of ids) {
|
|
36
|
+
const x = i * dim;
|
|
37
|
+
let max = -Infinity;
|
|
38
|
+
for (let c = 0; c < C; c++) {
|
|
39
|
+
let v = B[c];
|
|
40
|
+
const row = c * dim;
|
|
41
|
+
for (let j = 0; j < dim; j++)
|
|
42
|
+
v += W[row + j] * X[x + j];
|
|
43
|
+
z[c] = v;
|
|
44
|
+
if (v > max)
|
|
45
|
+
max = v;
|
|
46
|
+
}
|
|
47
|
+
let sum = 0;
|
|
48
|
+
for (let c = 0; c < C; c++)
|
|
49
|
+
sum += (z[c] = Math.exp(z[c] - max));
|
|
50
|
+
for (let c = 0; c < C; c++) {
|
|
51
|
+
const p = z[c] / sum - (c === y[i] ? 1 : 0);
|
|
52
|
+
gb[c] += p;
|
|
53
|
+
const row = c * dim;
|
|
54
|
+
for (let j = 0; j < dim; j++)
|
|
55
|
+
gW[row + j] += p * X[x + j];
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Fits weights, biases and temperature to embeddings `X` with label indices `y`. */
|
|
60
|
+
export async function fitHead(X, y, labels, opts = {}, priors, priorWeight = 1, centroidScale = CENTROID_SCALE, smallFloor = 1, calibration) {
|
|
61
|
+
// smallFloor: the lowest temperature a head with under 50 held-out answers may get; createDecider picks it per model.
|
|
62
|
+
// calibration: labelled examples known to be right to fit the temperature on instead of the training examples.
|
|
63
|
+
const { epochs = 60, learningRate = 2, l2 = 1e-4, holdout = 0.1, seed = 7, onEpoch } = opts;
|
|
64
|
+
const dim = X[0].length, C = labels.length;
|
|
65
|
+
// One flat buffer, so the inner loops read typed memory instead of an array of arrays.
|
|
66
|
+
const Xf = new Float64Array(X.length * dim);
|
|
67
|
+
X.forEach((x, i) => Xf.set(x, i * dim));
|
|
68
|
+
let s = seed;
|
|
69
|
+
const rand = () => (s = (s * 1103515245 + 12345) % 2 ** 31) / 2 ** 31;
|
|
70
|
+
const idx = X.map((_, i) => i).sort(() => rand() - 0.5);
|
|
71
|
+
const nVal = Math.max(1, Math.floor(idx.length * holdout));
|
|
72
|
+
const val = idx.slice(0, nVal);
|
|
73
|
+
// One training run on `rows`. With `check`, keeps the epoch that scores best on it.
|
|
74
|
+
async function run(rows, nEpochs, check) {
|
|
75
|
+
const head = { labels, dim, weights: new Float32Array(C * dim), bias: new Float32Array(C) };
|
|
76
|
+
// Start each label at the normalised mean of its examples (nearest centroid), scaled like zero-shot
|
|
77
|
+
// similarities. With a handful of examples per label this is most of the accuracy; gradient descent refines it.
|
|
78
|
+
for (const i of rows)
|
|
79
|
+
for (let j = 0; j < dim; j++)
|
|
80
|
+
head.weights[y[i] * dim + j] += X[i][j];
|
|
81
|
+
if (priors)
|
|
82
|
+
for (let c = 0; c < C; c++)
|
|
83
|
+
for (let j = 0; j < dim; j++)
|
|
84
|
+
head.weights[c * dim + j] += priorWeight * priors[c][j];
|
|
85
|
+
for (let c = 0; c < C; c++) {
|
|
86
|
+
const row = head.weights.subarray(c * dim, (c + 1) * dim);
|
|
87
|
+
const norm = Math.hypot(...row) || 1;
|
|
88
|
+
for (let j = 0; j < dim; j++)
|
|
89
|
+
row[j] = (row[j] / norm) * centroidScale;
|
|
90
|
+
}
|
|
91
|
+
const acc = () => check.filter((i) => { const z = logits(head, X[i]); return z.indexOf(Math.max(...z)) === y[i]; }).length / check.length;
|
|
92
|
+
let best = { acc: check ? acc() : 0, weights: head.weights.slice(), bias: head.bias.slice(), epochs: 0 };
|
|
93
|
+
const gW = new Float64Array(C * dim), gb = new Float64Array(C), z = new Float64Array(C);
|
|
94
|
+
const W = head.weights, B = head.bias;
|
|
95
|
+
const order = [...rows];
|
|
96
|
+
for (let ep = 0; ep < nEpochs; ep++) {
|
|
97
|
+
order.sort(() => rand() - 0.5);
|
|
98
|
+
for (let b = 0; b < order.length; b += BATCH) {
|
|
99
|
+
const ids = order.slice(b, b + BATCH);
|
|
100
|
+
gW.fill(0);
|
|
101
|
+
gb.fill(0);
|
|
102
|
+
batchGradient(ids, Xf, y, W, B, gW, gb, z, C, dim);
|
|
103
|
+
await yieldToPage();
|
|
104
|
+
for (let c = 0; c < C; c++) {
|
|
105
|
+
B[c] -= (learningRate * gb[c]) / ids.length;
|
|
106
|
+
for (let j = 0; j < dim; j++) {
|
|
107
|
+
const k = c * dim + j;
|
|
108
|
+
W[k] -= learningRate * (gW[k] / ids.length + l2 * W[k]);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (!check)
|
|
113
|
+
continue;
|
|
114
|
+
const a = acc();
|
|
115
|
+
onEpoch?.(ep, a);
|
|
116
|
+
if (a > best.acc)
|
|
117
|
+
best = { acc: a, weights: head.weights.slice(), bias: head.bias.slice(), epochs: ep + 1 };
|
|
118
|
+
}
|
|
119
|
+
return check ? { ...head, weights: best.weights, bias: best.bias, epochs: best.epochs } : { ...head, epochs: nEpochs };
|
|
120
|
+
}
|
|
121
|
+
// Pick the epoch count and temperature on held-out examples, then retrain on every example for that many epochs:
|
|
122
|
+
// with 5 examples per label, a holdout would otherwise cost each label one of its five. A holdout under 200
|
|
123
|
+
// examples gave a temperature off by up to 0.40 calibration error, so small sets use 5 folds instead: every example
|
|
124
|
+
// is held out once and the temperature is fitted on all of them.
|
|
125
|
+
const folds = nVal < 200 ? Array.from({ length: 5 }, (_, f) => idx.filter((_, i) => i % 5 === f)) : [val];
|
|
126
|
+
const heldOut = [], foldEpochs = [];
|
|
127
|
+
for (const check of folds) {
|
|
128
|
+
const probe = await run(idx.filter((i) => !check.includes(i)), epochs, check);
|
|
129
|
+
foldEpochs.push(probe.epochs);
|
|
130
|
+
// A label whose only examples are all held out can never be chosen; counting those answers as misses pulled
|
|
131
|
+
// the temperature far too high (1 example per label: 93% right at 48% mean confidence).
|
|
132
|
+
const seen = new Set(idx.filter((i) => !check.includes(i)).map((i) => y[i]));
|
|
133
|
+
for (const i of check)
|
|
134
|
+
if (seen.has(y[i]))
|
|
135
|
+
heldOut.push({ z: logits(probe, X[i]), y: y[i] });
|
|
136
|
+
}
|
|
137
|
+
const head = await run(idx, foldEpochs.sort((a, b) => a - b)[foldEpochs.length >> 1]);
|
|
138
|
+
const fitOn = calibration ? calibration.X.map((x, i) => ({ z: logits(head, x), y: calibration.y[i] })) : heldOut;
|
|
139
|
+
let temperature = 1, bestNll = Infinity;
|
|
140
|
+
// Log-spaced from 0.01 to 10: encoders whose similarities sit close together (multilingual-e5) need far lower
|
|
141
|
+
// temperatures than bge; a grid starting at 0.2 left their heads at about half the confidence they earned.
|
|
142
|
+
for (let k = 0; k <= 120; k++) {
|
|
143
|
+
const t = 0.01 * 1000 ** (k / 120);
|
|
144
|
+
const nll = fitOn.reduce((sum, h) => sum - Math.log(softmax(h.z, t)[h.y] + 1e-12), 0);
|
|
145
|
+
if (nll < bestNll) {
|
|
146
|
+
bestNll = nll;
|
|
147
|
+
temperature = Number(t.toPrecision(3));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Under 50 answers, a perfect score pulls the temperature to its floor and every answer to confidence 1.0 (seen
|
|
151
|
+
// with 6 examples). Never sharpen past the floor on that little evidence.
|
|
152
|
+
if (fitOn.length < 50)
|
|
153
|
+
temperature = Math.max(temperature, smallFloor);
|
|
154
|
+
return { labels, dim, weights: head.weights, bias: head.bias, temperature };
|
|
155
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** The result of `decide()`. */
|
|
2
|
+
export interface Decision<L extends string, C extends L | null = L> {
|
|
3
|
+
/** The most likely option or label; `null` when below `minConfidence`. */
|
|
4
|
+
choice: C;
|
|
5
|
+
/**
|
|
6
|
+
* Probability of `choice`, 0 to 1. Trained heads fit it on held-out examples. Zero-shot uses a fixed temperature
|
|
7
|
+
* per model (0.03 for the default bge-small, 0.05 for all-MiniLM-L6-v2), which measured close to calibrated on two
|
|
8
|
+
* intent datasets (error 0.03-0.04 on their dev splits); check it on your own options before thresholding.
|
|
9
|
+
*/
|
|
10
|
+
confidence: number;
|
|
11
|
+
/** Probability of every option; sums to 1. */
|
|
12
|
+
probabilities: Record<L, number>;
|
|
13
|
+
/**
|
|
14
|
+
* Zero-shot only: the best cosine similarity between the text and any option's name or description. Low means the
|
|
15
|
+
* text is about none of them (with bge-small, off-topic text was mostly under 0.5 and in-scope text over 0.7).
|
|
16
|
+
*/
|
|
17
|
+
similarity?: number;
|
|
18
|
+
}
|
|
19
|
+
/** A trained head: row-major weights (labels × dim), biases and the fitted temperature. */
|
|
20
|
+
export interface Head<L extends string = string> {
|
|
21
|
+
model: string;
|
|
22
|
+
labels: readonly L[];
|
|
23
|
+
dim: number;
|
|
24
|
+
weights: Float32Array;
|
|
25
|
+
bias: Float32Array;
|
|
26
|
+
temperature: number;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* What `decide()` chooses between: a list of labels, labels with descriptions (`{ refund: "wants their money back" }`,
|
|
30
|
+
* where the description is what gets compared), or a trained head.
|
|
31
|
+
*/
|
|
32
|
+
export type Choices<L extends string> = readonly L[] | Readonly<Record<L, string>> | Head<L>;
|
|
33
|
+
/** Options for one `decide()` call. */
|
|
34
|
+
export interface DecideOptions {
|
|
35
|
+
/**
|
|
36
|
+
* Below this confidence, `choice` is `null`: "none of these". Use it with trained heads, whose confidence is
|
|
37
|
+
* calibrated. On CLINC150, 0.93 returned null for 86.3% of out-of-scope queries.
|
|
38
|
+
*/
|
|
39
|
+
minConfidence?: number;
|
|
40
|
+
}
|
|
41
|
+
/** One labelled example for `train()`. */
|
|
42
|
+
export interface Example<L extends string> {
|
|
43
|
+
text: string;
|
|
44
|
+
label: L;
|
|
45
|
+
}
|
|
46
|
+
export interface TrainOptions<L extends string = string> {
|
|
47
|
+
/** Use label names as a hint (default true). Turn off when labels are opaque ids like `c17`. */
|
|
48
|
+
labelNames?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Labels to include even without examples yet. They start from their readable name alone, as zero-shot does, and
|
|
51
|
+
* can still be chosen. Needs `labelNames`.
|
|
52
|
+
*/
|
|
53
|
+
labels?: readonly L[];
|
|
54
|
+
/** Passes over the examples. Default 60; the best holdout epoch is kept. */
|
|
55
|
+
epochs?: number;
|
|
56
|
+
/** Default 2. */
|
|
57
|
+
learningRate?: number;
|
|
58
|
+
/** Weight decay. Default 1e-4. */
|
|
59
|
+
l2?: number;
|
|
60
|
+
/** Share of examples held out for early stopping and temperature fitting. Default 0.1. */
|
|
61
|
+
holdout?: number;
|
|
62
|
+
/** Seed for the holdout split and shuffling, so runs repeat. Default 7. */
|
|
63
|
+
seed?: number;
|
|
64
|
+
/**
|
|
65
|
+
* Examples known to be right, used only to fit the temperature (how far to trust the head). Pass them when the
|
|
66
|
+
* training examples come from a source that is sometimes wrong, such as an LLM through `withFallback()`: fitted on
|
|
67
|
+
* those, the temperature learns to doubt the head. 2 per label is enough (eval/long-stream.ts).
|
|
68
|
+
*/
|
|
69
|
+
calibrateOn?: readonly Example<L>[];
|
|
70
|
+
/** Called after each epoch with accuracy on the holdout. */
|
|
71
|
+
onEpoch?: (epoch: number, holdoutAccuracy: number) => void;
|
|
72
|
+
}
|
|
73
|
+
export interface DeciderOptions {
|
|
74
|
+
/**
|
|
75
|
+
* A Hugging Face sentence-embedding model with ONNX weights. Default `Xenova/bge-small-en-v1.5` (34 MB at q8).
|
|
76
|
+
* Pre-release builds defaulted to `Xenova/all-MiniLM-L6-v2` (23 MB): smaller and 1.6x faster, 3 to 5 points less
|
|
77
|
+
* accurate. Pass it to keep using heads trained on it.
|
|
78
|
+
*/
|
|
79
|
+
model?: string;
|
|
80
|
+
/** Defaults to transformers.js' choice: wasm in browsers, cpu in Node. */
|
|
81
|
+
device?: "wasm" | "webgpu" | "cpu";
|
|
82
|
+
/** Weight precision. Default `q8`. */
|
|
83
|
+
dtype?: "q8" | "fp16" | "fp32";
|
|
84
|
+
/** Called as model files download (first load only; later loads come from cache). */
|
|
85
|
+
onProgress?: (progress: {
|
|
86
|
+
file: string;
|
|
87
|
+
loaded: number;
|
|
88
|
+
total: number;
|
|
89
|
+
}) => void;
|
|
90
|
+
/**
|
|
91
|
+
* Softmax temperature for zero-shot similarities. Default 0.03 for bge-small (0.025 for described labels, which
|
|
92
|
+
* score higher), 0.05 for other models. Setting it uses the same value for both.
|
|
93
|
+
*/
|
|
94
|
+
temperature?: number;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Loads the encoder (downloaded once, then cached by the browser or transformers.js) and returns `decide()` and
|
|
98
|
+
* `train()`. In a browser, prefer `createWorkerDecider()` from `decidekit/browser`: the encoder blocks the thread
|
|
99
|
+
* it runs on.
|
|
100
|
+
*/
|
|
101
|
+
export declare function createDecider({ model, device, dtype, temperature, onProgress }?: DeciderOptions): Promise<{
|
|
102
|
+
model: string;
|
|
103
|
+
decide: {
|
|
104
|
+
<const L extends string>(text: string, using: Choices<L>, options?: {
|
|
105
|
+
minConfidence?: undefined;
|
|
106
|
+
}): Promise<Decision<L>>;
|
|
107
|
+
<const L extends string>(text: string, using: Choices<L>, options: DecideOptions): Promise<Decision<L, L | null>>;
|
|
108
|
+
};
|
|
109
|
+
decideMany: {
|
|
110
|
+
<const L extends string>(texts: readonly string[], using: Choices<L>, options?: {
|
|
111
|
+
minConfidence?: undefined;
|
|
112
|
+
}): Promise<Decision<L>[]>;
|
|
113
|
+
<const L extends string>(texts: readonly string[], using: Choices<L>, options: DecideOptions): Promise<Decision<L, L | null>[]>;
|
|
114
|
+
};
|
|
115
|
+
train: <const L extends string>(examples: readonly Example<L>[], opts?: TrainOptions<L>) => Promise<Head<L>>;
|
|
116
|
+
/** Embeds texts with the encoder: unit-length vectors, one per text. */
|
|
117
|
+
embed: (texts: readonly string[]) => Promise<number[][]>;
|
|
118
|
+
}>;
|
|
119
|
+
export { saveHead, loadHead } from "./head-io.ts";
|
|
120
|
+
export { withFallback, NONE, type FallbackOptions, type RoutedDecision, type Trainable } from "./fallback.ts";
|
|
121
|
+
export { describeLabels, describePrompt, parseDescriptions, type DescribeOptions } from "./describe.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// decide(text, options): typed, calibrated decisions on device. Zero-shot compares the text with each option's
|
|
2
|
+
// embedding; a trained head (a softmax layer over the same embeddings, 156 KB for 77 labels) is far more
|
|
3
|
+
// accurate. Both return the option literals as the choice type.
|
|
4
|
+
import { pipeline } from "@huggingface/transformers";
|
|
5
|
+
import { fitHead, logits, softmax } from "./head.js";
|
|
6
|
+
function isHead(c) {
|
|
7
|
+
if (Array.isArray(c) || !("weights" in c))
|
|
8
|
+
return false;
|
|
9
|
+
if (!(c.weights instanceof Float32Array))
|
|
10
|
+
throw new Error("decidekit: this head's weights aren't a Float32Array. Read head files with loadHead(), not JSON.parse.");
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
const readable = (label) => label.replace(/[_-]+/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase();
|
|
14
|
+
const BATCH = 64;
|
|
15
|
+
const LABEL_NAME_WEIGHT = 1;
|
|
16
|
+
// Zero-shot temperatures that measured calibrated on the BANKING77 and CLINC150 dev splits (eval/gains.ts). Models
|
|
17
|
+
// differ in how spread out their similarities are: at 0.05, bge-small answered only 3% of texts at 0.9 confidence.
|
|
18
|
+
// Described labels get their own: on bge-small, 0.025 halved their calibration error on CLINC150 (0.066 to 0.020) and
|
|
19
|
+
// raised it on BANKING77 (0.032 to 0.042); names alone measured best at 0.03 (eval/descriptions.ts).
|
|
20
|
+
// Small heads (under 50 held-out answers) can't fit a trustworthy temperature, so it has a floor. On bge-small a floor
|
|
21
|
+
// of 1 left them underconfident (calibration error 0.09 to 0.13; only 43 to 69% of texts at 0.9, nearly all right);
|
|
22
|
+
// 0.5 gave 0.009 to 0.017. MiniLM's similarities are spread differently and keep 1 (eval/small-floor.ts).
|
|
23
|
+
// multilingual-e5-small's similarities sit close together, so it needs far lower temperatures: on MASSIVE in English
|
|
24
|
+
// and Japanese, 0.0105 for label names (calibration error 0.03 to 0.04; descriptions not yet measured) and a floor of
|
|
25
|
+
// 0.4 (eval/e5-settings.ts). It also expects "query: " before every text.
|
|
26
|
+
const SMALL_HEAD_FLOOR = { "Xenova/bge-small-en-v1.5": 0.5, "Xenova/multilingual-e5-small": 0.4 };
|
|
27
|
+
const TEMPERATURES = { "Xenova/bge-small-en-v1.5": [0.03, 0.025], "Xenova/all-MiniLM-L6-v2": [0.05, 0.05], "Xenova/multilingual-e5-small": [0.0105, 0.0105] };
|
|
28
|
+
const PREFIX = { "Xenova/multilingual-e5-small": "query: " };
|
|
29
|
+
function toDecision(labels, p) {
|
|
30
|
+
let best = 0;
|
|
31
|
+
for (let i = 1; i < p.length; i++)
|
|
32
|
+
if (p[i] > p[best])
|
|
33
|
+
best = i;
|
|
34
|
+
const probabilities = Object.fromEntries(labels.map((l, i) => [l, p[i]]));
|
|
35
|
+
return { choice: labels[best], confidence: p[best], probabilities };
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Loads the encoder (downloaded once, then cached by the browser or transformers.js) and returns `decide()` and
|
|
39
|
+
* `train()`. In a browser, prefer `createWorkerDecider()` from `decidekit/browser`: the encoder blocks the thread
|
|
40
|
+
* it runs on.
|
|
41
|
+
*/
|
|
42
|
+
export async function createDecider({ model = "Xenova/bge-small-en-v1.5", device, dtype = "q8", temperature, onProgress } = {}) {
|
|
43
|
+
const embed = await pipeline("feature-extraction", model, {
|
|
44
|
+
device, dtype,
|
|
45
|
+
progress_callback: onProgress && ((p) => p.status === "progress" && onProgress({ file: p.file, loaded: p.loaded, total: p.total })),
|
|
46
|
+
});
|
|
47
|
+
const optionCache = new Map(), prefix = PREFIX[model] ?? "";
|
|
48
|
+
// The last training set's embeddings, so retraining after adding or fixing a few examples only encodes those.
|
|
49
|
+
let trainCache = new Map();
|
|
50
|
+
async function vectors(texts) {
|
|
51
|
+
const out = [];
|
|
52
|
+
for (let i = 0; i < texts.length; i += BATCH) {
|
|
53
|
+
const t = await embed(texts.slice(i, i + BATCH).map((x) => prefix + x), { pooling: "mean", normalize: true });
|
|
54
|
+
out.push(...t.tolist());
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
async function score(q, using, minConfidence) {
|
|
59
|
+
let d;
|
|
60
|
+
if (isHead(using)) {
|
|
61
|
+
if (using.model !== model)
|
|
62
|
+
throw new Error(`decidekit: this head was trained on ${using.model}, but the decider uses ${model}. Pass { model: "${using.model}" } to createDecider.`);
|
|
63
|
+
d = toDecision(using.labels, softmax(logits(using, q), using.temperature));
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
const labels = (Array.isArray(using) ? using : Object.keys(using));
|
|
67
|
+
if (labels.length < 2)
|
|
68
|
+
throw new Error(`decidekit: decide() needs at least 2 options, got ${labels.length}`);
|
|
69
|
+
// Bare labels are compared as readable text ("card_arrival" as "card arrival"): 2.6 points better on CLINC150's
|
|
70
|
+
// dev split. A described label scores by the better of its name and its description: on the dev splits that
|
|
71
|
+
// beat the description alone by 0.5 (BANKING77) and 3.1 points (CLINC150), and names alone by 7 and 10.
|
|
72
|
+
const texts = Array.isArray(using) ? labels.map(readable) : labels.flatMap((l) => [readable(l), using[l]]);
|
|
73
|
+
const key = texts.join("\u0000");
|
|
74
|
+
let opts = optionCache.get(key);
|
|
75
|
+
if (!opts)
|
|
76
|
+
optionCache.set(key, (opts = await vectors(texts)));
|
|
77
|
+
const all = opts.map((o) => o.reduce((s, x, i) => s + x * q[i], 0));
|
|
78
|
+
const sims = Array.isArray(using) ? all : labels.map((_, c) => Math.max(all[2 * c], all[2 * c + 1]));
|
|
79
|
+
const [names, described] = temperature !== undefined ? [temperature, temperature] : TEMPERATURES[model] ?? [0.05, 0.05];
|
|
80
|
+
d = { ...toDecision(labels, softmax(sims, Array.isArray(using) ? names : described)), similarity: Math.max(...sims) };
|
|
81
|
+
}
|
|
82
|
+
return minConfidence !== undefined && d.confidence < minConfidence ? { ...d, choice: null } : d;
|
|
83
|
+
}
|
|
84
|
+
async function decide(text, using, { minConfidence } = {}) {
|
|
85
|
+
const [q] = await vectors([text]);
|
|
86
|
+
return score(q, using, minConfidence);
|
|
87
|
+
}
|
|
88
|
+
async function decideMany(texts, using, { minConfidence } = {}) {
|
|
89
|
+
const qs = await vectors(texts);
|
|
90
|
+
return Promise.all(qs.map((q) => score(q, using, minConfidence)));
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Trains a head on labelled examples: mini-batch gradient descent on the embeddings, keeping the best holdout
|
|
94
|
+
* epoch, then a temperature fitted on the holdout so `confidence` is calibrated. Every label needs at least a few
|
|
95
|
+
* examples. Each label starts at the mean of its examples and its name, so 5 per label already gave 79% on BANKING77 and
|
|
96
|
+
* 87% on CLINC150 (see the README).
|
|
97
|
+
*/
|
|
98
|
+
async function train(examples, opts = {}) {
|
|
99
|
+
const bad = examples.findIndex((e) => typeof e?.text !== "string" || typeof e?.label !== "string" || !e.label);
|
|
100
|
+
if (bad >= 0)
|
|
101
|
+
throw new Error(`decidekit: example ${bad} needs a string text and a non-empty string label, got ${JSON.stringify(examples[bad])}`);
|
|
102
|
+
if (!examples.length)
|
|
103
|
+
throw new Error("decidekit: train() needs at least 1 example");
|
|
104
|
+
const labels = [...new Set([...(opts.labels ?? []), ...examples.map((e) => e.label)])].sort();
|
|
105
|
+
if (opts.labels && opts.labelNames === false && labels.some((l) => !examples.some((e) => e.label === l))) {
|
|
106
|
+
throw new Error("decidekit: labels without examples need labelNames, which is what they start from");
|
|
107
|
+
}
|
|
108
|
+
if (labels.length < 2)
|
|
109
|
+
throw new Error(`decidekit: train() needs at least 2 labels, got ${labels.length}`);
|
|
110
|
+
const y = examples.map((e) => labels.indexOf(e.label));
|
|
111
|
+
const texts = [...new Set(examples.map((e) => e.text))];
|
|
112
|
+
const fresh = texts.filter((t) => !trainCache.has(t));
|
|
113
|
+
const encoded = await vectors(fresh);
|
|
114
|
+
const cache = new Map(texts.map((t) => [t, trainCache.get(t)]));
|
|
115
|
+
fresh.forEach((t, i) => cache.set(t, encoded[i]));
|
|
116
|
+
trainCache = cache;
|
|
117
|
+
const X = examples.map((e) => cache.get(e.text));
|
|
118
|
+
// Each label's readable name counts as 1 extra example in the starting point. With bge-small that beat no prior
|
|
119
|
+
// by 1 to 14 points at 1 to 5 examples per label on the dev splits, and beat weight 3 (the MiniLM setting) by 0.9
|
|
120
|
+
// to 2.5 points (eval/priors.ts).
|
|
121
|
+
const priors = opts.labelNames === false ? undefined : await vectors(labels.map(readable));
|
|
122
|
+
const cal = opts.calibrateOn;
|
|
123
|
+
const unknown = cal?.find((e) => !labels.includes(e.label));
|
|
124
|
+
if (unknown)
|
|
125
|
+
throw new Error(`decidekit: calibrateOn has label ${JSON.stringify(unknown.label)}, which isn't one of the head's labels`);
|
|
126
|
+
const calibration = cal?.length ? { X: await vectors(cal.map((e) => e.text)), y: cal.map((e) => labels.indexOf(e.label)) } : undefined;
|
|
127
|
+
return { model, ...(await fitHead(X, y, labels, opts, priors, LABEL_NAME_WEIGHT, undefined, SMALL_HEAD_FLOOR[model] ?? 1, calibration)) };
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
model, decide, decideMany, train,
|
|
131
|
+
/** Embeds texts with the encoder: unit-length vectors, one per text. */
|
|
132
|
+
embed: vectors,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
export { saveHead, loadHead } from "./head-io.js";
|
|
136
|
+
export { withFallback, NONE } from "./fallback.js";
|
|
137
|
+
export { describeLabels, describePrompt, parseDescriptions } from "./describe.js";
|
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createWorkerDecider } from "./browser.ts";
|
|
2
|
+
import type { Choices, DeciderOptions, Decision } from "./index.ts";
|
|
3
|
+
export type WorkerDecider = Awaited<ReturnType<typeof createWorkerDecider>>;
|
|
4
|
+
export interface DeciderState {
|
|
5
|
+
/** The decider once the model has loaded; `null` before that. */
|
|
6
|
+
decider: WorkerDecider | null;
|
|
7
|
+
status: "loading" | "ready" | "error";
|
|
8
|
+
/** Bytes of model files downloaded so far, on a first visit. */
|
|
9
|
+
progress: {
|
|
10
|
+
loaded: number;
|
|
11
|
+
total: number;
|
|
12
|
+
} | null;
|
|
13
|
+
error: Error | null;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Starts a worker decider when the component mounts and terminates it on unmount. Call it once near the top of
|
|
17
|
+
* the tree and pass `decider` down: each call starts its own worker and loads its own copy of the model. Options are
|
|
18
|
+
* read on mount; change `model` or `dtype` by remounting (a `key`).
|
|
19
|
+
*/
|
|
20
|
+
export declare function useDecider(options?: Omit<DeciderOptions, "onProgress">): DeciderState;
|
|
21
|
+
export interface DecisionState<D> {
|
|
22
|
+
/** The decision for the latest text, or `null` before the first one (and for empty text). */
|
|
23
|
+
decision: D | null;
|
|
24
|
+
/** True while a decision for newer text is on its way. */
|
|
25
|
+
pending: boolean;
|
|
26
|
+
error: Error | null;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Decides `text` whenever it (or `using`) changes, after `debounceMs` of quiet (default 150). Answers for text that
|
|
30
|
+
* has changed since are dropped. Keep `using` stable (a constant, state or useMemo), or every render decides again.
|
|
31
|
+
*/
|
|
32
|
+
export declare function useDecision<const L extends string>(decider: WorkerDecider | null, text: string, using: Choices<L> | null | undefined, options?: {
|
|
33
|
+
minConfidence?: undefined;
|
|
34
|
+
debounceMs?: number;
|
|
35
|
+
}): DecisionState<Decision<L>>;
|
|
36
|
+
export declare function useDecision<const L extends string>(decider: WorkerDecider | null, text: string, using: Choices<L> | null | undefined, options: {
|
|
37
|
+
minConfidence: number;
|
|
38
|
+
debounceMs?: number;
|
|
39
|
+
}): DecisionState<Decision<L, L | null>>;
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// React hooks over createWorkerDecider(): the encoder loads in a worker once per component tree that calls
|
|
2
|
+
// useDecider(), and useDecision() decides as the text changes, dropping answers for text that has since changed.
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { createWorkerDecider } from "./browser.js";
|
|
5
|
+
/**
|
|
6
|
+
* Starts a worker decider when the component mounts and terminates it on unmount. Call it once near the top of
|
|
7
|
+
* the tree and pass `decider` down: each call starts its own worker and loads its own copy of the model. Options are
|
|
8
|
+
* read on mount; change `model` or `dtype` by remounting (a `key`).
|
|
9
|
+
*/
|
|
10
|
+
export function useDecider(options = {}) {
|
|
11
|
+
const [state, setState] = useState({ decider: null, status: "loading", progress: null, error: null });
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
let alive = true, made;
|
|
14
|
+
const files = new Map();
|
|
15
|
+
createWorkerDecider({
|
|
16
|
+
...options,
|
|
17
|
+
onProgress: ({ file, loaded, total }) => {
|
|
18
|
+
files.set(file, [loaded, total]);
|
|
19
|
+
let l = 0, t = 0;
|
|
20
|
+
for (const [a, b] of files.values()) {
|
|
21
|
+
l += a;
|
|
22
|
+
t += b;
|
|
23
|
+
}
|
|
24
|
+
if (alive)
|
|
25
|
+
setState((s) => ({ ...s, progress: { loaded: l, total: t } }));
|
|
26
|
+
},
|
|
27
|
+
}).then((d) => {
|
|
28
|
+
made = d;
|
|
29
|
+
if (alive)
|
|
30
|
+
setState((s) => ({ ...s, decider: d, status: "ready" }));
|
|
31
|
+
else
|
|
32
|
+
d.terminate();
|
|
33
|
+
}, (e) => alive && setState((s) => ({ ...s, status: "error", error: e instanceof Error ? e : new Error(String(e)) })));
|
|
34
|
+
return () => { alive = false; made?.terminate(); };
|
|
35
|
+
// Options are read once; see the JSDoc.
|
|
36
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
37
|
+
}, []);
|
|
38
|
+
return state;
|
|
39
|
+
}
|
|
40
|
+
export function useDecision(decider, text, using, { minConfidence, debounceMs = 150 } = {}) {
|
|
41
|
+
const [state, setState] = useState({ decision: null, pending: false, error: null });
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!decider || !using || !text.trim()) {
|
|
44
|
+
setState({ decision: null, pending: false, error: null });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
let current = true;
|
|
48
|
+
setState((s) => ({ ...s, pending: true }));
|
|
49
|
+
const timer = setTimeout(() => {
|
|
50
|
+
decider.decide(text, using, { minConfidence }).then((decision) => current && setState({ decision, pending: false, error: null }), (e) => current && setState({ decision: null, pending: false, error: e instanceof Error ? e : new Error(String(e)) }));
|
|
51
|
+
}, debounceMs);
|
|
52
|
+
return () => { current = false; clearTimeout(timer); };
|
|
53
|
+
}, [decider, text, using, minConfidence, debounceMs]);
|
|
54
|
+
return state;
|
|
55
|
+
}
|