schema 对齐
Day 132 选定了 IBM AMLworld 并量化了它的极端不平衡,但外部数据要能喂进我们既有的评测代码,必须先做列结构 → 领域模型的映射与运行时校验——这就是今天的 schema 对齐(schema alignment)。
阶段: B14 · 外部 ground-truth AML eval(Day 131-140) 标签: #schema-mapping #zod #runtime-validation #auditability
今日导引(由浅入深)
Day 132 选定了 IBM AMLworld 并量化了它的极端不平衡,但外部数据要能喂进我们既有的评测代码,必须先做列结构 → 领域模型的映射与运行时校验——这就是今天的 schema 对齐(schema alignment)。
它在 B14 的位置是「数据接入的第一道闸门」:没有可靠的解析与校验,后面 Day 134 的 loader、Day 135 的 judge、Day 136 的混淆矩阵都建在流沙上。一行脏数据(缺列、金额非数、标签是 'yes' 而非 1)若静默通过,会一路潜伏到混淆矩阵阶段才爆出 NaN,定位成本极高。
放到 B1→B18 能力曲线上:这是把「外部真值」真正接入系统的工程动作,对应 AI Solutions Architect 的「数据契约 + 防御式接入」能力。最小可判定产出:写 src/aml/amlworldSchema.ts,定义 AmlworldRow 行类型 + 列校验函数,对前 1000 行解析断言 0 错误(真实 1000 行需数据落盘后补测,先对合成 fixture 跑绿)。
1. 机理精读
为什么必须显式映射。 外部数据集的列结构(AMLworld 的银行/账户/金额/时间戳/支付格式/Is_Laundering)和我们的内部领域模型(src/aml/types.ts 的 AmlCase/AmlTransaction)是两套词汇。要复用既有评测代码(binaryEval、混淆矩阵、SAR、审计轨迹),必须建一座确定性、可校验、可审计的桥。
映射不是「随便对齐字段」,而是一份契约(contract):哪些列必填、数值/日期能否解析、Is_Laundering 是否严格 ∈ {0,1}。契约越严,下游越稳。
为什么要保留可审计原始字段。 AML 是强监管域——后续 SAR(可疑活动报告,Suspicious Activity Report)草稿和审计轨迹要能追溯到「这笔可疑结论基于哪几笔原始交易」。所以映射时不能丢弃原始 from/to/amount,要把它们作为可审计字段保留。
src/aml/types.ts 已为此设计好承接点:
AmlTransaction的counterpartyAccountId?(数据集内对手)、counterpartyName?(数据集外对手)、amountCents、memo?都能承接原始信息;AuditEvent(types.ts 第 113-118 行:at/actor/action/detail?)承接「谁、何时、做了什么」的审计三元组。
关键权衡:运行时校验 vs 编译期类型。 TypeScript 的类型只在编译期存在,CSV 是运行时才读到的外部输入——any 解析会把脏数据静默放进系统,到混淆矩阵阶段才爆出 NaN,难定位。
对策是运行时 schema 校验:解析时即断言「必填列存在、Amount 可解析为数、Timestamp 可解析为日期、Is_Laundering ∈ {0,1}」,校验失败立刻 fail-fast 报错并定位到行号。2026 的 TS 生态推荐用 zod 做这层运行时校验——类型推断 + 运行时校验合一,z.infer 让类型从 schema 自动派生,避免类型和校验逻辑两处维护、漂移不一致。
关键边界:schema 对齐 ≠ 语义建案。 今天只做「行级解析与校验」——确认每行结构合法、字段类型对。把多笔交易聚合成一个 AmlCase(带 subjectPartyId、windowDays、alertReason、按主体和时间窗分组)是更上层的建案逻辑,不在今天范围。
今天的产出是干净的 AmlworldRow[],它是 Day 134 loader 抽样的输入;建案聚合可以延后,但行级校验不能省——它是后续一切的前提。
2. 推导 / 手算 / 代码走读
注意:src/aml/amlworldSchema.ts 当前尚未创建(本日新建),下面是要建的契约设计,不是既有代码走读;映射目标 src/aml/types.ts 是真实既有代码(已 Read 确认字段)。
要新建的 AmlworldRow + 校验函数设计(落 src/aml/amlworldSchema.ts):
AmlworldRow行类型:字段对应 AMLworld 列——timestamp: string、fromBank: string、fromAccount: string、toBank: string、toAccount: string、amount: number、currency: string、paymentFormat: string、isLaundering: 0 | 1。- 必填列校验:解析表头时断言上述列全部存在,缺列 fail-fast(报缺失列名 + 行号)。
- 数值可解析:
Amount必须Number.isFinite(parsed)且非负;映射到amountCents时Math.round(amount * 100)(沿用types.ts的整数分纪律,禁止 float 金额)。 - 日期可解析:
Timestamp必须能解析为合法日期(用于后续算dayOffset),非法日期即报错。 - 标签域校验:
Is_Laundering严格 ∈ {0,1},其它值(如空、2、'yes'、'Y')即报错——这是真标签,不容松散;标签污染会直接扭曲 Day 136 的混淆矩阵。 - 行号定位:校验失败的错误信息带行号,便于排查脏数据。
- 推荐用 zod:
z.object({...})定义 schema,schema.safeParse(row)收集success/error,z.infer<typeof schema>派生AmlworldRow类型,避免手写松散any解析。
映射到领域模型(落 Day 134 loader 时用,今天先记下):isLaundering → 二元真标签;fromAccount/toAccount → accountId/counterpartyAccountId;amount×100 取整 → amountCents;paymentFormat → channel(Cash/Wire/ACH/Cheque…);timestamp → dayOffset(相对窗口最早一天的天序号)。
最小手算例(合成 fixture 设计):构造 5 行测试数据——
- 3 行合法(
Is_Laundering分别 0/0/1,Amount为合法浮点)→ 期望validateRows返回ok: true, errors: []; - 1 行缺
To Account列 → 期望报「缺失列 toAccount @ 行 4」; - 1 行
Is_Laundering=2→ 期望报「标签越界 @ 行 5」。
这组 fixture 不依赖真实数据,可立即跑绿,验证校验逻辑本身正确。
金额精度的边界推导(为什么必须整数分):AMLworld 的 Amount 是十进制浮点字符串,如 "9812.37"。若直接以 JS number(IEEE 754 双精度)存储并做加减,9812.37 + 0.01 这类运算会引入 ±1e-13 量级的二进制误差,跨上千笔交易累加后可能在「是否略低于 $10,000 申报阈值」的判定边界上翻转结论——而 structuring 判定恰恰盯着这个阈值。对策:解析即 Math.round(9812.37 * 100) = 981237(整数分),所有比较/累加都在整数域做,零精度损失。这与 src/aml/types.ts 顶部注释「金额一律整数分、避免 float 货币运算、避开 bigint 的 JSON/React 序列化问题」完全一致——金额上限远低于 2^53,整数 number 足够安全。
校验严格度的取舍(fail-fast vs 容错跳过):两种策略——
- fail-fast:遇到第一个脏行即抛错停止。适合「数据应当干净,脏即 bug」的场景,但一行坏数据卡死整批不友好。
- 收集式(safeParse 累加 errors):跑完全部行、汇总所有错误 + 各自行号,最后一次性返回
{ok: false, errors: [...]}。便于一次性看清数据质量全貌。
本任务取收集式:validateRows 跑完前 1000 行、汇总所有错误,断言 errors.length === 0;这样既能定位每个问题行,又能一眼看出「是个别脏行还是系统性列错位」。
3. 今日实战
- 新建
src/aml/amlworldSchema.ts:定义AmlworldRow类型 + 用 zod 写amlworldRowSchema+ 导出validateRows(rows): {ok, errors}列校验函数(必填列、数值/日期可解析、Is_Laundering ∈ {0,1})。 - 在
src/aml/__tests__/新增对应单测:先用手写合成 fixture(几行合法 + 几行故意脏:缺列、Amount非数、Is_Laundering=2)断言校验能正确 pass/fail,跑绿。 - 对下载的真实 HI-Small 前 1000 行跑
validateRows,断言 0 解析错误——此断言依赖数据落盘,先标注「待数据」。 - 保留可审计字段:映射时不丢弃原始 from/to/amount,为后续的 SAR 与审计轨迹留证据链。
4. 今日实测 / 产出
- 状态:待数据(下载公开集)。
- 产出(待数据后):通过的 schema 校验测试(1000 行 0 解析错误)。
- 可先做的部分:schema/校验代码本身可先写好,并对合成 fixture 跑绿(这部分不 gated,今天即可完成)。
- 诚实标注:对真实 1000 行的「0-error」断言需数据落盘后补测,标「待数据」。
src/aml/amlworldSchema.ts为本日新建文件(当前仓库中尚不存在,已确认)。
CSV 解析的常见暗坑(写校验时要防):
- 带引号的字段含逗号:如 memo
"Smith, John",朴素split(',')会错位列——用正经 CSV parser(如csv-parse)而非手写 split。 - CRLF vs LF 行尾:Windows 导出的 CSV 行尾是
\r\n,split('\n')会把\r残留进最后一列——解析时 trim。 - 表头大小写/空格不一:
Is_LaunderingvsIs Launderingvsis_laundering——校验前规范化列名。 - 科学计数法金额:超大额可能写成
1.2E6,parseFloat能解但要Number.isFinite兜底。
这些坑不防,会以「莫名其妙的 0 行解析成功」或「全列错位」形式在 Day 134 抽样时爆出来,比标签污染更难定位。
5. 常见误区 / 陷阱
- 手写松散
any解析:类型不安全、脏数据静默通过,到混淆矩阵阶段才爆 NaN——必须运行时校验(推荐 zod)。 - float 金额:
Amount直接当浮点存会引入货币精度误差;沿用整数分(Math.round(amount*100)),与types.ts纪律一致。 - 丢弃原始字段:只留派生字段会断掉审计链,SAR 无法追溯证据交易——保留 from/to/amount。
- 把「合成 fixture 跑绿」当成「真实 1000 行 0 错误」:两者状态不同,前者已可做、后者待数据,不可混淆升级状态。
- 用朴素
split(',')解析含引号的 CSV:列错位且静默,是本日最易踩的工程坑。
6. 学习资源(每条带 YYYY-MM)
- IBM AMLworld dataset card, Kaggle (2024-04)——列定义(Timestamp / From-Bank+Account / To-Bank+Account / Amount / Currency / Payment Format /
Is_Laundering)。 - zod 官方文档(v3/v4,2026 维护中)——运行时 schema 校验与
z.infer类型推断。 - 本仓代码:
src/aml/types.ts(AmlTransaction/AmlCase/AuditEvent映射目标,已 Read 确认)。 - Anthropic《Demystifying evals》(2026-01)——可审计、可复现的 eval 数据管线要求。
SOTA检查 (2026-06 更新)
- 当前主流:AMLworld 列结构自 2024-04 未变,映射契约稳定。
- 运行时校验:TypeScript 运行时校验推荐 zod(2026 仍主流,类型安全 + 校验合一);arktype/valibot 为可选替代但生态较小。
- CSV 解析:用成熟 parser(
csv-parse/papaparse,2026 仍维护)处理引号/转义/行尾,不手写 split。 - 是否仍 SOTA:是。schema-first + 运行时校验是外部数据接入的标准做法。
- 过时黑名单:避免手写松散
any解析——类型不安全且无法支撑可审计要求;避免用未校验的 CSV 直接喂下游。 - 下次复查点:Day 134 loader 前,确认 schema 输出的
AmlworldRow[]字段足以支撑分层抽样(isLaundering是抽样分层键,必须可靠)。 - 是否仍 SOTA(CSV 工具链):
csv-parse/papaparse为 2026 主流,无需替换;避免自研 CSV 状态机。
衔接
- 昨天:Day 132 — AML 公开标注集选型(选定 AMLworld,量化极端不平衡)。
- 今天:把 AMLworld 行映射到
AmlCase、写amlworldSchema.ts做运行时列校验,保留可审计原始字段。 - 明天:Day 134 — loader 与去偏(流式读 + 按
Is_Laundering分层平衡抽样,导出balanced_300.json)。