textopt 0.0.0 → 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/README.md +65 -25
- package/dist/bootstrap-search/index.cjs +159 -73
- package/dist/bootstrap-search/index.d.cts +32 -10
- package/dist/bootstrap-search/index.d.mts +32 -10
- package/dist/bootstrap-search/index.mjs +150 -66
- package/dist/demos-9v5ts7F3.cjs +244 -0
- package/dist/{demos-B0pVQjYC.d.mts → demos-ASsSXYXA.d.mts} +10 -3
- package/dist/demos-Brobjfuc.mjs +215 -0
- package/dist/{demos-BTuzFNsp.d.cts → demos-ByaLZy-Z.d.cts} +10 -3
- package/dist/file-cache.cjs +27 -8
- package/dist/file-cache.d.cts +13 -0
- package/dist/file-cache.d.mts +13 -0
- package/dist/file-cache.mjs +27 -8
- package/dist/gepa/index.cjs +128 -80
- package/dist/gepa/index.d.cts +15 -7
- package/dist/gepa/index.d.mts +15 -7
- package/dist/gepa/index.mjs +101 -55
- package/dist/index.cjs +157 -30
- package/dist/index.d.cts +177 -7
- package/dist/index.d.mts +177 -7
- package/dist/index.mjs +139 -18
- package/dist/{math-COOofUyv.cjs → math-BhlziRPc.cjs} +60 -9
- package/dist/math-Dqme4rYz.mjs +123 -0
- package/dist/mipro/index.cjs +104 -70
- package/dist/mipro/index.d.cts +17 -14
- package/dist/mipro/index.d.mts +17 -14
- package/dist/mipro/index.mjs +90 -58
- package/dist/opro/index.cjs +136 -51
- package/dist/opro/index.d.cts +17 -9
- package/dist/opro/index.d.mts +17 -9
- package/dist/opro/index.mjs +121 -38
- package/dist/{optimizer-B7SpRwl7.d.cts → optimizer-4Zv-Zt2t.d.cts} +90 -5
- package/dist/{optimizer-DqCoth_w.d.mts → optimizer-Ds5mzYjz.d.mts} +90 -5
- package/dist/random-search/index.cjs +99 -49
- package/dist/random-search/index.d.cts +15 -13
- package/dist/random-search/index.d.mts +15 -13
- package/dist/random-search/index.mjs +89 -41
- package/dist/{reflection-Cr_upzU0.d.mts → reflection-CMezGu6u.d.mts} +38 -14
- package/dist/{reflection-CQToe-5B.d.cts → reflection-D0A7eahD.d.cts} +38 -14
- package/dist/reporting-bq007_2z.d.cts +294 -0
- package/dist/reporting-bq007_2z.d.mts +294 -0
- package/dist/simba/index.cjs +216 -83
- package/dist/simba/index.d.cts +53 -13
- package/dist/simba/index.d.mts +53 -13
- package/dist/simba/index.mjs +206 -75
- package/dist/testing.cjs +1 -0
- package/dist/testing.d.cts +5 -3
- package/dist/testing.d.mts +5 -3
- package/dist/testing.mjs +1 -1
- package/dist/{evaluation-OZOp6TB7.cjs → warnings-CWRJF-jA.cjs} +228 -5
- package/dist/{evaluation-BV0nSZVx.mjs → warnings-OxvDi9kN.mjs} +175 -6
- package/docs/adapters.md +169 -0
- package/docs/benchmark.md +90 -0
- package/docs/data-prep.md +113 -0
- package/docs/distillation.md +128 -0
- package/docs/evaluation.md +87 -0
- package/docs/metric-preflight.md +132 -0
- package/docs/optimizers.md +293 -0
- package/docs/tuning.md +130 -0
- package/package.json +6 -4
- package/dist/demos-B9BJiNKz.cjs +0 -143
- package/dist/demos-Degx6UmP.mjs +0 -126
- package/dist/math-DhrDmpFS.mjs +0 -78
- package/dist/types-CWv4IQFF.d.cts +0 -129
- package/dist/types-CWv4IQFF.d.mts +0 -129
package/dist/index.mjs
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { S as createBudget, _ as componentNames, c as BudgetExhausted, d as measuredMean, f as requireMeasuredMean, g as runFingerprint, h as candidateFingerprint, l as costExhausted, m as assertResumable, n as seedScoreWarnings, o as isCandidateAccepted, p as createDeadline, s as isRunFinished, t as resolveValidationSet, u as createEvaluator, y as createMemoryCache } from "./warnings-OxvDi9kN.mjs";
|
|
2
2
|
import { t as mapWithConcurrency } from "./concurrency-D58PWeSk.mjs";
|
|
3
|
-
import {
|
|
4
|
-
import { n as
|
|
3
|
+
import { i as signFlipPValue, n as holmAdjust } from "./math-Dqme4rYz.mjs";
|
|
4
|
+
import { a as harvestRollouts, n as harvestFewShotExamples, r as parseDemos, t as formatDemos } from "./demos-Brobjfuc.mjs";
|
|
5
5
|
import { t as parseProposedText } from "./text--v4Ffbus.mjs";
|
|
6
6
|
//#region src/compare.ts
|
|
7
|
-
const EXACT_LIMIT =
|
|
7
|
+
const EXACT_LIMIT = 20;
|
|
8
|
+
/** Well above float subtraction noise (~1e-16), well below a real margin. */
|
|
9
|
+
const DEGENERACY_TOLERANCE = 1e-9;
|
|
8
10
|
/**
|
|
9
11
|
* Run several optimizers over the same seeds and report which one actually won.
|
|
10
12
|
*
|
|
@@ -19,7 +21,7 @@ const EXACT_LIMIT = 16;
|
|
|
19
21
|
* seed is constructor config and every optimizer here is deterministic given
|
|
20
22
|
* one — comparing two entrants at a single seed compares two anecdotes. It also
|
|
21
23
|
* leaves the task where the caller builds it, which is the only place the
|
|
22
|
-
* optimizer-specific parts of it (`reflect`, `cache`, `
|
|
24
|
+
* optimizer-specific parts of it (`reflect`, `cache`, `reporters`) are known.
|
|
23
25
|
*/
|
|
24
26
|
async function compare(args) {
|
|
25
27
|
const { entrants, seeds, concurrency = 1 } = args;
|
|
@@ -42,6 +44,8 @@ async function compare(args) {
|
|
|
42
44
|
bestScore: result.bestScore,
|
|
43
45
|
...result.testScore === void 0 ? {} : { testScore: result.testScore },
|
|
44
46
|
metricCalls: result.metricCalls,
|
|
47
|
+
cacheHits: result.cacheHits,
|
|
48
|
+
reflectionCalls: result.reflectionCalls ?? 0,
|
|
45
49
|
costUsd: result.usage.costUsd,
|
|
46
50
|
stopReason: result.stopReason
|
|
47
51
|
};
|
|
@@ -52,17 +56,25 @@ async function compare(args) {
|
|
|
52
56
|
runs: runs.filter((run) => run.entrant === entrant)
|
|
53
57
|
}));
|
|
54
58
|
const winner = summaries.reduce((best, summary) => summary.meanScore > best.meanScore ? summary : best);
|
|
59
|
+
const rawPValues = summaries.map((summary) => summary.entrant === winner.entrant ? void 0 : margin({
|
|
60
|
+
winner: winner.entrant,
|
|
61
|
+
entrant: summary.entrant,
|
|
62
|
+
runs,
|
|
63
|
+
seeds
|
|
64
|
+
}));
|
|
65
|
+
const holmAdjusted = holmAdjustSparse({
|
|
66
|
+
pValues: rawPValues,
|
|
67
|
+
familySize: names.length - 1
|
|
68
|
+
});
|
|
55
69
|
return {
|
|
56
70
|
winner: winner.entrant,
|
|
57
|
-
summaries: summaries.map((summary) =>
|
|
71
|
+
summaries: summaries.map((summary, index) => ({
|
|
58
72
|
...summary,
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
})
|
|
65
|
-
}),
|
|
73
|
+
...rawPValues[index] === void 0 ? {} : {
|
|
74
|
+
pValueVsWinner: rawPValues[index],
|
|
75
|
+
pValueVsWinnerHolm: holmAdjusted[index]
|
|
76
|
+
}
|
|
77
|
+
})),
|
|
66
78
|
runs
|
|
67
79
|
};
|
|
68
80
|
}
|
|
@@ -77,13 +89,28 @@ function summarize(args) {
|
|
|
77
89
|
minScore: Math.min(...scores),
|
|
78
90
|
maxScore: Math.max(...scores),
|
|
79
91
|
meanMetricCalls: mean(runs.map((run) => run.metricCalls)),
|
|
80
|
-
meanCostUsd: mean(runs.map((run) => run.costUsd))
|
|
92
|
+
meanCostUsd: mean(runs.map((run) => run.costUsd)),
|
|
93
|
+
meanCacheHits: mean(runs.map((run) => run.cacheHits)),
|
|
94
|
+
meanReflectionCalls: mean(runs.map((run) => run.reflectionCalls)),
|
|
95
|
+
distinctScores: new Set(scores).size
|
|
81
96
|
};
|
|
82
97
|
}
|
|
83
98
|
/**
|
|
84
99
|
* Paired across seeds rather than pooled: the same seed puts both entrants on
|
|
85
100
|
* the same sampling order, so the difference at a seed is a comparison and the
|
|
86
101
|
* spread between seeds is not.
|
|
102
|
+
*
|
|
103
|
+
* Returns `undefined` when every seed produced the exact same nonzero margin.
|
|
104
|
+
* That is not evidence of an n-seed-strong result — it is one realization the
|
|
105
|
+
* seed never varied, and a sign-flip p-value would report a precision from n
|
|
106
|
+
* independent trials that never happened. A margin of exactly zero every seed
|
|
107
|
+
* is not this case: `signFlipPValue` already reports that honestly as 1, no
|
|
108
|
+
* significance claimed either way, which is not a fabricated number.
|
|
109
|
+
*
|
|
110
|
+
* "Exact same" is judged within `DEGENERACY_TOLERANCE`, not `===`: subtracting
|
|
111
|
+
* two scores that are equal in substance can still land a few ULPs apart
|
|
112
|
+
* (0.95 - 0.55 and 0.9 - 0.5 differ at the 16th digit), and treating that as
|
|
113
|
+
* n real trials would be the same fabrication this check exists to prevent.
|
|
87
114
|
*/
|
|
88
115
|
function margin(args) {
|
|
89
116
|
const { winner, entrant, runs, seeds } = args;
|
|
@@ -98,6 +125,10 @@ function margin(args) {
|
|
|
98
125
|
seed
|
|
99
126
|
});
|
|
100
127
|
});
|
|
128
|
+
const [first] = differences;
|
|
129
|
+
if (first !== void 0) {
|
|
130
|
+
if (differences.reduce((widest, difference) => Math.max(widest, Math.abs(difference - first)), 0) < DEGENERACY_TOLERANCE && Math.abs(first) > DEGENERACY_TOLERANCE) return;
|
|
131
|
+
}
|
|
101
132
|
return signFlipPValue({
|
|
102
133
|
differences,
|
|
103
134
|
observed: differences.reduce((total, value) => total + value, 0),
|
|
@@ -108,6 +139,27 @@ function scoreOf(args) {
|
|
|
108
139
|
const { runs, entrant, seed } = args;
|
|
109
140
|
return runs.find((candidate) => candidate.entrant === entrant && candidate.seed === seed)?.score ?? 0;
|
|
110
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* `holmAdjust` over the raw p-values that exist, skipping the slots a
|
|
144
|
+
* withheld comparison left `undefined` — those still count toward
|
|
145
|
+
* `familySize`, they just have nothing of their own to adjust.
|
|
146
|
+
*/
|
|
147
|
+
function holmAdjustSparse(args) {
|
|
148
|
+
const { pValues, familySize } = args;
|
|
149
|
+
const present = pValues.map((pValue, index) => ({
|
|
150
|
+
pValue,
|
|
151
|
+
index
|
|
152
|
+
})).filter((entry) => entry.pValue !== void 0);
|
|
153
|
+
const adjusted = holmAdjust({
|
|
154
|
+
pValues: present.map((entry) => entry.pValue),
|
|
155
|
+
familySize
|
|
156
|
+
});
|
|
157
|
+
const result = new Array(pValues.length).fill(void 0);
|
|
158
|
+
present.forEach((entry, rank) => {
|
|
159
|
+
result[entry.index] = adjusted[rank];
|
|
160
|
+
});
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
111
163
|
function mean(values) {
|
|
112
164
|
return values.reduce((total, value) => total + value, 0) / values.length;
|
|
113
165
|
}
|
|
@@ -118,6 +170,51 @@ function standardDeviation(values) {
|
|
|
118
170
|
return Math.sqrt(variance);
|
|
119
171
|
}
|
|
120
172
|
//#endregion
|
|
173
|
+
//#region src/distillation.ts
|
|
174
|
+
const ROLES = /* @__PURE__ */ new Set([
|
|
175
|
+
"system",
|
|
176
|
+
"user",
|
|
177
|
+
"assistant"
|
|
178
|
+
]);
|
|
179
|
+
/**
|
|
180
|
+
* Serialize harvested rollouts as JSONL, one training example per line.
|
|
181
|
+
*
|
|
182
|
+
* The chat-messages shape rather than any one vendor's: it is what Axolotl,
|
|
183
|
+
* Together, Fireworks and the Hugging Face trainers all ingest, and the
|
|
184
|
+
* providers that read it outlive the ones that do not.
|
|
185
|
+
*
|
|
186
|
+
* Returns the text rather than writing it. Only `file-cache` touches the
|
|
187
|
+
* filesystem, and a caller uploading this straight to a provider should not
|
|
188
|
+
* have to round-trip it through a file to do so.
|
|
189
|
+
*/
|
|
190
|
+
function toTrainingJsonl(args) {
|
|
191
|
+
const { rollouts, render } = args;
|
|
192
|
+
const lines = [];
|
|
193
|
+
for (const [index, rollout] of rollouts.entries()) {
|
|
194
|
+
const example = render({
|
|
195
|
+
rollout,
|
|
196
|
+
index
|
|
197
|
+
});
|
|
198
|
+
if (example === null) continue;
|
|
199
|
+
assertUsable(example, index);
|
|
200
|
+
lines.push(JSON.stringify(example));
|
|
201
|
+
}
|
|
202
|
+
return lines.join("\n");
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Checked here rather than left to the provider. A malformed line surfaces as
|
|
206
|
+
* a rejected upload hours later, naming a line number in a file the caller
|
|
207
|
+
* never wrote by hand; naming the rollout at the point it was rendered is the
|
|
208
|
+
* same error while it is still fixable.
|
|
209
|
+
*/
|
|
210
|
+
function assertUsable(example, index) {
|
|
211
|
+
if (!Array.isArray(example.messages) || example.messages.length === 0) throw new Error(`rollout ${index} rendered no messages`);
|
|
212
|
+
for (const message of example.messages) {
|
|
213
|
+
if (!ROLES.has(message.role)) throw new Error(`rollout ${index} rendered the unknown role ${message.role}`);
|
|
214
|
+
if (typeof message.content !== "string") throw new Error(`rollout ${index} rendered non-text content`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
//#endregion
|
|
121
218
|
//#region src/judge.ts
|
|
122
219
|
const DEFAULT_SCALE = 5;
|
|
123
220
|
const SCORE = /<score\s+name="([^"]+)"\s*>\s*([\d.]+)\s*<\/score>/g;
|
|
@@ -140,6 +237,11 @@ const FEEDBACK = /<feedback>([\s\S]*?)<\/feedback>/;
|
|
|
140
237
|
function createJudge(args) {
|
|
141
238
|
const { model, criteria, scale = DEFAULT_SCALE, renderInput = stringify, renderOutput = stringify, buildPrompt = buildJudgePrompt } = args;
|
|
142
239
|
if (criteria.length === 0) throw new Error("createJudge requires at least one criterion");
|
|
240
|
+
if (!Number.isFinite(scale) || scale <= 0) throw new Error(`scale must be a positive number, received ${scale}`);
|
|
241
|
+
assertCriteria({
|
|
242
|
+
criteria,
|
|
243
|
+
scale
|
|
244
|
+
});
|
|
143
245
|
return async ({ input, output, expected, signal }) => {
|
|
144
246
|
return readVerdict({
|
|
145
247
|
response: await model({
|
|
@@ -183,6 +285,7 @@ function buildJudgePrompt(args) {
|
|
|
183
285
|
`Grade each criterion from 0 to ${scale}, where ${scale} is a perfect answer.`,
|
|
184
286
|
"",
|
|
185
287
|
"Then write feedback. It is read by a program that rewrites the system's instructions, not by a person reviewing this output, so say what the instructions should tell the system to do differently. Feedback about this particular answer is of no use to it.",
|
|
288
|
+
...expected === void 0 ? [] : ["", "Do not restate the expected answer, or any fact drawn from it, in the feedback. The instruction it is rewritten into is reused on inputs whose answers you have not seen: a fact copied out of the expected answer becomes an answer key memorised in the prompt, which raises the score on this input and teaches the system nothing. Name the kind of thing the answer was missing, not the thing itself."],
|
|
186
289
|
"",
|
|
187
290
|
"Reply in exactly this format and nothing else:",
|
|
188
291
|
...criteria.map((criterion) => `<score name="${criterion.name}">…</score>`),
|
|
@@ -201,6 +304,9 @@ function readVerdict(args) {
|
|
|
201
304
|
const graded = /* @__PURE__ */ new Map();
|
|
202
305
|
for (const match of response.matchAll(SCORE)) graded.set(match[1], Number(match[2]));
|
|
203
306
|
const objectiveScores = {};
|
|
307
|
+
let gated = false;
|
|
308
|
+
let weighted = 0;
|
|
309
|
+
let totalWeight = 0;
|
|
204
310
|
for (const criterion of criteria) {
|
|
205
311
|
const grade = graded.get(criterion.name);
|
|
206
312
|
if (grade === void 0 || Number.isNaN(grade)) return {
|
|
@@ -208,15 +314,30 @@ function readVerdict(args) {
|
|
|
208
314
|
feedback: `Judge did not grade "${criterion.name}".`,
|
|
209
315
|
transient: true
|
|
210
316
|
};
|
|
211
|
-
|
|
317
|
+
const normalized = clamp(grade / scale);
|
|
318
|
+
objectiveScores[criterion.name] = normalized;
|
|
319
|
+
if (criterion.gate !== void 0 && grade < criterion.gate) gated = true;
|
|
320
|
+
const weight = criterion.weight ?? 1;
|
|
321
|
+
weighted += normalized * weight;
|
|
322
|
+
totalWeight += weight;
|
|
212
323
|
}
|
|
213
|
-
const grades = Object.values(objectiveScores);
|
|
214
324
|
return {
|
|
215
|
-
score:
|
|
325
|
+
score: gated ? 0 : weighted / totalWeight,
|
|
216
326
|
feedback,
|
|
217
327
|
objectiveScores
|
|
218
328
|
};
|
|
219
329
|
}
|
|
330
|
+
function assertCriteria(args) {
|
|
331
|
+
const { criteria, scale } = args;
|
|
332
|
+
let totalWeight = 0;
|
|
333
|
+
for (const { name, weight = 1, gate } of criteria) {
|
|
334
|
+
if (!Number.isFinite(weight) || weight < 0) throw new Error(`weight on criterion "${name}" must be a non-negative number, received ${weight}`);
|
|
335
|
+
totalWeight += weight;
|
|
336
|
+
if (gate !== void 0 && (!Number.isFinite(gate) || gate <= 0)) throw new Error(`gate on criterion "${name}" must be greater than 0, received ${gate}; no grade can fall below 0`);
|
|
337
|
+
if (gate !== void 0 && gate > scale) throw new Error(`gate on criterion "${name}" is ${gate}, above the scale of ${scale}; no grade can reach it`);
|
|
338
|
+
}
|
|
339
|
+
if (totalWeight <= 0) throw new Error("criteria weights must sum to more than 0; at least one criterion has to count towards the score");
|
|
340
|
+
}
|
|
220
341
|
function stringify(value) {
|
|
221
342
|
return typeof value === "string" ? value : JSON.stringify(value);
|
|
222
343
|
}
|
|
@@ -242,4 +363,4 @@ function priceUsage(args) {
|
|
|
242
363
|
};
|
|
243
364
|
}
|
|
244
365
|
//#endregion
|
|
245
|
-
export { BudgetExhausted, assertResumable,
|
|
366
|
+
export { BudgetExhausted, assertResumable, buildJudgePrompt, candidateFingerprint, compare, componentNames, costExhausted, createBudget, createDeadline, createEvaluator, createJudge, createMemoryCache, formatDemos, harvestFewShotExamples, harvestRollouts, isCandidateAccepted, isRunFinished, mapWithConcurrency, measuredMean, parseDemos, parseProposedText, priceUsage, requireMeasuredMean, resolveValidationSet, runFingerprint, seedScoreWarnings, toTrainingJsonl };
|
|
@@ -44,17 +44,62 @@ function signFlipPValue(args) {
|
|
|
44
44
|
differences: moved,
|
|
45
45
|
observed
|
|
46
46
|
});
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
47
|
+
const sums = achievableSums(moved);
|
|
48
|
+
return sums.filter((total) => total >= observed).length / sums.length;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Holm-Bonferroni step-down adjustment: the p-value each comparison would need
|
|
52
|
+
* to survive on its own if the whole family were held to one error rate,
|
|
53
|
+
* rather than letting the smallest of several tests read as significant by
|
|
54
|
+
* volume alone. Sorted ascending, each rank is scaled by how many comparisons
|
|
55
|
+
* are still in contention at that rank, and the running maximum keeps a later,
|
|
56
|
+
* less-scaled rank from reporting looser than an earlier one already has.
|
|
57
|
+
*
|
|
58
|
+
* `familySize` may exceed `pValues.length`: a comparison `signFlipPValue`
|
|
59
|
+
* could not test at all (every paired difference identical, so no p reflects
|
|
60
|
+
* a real margin) is still a member of the family being controlled for, and
|
|
61
|
+
* excluding it from the denominator would understate the correction owed to
|
|
62
|
+
* the comparisons that could be tested.
|
|
63
|
+
*/
|
|
64
|
+
function holmAdjust(args) {
|
|
65
|
+
const { pValues, familySize } = args;
|
|
66
|
+
const ranked = pValues.map((pValue, index) => ({
|
|
67
|
+
pValue,
|
|
68
|
+
index
|
|
69
|
+
})).sort((a, b) => a.pValue - b.pValue);
|
|
70
|
+
const adjusted = new Array(pValues.length);
|
|
71
|
+
let runningMax = 0;
|
|
72
|
+
ranked.forEach(({ pValue, index }, rank) => {
|
|
73
|
+
runningMax = Math.max(runningMax, Math.min(1, pValue * (familySize - rank)));
|
|
74
|
+
adjusted[index] = runningMax;
|
|
75
|
+
});
|
|
76
|
+
return adjusted;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Every total reachable by flipping some subset of `differences`' signs,
|
|
80
|
+
* built by doubling rather than by scoring each of the 2^n sign masks
|
|
81
|
+
* independently: after `k` differences there are 2^k sums, and folding in
|
|
82
|
+
* difference `k+1` only ever adds or subtracts it from each of them, so the
|
|
83
|
+
* whole enumeration costs O(2^n) instead of the O(2^n * n) a per-mask loop
|
|
84
|
+
* pays for re-summing n terms every time. Measured at n=20, twenty seeds
|
|
85
|
+
* being the bench's ceiling: about 63ms scoring masks one at a time against
|
|
86
|
+
* about 16ms building sums this way — the difference between affording exact
|
|
87
|
+
* enumeration through a full twenty-seed run and falling back to the normal
|
|
88
|
+
* approximation, which is what raising `EXACT_LIMIT` in compare.ts to 20
|
|
89
|
+
* relies on.
|
|
90
|
+
*/
|
|
91
|
+
function achievableSums(differences) {
|
|
92
|
+
let sums = [0];
|
|
93
|
+
for (const difference of differences) {
|
|
94
|
+
const next = new Array(sums.length * 2);
|
|
95
|
+
for (let index = 0; index < sums.length; index += 1) {
|
|
96
|
+
const total = sums[index];
|
|
97
|
+
next[index] = total + difference;
|
|
98
|
+
next[index + sums.length] = total - difference;
|
|
54
99
|
}
|
|
55
|
-
|
|
100
|
+
sums = next;
|
|
56
101
|
}
|
|
57
|
-
return
|
|
102
|
+
return sums;
|
|
58
103
|
}
|
|
59
104
|
/**
|
|
60
105
|
* The same tail probability from a normal approximation, for batches too large
|
|
@@ -81,6 +126,12 @@ Object.defineProperty(exports, "argmax", {
|
|
|
81
126
|
return argmax;
|
|
82
127
|
}
|
|
83
128
|
});
|
|
129
|
+
Object.defineProperty(exports, "holmAdjust", {
|
|
130
|
+
enumerable: true,
|
|
131
|
+
get: function() {
|
|
132
|
+
return holmAdjust;
|
|
133
|
+
}
|
|
134
|
+
});
|
|
84
135
|
Object.defineProperty(exports, "mean", {
|
|
85
136
|
enumerable: true,
|
|
86
137
|
get: function() {
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
//#region src/math.ts
|
|
2
|
+
function argmax(values) {
|
|
3
|
+
let bestIndex = 0;
|
|
4
|
+
let bestValue = Number.NEGATIVE_INFINITY;
|
|
5
|
+
for (let index = 0; index < values.length; index += 1) {
|
|
6
|
+
const value = values[index];
|
|
7
|
+
if (value > bestValue) {
|
|
8
|
+
bestValue = value;
|
|
9
|
+
bestIndex = index;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return bestIndex;
|
|
13
|
+
}
|
|
14
|
+
/** Mean over the values that exist; unscored instances are not zeros. */
|
|
15
|
+
function mean(values) {
|
|
16
|
+
let total = 0;
|
|
17
|
+
let count = 0;
|
|
18
|
+
for (const value of values) if (value !== void 0) {
|
|
19
|
+
total += value;
|
|
20
|
+
count += 1;
|
|
21
|
+
}
|
|
22
|
+
return count === 0 ? 0 : total / count;
|
|
23
|
+
}
|
|
24
|
+
function sum(values) {
|
|
25
|
+
let total = 0;
|
|
26
|
+
for (const value of values) total += value;
|
|
27
|
+
return total;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* One-sided p-value from a paired sign-flip (permutation) test: how often a
|
|
31
|
+
* total this large arises when each difference is equally likely to have come
|
|
32
|
+
* out the other way. Exact by enumeration for small samples, normal-approximated
|
|
33
|
+
* past `maxExact` where 2^n stops being cheap.
|
|
34
|
+
*
|
|
35
|
+
* Paired and distribution-free, which is what a comparison of two runs over the
|
|
36
|
+
* same instances needs — the scores are not independent draws and are not
|
|
37
|
+
* normal, so a t-test on them is answering a different question.
|
|
38
|
+
*/
|
|
39
|
+
function signFlipPValue(args) {
|
|
40
|
+
const { differences, observed, maxExact } = args;
|
|
41
|
+
const moved = differences.filter((difference) => difference !== 0);
|
|
42
|
+
if (moved.length === 0) return 1;
|
|
43
|
+
if (moved.length > maxExact) return normalTailProbability({
|
|
44
|
+
differences: moved,
|
|
45
|
+
observed
|
|
46
|
+
});
|
|
47
|
+
const sums = achievableSums(moved);
|
|
48
|
+
return sums.filter((total) => total >= observed).length / sums.length;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Holm-Bonferroni step-down adjustment: the p-value each comparison would need
|
|
52
|
+
* to survive on its own if the whole family were held to one error rate,
|
|
53
|
+
* rather than letting the smallest of several tests read as significant by
|
|
54
|
+
* volume alone. Sorted ascending, each rank is scaled by how many comparisons
|
|
55
|
+
* are still in contention at that rank, and the running maximum keeps a later,
|
|
56
|
+
* less-scaled rank from reporting looser than an earlier one already has.
|
|
57
|
+
*
|
|
58
|
+
* `familySize` may exceed `pValues.length`: a comparison `signFlipPValue`
|
|
59
|
+
* could not test at all (every paired difference identical, so no p reflects
|
|
60
|
+
* a real margin) is still a member of the family being controlled for, and
|
|
61
|
+
* excluding it from the denominator would understate the correction owed to
|
|
62
|
+
* the comparisons that could be tested.
|
|
63
|
+
*/
|
|
64
|
+
function holmAdjust(args) {
|
|
65
|
+
const { pValues, familySize } = args;
|
|
66
|
+
const ranked = pValues.map((pValue, index) => ({
|
|
67
|
+
pValue,
|
|
68
|
+
index
|
|
69
|
+
})).sort((a, b) => a.pValue - b.pValue);
|
|
70
|
+
const adjusted = new Array(pValues.length);
|
|
71
|
+
let runningMax = 0;
|
|
72
|
+
ranked.forEach(({ pValue, index }, rank) => {
|
|
73
|
+
runningMax = Math.max(runningMax, Math.min(1, pValue * (familySize - rank)));
|
|
74
|
+
adjusted[index] = runningMax;
|
|
75
|
+
});
|
|
76
|
+
return adjusted;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Every total reachable by flipping some subset of `differences`' signs,
|
|
80
|
+
* built by doubling rather than by scoring each of the 2^n sign masks
|
|
81
|
+
* independently: after `k` differences there are 2^k sums, and folding in
|
|
82
|
+
* difference `k+1` only ever adds or subtracts it from each of them, so the
|
|
83
|
+
* whole enumeration costs O(2^n) instead of the O(2^n * n) a per-mask loop
|
|
84
|
+
* pays for re-summing n terms every time. Measured at n=20, twenty seeds
|
|
85
|
+
* being the bench's ceiling: about 63ms scoring masks one at a time against
|
|
86
|
+
* about 16ms building sums this way — the difference between affording exact
|
|
87
|
+
* enumeration through a full twenty-seed run and falling back to the normal
|
|
88
|
+
* approximation, which is what raising `EXACT_LIMIT` in compare.ts to 20
|
|
89
|
+
* relies on.
|
|
90
|
+
*/
|
|
91
|
+
function achievableSums(differences) {
|
|
92
|
+
let sums = [0];
|
|
93
|
+
for (const difference of differences) {
|
|
94
|
+
const next = new Array(sums.length * 2);
|
|
95
|
+
for (let index = 0; index < sums.length; index += 1) {
|
|
96
|
+
const total = sums[index];
|
|
97
|
+
next[index] = total + difference;
|
|
98
|
+
next[index + sums.length] = total - difference;
|
|
99
|
+
}
|
|
100
|
+
sums = next;
|
|
101
|
+
}
|
|
102
|
+
return sums;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The same tail probability from a normal approximation, for batches too large
|
|
106
|
+
* to enumerate. Under the sign-flip null each difference has mean zero and
|
|
107
|
+
* variance equal to its square, so the total's variance is their sum.
|
|
108
|
+
*/
|
|
109
|
+
function normalTailProbability(args) {
|
|
110
|
+
const { differences, observed } = args;
|
|
111
|
+
const variance = differences.reduce((total, difference) => total + difference * difference, 0);
|
|
112
|
+
if (variance === 0) return observed > 0 ? 0 : 1;
|
|
113
|
+
return 1 - standardNormalCdf(observed / Math.sqrt(variance));
|
|
114
|
+
}
|
|
115
|
+
/** Abramowitz and Stegun 7.1.26, which is accurate to about 1e-7. */
|
|
116
|
+
function standardNormalCdf(value) {
|
|
117
|
+
const sign = value < 0 ? -1 : 1;
|
|
118
|
+
const scaled = Math.abs(value) / Math.SQRT2;
|
|
119
|
+
const t = 1 / (1 + .3275911 * scaled);
|
|
120
|
+
return .5 * (1 + sign * (1 - t * (.254829592 + t * (-.284496736 + t * (1.421413741 + t * (-1.453152027 + t * 1.061405429)))) * Math.exp(-scaled * scaled)));
|
|
121
|
+
}
|
|
122
|
+
//#endregion
|
|
123
|
+
export { sum as a, signFlipPValue as i, holmAdjust as n, mean as r, argmax as t };
|