feat: 研究规则 rules 模块(任务书 02)
rules 是纯计算,src/rules 共 1761 行:方法.md 第六部分的全部公式、第七部分的余额与扣次、4.2 的入口分配,以及种子与规则登记表。 预期值有三个来源:方法.md 的手算例;tests/fixtures/rules/ 下不看实现写的 Python 脚本;参考项目 calc15、calc14b 里与现行方法一致的部分。审阅做了两轮,每轮都换一个全新上下文的子 Agent,提出的问题都已修复或写明不修的理由。 用户确认的约定(原话"1–8 都同意"):入池、采纳、消融中发现都用严格不等号;成本用 max(0, ĝ₀);没测的格附"含它的最好组合"的净值;战绩在分数原值上判。三个数改为严格递增后,删掉了尖峰的全部专门处理(bfb96ba)。vocab 里 Range 的说明同步改为严格递增。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
import type { Direction, Measured, Range } from "../vocab/index.ts";
|
||||
import { compareIds, improvement, INDISPENSABLE_SUPPORT, INTERACTION_CUT } from "./basics.ts";
|
||||
import { bitWidth, covers, disagreement, surprise, worldBits } from "./bits.ts";
|
||||
import { ablationCellMixture, mixtureRange } from "./distribution.ts";
|
||||
import { adoptionLine } from "./forward.ts";
|
||||
|
||||
// 方法.md 6.6 消融结算,4.3 ⑬ 列格、⑰ 结算与"消融中发现"。
|
||||
|
||||
/**
|
||||
* 一格的结局(6.6"一格的几种结局"):
|
||||
* - valid:有效分数(含合法化后的分数),只有它进标签的求和、判确认与否定、记比特和惊讶;
|
||||
* - insufficient:证据不足(启动了,但跑崩、超时、没有产物、修不了或判为违规);
|
||||
* - untested:没测;
|
||||
* - cannotRun:跑不起来(不启动、不扣次);
|
||||
* - joint:一起关,off 是实际关掉的全部因子;score 是那次运行的有效分数,没有时为 null。
|
||||
*/
|
||||
export type CellOutcome =
|
||||
| { kind: "valid"; score: number }
|
||||
| { kind: "insufficient" }
|
||||
| { kind: "untested" }
|
||||
| { kind: "cannotRun" }
|
||||
| { kind: "joint"; off: readonly string[]; score: number | null };
|
||||
|
||||
export interface AblationCell {
|
||||
factor: string;
|
||||
outcome: CellOutcome;
|
||||
}
|
||||
|
||||
/** 一条解释:作者,以及对每一格给的三个数(关掉这个因子后的分数,原值)。 */
|
||||
export interface AblationExplanation {
|
||||
id: string;
|
||||
author: string;
|
||||
cells: ReadonlyArray<{ factor: string; range: Range }>;
|
||||
}
|
||||
|
||||
/** 列格(4.3 ⑬):被采纳改法的每个因子各一格"只关掉它",按因子编号排。 */
|
||||
export function listCells(factors: readonly string[]): string[] {
|
||||
return [...factors].sort(compareIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 总提升 G = f(完整) − f(基线)(6.6"撑几成",按方向折算)。被消融的一定是被采纳的改法,
|
||||
* 所以 G 不小于采纳线;G ≤ 0 说明上游选错了改法,直接报错。
|
||||
*/
|
||||
export function totalGain(baseline: number, full: number, direction: Direction): number {
|
||||
const gain = improvement(baseline, full, direction);
|
||||
if (!(gain > 0)) {
|
||||
throw new Error(
|
||||
`消融结算(方法.md 6.6"撑几成"):总提升 G = ${gain} ≤ 0(基线 ${baseline},完整 ${full},方向 ${direction});` +
|
||||
`被消融的必须是被采纳的改法`,
|
||||
);
|
||||
}
|
||||
return gain;
|
||||
}
|
||||
|
||||
/** 撑几成(6.6)= [f(完整) − f(关掉 p)] ÷ G,按方向折算,不截断。 */
|
||||
export function supportOf(baseline: number, full: number, off: number, direction: Direction): number {
|
||||
return improvement(off, full, direction) / totalGain(baseline, full, direction);
|
||||
}
|
||||
|
||||
/**
|
||||
* 配合关系标签代号(6.6 表,从上往下判,命中即停):
|
||||
* offBetter 有因子关掉反而更好;incomplete 不全;indispensable 缺一不可;complementary 互补;
|
||||
* substitute 替代;independent 各管各。中文和"给下一轮的话"由 materials 写。
|
||||
*/
|
||||
export type SynergyLabel = "offBetter" | "incomplete" | "indispensable" | "complementary" | "substitute" | "independent";
|
||||
|
||||
/**
|
||||
* 配合关系标签(6.6)。supports 是每个因子"只关它、有效分数"的撑几成,没有这样的格时为 null。
|
||||
* 每个因子都有有效格时给出 Σ撑 和相对交互量 i = Σ撑 − 1,否则这两个数为 null。
|
||||
*/
|
||||
export function synergyLabel(supports: ReadonlyArray<number | null>): {
|
||||
label: SynergyLabel;
|
||||
supportSum: number | null;
|
||||
interaction: number | null;
|
||||
} {
|
||||
const complete = supports.every((s) => s !== null);
|
||||
const supportSum = complete ? (supports as number[]).reduce((t, s) => t + s, 0) : null;
|
||||
const interaction = supportSum === null ? null : supportSum - 1;
|
||||
const label: SynergyLabel = supports.some((s) => s !== null && s < 0)
|
||||
? "offBetter"
|
||||
: !complete
|
||||
? "incomplete"
|
||||
: (supports as number[]).every((s) => s > INDISPENSABLE_SUPPORT)
|
||||
? "indispensable"
|
||||
: interaction! > INTERACTION_CUT
|
||||
? "complementary"
|
||||
: interaction! < -INTERACTION_CUT
|
||||
? "substitute"
|
||||
: "independent";
|
||||
return { label, supportSum, interaction };
|
||||
}
|
||||
|
||||
/**
|
||||
* "一起关"是对称的(6.6):设置由实际关掉的因子集合决定,关 p 连带关 q 和关 q 连带关 p 是同一种设置。
|
||||
* 给出每一格的设置键,以及同一次消融里因子编号更小、设置相同的第一格(有它就沿用它的结果,不重跑、不重复扣次)。
|
||||
*/
|
||||
export function sharedSettings(
|
||||
cells: ReadonlyArray<{ factor: string; off: readonly string[] }>,
|
||||
): Array<{ factor: string; key: string; sameAs: string | null }> {
|
||||
const first = new Map<string, string>();
|
||||
return [...cells]
|
||||
.sort((a, b) => compareIds(a.factor, b.factor))
|
||||
.map((c) => {
|
||||
const key = [...new Set(c.off)].sort(compareIds).join("+");
|
||||
const sameAs = first.get(key) ?? null;
|
||||
if (sameAs === null) first.set(key, c.factor);
|
||||
return { factor: c.factor, key, sameAs };
|
||||
});
|
||||
}
|
||||
|
||||
export type ExplanationVerdict = "confirmed" | "denied";
|
||||
|
||||
/**
|
||||
* 确认与否定(6.6):每条解释在每个有效格上,实测落在它自己的 [q1, q9] 里(含两端)记确认,否则记否定。
|
||||
* 解释按编号排,格按因子编号排,只列有效格。
|
||||
*/
|
||||
export function judgeExplanations(
|
||||
explanations: readonly AblationExplanation[],
|
||||
cells: readonly AblationCell[],
|
||||
): Array<{ id: string; verdicts: Array<{ factor: string; verdict: ExplanationVerdict }> }> {
|
||||
const valid = [...cells].sort((a, b) => compareIds(a.factor, b.factor)).filter((c) => c.outcome.kind === "valid");
|
||||
return [...explanations]
|
||||
.sort((a, b) => compareIds(a.id, b.id))
|
||||
.map((e) => ({
|
||||
id: e.id,
|
||||
verdicts: valid.map((c) => {
|
||||
const range = e.cells.find((x) => x.factor === c.factor)!.range;
|
||||
const score = (c.outcome as { score: number }).score;
|
||||
return { factor: c.factor, verdict: covers(range, score) ? ("confirmed" as const) : ("denied" as const) };
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 现有测法分不开(6.6):在所有有效格上确认与否定序列完全相同的解释归为一组,只列两条以上的组。
|
||||
* 没有有效格时不标。组内按解释编号排,组之间按第一条的编号排。
|
||||
*/
|
||||
export function inseparableGroups(judgments: ReturnType<typeof judgeExplanations>): string[][] {
|
||||
if (judgments.length === 0 || judgments[0]!.verdicts.length === 0) return [];
|
||||
const groups = new Map<string, string[]>();
|
||||
for (const j of judgments) {
|
||||
const key = j.verdicts.map((v) => v.verdict).join(",");
|
||||
groups.set(key, [...(groups.get(key) ?? []), j.id]);
|
||||
}
|
||||
return [...groups.values()]
|
||||
.filter((g) => g.length > 1)
|
||||
.map((g) => g.sort(compareIds))
|
||||
.sort((a, b) => compareIds(a[0]!, b[0]!));
|
||||
}
|
||||
|
||||
/**
|
||||
* 消融中发现(4.3 ⑰):有效格和有分数的"一起关"格里,比当前基线(刚被采纳的完整代码,B = f(完整))
|
||||
* 的改进严格大于采纳线的格过线;几格过线取分数最好的,同分取因子编号小的。
|
||||
*/
|
||||
export function ablationFinding(input: {
|
||||
full: number;
|
||||
knownBest: number | null;
|
||||
epsilon: number;
|
||||
direction: Direction;
|
||||
cells: readonly AblationCell[];
|
||||
}): {
|
||||
line: number;
|
||||
quarter: number | null;
|
||||
passed: Array<{ factor: string; score: number; improvement: number }>;
|
||||
chosen: string | null;
|
||||
} {
|
||||
const { line, quarter } = adoptionLine({ ...input, baseline: input.full });
|
||||
const passed = [...input.cells]
|
||||
.sort((a, b) => compareIds(a.factor, b.factor))
|
||||
.flatMap((c) => {
|
||||
const o = c.outcome;
|
||||
const score = o.kind === "valid" || (o.kind === "joint" && o.score !== null) ? o.score : null;
|
||||
if (score === null) return [];
|
||||
const gain = improvement(input.full, score, input.direction);
|
||||
return gain > line ? [{ factor: c.factor, score, improvement: gain }] : [];
|
||||
});
|
||||
let chosen: (typeof passed)[number] | null = null;
|
||||
for (const p of passed) if (chosen === null || p.improvement > chosen.improvement) chosen = p;
|
||||
return { line, quarter, passed, chosen: chosen?.factor ?? null };
|
||||
}
|
||||
|
||||
/** 一次消融结算的输入(4.3 ⑰)。baseline 是本批基线 b,full 是被采纳的完整代码的分数。 */
|
||||
export interface SettleInput {
|
||||
baseline: number;
|
||||
full: number;
|
||||
direction: Direction;
|
||||
epsilon: number;
|
||||
knownBest: number | null;
|
||||
lambda: number;
|
||||
/** 被采纳改法的因子编号:按它列格、贴标签,每个因子都要有一格。 */
|
||||
factors: readonly string[];
|
||||
cells: readonly AblationCell[];
|
||||
explanations: readonly AblationExplanation[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次消融的结算(4.3 ⑰、6.6、6.9):每格的撑几成、合并范围,有效格的世界比特、惊讶、分歧量(格宽 ε/100,
|
||||
* 从 b 起切);配合关系标签;每条解释的确认与否定;分不开的组;消融中发现。押的因子另由 chooseBet 算。
|
||||
* 格按因子清单列(4.3 ⑬ 每个因子一格):清单里的因子没有格、或者有清单外的格,都是上游的错,直接报错,
|
||||
* 不把漏掉的因子当成"不全"。
|
||||
*/
|
||||
export function settleAblation(input: SettleInput): {
|
||||
gain: number;
|
||||
cells: Array<{
|
||||
factor: string;
|
||||
outcome: CellOutcome["kind"];
|
||||
support: number | null;
|
||||
merged: Range;
|
||||
worldBits: number | null;
|
||||
surprise: { cell: number; prior: number; bits: Measured } | null;
|
||||
disagreement: number | null;
|
||||
}>;
|
||||
label: ReturnType<typeof synergyLabel>;
|
||||
judgments: ReturnType<typeof judgeExplanations>;
|
||||
inseparable: string[][];
|
||||
finding: ReturnType<typeof ablationFinding>;
|
||||
} {
|
||||
const gain = totalGain(input.baseline, input.full, input.direction);
|
||||
const width = bitWidth(input.epsilon);
|
||||
const factors = listCells(input.factors);
|
||||
for (const c of input.cells) {
|
||||
if (!factors.includes(c.factor)) {
|
||||
throw new Error(`消融结算(方法.md 4.3 ⑬ 列格):格 ${c.factor} 不在被采纳改法的因子清单 ${factors.join("、")} 里`);
|
||||
}
|
||||
}
|
||||
const cells = factors
|
||||
.map((f) => {
|
||||
const c = input.cells.find((x) => x.factor === f);
|
||||
if (c === undefined) throw new Error(`消融结算(方法.md 4.3 ⑬ 列格):因子 ${f} 没有对应的格`);
|
||||
return c;
|
||||
})
|
||||
.map((c) => {
|
||||
const entries = input.explanations.map((e) => ({
|
||||
id: e.id,
|
||||
author: e.author,
|
||||
range: e.cells.find((x) => x.factor === c.factor)!.range,
|
||||
}));
|
||||
const mixture = ablationCellMixture(entries, input.lambda);
|
||||
const o = c.outcome;
|
||||
const valid = o.kind === "valid";
|
||||
return {
|
||||
factor: c.factor,
|
||||
outcome: o.kind,
|
||||
support: valid ? improvement(o.score, input.full, input.direction) / gain : null,
|
||||
merged: mixtureRange(mixture),
|
||||
worldBits: valid ? worldBits(mixture, input.baseline, width) : null,
|
||||
surprise: valid ? surprise(mixture, input.baseline, width, o.score) : null,
|
||||
disagreement: valid ? disagreement(entries, input.baseline, width) : null,
|
||||
};
|
||||
});
|
||||
const judgments = judgeExplanations(input.explanations, input.cells);
|
||||
return {
|
||||
gain,
|
||||
cells,
|
||||
label: synergyLabel(cells.map((c) => c.support)),
|
||||
judgments,
|
||||
inseparable: inseparableGroups(judgments),
|
||||
finding: ablationFinding(input),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Direction } from "../vocab/index.ts";
|
||||
|
||||
// 方法.md 6.11"常数与参数"里标"固定"的常数。标"跟随 ε"的量不写成常数,由 epsilonFloor 从传进来的 ε 算。
|
||||
|
||||
/** 配合关系标签的相对交互量界限 ±0.2(6.6"配合关系标签")。 */
|
||||
export const INTERACTION_CUT = 0.2;
|
||||
/** "缺一不可"的撑几成界限 0.9(6.6"配合关系标签")。 */
|
||||
export const INDISPENSABLE_SUPPORT = 0.9;
|
||||
/** 净值容差 1e-12(6.7"选格"第 2 条)。 */
|
||||
export const NET_TOLERANCE = 1e-12;
|
||||
/** 部分格积分的组合总数上限:测 k 格时每格小段数 m 满足 m^k ≤ 4,000,000(6.7"信息价值")。 */
|
||||
export const GRID_COMBINATION_CAP = 4_000_000;
|
||||
/** 部分格积分里每段最多等分几份(6.7"信息价值",s ≤ 20)。 */
|
||||
export const GRID_SUBDIVISIONS = 20;
|
||||
|
||||
/**
|
||||
* ε/100:采纳线下限、剩余空间下限、比特格宽 Δ 都是它(6.11"跟随 ε")。
|
||||
* 三处共用这一个函数,免得哪一处改写成别的写法后末位不同。
|
||||
*/
|
||||
export function epsilonFloor(epsilon: number): number {
|
||||
return epsilon / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* 改进量:从 from 到 to 按方向好了多少,正数表示变好(6.1"方向")。
|
||||
* 越大越好时是 to − from,越小越好时是 from − to。
|
||||
*/
|
||||
export function improvement(from: number, to: number, direction: Direction): number {
|
||||
return direction === "maximize" ? to - from : from - to;
|
||||
}
|
||||
|
||||
/**
|
||||
* 编号比较(自然序):候选、研究者、入口、因子、解释的编号都用它。方法说的"编号最小"是数字意义上的大小,
|
||||
* 所以 c2 < c10、researcher-2 < researcher-10、p2 < p10。编号切成数字段和非数字段逐段比:两段都是数字时按数值比
|
||||
* (先比去掉前导零后的位数,再比字符串,不转成浮点数,免得长编号丢精度),否则按 JavaScript 字符串顺序比;
|
||||
* 一方的段先用完的排前面。逐段都相同(如 p01 与 p1)时再按整串的字符串顺序定先后,保证不同编号不会相等。
|
||||
*/
|
||||
export function compareIds(a: string, b: string): number {
|
||||
const sa = a.match(SEGMENT) ?? [];
|
||||
const sb = b.match(SEGMENT) ?? [];
|
||||
for (let i = 0; i < Math.min(sa.length, sb.length); i++) {
|
||||
const c = compareSegment(sa[i]!, sb[i]!);
|
||||
if (c !== 0) return c;
|
||||
}
|
||||
if (sa.length !== sb.length) return sa.length - sb.length;
|
||||
return stringOrder(a, b);
|
||||
}
|
||||
|
||||
const SEGMENT = /\d+|\D+/g;
|
||||
const DIGITS = /^\d/;
|
||||
|
||||
function compareSegment(x: string, y: string): number {
|
||||
if (!DIGITS.test(x) || !DIGITS.test(y)) return stringOrder(x, y);
|
||||
const tx = x.replace(/^0+(?=\d)/, "");
|
||||
const ty = y.replace(/^0+(?=\d)/, "");
|
||||
return tx.length !== ty.length ? tx.length - ty.length : stringOrder(tx, ty);
|
||||
}
|
||||
|
||||
function stringOrder(x: string, y: string): number {
|
||||
return x < y ? -1 : x > y ? 1 : 0;
|
||||
}
|
||||
|
||||
/** 从 0..n−1 里选 k 个的全部组合,组合内升序,组合之间按字典序列出。 */
|
||||
export function combinations(n: number, k: number): number[][] {
|
||||
const out: number[][] = [];
|
||||
const pick: number[] = [];
|
||||
const walk = (start: number): void => {
|
||||
if (pick.length === k) {
|
||||
out.push([...pick]);
|
||||
return;
|
||||
}
|
||||
for (let i = start; i < n; i++) {
|
||||
pick.push(i);
|
||||
walk(i + 1);
|
||||
pick.pop();
|
||||
}
|
||||
};
|
||||
walk(0);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { Measured, Range } from "../vocab/index.ts";
|
||||
import { compareIds, epsilonFloor } from "./basics.ts";
|
||||
import { breakpoints, mixtureBelow, personKnots, type Knots, type Mixture } from "./distribution.ts";
|
||||
|
||||
// 方法.md 6.9 比特、惊讶、核对范围准不准。这是"唯一的尺子":不随选批、选格规则变,
|
||||
// 一律在分数原值上、从本批基线 b 起按格宽 Δ 切。
|
||||
|
||||
/** 格宽 Δ = ε/100(6.9)。 */
|
||||
export function bitWidth(epsilon: number): number {
|
||||
return epsilonFloor(epsilon);
|
||||
}
|
||||
|
||||
/** 第 j 小格的左端 b + jΔ。切格和找实测所在格都用这一个式子,边界上的值两边归格一致。 */
|
||||
function edge(origin: number, width: number, j: number): number {
|
||||
return origin + j * width;
|
||||
}
|
||||
|
||||
/** 从 lo 到 hi 全部覆盖的小格把握:第 j 格 [b + jΔ, b + (j+1)Δ) 的把握 = F(右端) − F(左端)。 */
|
||||
function massesOver(cdf: (x: number) => number, origin: number, width: number, lo: number, hi: number): number[] {
|
||||
const j0 = Math.floor((lo - origin) / width) - 1;
|
||||
const j1 = Math.ceil((hi - origin) / width) + 1;
|
||||
const out: number[] = [];
|
||||
for (let j = j0; j < j1; j++) out.push(cdf(edge(origin, width, j + 1)) - cdf(edge(origin, width, j)));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** −Σ p log₂ p,只算 p > 0 的项。 */
|
||||
export function entropyBits(ps: readonly number[]): number {
|
||||
let h = 0;
|
||||
for (const p of ps) if (p > 0) h -= p * Math.log2(p);
|
||||
return h;
|
||||
}
|
||||
|
||||
/**
|
||||
* 世界比特(6.9):合并分布在宽 Δ 的小格上的拿不准,−Σ p_j log₂ p_j。正向传候选的合并分布(分数原值),
|
||||
* 消融格传这一格的成分混合;origin 是本批基线 b。
|
||||
*/
|
||||
export function worldBits(m: Mixture, origin: number, width: number): number {
|
||||
const xs = breakpoints(m);
|
||||
return entropyBits(massesOver((x) => mixtureBelow(m, x), origin, width, xs[0]!, xs[xs.length - 1]!));
|
||||
}
|
||||
|
||||
/** 实测 v 所在小格的编号 j:b + jΔ ≤ v < b + (j+1)Δ,用和切格同一套边界核对,避免除法舍入把边界上的值归错格。 */
|
||||
export function cellIndex(origin: number, width: number, v: number): number {
|
||||
let j = Math.floor((v - origin) / width);
|
||||
while (v < edge(origin, width, j)) j -= 1;
|
||||
while (v >= edge(origin, width, j + 1)) j += 1;
|
||||
return j;
|
||||
}
|
||||
|
||||
/**
|
||||
* 惊讶(6.9)= −log₂ p,p 是合并分布给实测所在那一小格的事前把握。
|
||||
* p 为 0 时 bits 是"测不到"并写明原因,不拿 0 或无穷大顶替。
|
||||
*/
|
||||
export function surprise(
|
||||
m: Mixture,
|
||||
origin: number,
|
||||
width: number,
|
||||
value: number,
|
||||
): { cell: number; prior: number; bits: Measured } {
|
||||
const j = cellIndex(origin, width, value);
|
||||
const p = mixtureBelow(m, edge(origin, width, j + 1)) - mixtureBelow(m, edge(origin, width, j));
|
||||
const bits: Measured =
|
||||
p > 0 ? { status: "measured", value: -Math.log2(p) } : { status: "unmeasured", reason: "事前把握为 0,惊讶算不出" };
|
||||
return { cell: j, prior: p, bits };
|
||||
}
|
||||
|
||||
/**
|
||||
* 分歧量(6.9"比特、惊讶、核对范围准不准",只记录)= 各人折线等权平均后的世界比特 − 各人世界比特的平均,不掺开放预留。
|
||||
* 量的是人与人之间的差别:一个人交了几条解释(消融格)时,先把他的几条等权平均成"这个人的折线"再算他的比特,
|
||||
* 同一人几条解释之间的差别不算进来;然后在研究者之间等权平均。正向一人一个范围(id 用研究者编号),
|
||||
* "这个人的折线"就是他的那一条。研究者按编号排、每人的几条按 id 排后再求和,结果与传入次序无关。
|
||||
* 大家交的范围完全一样时为 0。
|
||||
*/
|
||||
export function disagreement(
|
||||
entries: ReadonlyArray<{ id: string; author: string; range: Range }>,
|
||||
origin: number,
|
||||
width: number,
|
||||
): number {
|
||||
const sorted = [...entries].sort((a, b) => compareIds(a.author, b.author) || compareIds(a.id, b.id));
|
||||
const lines: Knots[] = sorted.map((e) => personKnots(e.range));
|
||||
const xs = lines.flatMap((k) => k.map(([x]) => x));
|
||||
const lo = Math.min(...xs);
|
||||
const hi = Math.max(...xs);
|
||||
const byAuthor = new Map<string, Knots[]>();
|
||||
sorted.forEach((e, i) => byAuthor.set(e.author, [...(byAuthor.get(e.author) ?? []), lines[i]!]));
|
||||
// 每个人的小格把握:他的几条折线等权平均。
|
||||
const people = [...byAuthor.values()].map((own) => {
|
||||
const person: Mixture = own.map((knots) => ({ weight: 1 / own.length, knots }));
|
||||
return massesOver((x) => mixtureBelow(person, x), origin, width, lo, hi);
|
||||
});
|
||||
const mean = people[0]!.map((_, j) => people.reduce((s, ms) => s + ms[j]!, 0) / people.length);
|
||||
return entropyBits(mean) - people.reduce((s, ms) => s + entropyBits(ms), 0) / people.length;
|
||||
}
|
||||
|
||||
/** 落点四段(6.9):实测落在一个人范围的哪一段。 */
|
||||
export type Placement = "belowQ1" | "q1ToQ5" | "q5ToQ9" | "aboveQ9";
|
||||
|
||||
/** v < q1 低于一成;q1 ≤ v < q5 一成到一半;q5 ≤ v ≤ q9 一半到九成;v > q9 高于九成。 */
|
||||
export function placement(r: Range, v: number): Placement {
|
||||
return v < r.q1 ? "belowQ1" : v < r.q5 ? "q1ToQ5" : v <= r.q9 ? "q5ToQ9" : "aboveQ9";
|
||||
}
|
||||
|
||||
/** 实测落在 [q1, q9](含两端)就算包住;也是 6.6 里"确认"的判据。 */
|
||||
export function covers(r: Range, v: number): boolean {
|
||||
return r.q1 <= v && v <= r.q9;
|
||||
}
|
||||
|
||||
/** 落点四段的计数和覆盖率(6.9)。一条样本都没有时覆盖率"测不到",不记 0。 */
|
||||
export function coverage(samples: ReadonlyArray<{ range: Range; value: number }>): {
|
||||
counts: Record<Placement, number>;
|
||||
covered: number;
|
||||
total: number;
|
||||
rate: Measured;
|
||||
} {
|
||||
const counts: Record<Placement, number> = { belowQ1: 0, q1ToQ5: 0, q5ToQ9: 0, aboveQ9: 0 };
|
||||
let covered = 0;
|
||||
for (const s of samples) {
|
||||
counts[placement(s.range, s.value)] += 1;
|
||||
if (covers(s.range, s.value)) covered += 1;
|
||||
}
|
||||
const total = samples.length;
|
||||
const rate: Measured =
|
||||
total === 0 ? { status: "unmeasured", reason: "没有样本" } : { status: "measured", value: covered / total };
|
||||
return { counts, covered, total, rate };
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// 方法.md 第七部分"什么时候扣次数""什么时候开下一轮":余额从运行记录数出来,不单独存。
|
||||
// 计算时间不是预算,这里只数运行次数。
|
||||
|
||||
/**
|
||||
* 一次正式运行在账上的状态:
|
||||
* - reserved:已经预留了名额、还没有核对出结果(含"状态未知"),先占着名额;
|
||||
* - started:确认启动,扣一次(之后正常结束、失败、超时、被取消都算);
|
||||
* - notStarted:确认没有启动,退还名额。
|
||||
*/
|
||||
export type RunState = "reserved" | "started" | "notStarted";
|
||||
|
||||
/** 余额:已扣 = started 的个数;占用 = started + reserved;余额 = 运行次数总数 − 占用。 */
|
||||
export function runBalance(
|
||||
total: number,
|
||||
runs: readonly RunState[],
|
||||
): { charged: number; reserved: number; balance: number } {
|
||||
const charged = runs.filter((r) => r === "started").length;
|
||||
const reserved = runs.filter((r) => r === "reserved").length;
|
||||
return { charged, reserved, balance: total - charged - reserved };
|
||||
}
|
||||
|
||||
/** 本轮正向的批量 k = min(每批上限, 余额)。 */
|
||||
export function forwardBatchSize(batchCap: number, balance: number): number {
|
||||
return Math.min(batchCap, balance);
|
||||
}
|
||||
|
||||
/** 消融最多测"余额 − 1"格,至少留 1 次给下一轮正向;余额为 0 或 1 时是 0。 */
|
||||
export function ablationCellLimit(balance: number): number {
|
||||
return Math.max(0, balance - 1);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import type { AblationCell } from "./ablation.ts";
|
||||
import { compareIds, improvement, NET_TOLERANCE } from "./basics.ts";
|
||||
import { ablationCellLimit } from "./budget.ts";
|
||||
import { density } from "./distribution.ts";
|
||||
import { seededSample } from "./random.ts";
|
||||
import { ablationModel, evaluateCombos, type AblationModelInput, type ComboParams, type ComboValue } from "./value.ts";
|
||||
|
||||
// 方法.md 6.7"选格"、第十一部分的四条选格规则,以及 6.8 押的因子。
|
||||
|
||||
/** 选格规则的统一输入:同一份输入可以换选格规则重算。 */
|
||||
export interface CellRuleInput extends AblationModelInput, ComboParams {
|
||||
/** 本次决定的种子(decisionSeed)。只有随机规则用它,照样记进记录。 */
|
||||
seed: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一格没测的原因:budget 剩余次数不够;value 信息价值不足以抵过成本(6.7"选格"第 3 条);
|
||||
* notDrawn 随机规则没抽中;ruleNone 规则是"都不测"。后两种只出现在对照规则里。
|
||||
*/
|
||||
export type UntestedReason = "budget" | "value" | "notDrawn" | "ruleNone";
|
||||
|
||||
/** 选格规则的统一输出,含 6.7 要求附上的全部数字;规则名和版本由登记表补上。 */
|
||||
export interface CellDecision {
|
||||
seed: string;
|
||||
params: {
|
||||
balance: number;
|
||||
batchCap: number;
|
||||
u: number;
|
||||
decay: number;
|
||||
lambda: number;
|
||||
epsilon: number;
|
||||
knownBest: number | null;
|
||||
};
|
||||
/** 总提升 G。 */
|
||||
gain: number;
|
||||
/** 剩余空间 h。 */
|
||||
space: number;
|
||||
components: string[];
|
||||
/** 起始权重,与 components 对齐。 */
|
||||
weights: number[];
|
||||
/** 各因子的事前期望方向收益 E(收益_p),按因子编号排。 */
|
||||
priorGains: Array<{ factor: string; gain: number }>;
|
||||
/** ĝ₀ = max_p E(收益_p) 的原值(成本里取 max(0, ĝ₀))。 */
|
||||
g0: number;
|
||||
/** 可选的全部组合及其价值、成本、净值(余额 ≤ 1 时为空)。 */
|
||||
combos: ComboValue[];
|
||||
/** 选中要测的格,按因子编号排。 */
|
||||
chosen: string[];
|
||||
/** 选中组合的净值;一格都不测时是空组合的 0。 */
|
||||
chosenNet: number;
|
||||
/**
|
||||
* 每一格:测不测;没测的写原因,并附"含这一格的可选组合里净值最大的那个"(按 topCombo 挑);
|
||||
* 没有任何含它的可选组合(余额 ≤ 1)时 bestCombo 为 null。
|
||||
*/
|
||||
cells: Array<{
|
||||
factor: string;
|
||||
tested: boolean;
|
||||
reason: UntestedReason | null;
|
||||
bestCombo: { factors: string[]; net: number } | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** 四条选格规则共用:建模型、列组合,再按 pick 选出的格和没测原因拼成决定。 */
|
||||
function decide(
|
||||
input: CellRuleInput,
|
||||
pick: (ctx: { factors: string[]; combos: ComboValue[]; limit: number }) => {
|
||||
chosen: string[];
|
||||
reasonFor: (factor: string) => UntestedReason;
|
||||
},
|
||||
): CellDecision {
|
||||
const model = ablationModel(input);
|
||||
const { g0, combos } = evaluateCombos(model, input);
|
||||
const { chosen, reasonFor } = pick({ factors: model.factors, combos, limit: ablationCellLimit(input.balance) });
|
||||
const key = chosen.join(",");
|
||||
const bestComboOf = (factor: string): { factors: string[]; net: number } | null => {
|
||||
const best = topCombo(combos.filter((c) => c.factors.includes(factor)));
|
||||
return best === null ? null : { factors: [...best.factors], net: best.net };
|
||||
};
|
||||
return {
|
||||
seed: input.seed,
|
||||
params: {
|
||||
balance: input.balance,
|
||||
batchCap: input.batchCap,
|
||||
u: input.u,
|
||||
decay: input.decay,
|
||||
lambda: input.lambda,
|
||||
epsilon: input.epsilon,
|
||||
knownBest: input.knownBest,
|
||||
},
|
||||
gain: model.gain,
|
||||
space: model.space,
|
||||
components: model.components,
|
||||
weights: model.weights,
|
||||
priorGains: model.factors.map((factor, c) => ({ factor, gain: model.space * model.prior[c]! })),
|
||||
g0,
|
||||
combos,
|
||||
chosen,
|
||||
chosenNet: chosen.length === 0 ? 0 : netOf(combos, key),
|
||||
cells: model.factors.map((factor) => {
|
||||
const tested = chosen.includes(factor);
|
||||
return {
|
||||
factor,
|
||||
tested,
|
||||
reason: tested ? null : reasonFor(factor),
|
||||
bestCombo: tested ? null : bestComboOf(factor),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 选中组合的净值。选中的组合一定在组合表里,找不到是内部错误。 */
|
||||
function netOf(combos: readonly ComboValue[], key: string): number {
|
||||
const c = combos.find((x) => x.factors.join(",") === key);
|
||||
if (c === undefined) throw new Error(`选格(方法.md 6.7"选格"):选中的组合 {${key}} 不在可选组合表里`);
|
||||
return c.net;
|
||||
}
|
||||
|
||||
/**
|
||||
* 净值最大的组合(6.7"选格"第 2 条的比较规则):求最大净值 M;净值 ≥ M − 1e-12 的组合视为和 M 相同,
|
||||
* 在它们里先取格数少的,再按因子编号逐个比、取小的。没有组合时为 null。
|
||||
* "含这一格的净值最大组合"直接按这条挑(只是记录,不要求净值 > 1e-12)。
|
||||
*/
|
||||
export function topCombo<C extends { factors: readonly string[]; net: number }>(combos: readonly C[]): C | null {
|
||||
if (combos.length === 0) return null;
|
||||
const top = Math.max(...combos.map((c) => c.net));
|
||||
const near = combos.filter((c) => c.net >= top - NET_TOLERANCE);
|
||||
return near.reduce((best, c) => (compareCombos(c.factors, best.factors) < 0 ? c : best));
|
||||
}
|
||||
|
||||
/** 格数少的在前;格数相同时按因子编号逐个比。 */
|
||||
function compareCombos(a: readonly string[], b: readonly string[]): number {
|
||||
if (a.length !== b.length) return a.length - b.length;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const c = compareIds(a[i]!, b[i]!);
|
||||
if (c !== 0) return c;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按净值挑要测的组合(6.7"选格"第 2 条):先只留净值 > 1e-12 的组合,一个都没有就不测;
|
||||
* 在留下的组合里按 topCombo 挑。组合里的因子按编号排好传进来。
|
||||
*/
|
||||
export function pickByNet(combos: ReadonlyArray<{ factors: readonly string[]; net: number }>): string[] {
|
||||
const best = topCombo(combos.filter((c) => c.net > NET_TOLERANCE));
|
||||
return best === null ? [] : [...best.factors];
|
||||
}
|
||||
|
||||
/**
|
||||
* 选格规则"信息价值减成本"(6.7"选格"):按 pickByNet 选。没选中的格:已选的再加这一格超过"余额 − 1"
|
||||
* 记"剩余次数不够",否则记"信息价值不足以抵过成本"。
|
||||
*/
|
||||
export function selectByValue(input: CellRuleInput): CellDecision {
|
||||
return decide(input, ({ combos, limit }) => {
|
||||
const chosen = pickByNet(combos);
|
||||
return { chosen, reasonFor: () => (chosen.length + 1 > limit ? "budget" : "value") };
|
||||
});
|
||||
}
|
||||
|
||||
/** 选格规则"全测"(第十一部分):按因子编号测前"余额 − 1"格,放不下的记"剩余次数不够"。 */
|
||||
export function selectAll(input: CellRuleInput): CellDecision {
|
||||
return decide(input, ({ factors, limit }) => ({ chosen: factors.slice(0, limit), reasonFor: () => "budget" }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 选格规则"随机抽同样多的格"(第十一部分):格数 = "信息价值减成本"对同一输入会选的格数,
|
||||
* 从全部格里用种子不放回地均匀抽。没抽中的记 notDrawn。
|
||||
*/
|
||||
export function selectRandomSameCount(input: CellRuleInput): CellDecision {
|
||||
const count = selectByValue(input).chosen.length;
|
||||
return decide(input, ({ factors }) => ({
|
||||
chosen: seededSample(factors, count, input.seed),
|
||||
reasonFor: () => "notDrawn",
|
||||
}));
|
||||
}
|
||||
|
||||
/** 选格规则"都不测"(第十一部分):全部记没测,原因 ruleNone。 */
|
||||
export function selectNone(input: CellRuleInput): CellDecision {
|
||||
return decide(input, () => ({ chosen: [], reasonFor: () => "ruleNone" }));
|
||||
}
|
||||
|
||||
/** 押的因子(6.8)的结果。 */
|
||||
export interface BetResult {
|
||||
/** 更新后的成分权重(与模型的成分对齐);无从更新时等于起始权重。 */
|
||||
weights: number[];
|
||||
/** 实测落在所有成分的范围之外、权重之和为 0 时为 true,此时保持起始权重。 */
|
||||
weightsKept: boolean;
|
||||
/** 各因子的方向收益(按因子编号排):measured 为 true 的是有效格实测换算的,其余是按更新后的权重估的。 */
|
||||
gains: Array<{ factor: string; gain: number; measured: boolean }>;
|
||||
/** 方向收益最大的因子(严格更大才替换,平局取编号小的),以及它的预测收益。不押时照样记。 */
|
||||
factor: string;
|
||||
predictedGain: number;
|
||||
/** bet 押;offBetter 任一有效格撑几成 < 0;notPositive 挑出的因子估计收益不为正。 */
|
||||
decision: "bet" | "offBetter" | "notPositive";
|
||||
/** 撑几成 < 0 的有效格:关掉它比完整代码好多少(按方向折算,正数),按因子编号排。 */
|
||||
offBetter: Array<{ factor: string; better: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 押的因子(6.8):成分新权重 ∝ 起始权重 × 各有效格(只关一个因子)上这个成分在实测处的密度(分数原值上,连乘);
|
||||
* 有效格的收益用实测换算,其余格(没测、证据不足、跑不起来、一起关)用新权重对各成分的期望收益加权平均;
|
||||
* "一起关"的分数不参与更新。权重之和为 0 时保持起始权重。
|
||||
*/
|
||||
export function chooseBet(input: AblationModelInput & { cells: readonly AblationCell[] }): BetResult {
|
||||
const model = ablationModel(input);
|
||||
const scores = model.factors.map((f) => {
|
||||
const o = input.cells.find((c) => c.factor === f)?.outcome;
|
||||
return o?.kind === "valid" ? o.score : null;
|
||||
});
|
||||
const raw = model.weights.map((w, j) =>
|
||||
scores.reduce<number>((v, x, c) => (x === null ? v : v * density(model.rawKnots[c]![j]!, x)), w),
|
||||
);
|
||||
const sum = raw.reduce((t, v) => t + v, 0);
|
||||
const weightsKept = sum === 0;
|
||||
const weights = weightsKept ? [...model.weights] : raw.map((v) => v / sum);
|
||||
const gains = model.factors.map((factor, c) => {
|
||||
const x = scores[c];
|
||||
const gain =
|
||||
x !== null && x !== undefined
|
||||
? model.space * (improvement(x, input.full, input.direction) / model.gain)
|
||||
: model.space * model.expected[c]!.reduce((t, e, j) => t + weights[j]! * e, 0);
|
||||
return { factor, gain, measured: x !== null };
|
||||
});
|
||||
let best = 0;
|
||||
for (let c = 1; c < gains.length; c++) if (gains[c]!.gain > gains[best]!.gain) best = c;
|
||||
const offBetter = model.factors.flatMap((factor, c) => {
|
||||
const x = scores[c];
|
||||
if (x === null || x === undefined) return [];
|
||||
const better = improvement(input.full, x, input.direction);
|
||||
return better > 0 ? [{ factor, better }] : [];
|
||||
});
|
||||
const predictedGain = gains[best]!.gain;
|
||||
return {
|
||||
weights,
|
||||
weightsKept,
|
||||
gains,
|
||||
factor: gains[best]!.factor,
|
||||
predictedGain,
|
||||
decision: offBetter.length > 0 ? "offBetter" : predictedGain > 0 ? "bet" : "notPositive",
|
||||
offBetter,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import type { Direction, Range } from "../vocab/index.ts";
|
||||
import { compareIds, improvement } from "./basics.ts";
|
||||
|
||||
// 方法.md 6.1–6.3:三个数 → 一个人的折线;开放预留;合并分布;从合并分布读一成、一半、九成。
|
||||
// 另有积分 ∫[1 − ∏F] 的逐段多项式精确算法,6.4 的批内最好期望和 6.7 全测时的信息价值共用。
|
||||
|
||||
/** 折线上的一个点:(横坐标 x, "低于 x 的把握" y)。 */
|
||||
export type Point = readonly [number, number];
|
||||
/** 一条折线:点按横坐标严格递增排列,纵坐标从 0 升到 1。折线连续,每一段宽度都大于 0。 */
|
||||
export type Knots = readonly Point[];
|
||||
/** 合并分布的一个成分:一条折线和它的权重。 */
|
||||
export interface Component {
|
||||
weight: number;
|
||||
knots: Knots;
|
||||
}
|
||||
/** 合并分布:成分的加权和,权重加起来是 1。开放预留也是一个成分(它的折线是两点的均匀段)。 */
|
||||
export type Mixture = readonly Component[];
|
||||
|
||||
/**
|
||||
* 一个人的折线(6.2):(q1 − (q5 − q1), 0)、(q1, 0.1)、(q5, 0.5)、(q9, 0.9)、(q9 + (q9 − q5), 1)。
|
||||
* 三个数严格递增(方法.md 第五部分),五个点的横坐标也严格递增。
|
||||
*/
|
||||
export function personKnots(r: Range): Knots {
|
||||
return [
|
||||
[r.q1 - (r.q5 - r.q1), 0],
|
||||
[r.q1, 0.1],
|
||||
[r.q5, 0.5],
|
||||
[r.q9, 0.9],
|
||||
[r.q9 + (r.q9 - r.q5), 1],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* "低于 x 的把握" F(x)。折线连续,低于和不高于 x 的把握相同。取最后一个横坐标 < x 的点 i:没有就是 0,是末点就是 1,
|
||||
* 否则在 i、i+1 之间线性插值。
|
||||
*/
|
||||
export function below(k: Knots, x: number): number {
|
||||
let i = -1;
|
||||
for (let j = 0; j < k.length; j++) if (k[j]![0] < x) i = j;
|
||||
if (i < 0) return 0;
|
||||
if (i === k.length - 1) return 1;
|
||||
const [x0, y0] = k[i]!;
|
||||
const [x1, y1] = k[i + 1]!;
|
||||
return y0 + ((y1 - y0) * (x - x0)) / (x1 - x0);
|
||||
}
|
||||
|
||||
/** 折线的期望 Σ (y_{i+1} − y_i)(x_i + x_{i+1})/2:每段把握均匀摊开,期望是段中点。 */
|
||||
export function knotsMean(k: Knots): number {
|
||||
let m = 0;
|
||||
for (let i = 0; i + 1 < k.length; i++) m += (k[i + 1]![1] - k[i]![1]) * ((k[i]![0] + k[i + 1]![0]) / 2);
|
||||
return m;
|
||||
}
|
||||
|
||||
/** 折线在 x 处的密度:所在段的斜率,段取左闭右开 [x0, x1),落在折点上时取右边那一段;范围外是 0。 */
|
||||
export function density(k: Knots, x: number): number {
|
||||
for (let i = 0; i + 1 < k.length; i++) {
|
||||
const [x0, y0] = k[i]!;
|
||||
const [x1, y1] = k[i + 1]!;
|
||||
if (x0 <= x && x < x1) return (y1 - y0) / (x1 - x0);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把折线搬到另一把刻度上:每个横坐标经 f 换算。f 递减时点的次序倒过来,把握换成 1 − 原把握
|
||||
* (P(f(X) < t) = P(X > f⁻¹(t)))。
|
||||
*/
|
||||
export function mapKnots(k: Knots, f: (x: number) => number, decreasing: boolean): Knots {
|
||||
const mapped = k.map(([x, y]) => [f(x), y] as const);
|
||||
return decreasing ? mapped.reverse().map(([x, y]) => [x, 1 - y] as const) : mapped;
|
||||
}
|
||||
|
||||
/** 开放预留(6.3):取给定全部折线(含尾巴)的最低点 L、最高点 U,W = U − L,在 [L − W/2, U + W/2] 上均匀铺开。 */
|
||||
export function reserveKnots(lines: readonly Knots[]): Knots {
|
||||
const L = Math.min(...lines.map((k) => k[0]![0]));
|
||||
const U = Math.max(...lines.map((k) => k[k.length - 1]![0]));
|
||||
const W = U - L;
|
||||
return [
|
||||
[L - W / 2, 0],
|
||||
[U + W / 2, 1],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 研究者之间等权、同一个人的几条平分(6.3"合并"、6.7"成分与权重")。
|
||||
* authors 是每条解释(或每个范围)的作者,返回的占比与之对齐,加起来是 1;分母是不同作者的人数。
|
||||
*/
|
||||
export function authorShares(authors: readonly string[]): number[] {
|
||||
const people = new Set(authors).size;
|
||||
const count = new Map<string, number>();
|
||||
for (const a of authors) count.set(a, (count.get(a) ?? 0) + 1);
|
||||
return authors.map((a) => 1 / people / count.get(a)!);
|
||||
}
|
||||
|
||||
export function mixtureBelow(m: Mixture, x: number): number {
|
||||
let s = 0;
|
||||
for (const c of m) s += c.weight * below(c.knots, x);
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 合并分布全部折点的横坐标(含开放预留两端),去重、升序。 */
|
||||
export function breakpoints(m: Mixture): number[] {
|
||||
return [...new Set(m.flatMap((c) => c.knots.map(([x]) => x)))].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** 每个成分的折线都搬到另一把刻度上,权重不变(见 mapKnots)。 */
|
||||
export function mapMixture(m: Mixture, f: (x: number) => number, decreasing: boolean): Mixture {
|
||||
return m.map((c) => ({ weight: c.weight, knots: mapKnots(c.knots, f, decreasing) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 正向的合并分布(6.3"正向"、"合并"),按分数原值:每个候选 F = (1 − λ) × 各人折线的平均 + λ × 开放预留。
|
||||
* 开放预留由这次选批里全部候选、全部人的折线一起定,所有候选共用。各人按研究者编号排好再合并,
|
||||
* 求和次序与传入次序无关,重算时逐位相同。返回值与 candidates 对齐。
|
||||
*/
|
||||
export function forwardMixtures(
|
||||
candidates: ReadonlyArray<{ ranges: ReadonlyArray<{ researcher: string; range: Range }> }>,
|
||||
lambda: number,
|
||||
): Mixture[] {
|
||||
const lines = candidates.map((c) =>
|
||||
[...c.ranges].sort((a, b) => compareIds(a.researcher, b.researcher)).map((r) => personKnots(r.range)),
|
||||
);
|
||||
const reserve = reserveKnots(lines.flat());
|
||||
return lines.map((people) => [
|
||||
...people.map((knots) => ({ weight: (1 - lambda) / people.length, knots })),
|
||||
{ weight: lambda, knots: reserve },
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 消融一格的合并分布(6.3"消融"、"合并"),按分数原值:这一格的全部解释按 authorShares 加权占 1 − λ,
|
||||
* 这一格自己的开放预留占 λ(L、U 只取这一格的解释)。成分按解释编号排(求和次序由编号定,与传入次序无关,
|
||||
* 重算时逐位相同),开放预留在最后。
|
||||
*/
|
||||
export function ablationCellMixture(
|
||||
entries: ReadonlyArray<{ id: string; author: string; range: Range }>,
|
||||
lambda: number,
|
||||
): Mixture {
|
||||
const sorted = [...entries].sort((a, b) => compareIds(a.id, b.id));
|
||||
const lines = sorted.map((e) => personKnots(e.range));
|
||||
const shares = authorShares(sorted.map((e) => e.author));
|
||||
return [
|
||||
...lines.map((knots, i) => ({ weight: (1 - lambda) * shares[i]!, knots })),
|
||||
{ weight: lambda, knots: reserveKnots(lines) },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 换算成改进量 Y = improvement(b, 分数)(6.1)。越小越好时 Y = b − 分数,刻度反向,一成和九成随之对调。
|
||||
* 先补尾巴、定预留再换算,和先换算三个数再补尾巴结果相同(尾巴两侧按同侧半宽补,是镜像)。
|
||||
*/
|
||||
export function toImprovement(m: Mixture, b: number, direction: Direction): Mixture {
|
||||
return mapMixture(m, (x) => improvement(b, x, direction), direction === "minimize");
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并分布的分位点 inf{x : F(x) ≥ p},0 < p < 1。F 在相邻折点之间是一次函数,找到第一个 F ≥ p 的折点,
|
||||
* 在它和前一个折点之间反解;几条折线之间有空当时 F 走平,取平段的左端。
|
||||
* 只给人看"大家合起来的范围",不参与任何决定。
|
||||
*/
|
||||
export function mixtureQuantile(m: Mixture, p: number): number {
|
||||
const xs = breakpoints(m);
|
||||
let prevX = xs[0]!;
|
||||
let prevF = 0;
|
||||
for (let i = 1; i < xs.length; i++) {
|
||||
const x = xs[i]!;
|
||||
const f = mixtureBelow(m, x);
|
||||
if (f >= p) return prevX + ((p - prevF) / (f - prevF)) * (x - prevX);
|
||||
prevX = x;
|
||||
prevF = f;
|
||||
}
|
||||
return prevX;
|
||||
}
|
||||
|
||||
/** 合并分布的一成、一半、九成,按分数原值(消融格用)。 */
|
||||
export function mixtureRange(m: Mixture): Range {
|
||||
return { q1: mixtureQuantile(m, 0.1), q5: mixtureQuantile(m, 0.5), q9: mixtureQuantile(m, 0.9) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 正向候选"大家合起来的范围"(6.1、6.3):在改进量上求一成、一半、九成,再换回分数原值;
|
||||
* 越小越好时换回后一成和九成对调。
|
||||
*/
|
||||
export function forwardMergedRange(imp: Mixture, b: number, direction: Direction): Range {
|
||||
const y1 = mixtureQuantile(imp, 0.1);
|
||||
const y5 = mixtureQuantile(imp, 0.5);
|
||||
const y9 = mixtureQuantile(imp, 0.9);
|
||||
return direction === "maximize"
|
||||
? { q1: b + y1, q5: b + y5, q9: b + y9 }
|
||||
: { q1: b - y9, q5: b - y5, q9: b - y1 };
|
||||
}
|
||||
|
||||
/** 多项式相乘,系数从常数项开始。 */
|
||||
function polyMul(a: readonly number[], b: readonly number[]): number[] {
|
||||
const out = new Array<number>(a.length + b.length - 1).fill(0);
|
||||
for (let i = 0; i < a.length; i++) for (let j = 0; j < b.length; j++) out[i + j]! += a[i]! * b[j]!;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* ∫_from^top [1 − ∏_d F_d(t)] dt,F_d 是各分布"低于 t 的把握",top 是全部折点里最大的(再往上被积函数是 0)。
|
||||
* 切点是全部分布的折点(含开放预留两端)里大于 from 的部分,加上 from 本身。每段内各 F_d 是一次函数,
|
||||
* 由两端的值定出;乘积是多项式,按系数精确积分。
|
||||
* 用在 6.4 的批内最好期望(from = 0,改进量上)和 6.7 全测时一个成分下各格最大值的期望。
|
||||
*/
|
||||
export function integrateUpperTail(ds: readonly Mixture[], from: number): number {
|
||||
const xs = [...new Set(ds.flatMap(breakpoints))].sort((a, b) => a - b);
|
||||
const edges = [from, ...xs.filter((x) => x > from)];
|
||||
let total = 0;
|
||||
for (let i = 0; i + 1 < edges.length; i++) {
|
||||
const a = edges[i]!;
|
||||
const b = edges[i + 1]!;
|
||||
const h = b - a;
|
||||
let poly = [1];
|
||||
for (const d of ds) {
|
||||
const left = mixtureBelow(d, a);
|
||||
const right = mixtureBelow(d, b);
|
||||
poly = polyMul(poly, [left, (right - left) / h]);
|
||||
}
|
||||
// ∫₀ʰ (1 − Σ c_k t^k) dt
|
||||
let integral = h;
|
||||
for (let k = 0; k < poly.length; k++) integral -= (poly[k]! * h ** (k + 1)) / (k + 1);
|
||||
total += integral;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { compareIds } from "./basics.ts";
|
||||
|
||||
// 方法.md 4.2"分配规则":第零轮按志愿一轮一轮分入口。
|
||||
|
||||
export interface EntryAssignment {
|
||||
researcher: string;
|
||||
entry: string;
|
||||
/** 第几志愿(从 1 数);按第 3 条分到剩下入口的人为 null。 */
|
||||
rank: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 入口分配(4.2)。研究者和入口都先按编号(compareIds,自然序)排好,"编号最小"按编号判,与传入的数组次序无关。
|
||||
* 1. 第 k 轮里还没分到的人看自己的第 k 志愿:入口还空着、只有他一个人选,就分给他;
|
||||
* 2. 几个人同时选了同一个空着的入口:作者在其中就分给作者,否则分给编号最小的人;
|
||||
* 3. 志愿用完还没分到的人,按编号从小到大,各拿剩下入口里编号最小的一个。
|
||||
* N = 1 时第 1 轮就把第一志愿分给他,不需要另写分支。结果按研究者编号排。
|
||||
*/
|
||||
export function assignEntries(input: {
|
||||
researchers: ReadonlyArray<{ id: string; choices: readonly string[] }>;
|
||||
entries: ReadonlyArray<{ id: string; author: string }>;
|
||||
}): EntryAssignment[] {
|
||||
const researchers = [...input.researchers].sort((a, b) => compareIds(a.id, b.id));
|
||||
const entries = [...input.entries].sort((a, b) => compareIds(a.id, b.id));
|
||||
const authorOf = new Map(entries.map((e) => [e.id, e.author] as const));
|
||||
const taken = new Set<string>();
|
||||
const result = new Map<string, EntryAssignment>();
|
||||
const rounds = Math.max(0, ...researchers.map((r) => r.choices.length));
|
||||
for (let k = 0; k < rounds; k++) {
|
||||
// 本轮各个空着的入口被谁选了;选的人按研究者编号次序加入,所以 who[0] 是编号最小的。
|
||||
const wants = new Map<string, string[]>();
|
||||
for (const r of researchers) {
|
||||
const choice = r.choices[k];
|
||||
if (result.has(r.id) || choice === undefined || taken.has(choice)) continue;
|
||||
wants.set(choice, [...(wants.get(choice) ?? []), r.id]);
|
||||
}
|
||||
for (const [entry, who] of wants) {
|
||||
const author = authorOf.get(entry)!;
|
||||
const winner = who.includes(author) ? author : who[0]!;
|
||||
taken.add(entry);
|
||||
result.set(winner, { researcher: winner, entry, rank: k + 1 });
|
||||
}
|
||||
}
|
||||
const left = entries.filter((e) => !taken.has(e.id));
|
||||
for (const r of researchers) {
|
||||
if (result.has(r.id)) continue;
|
||||
const e = left.shift()!;
|
||||
result.set(r.id, { researcher: r.id, entry: e.id, rank: null });
|
||||
}
|
||||
return researchers.map((r) => result.get(r.id)!);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { Direction, Range } from "../vocab/index.ts";
|
||||
import { combinations, compareIds, epsilonFloor, improvement } from "./basics.ts";
|
||||
import { forwardBatchSize } from "./budget.ts";
|
||||
import {
|
||||
forwardMergedRange,
|
||||
forwardMixtures,
|
||||
integrateUpperTail,
|
||||
mixtureBelow,
|
||||
toImprovement,
|
||||
type Mixture,
|
||||
} from "./distribution.ts";
|
||||
import { seededSample } from "./random.ts";
|
||||
|
||||
// 方法.md 6.4 入池、选批、分数尺读数;6.5 采纳线;4.3 ⑫ 采纳。
|
||||
|
||||
/** 选批规则的统一输入:同一份输入可以换选批规则重算。 */
|
||||
export interface BatchInput {
|
||||
/** 本轮的全部候选(入池在规则里判);ranges 是每位研究者第二次预测的三个数(分数原值),一人一份,带研究者编号。 */
|
||||
candidates: ReadonlyArray<{ id: string; ranges: ReadonlyArray<{ researcher: string; range: Range }> }>;
|
||||
/** 本批基线分数 b。 */
|
||||
baseline: number;
|
||||
direction: Direction;
|
||||
epsilon: number;
|
||||
/** 开放预留 λ。 */
|
||||
lambda: number;
|
||||
batchCap: number;
|
||||
/** 运行次数余额。 */
|
||||
balance: number;
|
||||
/** 本次决定的种子(decisionSeed)。批内最好期望规则不用它,照样记进记录。 */
|
||||
seed: string;
|
||||
}
|
||||
|
||||
/** 选批规则的统一输出,含 6.4"选批记录写入"的全部内容;规则名和版本由登记表补上。 */
|
||||
export interface BatchDecision {
|
||||
seed: string;
|
||||
params: { epsilon: number; lambda: number; batchCap: number; balance: number };
|
||||
/** 批量 k = min(每批上限, 余额)。 */
|
||||
k: number;
|
||||
/** 每个候选(按编号排):大家合起来的范围(分数原值)、"比 b 低 ε 以上"的把握、是否入池。 */
|
||||
candidates: Array<{ id: string; merged: Range; lossProbability: number; inPool: boolean }>;
|
||||
/** 入池的候选编号(按编号排)。 */
|
||||
pool: string[];
|
||||
/** 列出的每个组合及其批内最好期望 V(均匀随机规则不列组合,是空数组)。 */
|
||||
combos: Array<{ ids: string[]; value: number }>;
|
||||
/** 选中的组合(按编号排)。 */
|
||||
chosen: string[];
|
||||
/** V(S*)。 */
|
||||
chosenValue: number;
|
||||
/** E[max(0, max_{c∈池} Y_c)] = V(候选池)。 */
|
||||
poolValue: number;
|
||||
/** 分数尺读数 = V(候选池) − V(S*),只记录。 */
|
||||
scaleReading: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批内最好期望 V(S) = ∫_0^∞ [1 − ∏_{c∈S} F_c(t)] dt(6.4),F_c 是改进量上的合并分布,候选之间按互不相关,
|
||||
* 低于 b 的部分按 0 算。空组合是 0。
|
||||
*/
|
||||
export function batchValue(imps: readonly Mixture[]): number {
|
||||
return imps.length === 0 ? 0 : integrateUpperTail(imps, 0);
|
||||
}
|
||||
|
||||
/** 入池(6.4):合并分布里"比 b 低 ε 以上"的把握 P(Y < −ε) 严格小于一半才入池;正好低 ε 不算"低 ε 以上"。 */
|
||||
export function lossProbability(imp: Mixture, epsilon: number): number {
|
||||
return mixtureBelow(imp, -epsilon);
|
||||
}
|
||||
|
||||
/** 两条选批规则共用的部分:合并、入池、合并范围、批量、候选池的 V。 */
|
||||
function assess(input: BatchInput) {
|
||||
const sorted = [...input.candidates].sort((a, b) => compareIds(a.id, b.id));
|
||||
const imps = forwardMixtures(sorted, input.lambda).map((m) => toImprovement(m, input.baseline, input.direction));
|
||||
const candidates = sorted.map((c, i) => {
|
||||
const loss = lossProbability(imps[i]!, input.epsilon);
|
||||
return {
|
||||
id: c.id,
|
||||
merged: forwardMergedRange(imps[i]!, input.baseline, input.direction),
|
||||
lossProbability: loss,
|
||||
inPool: loss < 0.5,
|
||||
};
|
||||
});
|
||||
const poolIdx = candidates.flatMap((c, i) => (c.inPool ? [i] : []));
|
||||
return {
|
||||
imps,
|
||||
candidates,
|
||||
poolIdx,
|
||||
k: forwardBatchSize(input.batchCap, input.balance),
|
||||
poolValue: batchValue(poolIdx.map((i) => imps[i]!)),
|
||||
params: { epsilon: input.epsilon, lambda: input.lambda, batchCap: input.batchCap, balance: input.balance },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 选批规则"批内最好期望"(6.4):列出候选池里所有 min(k, 池里个数) 个候选的组合,取 V 最大的;
|
||||
* V 完全相等(不带容差)时取编号字典序小的组合(组合按字典序列出,后来者严格更大才替换)。
|
||||
*/
|
||||
export function selectBestExpected(input: BatchInput): BatchDecision {
|
||||
const a = assess(input);
|
||||
const size = Math.min(a.k, a.poolIdx.length);
|
||||
const picks = size === 0 ? [] : combinations(a.poolIdx.length, size);
|
||||
const combos = picks.map((pick) => {
|
||||
const idx = pick.map((p) => a.poolIdx[p]!);
|
||||
return { ids: idx.map((i) => a.candidates[i]!.id), value: batchValue(idx.map((i) => a.imps[i]!)) };
|
||||
});
|
||||
// 池空或批量为 0 时一个组合都没有,S* 是空组合,V = 0。
|
||||
let best = combos[0] ?? { ids: [], value: 0 };
|
||||
for (const c of combos) if (c.value > best.value) best = c;
|
||||
return {
|
||||
seed: input.seed,
|
||||
params: a.params,
|
||||
k: a.k,
|
||||
candidates: a.candidates,
|
||||
pool: a.poolIdx.map((i) => a.candidates[i]!.id),
|
||||
combos,
|
||||
chosen: best.ids,
|
||||
chosenValue: best.value,
|
||||
poolValue: a.poolValue,
|
||||
scaleReading: a.poolValue - best.value,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 选批规则"均匀随机"(第十一部分):从全部候选里(不看入池门槛)不放回地均匀抽 min(k, 候选数) 个。
|
||||
* 入池、合并范围、分数尺读数照算照记;S* 是抽中的组合,可能含池外候选。
|
||||
*/
|
||||
export function selectUniformRandom(input: BatchInput): BatchDecision {
|
||||
const a = assess(input);
|
||||
const ids = a.candidates.map((c) => c.id);
|
||||
const chosen = seededSample(ids, Math.min(a.k, ids.length), input.seed);
|
||||
const chosenValue = batchValue(chosen.map((id) => a.imps[ids.indexOf(id)]!));
|
||||
return {
|
||||
seed: input.seed,
|
||||
params: a.params,
|
||||
k: a.k,
|
||||
candidates: a.candidates,
|
||||
pool: a.poolIdx.map((i) => a.candidates[i]!.id),
|
||||
combos: [],
|
||||
chosen,
|
||||
chosenValue,
|
||||
poolValue: a.poolValue,
|
||||
scaleReading: a.poolValue - chosenValue,
|
||||
};
|
||||
}
|
||||
|
||||
/** 采纳线的输入(6.5):B 是当前基线分数,K* 是已知最好(没有时 null)。 */
|
||||
export interface AdoptionLineInput {
|
||||
baseline: number;
|
||||
knownBest: number | null;
|
||||
epsilon: number;
|
||||
direction: Direction;
|
||||
}
|
||||
|
||||
/**
|
||||
* 采纳线(6.5):没有已知最好时是 ε;有时是 min(ε, (K* − B) ÷ 4)(按方向折算),结果低于 ε/100 时取 ε/100,
|
||||
* B 已经超过 K*((K* − B) 为负)时同样取 ε/100。quarter 是 (K* − B) ÷ 4 的原值,没有已知最好时为 null。
|
||||
*/
|
||||
export function adoptionLine(input: AdoptionLineInput): { line: number; quarter: number | null } {
|
||||
if (input.knownBest === null) return { line: input.epsilon, quarter: null };
|
||||
const quarter = improvement(input.baseline, input.knownBest, input.direction) / 4;
|
||||
return { line: Math.max(epsilonFloor(input.epsilon), Math.min(input.epsilon, quarter)), quarter };
|
||||
}
|
||||
|
||||
/**
|
||||
* 采纳(6.5、4.3 ⑫):只看实测分数。candidates 是有有效分数的正向候选;最好的那个(按方向,同分取编号小的)
|
||||
* 比基线的改进严格大于采纳线才采纳。
|
||||
*/
|
||||
export function adopt(
|
||||
input: AdoptionLineInput & { candidates: ReadonlyArray<{ id: string; score: number }> },
|
||||
): {
|
||||
line: number;
|
||||
quarter: number | null;
|
||||
best: { id: string; score: number; improvement: number } | null;
|
||||
adopted: string | null;
|
||||
} {
|
||||
const { line, quarter } = adoptionLine(input);
|
||||
let best: { id: string; score: number; improvement: number } | null = null;
|
||||
for (const c of [...input.candidates].sort((x, y) => compareIds(x.id, y.id))) {
|
||||
const gain = improvement(input.baseline, c.score, input.direction);
|
||||
if (best === null || gain > best.improvement) best = { id: c.id, score: c.score, improvement: gain };
|
||||
}
|
||||
return { line, quarter, best, adopted: best !== null && best.improvement > line ? best.id : null };
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// rules:方法.md 第六部分的全部公式和常数、第七部分的余额、4.2 的入口分配(docs/结构.md 第二部分)。
|
||||
// 纯计算:输入数字,输出数字,不碰文件、数据库、模型,也不用全局随机数。
|
||||
export {
|
||||
combinations,
|
||||
compareIds,
|
||||
epsilonFloor,
|
||||
GRID_COMBINATION_CAP,
|
||||
GRID_SUBDIVISIONS,
|
||||
improvement,
|
||||
INDISPENSABLE_SUPPORT,
|
||||
INTERACTION_CUT,
|
||||
NET_TOLERANCE,
|
||||
} from "./basics.ts";
|
||||
export {
|
||||
ablationCellMixture,
|
||||
authorShares,
|
||||
below,
|
||||
breakpoints,
|
||||
density,
|
||||
forwardMergedRange,
|
||||
forwardMixtures,
|
||||
integrateUpperTail,
|
||||
knotsMean,
|
||||
mapKnots,
|
||||
mapMixture,
|
||||
mixtureBelow,
|
||||
mixtureQuantile,
|
||||
mixtureRange,
|
||||
personKnots,
|
||||
reserveKnots,
|
||||
toImprovement,
|
||||
type Component,
|
||||
type Knots,
|
||||
type Mixture,
|
||||
type Point,
|
||||
} from "./distribution.ts";
|
||||
export {
|
||||
adopt,
|
||||
adoptionLine,
|
||||
batchValue,
|
||||
lossProbability,
|
||||
selectBestExpected,
|
||||
selectUniformRandom,
|
||||
type AdoptionLineInput,
|
||||
type BatchDecision,
|
||||
type BatchInput,
|
||||
} from "./forward.ts";
|
||||
export {
|
||||
ablationFinding,
|
||||
inseparableGroups,
|
||||
judgeExplanations,
|
||||
listCells,
|
||||
settleAblation,
|
||||
sharedSettings,
|
||||
supportOf,
|
||||
synergyLabel,
|
||||
totalGain,
|
||||
type AblationCell,
|
||||
type AblationExplanation,
|
||||
type CellOutcome,
|
||||
type SettleInput,
|
||||
type SynergyLabel,
|
||||
type ExplanationVerdict,
|
||||
} from "./ablation.ts";
|
||||
export {
|
||||
ablationModel,
|
||||
cellGrid,
|
||||
evaluateCombos,
|
||||
gridSize,
|
||||
informationValue,
|
||||
informationValueExact,
|
||||
informationValueGrid,
|
||||
measuredGrid,
|
||||
OPEN_RESERVE,
|
||||
remainingSpace,
|
||||
type AblationModel,
|
||||
type AblationModelInput,
|
||||
type ComboParams,
|
||||
type ComboValue,
|
||||
} from "./value.ts";
|
||||
export {
|
||||
chooseBet,
|
||||
pickByNet,
|
||||
selectAll,
|
||||
selectByValue,
|
||||
selectNone,
|
||||
selectRandomSameCount,
|
||||
topCombo,
|
||||
type BetResult,
|
||||
type CellDecision,
|
||||
type CellRuleInput,
|
||||
type UntestedReason,
|
||||
} from "./cells.ts";
|
||||
export {
|
||||
bitWidth,
|
||||
cellIndex,
|
||||
coverage,
|
||||
covers,
|
||||
disagreement,
|
||||
entropyBits,
|
||||
placement,
|
||||
surprise,
|
||||
worldBits,
|
||||
type Placement,
|
||||
} from "./bits.ts";
|
||||
export { ablationCellLimit, forwardBatchSize, runBalance, type RunState } from "./budget.ts";
|
||||
export { authorVsMerged, trackRecord, type Phase, type Tally } from "./record.ts";
|
||||
export { assignEntries, type EntryAssignment } from "./entries.ts";
|
||||
export { decisionSeed, seededRandom, seededSample } from "./random.ts";
|
||||
export {
|
||||
BATCH_RULES,
|
||||
CELL_RULES,
|
||||
runBatchRule,
|
||||
runCellRule,
|
||||
type BatchRuleName,
|
||||
type CellRuleName,
|
||||
} from "./registry.ts";
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { hash } from "../vocab/index.ts";
|
||||
import { compareIds } from "./basics.ts";
|
||||
|
||||
// 方法.md 第九部分:需要随机时,种子由研究编号、轮次、版本算出;不用全局随机数,同一个种子永远得到同一串数。
|
||||
|
||||
/** 决定的种子:vocab hash({ studyId, round, version }),64 位十六进制。 */
|
||||
export function decisionSeed(input: { studyId: string; round: number; version: number }): string {
|
||||
return hash({ studyId: input.studyId, round: input.round, version: input.version });
|
||||
}
|
||||
|
||||
/**
|
||||
* 从种子确定地展开的随机整数。第 counter 个数取 sha256(`${seed}:${counter}`) 的前 6 字节(大端 48 位整数)v,
|
||||
* counter 从 0 起每取一次加 1;v 落在 n 的最大整数倍以内才收下并返回 v mod n,否则换下一个 counter(没有取模偏差)。
|
||||
*/
|
||||
export function seededRandom(seed: string): { uniformInt: (n: number) => number } {
|
||||
let counter = 0;
|
||||
const span = 2 ** 48;
|
||||
return {
|
||||
uniformInt(n: number): number {
|
||||
const limit = span - (span % n);
|
||||
for (;;) {
|
||||
const v = createHash("sha256").update(`${seed}:${counter++}`).digest().readUIntBE(0, 6);
|
||||
if (v < limit) return v % n;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 不放回地均匀抽 size 个:先按编号(compareIds,自然序)排序,再做部分 Fisher–Yates(第 i 步与 i + uniformInt(n − i) 交换),
|
||||
* 取前 size 个再按编号排序。结果只取决于种子和编号集合,跟调用方给的顺序无关。候选和因子都这样抽。
|
||||
*/
|
||||
export function seededSample(items: readonly string[], size: number, seed: string): string[] {
|
||||
const xs = [...items].sort(compareIds);
|
||||
const random = seededRandom(seed);
|
||||
for (let i = 0; i < size; i++) {
|
||||
const j = i + random.uniformInt(xs.length - i);
|
||||
[xs[i], xs[j]] = [xs[j]!, xs[i]!];
|
||||
}
|
||||
return xs.slice(0, size).sort(compareIds);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { Direction, Range } from "../vocab/index.ts";
|
||||
import { compareIds, improvement } from "./basics.ts";
|
||||
import { covers, placement, type Placement } from "./bits.ts";
|
||||
|
||||
// 方法.md 6.10 第 4 条"战绩"、6.9"按人分开、按正向与消融分开"。只用来显示,不改任何权重。
|
||||
// 一律按分数原值比,不按方向折算。
|
||||
|
||||
export type Phase = "forward" | "ablation";
|
||||
|
||||
/** 一个人的战绩。 */
|
||||
export interface Tally {
|
||||
total: number;
|
||||
/** 中位数偏高:q5 > 实测。 */
|
||||
medianHigh: number;
|
||||
/** 中位数偏低:q5 < 实测。 */
|
||||
medianLow: number;
|
||||
/** 被穿过上沿:实测 > q9。 */
|
||||
aboveTop: number;
|
||||
/** 被穿过下沿:实测 < q1。 */
|
||||
belowBottom: number;
|
||||
/** 包住:q1 ≤ 实测 ≤ q9。 */
|
||||
covered: number;
|
||||
/** 落点四段的计数。 */
|
||||
placements: Record<Placement, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 战绩:按研究者、按正向与消融分开数(6.9、6.10)。samples 是每人每次的范围(第二次预测或解释的每格)和有效实测。
|
||||
* 结果按研究者编号、再按 forward、ablation 排,只列有样本的组合。
|
||||
*/
|
||||
export function trackRecord(
|
||||
samples: ReadonlyArray<{ researcher: string; phase: Phase; range: Range; value: number }>,
|
||||
): Array<{ researcher: string; phase: Phase; tally: Tally }> {
|
||||
const groups = new Map<string, { researcher: string; phase: Phase; tally: Tally }>();
|
||||
for (const s of samples) {
|
||||
const key = `${s.researcher}\u0000${s.phase}`;
|
||||
let g = groups.get(key);
|
||||
if (g === undefined) {
|
||||
g = {
|
||||
researcher: s.researcher,
|
||||
phase: s.phase,
|
||||
tally: {
|
||||
total: 0,
|
||||
medianHigh: 0,
|
||||
medianLow: 0,
|
||||
aboveTop: 0,
|
||||
belowBottom: 0,
|
||||
covered: 0,
|
||||
placements: { belowQ1: 0, q1ToQ5: 0, q5ToQ9: 0, aboveQ9: 0 },
|
||||
},
|
||||
};
|
||||
groups.set(key, g);
|
||||
}
|
||||
const t = g.tally;
|
||||
const { range: r, value: v } = s;
|
||||
t.total += 1;
|
||||
if (r.q5 > v) t.medianHigh += 1;
|
||||
if (r.q5 < v) t.medianLow += 1;
|
||||
if (v > r.q9) t.aboveTop += 1;
|
||||
if (v < r.q1) t.belowBottom += 1;
|
||||
if (covers(r, v)) t.covered += 1;
|
||||
t.placements[placement(r, v)] += 1;
|
||||
}
|
||||
return [...groups.values()].sort(
|
||||
(a, b) => compareIds(a.researcher, b.researcher) || (a.phase === b.phase ? 0 : a.phase === "forward" ? -1 : 1),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "作者自评 vs 大家合并"(6.10 第 4 条):只收已经跑过、有有效成绩的候选(由调用方筛)。
|
||||
* optimism = 作者一半比合并一半按方向好多少,正数表示作者更乐观;另给实测落在两个范围的哪一段。
|
||||
* 结果按候选编号(自然序)排。
|
||||
*/
|
||||
export function authorVsMerged(
|
||||
items: ReadonlyArray<{ candidate: string; author: string; authorRange: Range; mergedRange: Range; value: number }>,
|
||||
direction: Direction,
|
||||
): Array<{ candidate: string; author: string; optimism: number; authorPlacement: Placement; mergedPlacement: Placement }> {
|
||||
return [...items].sort((a, b) => compareIds(a.candidate, b.candidate)).map((i) => ({
|
||||
candidate: i.candidate,
|
||||
author: i.author,
|
||||
optimism: improvement(i.mergedRange.q5, i.authorRange.q5, direction),
|
||||
authorPlacement: placement(i.authorRange, i.value),
|
||||
mergedPlacement: placement(i.mergedRange, i.value),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { selectAll, selectByValue, selectNone, selectRandomSameCount, type CellDecision, type CellRuleInput } from "./cells.ts";
|
||||
import { selectBestExpected, selectUniformRandom, type BatchDecision, type BatchInput } from "./forward.ts";
|
||||
|
||||
// 规则登记表(结构.md 第五部分"决策规则"、方法.md 第十一部分):名字 → 函数,带版本号。
|
||||
// 配置写规则名;决定记录里的规则名、版本从这里取。规则的算法改了就把版本加 1。
|
||||
|
||||
export const BATCH_RULES = {
|
||||
/** 批内最好期望(6.4)。 */
|
||||
bestExpected: { version: 1, run: selectBestExpected },
|
||||
/** 均匀随机(第十一部分)。 */
|
||||
uniformRandom: { version: 1, run: selectUniformRandom },
|
||||
} as const satisfies Record<string, { version: number; run: (input: BatchInput) => BatchDecision }>;
|
||||
|
||||
export const CELL_RULES = {
|
||||
/** 信息价值减成本(6.7)。 */
|
||||
valueMinusCost: { version: 1, run: selectByValue },
|
||||
/** 全测。 */
|
||||
all: { version: 1, run: selectAll },
|
||||
/** 随机抽同样多的格。 */
|
||||
randomSameCount: { version: 1, run: selectRandomSameCount },
|
||||
/** 都不测。 */
|
||||
none: { version: 1, run: selectNone },
|
||||
} as const satisfies Record<string, { version: number; run: (input: CellRuleInput) => CellDecision }>;
|
||||
|
||||
export type BatchRuleName = keyof typeof BATCH_RULES;
|
||||
export type CellRuleName = keyof typeof CELL_RULES;
|
||||
|
||||
/** 按名字跑一条选批规则,结果带上规则名和版本。 */
|
||||
export function runBatchRule(
|
||||
name: BatchRuleName,
|
||||
input: BatchInput,
|
||||
): BatchDecision & { rule: { name: BatchRuleName; version: number } } {
|
||||
const entry = BATCH_RULES[name];
|
||||
return { rule: { name, version: entry.version }, ...entry.run(input) };
|
||||
}
|
||||
|
||||
/** 按名字跑一条选格规则,结果带上规则名和版本。 */
|
||||
export function runCellRule(
|
||||
name: CellRuleName,
|
||||
input: CellRuleInput,
|
||||
): CellDecision & { rule: { name: CellRuleName; version: number } } {
|
||||
const entry = CELL_RULES[name];
|
||||
return { rule: { name, version: entry.version }, ...entry.run(input) };
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import type { Direction } from "../vocab/index.ts";
|
||||
import type { AblationExplanation } from "./ablation.ts";
|
||||
import { totalGain } from "./ablation.ts";
|
||||
import {
|
||||
combinations,
|
||||
compareIds,
|
||||
epsilonFloor,
|
||||
GRID_COMBINATION_CAP,
|
||||
GRID_SUBDIVISIONS,
|
||||
improvement,
|
||||
} from "./basics.ts";
|
||||
import { ablationCellMixture, below, integrateUpperTail, knotsMean, mapKnots, type Knots } from "./distribution.ts";
|
||||
|
||||
// 方法.md 6.7:方向收益、成分与权重、信息价值、价值减成本。选格规则和押的因子在 cells.ts。
|
||||
//
|
||||
// 一律先在"撑几成"刻度 s(x) = [f(完整) − x] ÷ G(按方向折算)上算,最后乘剩余空间 h:
|
||||
// 收益_p = h × s_p,h 对所有因子相同且为正,所以信息价值、期望收益、ĝ₀ 都等于 h × (撑几成刻度上的同一个量)。
|
||||
// 例外是部分格网格的切段:在分数原值上取折点、切小段,再把中点换成撑几成(见 measuredGrid)。
|
||||
|
||||
/** 开放预留成分的名字(成分名单里排在最后)。 */
|
||||
export const OPEN_RESERVE = "openReserve";
|
||||
|
||||
/** 建成分模型要的输入(6.7)。baseline 是本批基线 b,full 是被采纳的完整代码的分数。 */
|
||||
export interface AblationModelInput {
|
||||
baseline: number;
|
||||
full: number;
|
||||
direction: Direction;
|
||||
knownBest: number | null;
|
||||
epsilon: number;
|
||||
lambda: number;
|
||||
/** 被采纳改法的因子编号。 */
|
||||
factors: readonly string[];
|
||||
/** 每条解释对每一格都给了三个数。 */
|
||||
explanations: readonly AblationExplanation[];
|
||||
}
|
||||
|
||||
export interface AblationModel {
|
||||
/** 因子编号,按编号排;下文"格的下标"都按这个次序。 */
|
||||
factors: string[];
|
||||
/** 成分:各条解释的编号(按编号排),最后是开放预留。 */
|
||||
components: string[];
|
||||
/** 起始权重(6.7"成分与权重"):解释合起来 1 − λ(研究者等权、同一人平分),开放预留 λ。 */
|
||||
weights: number[];
|
||||
/** 被采纳的完整代码的分数 f(完整) 和方向:分数原值换算成撑几成要用。 */
|
||||
full: number;
|
||||
direction: Direction;
|
||||
/** 总提升 G。 */
|
||||
gain: number;
|
||||
/** 剩余空间 h。 */
|
||||
space: number;
|
||||
/** rawKnots[格][成分]:分数原值上的折线(开放预留是这一格自己的均匀段)。 */
|
||||
rawKnots: Knots[][];
|
||||
/** supportKnots[格][成分]:撑几成刻度上的折线。 */
|
||||
supportKnots: Knots[][];
|
||||
/** expected[格][成分]:这个成分下这一格撑几成的期望。 */
|
||||
expected: number[][];
|
||||
/** prior[格]:按起始权重平均的期望撑几成;乘 h 就是 E(收益_p)。 */
|
||||
prior: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 剩余空间 h(6.7"方向收益"):有已知最好时 max(K* − f(完整), ε/100)(按方向折算),没有时 1。
|
||||
*/
|
||||
export function remainingSpace(full: number, knownBest: number | null, epsilon: number, direction: Direction): number {
|
||||
return knownBest === null ? 1 : Math.max(improvement(full, knownBest, direction), epsilonFloor(epsilon));
|
||||
}
|
||||
|
||||
/** 分数原值 x → 撑几成 [f(完整) − x] ÷ G(按方向折算)。 */
|
||||
function supportScale(full: number, direction: Direction, gain: number): (x: number) => number {
|
||||
return (x) => improvement(x, full, direction) / gain;
|
||||
}
|
||||
|
||||
/**
|
||||
* 成分模型(6.7"成分与权重"):每条解释一个成分,开放预留一个成分;每个成分对每一格有一个分布,
|
||||
* 解释用它自己的三个数补尾巴,开放预留用这一格的均匀分布(6.3"消融")。同一成分内各格互相独立。
|
||||
*/
|
||||
export function ablationModel(input: AblationModelInput): AblationModel {
|
||||
const gain = totalGain(input.baseline, input.full, input.direction);
|
||||
const factors = [...input.factors].sort(compareIds);
|
||||
const explanations = [...input.explanations].sort((a, b) => compareIds(a.id, b.id));
|
||||
const mixtures = factors.map((f) =>
|
||||
ablationCellMixture(
|
||||
explanations.map((e) => ({ id: e.id, author: e.author, range: e.cells.find((c) => c.factor === f)!.range })),
|
||||
input.lambda,
|
||||
),
|
||||
);
|
||||
const toSupport = supportScale(input.full, input.direction, gain);
|
||||
const rawKnots = mixtures.map((m) => m.map((c) => c.knots));
|
||||
const supportKnots = rawKnots.map((cell) => cell.map((k) => mapKnots(k, toSupport, input.direction === "maximize")));
|
||||
const weights = mixtures[0]!.map((c) => c.weight);
|
||||
const expected = supportKnots.map((cell) => cell.map(knotsMean));
|
||||
return {
|
||||
factors,
|
||||
components: [...explanations.map((e) => e.id), OPEN_RESERVE],
|
||||
weights,
|
||||
full: input.full,
|
||||
direction: input.direction,
|
||||
gain,
|
||||
space: remainingSpace(input.full, input.knownBest, input.epsilon, input.direction),
|
||||
rawKnots,
|
||||
supportKnots,
|
||||
expected,
|
||||
prior: expected.map((row) => row.reduce((s, e, j) => s + weights[j]! * e, 0)),
|
||||
};
|
||||
}
|
||||
|
||||
/** 测 k 格时每格的小段数 m:满足 m^k ≤ 4,000,000 的最大整数(6.7"信息价值"),用整数运算核对。 */
|
||||
export function gridSize(k: number): number {
|
||||
let m = Math.floor(GRID_COMBINATION_CAP ** (1 / k));
|
||||
while (m ** k > GRID_COMBINATION_CAP) m -= 1;
|
||||
while ((m + 1) ** k <= GRID_COMBINATION_CAP) m += 1;
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一格的网格(6.7"只测一部分格"):取这一格全部成分的折点(含开放预留两端)去重排序,段数 = 折点数 − 1;
|
||||
* s = min(20, ⌊m ÷ 段数⌋),s ≥ 1 时每段等分 s 份,段数超过 m 时整格等宽切 m 份。
|
||||
* 每个小段的代表点是中点;成分 j 在小段 [左, 右] 的把握 = F_j(右) − F_j(左),各小段把握加起来是 1。
|
||||
*/
|
||||
export function cellGrid(knots: readonly Knots[], k: number): { mids: number[]; mass: number[][] } {
|
||||
const brk = [...new Set(knots.flatMap((kk) => kk.map(([x]) => x)))].sort((a, b) => a - b);
|
||||
const segs = brk.length - 1;
|
||||
const m = gridSize(k);
|
||||
const s = Math.min(GRID_SUBDIVISIONS, Math.floor(m / segs));
|
||||
const edges: number[] = [];
|
||||
if (s >= 1) {
|
||||
for (let i = 0; i < segs; i++) {
|
||||
const a = brk[i]!;
|
||||
const step = (brk[i + 1]! - a) / s;
|
||||
for (let j = 0; j < s; j++) edges.push(a + j * step);
|
||||
}
|
||||
} else {
|
||||
const step = (brk[segs]! - brk[0]!) / m;
|
||||
for (let j = 0; j < m; j++) edges.push(brk[0]! + j * step);
|
||||
}
|
||||
edges.push(brk[segs]!);
|
||||
const mids: number[] = [];
|
||||
for (let i = 0; i + 1 < edges.length; i++) mids.push((edges[i]! + edges[i + 1]!) / 2);
|
||||
const mass = knots.map((kk) => {
|
||||
const cdf = edges.map((x) => below(kk, x));
|
||||
return mids.map((_, i) => cdf[i + 1]! - cdf[i]!);
|
||||
});
|
||||
return { mids, mass };
|
||||
}
|
||||
|
||||
/**
|
||||
* 模型里第 c 格的网格(6.7"只测一部分格"),测 k 格时用。在分数原值上取折点、切小段:方法说"每格在自己的开放预留范围内
|
||||
* 按折点切段",开放预留和折点都是在分数原值上定的(尾巴按 q1 − (q5 − q1)、q9 + (q9 − q5),预留按 L − W/2、U + W/2
|
||||
* 的浮点原式),去重只合并浮点上完全相等的值。先换到撑几成刻度再去重,相差一个末位的两个原值折点可能换算后变成同一个数,
|
||||
* 段数和 s 就会跟原值上的不一样。切好后只把小段中点换算成撑几成;把握与刻度无关。
|
||||
*/
|
||||
export function measuredGrid(model: AblationModel, c: number, k: number): { mids: number[]; mass: number[][] } {
|
||||
const { mids, mass } = cellGrid(model.rawKnots[c]!, k);
|
||||
return { mids: mids.map(supportScale(model.full, model.direction, model.gain)), mass };
|
||||
}
|
||||
|
||||
/**
|
||||
* 只测 S 里的格时的 E_{S 的结果}[max_p E(撑_p | S 的结果)](6.7"信息价值"),在撑几成刻度上。
|
||||
* 对测到的格的全部小段组合求和:成分 j 的联合把握 = 起始权重 × 各测到格在所选小段的把握;它们的和 marg 是这个组合的把握,
|
||||
* 按它归一就是更新后的权重。网格见 measuredGrid。测到的格取小段中点(已换成撑几成),没测的格用更新后的权重平均各成分的期望,取各因子里最大的。
|
||||
*/
|
||||
function expectedBestGrid(model: AblationModel, S: readonly number[]): number {
|
||||
const k = S.length;
|
||||
const J = model.weights.length;
|
||||
const grids = S.map((c) => measuredGrid(model, c, k));
|
||||
const rest = model.factors.map((_, c) => c).filter((c) => !S.includes(c));
|
||||
const partial = Array.from({ length: k + 1 }, () => new Float64Array(J));
|
||||
for (let j = 0; j < J; j++) partial[0]![j] = model.weights[j]!;
|
||||
let total = 0;
|
||||
const walk = (level: number, seen: number): void => {
|
||||
const cur = partial[level]!;
|
||||
if (level === k) {
|
||||
let marg = 0;
|
||||
for (let j = 0; j < J; j++) marg += cur[j]!;
|
||||
if (marg === 0) return;
|
||||
let best = seen;
|
||||
for (const c of rest) {
|
||||
const e = model.expected[c]!;
|
||||
let num = 0;
|
||||
for (let j = 0; j < J; j++) num += cur[j]! * e[j]!;
|
||||
if (num / marg > best) best = num / marg;
|
||||
}
|
||||
total += best * marg;
|
||||
return;
|
||||
}
|
||||
const g = grids[level]!;
|
||||
const next = partial[level + 1]!;
|
||||
for (let i = 0; i < g.mids.length; i++) {
|
||||
for (let j = 0; j < J; j++) next[j] = cur[j]! * g.mass[j]![i]!;
|
||||
walk(level + 1, Math.max(seen, g.mids[i]!));
|
||||
}
|
||||
};
|
||||
walk(0, -Infinity);
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部格都测时的 E[max_p 撑_p](6.7"信息价值"):同一成分内"各格撑几成的最大值低于 t"的把握 = 各格把握连乘,
|
||||
* 期望 = 下端 + ∫(1 − 连乘) dt,逐段多项式精确积分;再按起始权重相加。
|
||||
*/
|
||||
function expectedBestAll(model: AblationModel): number {
|
||||
let v = 0;
|
||||
for (let j = 0; j < model.weights.length; j++) {
|
||||
const lines = model.supportKnots.map((cell) => cell[j]!);
|
||||
const lo = Math.min(...lines.map((k) => k[0]![0]));
|
||||
v += model.weights[j]! * (lo + integrateUpperTail(lines.map((knots) => [{ weight: 1, knots }]), lo));
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/** 网格算法算的信息价值,S 是格的下标(不论测几格都走网格;用来和精确算法互相对照)。 */
|
||||
export function informationValueGrid(model: AblationModel, S: readonly number[]): number {
|
||||
return model.space * (expectedBestGrid(model, [...S].sort((a, b) => a - b)) - Math.max(...model.prior));
|
||||
}
|
||||
|
||||
/** 全部格都测时的信息价值(一维精确积分)。 */
|
||||
export function informationValueExact(model: AblationModel): number {
|
||||
return model.space * (expectedBestAll(model) - Math.max(...model.prior));
|
||||
}
|
||||
|
||||
/**
|
||||
* 信息价值 VOI(S) = E_{S 的结果}[max_p E(收益_p | S 的结果)] − max_p E(收益_p)(6.7),S 是格的下标;
|
||||
* 全部格都测走一维精确积分,只测一部分走网格,空集是 0。不截断。
|
||||
*/
|
||||
export function informationValue(model: AblationModel, S: readonly number[]): number {
|
||||
if (S.length === 0) return 0;
|
||||
return S.length === model.factors.length ? informationValueExact(model) : informationValueGrid(model, S);
|
||||
}
|
||||
|
||||
/** 一个测格组合的价值与成本(6.7"价值减成本")。 */
|
||||
export interface ComboValue {
|
||||
/** 组合里的因子,按编号排。 */
|
||||
factors: string[];
|
||||
voi: number;
|
||||
/** R̂ = ⌈(余额 − |S|) ÷ 每批上限⌉。 */
|
||||
rHat: number;
|
||||
/** m′ = 1 + r + … + r^(R̂−1),R̂ = 0 时是 0。 */
|
||||
mPrime: number;
|
||||
/** 价值 = u × m′ × VOI。 */
|
||||
value: number;
|
||||
/** 成本 = 2 max(0, ĝ₀) × [(K + |S|)/(K + |S| + 1) − K/(K + 1)]。 */
|
||||
cost: number;
|
||||
/** 净值 = 价值 − 成本。 */
|
||||
net: number;
|
||||
}
|
||||
|
||||
export interface ComboParams {
|
||||
balance: number;
|
||||
batchCap: number;
|
||||
/** 测出的知识会被用上的概率 u。 */
|
||||
u: number;
|
||||
/** 衰减 r。 */
|
||||
decay: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出格数 1..min(格数, 余额 − 1) 的全部非空组合并算价值、成本、净值(6.7"选格"第 1 条)。余额为 0 或 1 时一个都没有。
|
||||
* 组合按格数从少到多、同格数按因子编号字典序列出。ĝ₀ = h × max_p 期望撑几成,原值另给,成本里取 max(0, ĝ₀)。
|
||||
*/
|
||||
export function evaluateCombos(model: AblationModel, params: ComboParams): { g0: number; combos: ComboValue[] } {
|
||||
const g0 = model.space * Math.max(...model.prior);
|
||||
const K = params.batchCap;
|
||||
const combos: ComboValue[] = [];
|
||||
for (let size = 1; size <= Math.min(model.factors.length, params.balance - 1); size++) {
|
||||
for (const S of combinations(model.factors.length, size)) {
|
||||
const voi = informationValue(model, S);
|
||||
const rHat = Math.ceil((params.balance - size) / K);
|
||||
let mPrime = 0;
|
||||
for (let j = 0; j < rHat; j++) mPrime += params.decay ** j;
|
||||
const value = params.u * mPrime * voi;
|
||||
const cost = 2 * Math.max(0, g0) * ((K + size) / (K + size + 1) - K / (K + 1));
|
||||
combos.push({ factors: S.map((c) => model.factors[c]!), voi, rHat, mPrime, value, cost, net: value - cost });
|
||||
}
|
||||
}
|
||||
return { g0, combos };
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export const Range = Type.Object(
|
||||
{
|
||||
...CLOSED,
|
||||
description:
|
||||
"三个数,按分数原值写,不管越大越好还是越小越好。要满足 q1 ≤ q5 ≤ q9,可以相等。" +
|
||||
"三个数,按分数原值写,不管越大越好还是越小越好。必须严格递增 q1 < q5 < q9,认为几乎不变就写一个很窄的范围。" +
|
||||
"没有\"不知道\"这个选项:拿不准就把 q1 和 q9 拉开",
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user