第11章 - experiment模板-数据与实验
experiment 是「先采样、再下结论」的模板。它和 construct 的分工很清楚:construct 判定的是「你摆出来的东西对不对」,experiment 判定的是「你的结论有没有数据支撑」。这两个模板都会拒绝空答案,但拒绝的理由不同——construct 说「构造不满足约束」,experiment 说「你还没采样」。
实验模板在首发 20 关里占 5 关,是仅次于 construct 的第二大模板:
| 关卡 | 学段/主线 | mode | 工具 | 判定核心 |
|---|---|---|---|---|
k-data-tally |
K / 数据 | tally |
probe | 20 个图形按 4 类统计,结论说「最多/最少」 |
g13-data-weather |
G1–3 / 数据 | tally |
probe | 14 天天气按 4 类统计,偏好结论 |
g79-data-sampling |
G7–9 / 数据 | sampling |
probe | 从红蓝总体抽样,样本比例 + Wilson 区间要兜住真值 |
g1012-data-inference |
G10–12 / 数据 | inference |
probe | 两组比例比较,方向与「是否跨 0」要对 |
g1012-modeling-winrate |
G10–12 / 建模 | duel |
knob + probe | 1000 局对局胜率 ≥ 55%、区间下界 ≥ 50%、B 种子 ≥ 52% |
一个容易踩的坑:packages/templates/src/experiment/schema.ts 里的 discriminatedUnion 只登记了 tally 与 duel 两种 mode。sampling 与 inference 是后来加进来的,它们的 schema 分别住在 sampling.ts 与 inference.ts,由 experimentVerifier 在分派层补上:
// packages/templates/src/experiment/verifier.ts
function modeOf(detail: LevelDetail): 'tally' | 'duel' | 'sampling' | 'inference' {
const script = detail.levelScript as { mode?: string } | null;
if (script?.mode === 'sampling') return 'sampling';
if (script?.mode === 'inference') return 'inference';
return 'tally';
}
export const experimentVerifier = {
parseScript(input: unknown): unknown {
const script = input as { mode?: string } | null;
if (script?.mode === 'sampling') return samplingScriptSchema.parse(input);
if (script?.mode === 'inference') return inferenceScriptSchema.parse(input);
return experiment.parseScript(input);
},
// ...
};
所以读 experiment 的代码不能只看 schema.ts:真正的模式清单在 verifier.ts 的分派里。这种「主 schema + 后加模式」的演化痕迹在四个模板里都出现过,第 9 章的 rule 也有类似的死失败类残留。
11.1 probe 工具:采样、统计与三类可校验结论
experiment 关没有画布,一切交互都来自 probe(探针)与 knob(旋钮)两个工具。探针的动作在契约里定义得很窄(packages/game-kit/src/runtime.ts):
'probe/sample': { n?: number };
'probe/clear': Record<string, never>;
'probe/focus': { id: string };
'probe/claim': { claim: ClaimInput };
'probe/tally': { counts: Record<string, number> };
其中 ClaimInput 是这套模板的核心契约——玩家的结论必须写成三种可校验形状之一:
export type ClaimInput =
| { kind: 'count-compare'; category: string; direction: 'most' | 'least'; tiedWith?: string[]; n: number }
| { kind: 'stat'; statistic: 'mean' | 'proportion'; value: number; n: number; ci?: [number, number] }
| { kind: 'difference'; delta: number; ci: [number, number]; n: number };
三种形状正好覆盖三类实验:
count-compare:分类计数关用。「哪个类别最多/最少」这种结论不是一句自由文本,而是「方向 + 类别 + 并列清单 + 样本量」四个字段。判定器可以逐字段核对。stat:比例估计关用。结论是「统计量 + 数值 + 样本量 + 区间」,胜率关和抽样关都用它。difference:两组比较关用。结论是「差值 + 差值区间 + 总样本量」,推断关用它。
把结论强行结构化,是为了让「结论与数据是否一致」变成可判定的问题。如果结论是一句自然语言,判定器只能做关键词匹配,那和刷题没有区别。
11.2 tally:整筐倒出来、数字框、合并答案
分类计数关(k-data-tally 与 g13-data-weather)的脚本非常薄,只有三样东西:每批采样量、类别定义、以及一整筐待分类的数据。
{
"mode": "tally",
"batchSize": 5,
"categories": [
{ "id": "circle", "label": "圆", "token": "●", "count": 6 },
{ "id": "square", "label": "方", "token": "■", "count": 5 },
{ "id": "triangle", "label": "三角", "token": "▲", "count": 6 },
{ "id": "star", "label": "星", "token": "★", "count": 3 }
],
"items": ["circle", "square", "triangle", "star", "circle", "..." ]
}
注意 categories[].count 和 items 是两份数据,它们必须一致。scriptIssues 会逐类校验:
// packages/templates/src/experiment/logic.ts
const truth = truthCounts(script);
for (const category of script.categories) {
if ((truth[category.id] ?? 0) !== category.count) {
issues.push('类别数量与 items 不符:' + category.id + ' 声明 ' + category.count
+ ',实际 ' + (truth[category.id] ?? 0));
}
}
这个「同一事实写两遍、再交叉校验」的做法在内容里很常见:categories[].count 是给人读的规格,items 是给运行时用的数据源,scriptIssues 保证两者不会悄悄漂移。
交互简化后,玩家不需要真的点 20 次「抓一把」。TallyTool 上有一个「全部倒出来」按钮:
/** 一次点击把整筐倒出来:复用 probe/sample 的 payload.n(运行时按剩余量切片)。 */
async function dumpAll(): Promise<void> {
await dispatch({ type: 'probe/sample', payload: { n: remaining.value } });
}
运行时按剩余量切片,所以 n 传大了也不会重复采:
case 'probe/sample': {
if (state.collected.length >= script.items.length) {
return { state, error: '这一筐已经采完了,去看统计条。' };
}
const requested = action.payload.n;
const take = typeof requested === 'number' && Number.isFinite(requested)
? Math.max(1, Math.floor(requested))
: script.batchSize;
const next = script.items.slice(state.collected.length, state.collected.length + take);
// ...
}
采完之后,统计条是四个数字框(不是柱子)。一次 probe/tally 要把整份 counts 一次提交,运行时只认整份:
/** 数字框:blur / Enter 时把整份统计条一次 dispatch(运行时只认整份 counts)。 */
async function commitTally(): Promise<void> {
if (busy.value || !collectionComplete.value) return;
if (Object.keys(drafts.value).length === 0) return;
const counts: Record<string, number> = {};
for (const item of categories.value) {
counts[item.id] = clampCount(drafts.value[item.id], recorded.value[item.id] ?? 0);
}
const ok = await dispatch({ type: 'probe/tally', payload: { counts } });
if (ok) drafts.value = {};
}
最后是「合并答案」:一个 chip 同时给出「最多/最少 + 某个类别」,点一次写结论。这里最容易被忽略的是 tiedWith——K 关里圆和三角都是 6 个,说「最多是圆」必须把三角列进并列清单:
/** 合并答案:一个 chip 同时给出「最多/最少 + 类别」,点一次就写结论。 */
async function answer(direction: 'most' | 'least', categoryId: string): Promise<void> {
if (!categoryId) return;
const claim: ClaimInput = {
kind: 'count-compare',
category: categoryId,
direction,
tiedWith: tiedWithFor(categoryId),
n: collected.value.length,
};
await dispatch({ type: 'probe/claim', payload: { claim } });
}
判定侧对并列的处理是严格的(packages/templates/src/experiment/tally.ts):
/**
* 结论必须和数据对得上:
* - 方向 most / least 的类别要在对应极值集合里;
* - 并列时 tiedWith 要把另外几个极值类别都列出来(多列、漏列都算错);
* - n 要正好等于采集总数。
*/
export function claimMatches(counts: TallyCounts, claim: ClaimInput | undefined, total: number): boolean
「多列、漏列都算错」这一条是有代价的:玩家可能觉得「我选了最多的那个,为什么还错」。但并列本身就是数据的一部分——如果允许漏列,玩家只要随便点一个极值类就过,count-compare 就退化成了单项选择。
失败类一共四条,全部是「还没准备好就下结论」的不同变体:
| 失败类 | 触发 |
|---|---|
probe-not-collected |
完全没有采集数据 |
probe-sample-small |
采了但没采完整筐 |
probe-tally-wrong |
统计条与真实数量不一致 |
probe-claim-mismatch |
结论与数据对不上(含并列漏列) |
11.3 duel:1000 局、Wilson 区间与三条目标线
g1012-modeling-winrate(胜率实验室)是实验模板里最重的一关,也是唯一同时用 knob 与 probe 的实验关。它玩的是「资源分配 + 用大样本判断策略是否真的更好」。
模型是完全确定性的(packages/templates/src/experiment/duel.ts 文件头写得很清楚):
/**
* duel(10 回合对撞实验)的纯逻辑:
* - 每局按固定系数把 attack/defense/economy 摊成攻守分,双方同时出手;
* - 噪声只来自注入的种子(mulberry32),同一 (seed, 局号, 回合, 位置) 结果唯一;
* - 1000 局后给胜率与 Wilson 95% 区间。
* 不 import Phaser/Vue/DOM,不调用 Math.random / Date.now。
*/
一局打 10 回合,双方每回合按经济成长算攻守分,各自乘一个噪声系数;噪声不是 Math.random(),而是「种子 + 回合 + 位置」喂给 mulberry32 得到的确定值:
/** 噪声:同一种子下每局每回合四个位置各取一次,映射到 [noiseLow, noiseHigh]。 */
function noise(base: number, round: number, side: number, coefficients: DuelCoefficients): number {
const random = mulberry32(base + round * 131 + side)();
return coefficients.noiseLow + random * (coefficients.noiseHigh - coefficients.noiseLow);
}
simulateDuel 跑 n 局,返回胜率与区间:
/** 跑 n 局,返回胜率与 Wilson 95% 区间;完全确定性。 */
export function simulateDuel(
script: DuelScript,
params: DuelParams,
seed: number,
n: number,
): DuelResult
关卡脚本给了一把很具体的尺子:
{
"mode": "duel",
"rounds": 10,
"resources": 100,
"step": 5,
"matches": 1000,
"seeds": [101, 202],
"seedIndex": 0,
"targets": { "winRate": 0.55, "winRateLow": 0.5, "baselineB": 0.52 }
}
三条目标线各有分工:winRate: 0.55 是「A 种子胜率至少 55%」,winRateLow: 0.5 是「Wilson 95% 区间下界至少站住 50%」,baselineB: 0.52 是「换成 B 种子(202)以后仍要 ≥ 52%」。前两条防「点估计碰巧高一点」,第三条防「只对一个种子过拟合」。
边界情况被钉得很死。paramsValid 要求三个旋钮都用完资源、且都是 step 的倍数:
/** 参数必须正好用完资源点、且每个值都是 step 的整数倍。 */
export function paramsValid(script: DuelScript, params: DuelParams | undefined): boolean {
if (!params) return false;
for (const value of [params.attack, params.defense, params.economy]) {
if (!Number.isInteger(value) || value < 0 || value % script.step !== 0) return false;
}
return paramsSum(params) === script.resources;
}
局数也有上限。MAX_MATCHES = 5000 写在一个地方,schema、运行时、校验器共用同一个常量:
// packages/templates/src/experiment/duel.ts
const rawN = input.dataset?.n;
const datasetN = typeof rawN === 'number' && Number.isInteger(rawN) ? rawN : 0;
if (datasetN <= 0) { /* exp-not-run */ }
if (datasetN > MAX_MATCHES) { /* exp-not-run:采样局数超出这一关的上限 */ }
if (datasetN < script.matches) { /* exp-sample-small */ }
这条上限是审查轮补的:旧实现只挡 n <= 0,传 1e9 进来会把页面跑死。判定器拿到任何产物都不直接信,而是按同一个种子和参数重新 simulateDuel 一遍:
const result = simulateDuel(script, input.params, input.seed, datasetN);
// ...
const target = input.checkClaim ? script.targets.winRate : script.targets.baselineB;
if (result.winRate < target) {
return { passed: false, failureClass: 'exp-below-baseline', /* ... */ };
}
if (input.checkClaim && result.ci[0] < script.targets.winRateLow) {
return { passed: false, failureClass: 'exp-below-baseline', /* ... */ };
}
结论还要落在重算出的区间里:
const tolerance = 0.01;
const inInterval = claim.value >= result.ci[0] - tolerance && claim.value <= result.ci[1] + tolerance;
if (!inInterval || Math.abs(claim.value - result.winRate) > 0.05) {
return { passed: false, failureClass: 'exp-claim-beyond-ci', /* ... */ };
}
「目标线」在这里是四个常量:55%、50%(区间下界)、52%(B 种子)、1000(局数)。界面上它们必须上屏——KnobTool 的注释写着「目标线必须上屏:这三条决定『多高才算过』,winrate 关尤其不能藏」。
11.4 种子与迁移门:为什么这条关有、抽样关没有
experiment 是六个模板里唯一在运行时保留迁移门的模板之一(另一个是 pattern)。它的迁移门只对 duel 生效:
/**
* 迁移门:tally 只换外观(不做 transfer);duel 换到 B 种子再验一次胜率基线。
*/
export function transferVariants(detail: LevelDetail): RuntimeVariant[] {
const script = parseScript(detail.levelScript);
if (script.mode !== 'duel') return [];
if (script.seeds.length < 2) return [];
return [{ id: 'v-seed-b', axis: 'transfer', patch: { seedIndex: 1 } }];
}
对应地,duel 的校验在迁移变体下只查胜率基线、不查结论(checkClaim: !isTransfer),因为玩家写的那条结论是针对 A 种子的:
const isTransfer = variant.axis === 'transfer';
const check = checkDuelArtifact(script, {
params: asParams(raw.params),
dataset: raw.dataset,
claim: raw.claim,
seed,
checkClaim: !isTransfer,
});
失败类在迁移场景下会被改写成 exp-overfit-seed:
if (isTransfer && failureClass === 'exp-below-baseline') failureClass = 'exp-overfit-seed';
这条门为什么是真的?因为它改变的是数据源:换种子 = 换一批对手抽取序列与噪声,参数不动、结论不放宽。一个只对种子 101 有效的策略会在种子 202 上掉下 52%,门就拒绝。这是「换条件重跑」的迁移门,不是「把同一个结果再判一遍」的恒真门。
反过来,tally / sampling / inference 三关的 transferVariants 一律返回空数组:
transferVariants(detail: LevelDetail) {
return isSampling(detail) || isInference(detail) ? [] : experiment.transferVariants(detail);
}
原因和 rule 删旋转门一样:这三关的变体是「换标签、换符号、换样本量」的表征变体,判定逻辑不变。抽样关的内容里写着 v-symbols(换红球标签)与 v-label(换成红珠子),它们的作用是让玩家在换皮后仍然认得出同一个统计问题,而不是迁移证据。
11.5 sampling:区间必须兜住真值
抽样关(g79-data-sampling)给定一个红蓝总体(红 60 / 蓝 40,共 100)与固定批次,要求玩家抽两批以上、用样本比例估计总体:
{
"mode": "sampling",
"population": { "red": 60, "blue": 40, "label": "红球" },
"batchSizes": [20],
"minSample": 40,
"tolerance": 0.12,
"minBatches": 2
}
抽样本身用 mulberry32(seed) 驱动 Fisher-Yates,只抽前 size 个(无放回),同一个种子结果一致:
export function drawSample(script: SamplingScript, n: number, seed: number): SamplingSample {
const total = script.population.red + script.population.blue;
const size = Math.max(1, Math.min(total, Math.trunc(n)));
const rng = mulberry32(seed);
const pool: boolean[] = [];
for (let i = 0; i < total; i += 1) pool.push(i < script.population.red);
// Fisher-Yates 只抽前 size 个
for (let i = 0; i < size; i += 1) {
const j = i + Math.floor(rng() * (total - i));
// swap ...
}
// ...
const interval = wilsonInterval(red, size);
return { n: size, red, proportion, ci: [interval.low, interval.high], trueProportion: trueProportion(script) };
}
判定要过五关,顺序本身说明了「先有样本、再谈结论」:
- 有样本(
sampling-no-sample); - 样本量 ≥
minSample(sampling-too-small); - 结论存在且是
stat(sampling-no-claim); - 结论里的 n 与比例和样本对得上(
sampling-claim-mismatch); - 批次数 ≥
minBatches(sampling-too-few-batches)。
最后两条是「够不够准」:
const trueValue = trueProportion(script);
if (Math.abs(claim.value - trueValue) > script.tolerance) {
return { passed: false, failures: ['sampling-far'], metrics };
}
if (claim.ci[0] > trueValue || claim.ci[1] < trueValue) {
return { passed: false, failures: ['sampling-ci-miss'], metrics };
}
sampling-far 是点估计偏差超过容差(0.12),sampling-ci-miss 是区间没兜住真值。后者是抽样关最核心的一课:点估计可以偏,但你要把不确定性也报出来。区间由 UI 侧的 wilsonInterval 计算,再随 probe/claim 一起提交;判定器拿区间和真值比,不看样本本身。
Wilson 区间的实现放在 toolkit(packages/toolkit/src/shared/stats.ts),注释写明了为什么不用正态近似:
/** Wilson 95% 区间;小样本与 0/1 边界都比正态近似稳。 */
export function wilsonInterval(successes: number, n: number, z = 1.96): Interval {
if (n <= 0) return { low: 0, high: 1 };
const p = Math.min(1, Math.max(0, successes / n));
const z2 = z * z;
const denom = 1 + z2 / n;
const center = (p + z2 / (2 * n)) / denom;
const margin = (z * Math.sqrt((p * (1 - p) + z2 / (4 * n)) / n)) / denom;
return { low: Math.max(0, center - margin), high: Math.min(1, center + margin) };
}
抽样关的脚本里 tolerance: 0.12 与 minSample: 40 是一组配套的数:40 个样本在 p=0.6 时 Wilson 区间半宽约 0.15,把容差设成 0.12 意味着「点估计必须相当接近真值」,同时区间还要能兜住——两个条件一起,逼玩家真的抽够两批并取合计比例。
11.6 inference:两组比较、区间重叠与「差异不明确」
推断关(g1012-data-inference)把两组固定样本摆出来,让玩家判断「A 组是不是真的更高」:
{
"mode": "inference",
"groups": {
"a": { "label": "A 组", "n": 50, "successes": 35 },
"b": { "label": "B 组", "n": 50, "successes": 15 }
},
"minN": 30,
"overlapTolerance": 0.02,
"claimOptions": ["a-higher", "b-higher", "no-clear"]
}
真值判定用两组 Wilson 区间是否重叠:
export function inferenceStats(script: InferenceScript): InferenceStats {
const a = statsOf(script.groups.a);
const b = statsOf(script.groups.b);
const overlap = a.ci[0] <= b.ci[1] + script.overlapTolerance && b.ci[0] <= a.ci[1] + script.overlapTolerance;
a.overlaps = overlap;
b.overlaps = overlap;
let truthfulClaim: InferenceStats['truthfulClaim'] = 'no-clear';
if (!overlap) truthfulClaim = a.proportion > b.proportion ? 'a-higher' : 'b-higher';
return { a, b, truthfulClaim };
}
A 组 70%、B 组 30%,区间不重叠,真值是 a-higher。玩家要在三个选项里选一个,结论以 difference 形式提交:差值 + 差值区间 + 总样本量。
判定只认三件事:样本量达标、方向与真值一致、以及「方向明确时差值的区间下界要大于 0」:
const claimDirection = raw.claim.delta > 0 ? 'a-higher' : raw.claim.delta < 0 ? 'b-higher' : 'no-clear';
if (claimDirection !== truth.truthfulClaim) {
return { passed: false, failures: ['inference-claim-wrong'], metrics };
}
// ...
if (truth.truthfulClaim !== 'no-clear' && raw.claim.ci[0] <= 0) {
return { passed: false, failures: ['inference-ci-includes-zero'], metrics };
}
最后这一条是这一关的教学点:方向明确时,差值区间不能跨 0。如果玩家说「A 更高」但给出的区间是 [-0.05, 0.45],那他的区间实际上承认了「差异可能为零」,结论与证据自相矛盾。inference-ci-includes-zero 就是为这种情况准备的失败类。
11.7 证据充分性:三种关卡的「没采样就写结论」分别长什么样
experiment 的失败类里,「没采样就写结论」占了很大比重,但三种关卡的实现位置不同:
| 关卡 | 运行时拦截 | 判定器失败类 | UI 侧保护 |
|---|---|---|---|
| tally | probe/tally 在未采完时返回错误 |
probe-not-collected、probe-sample-small |
统计条与答案在整筐倒出前禁用 |
| duel | probe/claim 在无样本时返回错误 |
exp-not-run、exp-sample-small |
结论按钮在无数据时禁用 |
| sampling | — | sampling-no-sample、sampling-too-small |
结论按钮在样本不足时禁用 |
三处一起兜底,是因为任何一处都能被绕过:运行时能挡直接派发的非法动作,判定器能挡伪造的产物,UI 只负责让玩家「看得见能不能提交」。任何一层缺失,都会出现「按钮能点、点完报错」或者「不采样也能提交」的漏洞。
还有一个反复出现的口径:产物里的数字只当摘要,判定永远从原始输入重算。
- tally:
claimMatches(truth, claim, total)里的truth由items重算,不看dataset.counts; - duel:
checkDuelArtifact用simulateDuel重跑,不看产物里的wins/winRate; - sampling:真值由
population直接算,不看样本; - inference:区间由
inferenceStats重算,不看产物里的ci。
这是四个模板共同的防御姿态。实验关尤其不能信产物:玩家自己填的胜率若能过关,「跑 1000 局」就变成了装饰。
11.8 本章要点
- experiment 有四个真实 mode(tally / duel / sampling / inference),但主 schema 只登记前两个;完整清单要看
experimentVerifier的分派。 - 结论必须写成三种可校验形状之一(
count-compare/stat/difference),自由文本无法判定「结论与数据是否一致」。 - tally 关的并列必须列全(
tiedWith多列漏列都算错),否则count-compare会退化成单项选择。 - duel 关用确定性
mulberry32噪声,1000 局跑出胜率与 Wilson 区间;三条目标线(55% / 50% 下界 / 52%B 种子)必须上屏。 - 迁移门只对 duel 生效:换 B 种子重跑,失败判
exp-overfit-seed;其余三关的变体是表征轴,不做迁移。 - sampling 关的
sampling-ci-miss与 inference 关的inference-ci-includes-zero是同一个教学点:结论要带上不确定性,区间不能自相矛盾;「没采样就写结论」则由运行时、判定器、UI 三层共同拦截。
实验模板想教的能力,说到底是「把话说得能被数据检验」:结论要有样本量,要有区间,要能被同一份数据复算出来。