loader 与去偏
Day 133 把 AMLworld 行结构校验干净了,但有一根刺没拔:Is_Laundering 极度不平衡(<0.1% 正例)。今天做的 loader 与去偏(debiasing)就是拔这根刺——用流式读取避免一次性载入大文件,用分层平衡抽样(stratified balanced sampling)把均衡子集喂给裁判,让召回率和置信区间可解释。
阶段: B14 · 外部 ground-truth AML eval(Day 131-140) 标签: #stratified-sampling #class-imbalance #streaming #reproducibility
今日导引(由浅入深)
Day 133 把 AMLworld 行结构校验干净了,但有一根刺没拔:Is_Laundering 极度不平衡(<0.1% 正例)。今天做的 loader 与去偏(debiasing)就是拔这根刺——用流式读取避免一次性载入大文件,用分层平衡抽样(stratified balanced sampling)把均衡子集喂给裁判,让召回率和置信区间可解释。
它在 B14 的位置是「把干净行变成均衡评测集」,是 Day 135 LLM judge 校准和 Day 136 混淆矩阵的直接输入。如果跳过去偏,Day 136 算出的 recall 会因为正例太少而方差爆炸、毫无意义。
放到 B1→B18 能力曲线上:这是「评测集工程」的核心一环,对应数据科学里处理不平衡数据的标准纪律,也是 AI Solutions Architect 在「评测可信度」上的硬功夫。最小可判定产出:写 src/aml/amlworldLoader.ts,固定 seed 抽 N≈300(约 150 正 / 150 负)导出 balanced_300.json,loader 单测断言正负各 150±5、seed 可复现。
1. 机理精读
不平衡为什么毁掉评测。 Is_Laundering 正例 <0.1%,若直接随机抽 N=300 裁判,期望正例约 300 × 0.001 = 0.3 条——recall 的分母 TP+FN 几乎为 0,要么算出 NaN,要么单条样本的对错就把 recall 从 0 翻到 1,方差爆炸、毫无统计意义。
更隐蔽的是多数类(normal)会把整个评测淹没:一个「全判 normal」的裁判在原始分布上 accuracy ≈ 99.9%,却 recall = 0——这正是 Day 136 强调「不平衡场景别只报 accuracy」的根因。accuracy 在极端不平衡下是个谎报指标。
对策:分层平衡抽样。 按 Is_Laundering 分层(正例一层、负例一层),各层独立抽样,凑成 N≈300 的均衡子集(约 150 正 / 150 负)。这样做的收益:
- recall 分母稳定(~150 个真正例),单点估计可靠;
- Day 137 的 bootstrap CI 更窄、更可解释——正例越多,区间越紧;
- 裁判不会被多数类带偏——judge 看到的正负比例 1:1,不会因为「反正大多是 normal」而退化成常判 normal。
代价是:均衡子集的先验偏离真实分布——所以在均衡集上测出的是「条件召回/精度」,FPR 和 precision 的绝对值需结合真实基率(base rate)换算后才能谈业务产能,这一点要在报告里诚实标注,不能把均衡集上的 precision 当真实环境的 precision。
为什么固定 seed。 eval 必须可复现。抽样用伪随机,固定 seed(如 mulberry32(seed))保证「同一份 CSV + 同一 seed → 同一份 balanced_300.json」。可复现性是 2026 eval-rigor 共识的硬要求,也是「别人能验证你的数字」的前提(与 first-party / 可外部验证原则一致——一个不可复现的评测集,等于没有评测集)。
为什么流式读取。 AMLworld HI-Small 可能数百万行,一次性 readFileSync + split('\n') 会爆内存。流式(line-by-line stream)逐行读、逐行校验、按层做蓄水池抽样(reservoir sampling),内存占用与文件大小解耦——无论文件 100MB 还是 2GB,常驻内存只有两个 ~150 容量的桶。这是工程纪律,不是可选项。
关键边界:去偏 ≠ 过采样。 我们做的是抽样平衡(从真实正/负例里各抽固定数量,对多数类做下采样),不是合成过采样(如 SMOTE 用 k 近邻插值造新正例)。
SMOTE 会引入数据集里不存在的合成样本,污染外部效度——评测集必须全是真实带标样本,否则又退回到「测自己造的数据」的循环陷阱(Day 131 的病又回来了)。所以 eval 集严禁 SMOTE / 合成过采样,只许下采样多数类。(训练阶段可酌情用 SMOTE,但 eval 阶段绝对禁止——这是两个不同的纪律。)
三种平衡策略的取舍对照:
| 策略 | 做法 | 外部效度 | eval 可用 |
|---|---|---|---|
| 下采样多数类 | 从真实负例抽 ~150 | 保留(全真实样本) | ✓(本任务选此) |
| 过采样少数类(复制) | 真实正例重复采样到 150 | 部分保留(无新样本但有重复偏置) | △(CI 会被重复样本压窄、虚假自信) |
| 合成过采样(SMOTE) | k 近邻插值造新正例 | 破坏(引入不存在的样本) | ✗(污染外部效度) |
本任务选「下采样多数类」:负例够多(~500 万),抽 150 不会样本枯竭;正例 ~5000 也足够抽 150 而无需重复。只有当正例少到抽不满 150(如 <150 真正例)时,才需考虑放宽到全量正例 + 等量负例的非对称设计——AMLworld HI-Small 不会触发这个边界。
2. 推导 / 手算 / 代码走读
注意:src/aml/amlworldLoader.ts 当前尚未创建(本日新建),下面是要建的设计;其下游 src/aml/groundTruthEval.ts(binaryEval/binaryEvalWithCI)是真实既有代码(已 Read 确认)。
要新建的 amlworldLoader.ts 设计要点:
- 流式读取:用 Node 的
readline/stream 逐行读 CSV,避免readFileSync全量载入;逐行交给 Day 133 的validateRows/zod schema 校验,跳过或报告脏行。 - 分层蓄水池抽样:维护两个桶(正例、负例),用固定 seed 的 RNG 做蓄水池抽样(reservoir sampling),各抽 ~150,单遍流式即可,无需全量驻留。蓄水池抽样保证每个样本被选概率均等,即便不知道总行数也成立。
- 固定 seed RNG:复用测试里已验证的
mulberry32(seed)模式(见src/aml/__tests__/groundTruthEval.test.ts第 4-12 行的mulberry32实现:a = seed >>> 0起步,每次更新a并Math.imul混合)——确定性、可注入、可复现。 - 导出格式:写出
balanced_300.json,每条形如{ ...AmlworldRow, label }(label由isLaundering派生:1→可疑类、0→'normal'),供 Day 135 judge 与 Day 136binaryEval直接消费。 - 可审计:保留原始 from/to/amount 字段,落盘时记录来源文件 + seed + 抽样数 + 时间戳,便于复现与审计。
下游对接(真实代码走读,src/aml/groundTruthEval.ts):
balanced_300.json里每条提取出{label, predicted}后喂给binaryEval(items, 'normal')(第 38 行)。该函数逐条判定actualPos = i.label !== normalLabel、predPos = i.predicted !== normalLabel,累加 tp/fp/tn/fn(第 43-50 行),返回recall/precision/fpr/f1/n(第 51-61 行)。binaryEvalWithCI(第 65 行)再做 bootstrap CI(默认bootstrap=2000,可注入rng)。- 所以 loader 的产物必须让
label字段和binaryEval的normalLabel='normal'约定对齐——负例的label必须正好是字符串'normal',否则会被当成阳性类。
手算:为什么 150/150 而非 30/270。 设裁判真召回 r=0.8。
- N_pos=150 时,recall 标准误 ≈ √(r(1-r)/150) = √(0.16/150) ≈ 0.033;
- N_pos=30 时,≈ √(0.16/30) ≈ 0.073。
正例越多、recall 区间越窄——这就是把正例抽到 ~150 的统计理由。150/150 在「区间够窄」和「裁判 token 成本可控」之间取平衡。
手算:均衡集 precision 为何不能直接外推(base-rate 换算)。 这是 Day 134 必须诚实标注的核心陷阱。设裁判 recall=0.8、FPR=0.1:
- 在均衡集(150 正/150 负)上,precision = TP/(TP+FP) = (0.8×150)/(0.8×150 + 0.1×150) = 120/135 ≈ 0.89——看起来很好。
- 但真实分布正例率仅 0.1%(10000 笔里 10 正 / 9990 负),同样 recall=0.8、FPR=0.1 时:TP=0.8×10=8,FP=0.1×9990=999,真实 precision = 8/(8+999) ≈ 0.008。
同一个裁判,均衡集 precision 0.89、真实环境 0.8%——相差百倍。所以报告里 precision/FPR 的绝对值必须标「均衡集条件下」,谈业务产能(每天人工要复核多少误报)时要用真实 base rate 重算。recall 不受 base rate 影响(分母只含真正例),可以直接报;precision/FPR 的绝对值则强依赖基率。这也解释了为什么 AML 业务偏好高 recall、容忍高 FP(Day 136 主题)——在极端不平衡下,precision 天然被基率压低,硬追 precision 会牺牲 recall。
3. 今日实战
- 新建
src/aml/amlworldLoader.ts:CSV 流式读取(readline)+ 按Is_Laundering分层 + 固定 seed 的平衡抽样,导出balanced_300.json(约 150 正 / 150 负)。 - 复用
groundTruthEval.test.ts里的mulberry32(seed)作为可注入 RNG,保证可复现。 - 新增 loader 单测:先对合成 CSV fixture(造若干正/负行)跑绿,断言:(a)正负各 150±5;(b)同一 seed 两次抽样结果一致(可复现);(c)流式不全量载入(验证逻辑路径不依赖总行数)。
- 对真实 AMLworld 跑一次,落盘
balanced_300.json(git-ignore 大数据,只提交统计/来源记录)——标「待数据」。
4. 今日实测 / 产出
- 状态:待数据(下载公开集)。
- 产出(待数据后):落盘
balanced_300.json+ loader 单测绿(正负各 150±5)。 - 可先做的部分:流式 loader + 抽样逻辑可先对合成 CSV fixture 写测试跑绿(不 gated,今天即可完成代码 + 合成测试)。
- 诚实标注:对真实 AMLworld 的 300 子集落盘需数据下载后产出,标「待数据」。
src/aml/amlworldLoader.ts为本日新建文件(当前仓库中尚不存在,已确认)。
5. 常见误区 / 陷阱
- 直接随机抽样不分层:300 条里几乎无正例,recall 失真——必须按
Is_Laundering分层。 - 用 SMOTE / 合成过采样平衡 eval 集:会污染外部效度,把真值评测退化成测合成数据;eval 集只许下采样真实样本(训练可用,eval 禁用)。
- 忘记固定 seed:抽样不可复现,别人无法验证你的数字,违反可外部验证原则。
- 把均衡子集的 precision/FPR 绝对值当真实业务值:均衡先验偏离真实基率,需结合 base rate 换算后才谈产能,报告里要标注。
readFileSync全量载入大 CSV:内存爆炸——必须流式 + 蓄水池抽样。
6. 学习资源(每条带 YYYY-MM)
- Anthropic《Demystifying evals》(2026-01)——eval 集需控制类别分布、报告可复现的方法论。
- IBM AMLworld dataset card, Kaggle (2024-04)——
Is_Laundering分布与规模。 - He & Garcia《Learning from Imbalanced Data》(IEEE TKDE, 2009-09)——不平衡数据抽样与去偏经典综述(概念打底,非主线)。
- Vitter《Random Sampling with a Reservoir》(ACM TOMS, 1985-03)——蓄水池抽样原始算法(单遍流式抽样的理论基础)。
- 本仓代码:
src/aml/groundTruthEval.ts(下游binaryEval/binaryEvalWithCI,已 Read)、src/aml/__tests__/groundTruthEval.test.ts(mulberry32可复现 RNG 范式,已 Read)。
SOTA检查 (2026-06 更新)
- 当前主流:流式 + 分层抽样是不平衡数据评测的标准做法,无替代。
- 可复现性:固定 seed 的可复现性要求与 2026 eval-rigor 共识一致。
- 是否仍 SOTA:是。下采样多数类 + 固定 seed 仍是 eval 集构建首选。
- 过时黑名单:避免用 SMOTE 等合成过采样做 eval 集——会污染外部效度(训练时可酌情用,eval 时禁用);避免不固定 seed 的一次性抽样。
- 下次复查点:Day 135 judge 校准前,确认
balanced_300.json的label字段与binaryEval的normalLabel='normal'约定一致(负例 label 必须精确为'normal')。
衔接
- 昨天:Day 133 — schema 对齐(AMLworld 行映射到
AmlCase、运行时列校验)。 - 今天:流式读 + 按
Is_Laundering分层平衡抽样,固定 seed 导出balanced_300.json(约 150 正/150 负)。 - 明天:Day 135 — LLM judge 校准(用 NAMED
deepseek-v4-flash对Is_Laundering做二分类,30 条手验子集算一致率)。