decidekit 0.1.1 → 0.2.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 CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 (unreleased)
4
+
5
+ ### Training
6
+
7
+ - From 8 examples per label (mean across labels), `train()` fits the head with full-batch Adam, starting from the same centroid and label-name point. Test accuracy with all data: BANKING77 90.6% to 93.1%, CLINC150 94.6% to 96.1%, HWU64 90.15% to 91.17% (one seed, on cached embeddings; through train() in eval/results-h14.json, BANKING77 93.1% and CLINC150 96.3%). At 5 per label nothing changes. At 10 per label: BANKING77 84.4% to 87.5%, CLINC150 92.2% to 92.5%, HWU64 85.50% to 85.38%. Adam's settings come from jimothy's `src/linear.ts` (MIT, Andrew Prifer).
8
+ - New option `solver`. Setting `learningRate` or `l2` without it keeps SGD, so tuned calls behave as before. The default `epochs` is 100 with Adam.
9
+ - Saved heads keep working unchanged. Retrained heads can score differently, so pick `minConfidence` thresholds again: the CLINC150 out-of-scope threshold moved from 0.9 to 0.98.
10
+ - Training takes longer: in Node, BANKING77 went from 84 s to 123 s on cached embeddings. In Chrome, BANKING77 took 208 s with a longest page stall of 19 ms.
11
+
3
12
  ## 0.1.1 (2026-11-02)
4
13
 
5
14
  ### Models
package/README.md CHANGED
@@ -16,8 +16,8 @@ const r = await d.decide("my card never arrived", ["card_arrival", "refund", "ot
16
16
  r.choice; // "card_arrival" | "refund" | "other"
17
17
  ```
18
18
 
19
- Give it 5 examples per label and it reaches 82% on BANKING77 (77 intents); give it the full training set and it
20
- reaches 90.7%, with each answer taking about 8 ms (median) in Chrome. Training runs in the user's tab, so their data never
19
+ Give it 5 examples per label and it reaches 84% on BANKING77 (77 intents); give it the full training set and it
20
+ reaches 93.1%, with each answer taking about 8 ms (median) in Chrome. Training runs in the user's tab, so their data never
21
21
  leaves the device.
22
22
 
23
23
  ## Zero-shot
@@ -167,6 +167,15 @@ Decide Small (bge-small-en-v1.5, the default) at q8, without label names, Ryzen
167
167
  and calibration error (ECE, lower is better) on each dataset's test split, mean of 3 seeds (one run for "all"):
168
168
  [eval/results-bge-small.json](eval/results-bge-small.json).
169
169
 
170
+ | Examples per label | 5 | 10 | 25 | all |
171
+ |---|---|---|---|---|
172
+ | BANKING77 (77 labels) | 83.7% | 87.5% | 90.2% | 93.1% |
173
+ | CLINC150 (150 labels) | 90.2% | 92.8% | 94.3% | 96.3% |
174
+ | ECE, BANKING77 | 0.050 | 0.015 | 0.013 | 0.013 |
175
+ | ECE, CLINC150 | 0.020 | 0.015 | 0.016 | 0.008 |
176
+
177
+ Those are 0.2, with label names, from [eval/results-h14.json](eval/results-h14.json). HWU64 with all data goes from 90.15% to 91.17%. Before 0.2 (SGD at every size, no label names, `eval/results-bge-small.json`):
178
+
170
179
  | Examples per label | 5 | 10 | 25 | all |
171
180
  |---|---|---|---|---|
172
181
  | BANKING77 (77 labels) | 82.3% | 83.8% | 88.7% | 90.7% |
@@ -189,7 +198,7 @@ queries, [eval/tiny.ts](eval/tiny.ts), measured with all-MiniLM-L6-v2), 1 exampl
189
198
  a threshold there.
190
199
 
191
200
  **In Chrome** (BANKING77, full training set, worker, cross-origin isolated, 16 threads): 7.7 ms median and 13.8 ms
192
- p95 per decision, 228 s to train on 10,003 examples with no page stall longer than 18 ms. all-MiniLM-L6-v2 in the
201
+ p95 per decision before 0.2, 228 s to train on 10,003 examples with no page stall longer than 18 ms. A 0.2 run on the same machine took 208 s, with a longest stall of 19 ms and 93.05% test accuracy (runs on this laptop vary: another 0.1 run took 147 s). all-MiniLM-L6-v2 in the
193
202
  same run: 4.2 ms median, 7.7 ms p95. In Node: 3.2 ms median for a single text, 1.1 ms per text batched (MiniLM 2.0
194
203
  and 0.7). Earlier MiniLM run: [bench/results.json](bench/results.json).
195
204
 
package/dist/head.js CHANGED
@@ -1,5 +1,10 @@
1
1
  const BATCH = 64;
2
2
  const CENTROID_SCALE = 20;
3
+ // At 5 per label Adam lost 0.29 points on CLINC150, at 10 it gained on BANKING77 and CLINC150 (eval/spike). The cutoff
4
+ // between them wasn't measured on its own.
5
+ const ADAM_FROM = 8;
6
+ // Without a better holdout loss for this many epochs, stop the probe run.
7
+ const ADAM_PATIENCE = 30;
3
8
  // Lets a tab paint and handle input between training batches. A message-channel task, unlike setTimeout, isn't
4
9
  // throttled to once a second (or minute) when the tab is in the background.
5
10
  function yieldToPage() {
@@ -56,11 +61,23 @@ function batchGradient(ids, X, y, W, B, gW, gb, z, C, dim) {
56
61
  }
57
62
  }
58
63
  }
64
+ // One Adam step from the summed gradient. Adam's settings follow jimothy's src/linear.ts (MIT, Andrew Prifer).
65
+ function adamStep(p, g, m, v, rows, l2, lr, epoch) {
66
+ const c1 = 1 - 0.9 ** epoch, c2 = 1 - 0.999 ** epoch;
67
+ for (let k = 0; k < p.length; k++) {
68
+ const d = g[k] / rows + l2 * p[k];
69
+ m[k] = 0.9 * m[k] + 0.1 * d;
70
+ v[k] = 0.999 * v[k] + 0.001 * d * d;
71
+ p[k] -= (lr * m[k]) / c1 / (Math.sqrt(v[k] / c2) + 1e-8);
72
+ }
73
+ }
59
74
  /** Fits weights, biases and temperature to embeddings `X` with label indices `y`. */
60
75
  export async function fitHead(X, y, labels, opts = {}, priors, priorWeight = 1, centroidScale = CENTROID_SCALE, smallFloor = 1, calibration) {
61
76
  // smallFloor: the lowest temperature a head with under 50 held-out answers may get; createDecider picks it per model.
62
77
  // 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;
78
+ // A set learningRate or l2 was tuned for SGD, so it keeps SGD unless the solver is named.
79
+ const adam = opts.solver ? opts.solver === "adam" : opts.learningRate === undefined && opts.l2 === undefined && X.length / labels.length >= ADAM_FROM;
80
+ const { epochs = adam ? 100 : 60, learningRate = adam ? 0.05 : 2, l2 = adam ? 0 : 1e-4, holdout = 0.1, seed = 7, onEpoch } = opts;
64
81
  const dim = X[0].length, C = labels.length;
65
82
  // One flat buffer, so the inner loops read typed memory instead of an array of arrays.
66
83
  const Xf = new Float64Array(X.length * dim);
@@ -92,6 +109,33 @@ export async function fitHead(X, y, labels, opts = {}, priors, priorWeight = 1,
92
109
  let best = { acc: check ? acc() : 0, weights: head.weights.slice(), bias: head.bias.slice(), epochs: 0 };
93
110
  const gW = new Float64Array(C * dim), gb = new Float64Array(C), z = new Float64Array(C);
94
111
  const W = head.weights, B = head.bias;
112
+ if (adam) {
113
+ const mW = new Float64Array(C * dim), vW = new Float64Array(C * dim), mB = new Float64Array(C), vB = new Float64Array(C);
114
+ let bestLoss = Infinity;
115
+ for (let ep = 0; ep < nEpochs; ep++) {
116
+ gW.fill(0);
117
+ gb.fill(0);
118
+ for (let b = 0; b < rows.length; b += BATCH) {
119
+ batchGradient(rows.slice(b, b + BATCH), Xf, y, W, B, gW, gb, z, C, dim);
120
+ await yieldToPage();
121
+ }
122
+ adamStep(W, gW, mW, vW, rows.length, l2, learningRate, ep + 1);
123
+ adamStep(B, gb, mB, vB, rows.length, 0, learningRate, ep + 1);
124
+ if (!check)
125
+ continue;
126
+ const loss = check.reduce((sum, i) => sum - Math.log(softmax(logits(head, X[i]), 1)[y[i]] + 1e-300), 0);
127
+ if (onEpoch)
128
+ onEpoch(ep, acc());
129
+ if (loss < bestLoss * (1 - 1e-7)) {
130
+ bestLoss = loss;
131
+ best = { acc: 0, weights: W.slice(), bias: B.slice(), epochs: ep + 1 };
132
+ }
133
+ if (ep + 1 - best.epochs >= ADAM_PATIENCE)
134
+ break;
135
+ await yieldToPage();
136
+ }
137
+ return check ? { ...head, weights: best.weights, bias: best.bias, epochs: best.epochs } : { ...head, epochs: nEpochs };
138
+ }
95
139
  const order = [...rows];
96
140
  for (let ep = 0; ep < nEpochs; ep++) {
97
141
  order.sort(() => rand() - 0.5);
package/dist/index.d.ts CHANGED
@@ -51,11 +51,16 @@ export interface TrainOptions<L extends string = string> {
51
51
  * can still be chosen. Needs `labelNames`.
52
52
  */
53
53
  labels?: readonly L[];
54
- /** Passes over the examples. Default 60; the best holdout epoch is kept. */
54
+ /**
55
+ * How to fit the head. By default `adam` from 8 examples per label (mean across labels), `sgd` below that. Setting
56
+ * `learningRate` or `l2` without this also picks `sgd`.
57
+ */
58
+ solver?: "adam" | "sgd";
59
+ /** Most passes over the examples. Default 100 with `adam`, 60 with `sgd`; the best holdout epoch is kept. */
55
60
  epochs?: number;
56
- /** Default 2. */
61
+ /** Default 0.05 with `adam`, 2 with `sgd`. */
57
62
  learningRate?: number;
58
- /** Weight decay. Default 1e-4. */
63
+ /** Weight decay. Default 0 with `adam`, 1e-4 with `sgd`. */
59
64
  l2?: number;
60
65
  /** Share of examples held out for early stopping and temperature fitting. Default 0.1. */
61
66
  holdout?: number;
@@ -74,7 +79,7 @@ export interface DeciderOptions {
74
79
  /**
75
80
  * A Hugging Face sentence-embedding model with ONNX weights. Default `debuffjp/decide-small` (Decide Small, 34 MB at q8).
76
81
  * Before 0.1.1 the default id was `Xenova/bge-small-en-v1.5`, the same weights; that id and heads recording it still work.
77
- * Pre-release builds defaulted to `Xenova/all-MiniLM-L6-v2` (23 MB): smaller and 1.6x faster, 3 to 5 points less
82
+ * Pre-release builds defaulted to `Xenova/all-MiniLM-L6-v2` (23.7 MB): smaller and about 1.8x faster in Chrome, 3 to 5 points less
78
83
  * accurate. Pass it to keep using heads trained on it.
79
84
  */
80
85
  model?: string;
package/dist/index.js CHANGED
@@ -103,8 +103,8 @@ export async function createDecider({ model: requested = SMALL, device, dtype =
103
103
  /**
104
104
  * Trains a head on labelled examples: mini-batch gradient descent on the embeddings, keeping the best holdout
105
105
  * epoch, then a temperature fitted on the holdout so `confidence` is calibrated. Every label needs at least a few
106
- * examples. Each label starts at the mean of its examples and its name, so 5 per label already gave 79% on BANKING77 and
107
- * 87% on CLINC150 (see the README).
106
+ * examples. Each label starts at the mean of its examples and its name, so 5 per label already gave 82.3% on BANKING77 and
107
+ * 89.6% on CLINC150 (see the README).
108
108
  */
109
109
  async function train(examples, opts = {}) {
110
110
  const bad = examples.findIndex((e) => typeof e?.text !== "string" || typeof e?.label !== "string" || !e.label);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "decidekit",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "A System One model in the browser: typed, calibrated text decisions on the user's device. Learns from an LLM when it isn't sure.",
5
5
  "license": "MIT",
6
6
  "repository": "github:debuffjp/decidekit",