← 返回文章列表
AI25 分钟阅读

用 BERT 小模型做流量 PII 识别:训练历程和踩坑复盘

用一个 307M 的编码器替代大模型给 API 流量做个人信息打标,宏平均 AP 0.93。记录数据切分、滑动窗口、DAPT、长上下文对比、显存优化,以及发布前才发现的标签互相压制问题。

#ChatGPT model#AI#大模型#模型微调#BERT#PII
Read in English→

用 BERT 小模型做流量 PII 识别:训练历程和踩坑复盘

这个 PII 识别模型我前后迭代了二十多个版本,这里把真实踩过的坑、做过的取舍、测出来的数字都记下来。失败的尝试也写进来了,下次再做类似的东西不用再试一遍。

先说为什么要做

API 流量经常要判断里面有没有个人信息。最省事的办法是每个响应体丢给大模型问一句“这里面有没有手机号、身份证、银行卡”,能跑通,但问题也很明显:

  1. 贵,而且是跟着流量线性涨的。流量体比想象的长,我实测 token 数中位数 829,p90 是 33,803,最长的 150,547。按 p90 算一次就要送三万多 token,流量又不会停。
  2. 延迟不可控,流量检测很多时候要放在链路里同步做。
  3. 结果不可复现,同样的输入换个时间、换个模型版本答案可能就变了。做合规的东西这个比较要命,你没法跟别人证明“上个月这条流量判的是无风险”。

所以目标就是训一个小模型,从“每次调用都付钱”变成“训一次,推理随便跑”。

先放最终结果:一个 307M 参数的编码器,宏平均 AP 0.93,23 个标签里 14 个能用。CPU 单进程延迟中位数 339 ms,没有按 token 算的成本。

不过延迟分布是很偏的,p90 是 16.8 秒,p99 是 73 秒,因为有 21% 的流量超过 8k token。这个长尾后面会说。

和大模型有什么区别

直接 show me,同一条响应体(值都是编的):

{"code":0,"data":{"addresses":[{"address_id":"778901","receiver_name":"Andi Saputra","phone":"+62 812-3456-7890","detail":"Jl. Melati Raya No. 42, RT.003/RW.005","city":"Jakarta Pusat","province":"DKI Jakarta","post_code":"10510","is_default":true}]}}

问大模型“这里面有哪些个人信息”,一般会回一段话,大概是这样:

这段数据包含以下个人信息:
- 姓名:Andi Saputra(receiver_name 字段)
- 手机号:+62 812-3456-7890
- 地址:Jl. Melati Raya No. 42, RT.003/RW.005, Jakarta Pusat
- 邮编:10510
建议对这些字段做脱敏处理。

这个小模型的输出是这样的(真实跑出来的):

["phone_number", "person_name", "street_address", "postal_code"]

street_address  1.0000  (阈值 0.3451)
postal_code     0.9999  (阈值 0.9756)
person_name     0.9997  (阈值 0.9560)
phone_number    0.9996  (阈值 0.9872)
其余 19 个标签   ≤ 0.06

两边判断的结果差不多,区别在这几个地方:

大模型 这个小模型
模型类型 生成式(decoder),一个字一个字往外写 编码器(BERT 类),读完整段直接打分
大小 几十到几千亿参数 307M
输出 自然语言,要再解析 23 个固定标签的概率,直接能用
能不能指出值在哪 能,还能解释为什么 不能,只说有没有
加新标签 改 prompt 就行 要重新标数据、重新训
成本 按 token 付费,跟着流量涨 训一次,CPU 就能跑
同一输入结果一样吗 不保证 每次一样
长文本 看上下文窗口,15 万 token 很贵 切窗口,多长都能跑

简单说,大模型什么都能干,但贵、慢、每次答案可能不一样;这个小模型只干一件事,就是判断这 23 类有没有,但便宜、快、稳定。

所以它不是用来替代大模型的全部能力,而是把“大批量流量打标”这个固定的、重复的活从大模型那里接过来。要抽值、要解释、要处理新类型的时候,还是得用大模型。

顺便说下最近很火的 Jev

这两天 TypeSafe AI 发了个 Jev(官方介绍),自称 System One 模型:不生成文字,输入一段状态和几个预先定义好的问题(选哪个、打几分、是不是),一次并行前向返回带概率的结构化答案,官方说延迟 70–500 ms。

看完我的理解,它和我这个模型走的是同一个方向:不让模型“写答案”,而是在固定的输出空间里直接打分。这也是我上面表格里小模型和大模型的核心区别。

但两者也有明显不同:

Jev 我这个模型
问题在哪定义 每次请求时现写,不用训练 训练时定死,就这 23 个标签
规模 没公开,只能走官方 API 307M,自己部署,CPU 能跑
概率 用 RLCD 专门训练概率校准 每个标签在单独的标定集上拟合阈值
上下文 状态加问题一共约 64k token 切窗口,长度不限

所以回答“它本质是不是大模型”:它不是 GPT 那种一个字一个字往外写的生成式模型,但它能在运行时接任意新问题、不用微调,这种通用能力靠的应该还是大规模预训练,参数量和架构官方都没公开。我的理解是它更像“不生成文字的通用大模型”,而我这个是“只会一件事的专用小模型”。

说白了,我这个算是用小模型的方式,在一个固定任务上实现了类似 System One 的效果,思路是一样的。Jev 的好处是换个问题不用重新标数据、重新训;我的好处是便宜、能本地跑、数据不出去。

不过 Jev 现在还没完全公开,只能走官方 API,架构和参数量都没放出来,本质上可能还是个大模型,成本和数据出境这些问题不一定比直接用大模型好多少。所以我的打算是:先继续用自己这个小模型,同时把 Jev 接进来研究,拿同一批验证集让两边都跑一遍,看看效果和成本到底差多少。研究清楚了再决定要不要切换,到时候再单独写一篇。

任务定义:先把事情收窄

这一步花的时间比我预期的多,但后面省了很多事。

只判断有没有,不管在哪。 我不做“手机号在第几个字符”,只回答“这段文本里有没有手机号”。下游要的是“这个接口要不要打标”,不是把值抠出来。这样任务就从序列标注变成了多标签分类,标注成本低了一个数量级。

标签是独立判断的,不互斥。 一个订单响应体里同时有商品、姓名、手机号、地址很正常,所以是 23 个独立的二分类头,共享一个编码器,每个标签有自己的阈值。

对外的标签名用英文。 标注规范是中文写的(手机号、姓名、地址……),发布的模型里改成了 phone_number、person_name、street_address 这种,中文名留在配置的 label_names_zh 里。改名只动元数据,分类头 23 个输出的顺序没变,我拿同一个输入对过,改名前后分数完全一样,偏差 0.0。

明确不做的: 不做归属判断(不区分买家电话和卖家电话),不做值抽取,不做跨字段关联。这个取舍让架构简单了很多,后面讲窗口的时候会说到。

数据:流量怎么来的,标签怎么定的

采集

训练数据用的是真实的 API 流量。按标签关键词检索可能相关的接口,再拉每个接口最新的样本,只留响应体,去掉 HTTP 头。

这里就有第一个坑:我一开始只采了响应体,没采请求侧。 这是沿用更早版本的做法,当时也没人论证过。后来才发现采样接口本来就同时返回请求头、查询参数、请求体、路径和方法,采集脚本只读了其中一个字段。

补上之后量了一下这个缺口:在含有对应模式的样本里,只出现在请求侧、响应里没有的,邮箱 7%、手机号 5%、长数字 ID 16%。不算大,但也不能忽略。

标注

全部用 AI 审核员逐行盲审。盲审的意思是审核员只看文本,不看已有标签、不看模型分数、不看路由提示,也不许写正则脚本批量打标。每 10 行一批,写成单独的文件,有个校验器检查批次和队列对不对得上。

出处记录的是实际用的审核模型名,从来不记成人工。这个后来帮了忙,有一次复核跨了两个不同的模型,我能按批分开记,不用笼统写一个。

切分:按端点分组,不要按行随机

这一步最容易错,错了后果也最严重。

同一个 API 端点会产出成百上千条结构几乎一样的响应体。按行随机切的话,同一个端点的样本会同时出现在训练集和验证集,验证分数会虚高很多,模型只要记住“这个接口长什么样”就能答对。

所以要按端点分组切,保证一个端点的所有样本整体进入同一个划分。我是按哈希分桶,训练、验证、标定三段不重叠。

结果后来还是被坑了一次:验证集扩容的时候,新的哈希区间是旧区间的超集。我拿旧模型在新验证集上做参照,303 行里有 49 行其实是它的训练数据,它在这些行上 AP 是 1.000。发现之后所有对比都改成只在“干净行”上算。

结论就是:拿旧模型在新划分上做参照之前,先查一下样本哈希有没有交集。

三个划分各自干什么

划分 行数 用途
训练 13,915 只用于反向传播
验证 4,359 选 checkpoint、算成绩、做统计检验
标定 1,600 只用来拟合每个标签的阈值

标定集要单独拿出来,是因为阈值本身也是在数据上拟合出来的。如果在验证集上拟合阈值又在验证集上报成绩,F1 就是虚高的。

模型和架构

基座

用的是 ModernBERT 架构的多语言编码器 mmBERT-base,22 层、768 hidden、256k 词表,原生支持 8192 位置。选多语言是因为流量里中文、英文、印尼文都有。

长文本:滑动窗口 + 跨窗口取 max

编码器一次吃不下 15 万个 token,只能切。流程是这样的:

长文本 → 切成 512 token 的窗口(相邻重叠 128)
       → 每个窗口过编码器,mask 掉 padding 后做 token 均值池化,过线性头得到 23 个 logit
       → 每个标签在所有窗口里取 max,作为整篇的分数
       → sigmoid 后和该标签的阈值比

取 max 这一步是核心,本质上是多实例学习(MIL):一篇文档是一个“袋子”,只要里面有一个窗口看到了手机号,整篇就算有。训练时梯度也只回传到最强的那个窗口。

之前有人问我这个窗口是模型自带的还是自己写的。其实切窗口是 HF 分词器的标准功能,return_overflowing_tokens=True 加一个 stride 参数就有了。自己要写的只有取 max 这一行,还有训练时挑窗口的逻辑。

还有个细节我自己一开始也说反了:stride 是重叠量,不是步长。512 减去 2 个特殊 token 剩 510 个位置,stride=128 是相邻两个窗口共享 128 个 token,所以每次实际前进 382 个。

训练过程和踩的坑

最早的训练脚本有三个问题

这三个都不会报错,但都在悄悄拉低效果:

  1. 标签轮转采样。 每个标签分到差不多的更新次数,不管它有几个正例。结果有个标签只有 1 个真实正例,这一条被重复抽了两百多次,严重过拟合。改成按正例数开平方加权,正例少于 3 个的标签不作为采样目标。
  2. 没有 checkpoint 选择。 跑完固定步数直接拿最后的权重。改成每 1500 步在验证子集上评估一次,训完恢复最好的那个。
  3. 正例只从 argmax 窗口回传梯度。 要是某个噪声窗口碰巧分数最高,梯度就去强化了错误的窗口。加了个选项可以对正例取 top-k 窗口的均值。实测 k=3 单独用有效,和其他改动叠在一起反而变差,最后没进配方。

怎么判断一个改动是不是真有用

同一个配方只换随机种子,宏平均 AP 就能波动 0.019。也就是说任何小于 0.02 的提升,只跑一个种子的话根本分不清是真的还是运气。

我的做法是按端点分组做配对 bootstrap,算改动前后差值的 95% 置信区间,区间跨零就算分不出来,点估计再好看也没用。每个标签也单独做一遍,另外加了个“实质性”门槛:统计上显著但绝对值下降不到 0.02 的,不算真退步。

用这个标准回头看,我跑的 14 组超参数全落在 0.891 到 0.914 之间,没有一组能和基线区分开。学习率、窗口步长、每次更新的文档数、分类头学习率,调来调去都在噪声里。

真正有用的四件事

按效果排:

1. 标签对不对,比任何超参数都重要。

我发现旧数据里“地址”的口径和现在的规范不一致:只有城市或者邮编的行也被标成了地址。模型判断“这不是街道地址”其实是对的,却被算成错。做了一轮盲审修正之后,同一个模型什么都没改,地址(street_address)的 AP 从 0.819 到了 0.889,正例数从 62 降到 39。

这部分提升和模型没关系,只是把尺子校准了。反过来想,要是没发现这个问题,我可能一直以为是模型能力不够,然后继续去调那些没用的超参数。

同一轮复核还暴露了一个真的盲区:拼音账号名(liruiming 这种)和 display_name 字段里的真名,按现在的规范应该算姓名,但旧标签没标,模型也就从来没学过,这类样本打分接近 0。

这里有个陷阱要小心。改完标签之后,用新标签训的模型在新验证集上当然更好,但这可能只是在拟合审核员的偏好,不代表真的更强。我把验证行按“标签有没有被改过”拆成两组分别算:增益全部来自被改过的那 58 行,在口径没变的 2,362 行上新模型反而略低。所以准确的说法是“模型换成了新口径”,不是“模型更会找人名了”。

2. DAPT(领域自适应预训练)。

先在同分布的无标注文本上做一轮 MLM,再微调。用了 24 万个无标注窗口,留出集 MLM loss 从 13.08 降到 0.743。

效果换种子也能复现,而且增益集中在正例最少的几个标签上:银行卡号(payment_card_number)0.82 → 0.86,身份证号(national_id)0.84 → 0.87,税号(tax_id)0.93 → 0.97。这也说得通,编码器先熟悉了这类文本长什么样,稀有标签就不用从零学表示。

这一步踩了个显存坑。MLM 要在每个位置上预测整个词表的分布,这个基座词表是 256k,输出张量跟着 batch 和序列长度一起涨,直接把卡打满。

解决办法是开稀疏预测,只在被 mask 的位置算 logits。mask 率 0.3,输出张量直接降到三分之一以下。

坑在这里:这个开关必须在 from_pretrained 的时候传进去,模型构造完再改 config 是没用的,因为输出层在构造时就已经按稠密模式建好了。我在这上面白跑了两次,第二次还以为是 batch 不够小。最后的配置是稀疏预测 + 梯度检查点,batch 8,梯度累积 4。

3. 全部层解冻。 只微调顶部几层和解冻全部 22 层比,后者高 0.029 AP,是单项最大的增益。

4. 权重平均(model soup),但有前提。 同配方不同种子的权重做均匀平均。三个种子平均后 AP 和 F1 的置信区间都不含零,AP 区间也是所有组里最窄的。但前提是被平均的每个模型都得够强,我另一组两个种子的平均反而比两个成员都低。

试了没用的三件事

  1. LLRD(逐层学习率衰减),这是唯一一个能明确分出来的负结果。让底层学习率按层数指数衰减,直觉上对小数据微调有利,实测两个种子的置信区间都不含零,平均掉了 0.041。事后想也合理:全部层解冻本来就是最大的增益来源,压低底层学习率等于把它抵消了。
  2. 权重 EMA,+0.001 和 +0.003,区间很窄而且跨零。
  3. 非对称损失(ASL),专门针对类别不平衡的,+0.009 和 +0.001,在噪声里。

这三个开关我都留在代码里了(默认关,不影响行为),但没进配方。写下来就是为了下次别再试一遍。

一个静默失败的 bug

我把新采的请求侧数据加进训练集,跑完一看成绩跟之前几乎一模一样。查下来发现训练脚本只训“真实来源”白名单里的行,我给新数据起的来源名不在白名单里,369 行被静默过滤了,一行都没参与训练。

退出码 0,gate 通过,报告也正常,就是什么都没学到。

唯一能看出来的地方是报告里的训练集行数。所以加新数据源之后,一定要核对实际参与训练的行数。

长上下文到底行不行

既然基座原生支持 8192,为什么不直接整篇塞进去,把切窗口这套省掉?

我一开始的想法是:长窗口效果差,是因为窗口内用的是 token 均值池化,窗口越长,一个手机号在池化向量里的权重就被稀释得越厉害。如果换成不稀释的池化方式,长上下文应该就能行。

实测下来这个想法是错的:

配方 窗口 池化 AP 长文档 AP 训练耗时
短窗口 512 均值 0.930 0.909 94–156 分钟
长上下文 2048 均值 0.896 0.825 188 分钟
长上下文 2048 近似 token max 0.894 0.848 189 分钟

两种池化都输,所以问题不在池化,在长度本身。

而且长上下文在长文档上最差,这跟“长窗口更能抓长距离关联”的直觉正好相反。

我现在的理解是:一个 512 的窗口如果刚好装着电话字段和它周围的内容,给出的是很干净的正信号;换成 2048,同一个电话被淹在 1500 个无关 token 里,模型要在一个池化向量里大海捞针。窗口越小,每个窗口的判断越简单,取 max 的时候越容易有一个干净的正例。

速度上长上下文也输

注意力是序列长度的平方复杂度,实测:

实际 token 数 512 窗口 2048 窗口 8192 窗口
514 2 窗 0.35 秒 1 窗 0.19 秒 1 窗 0.19 秒
2,045 6 窗 0.97 秒 1 窗 1.03 秒 1 窗 1.01 秒
4,098 11 窗 1.83 秒 3 窗 2.97 秒 1 窗 3.07 秒
8,269 22 窗 3.57 秒 5 窗 5.44 秒 2 窗 20.45 秒
32,936 86 窗 15.87 秒 19 窗 22.68 秒 5 窗 52.41 秒

看 8,269 那一行最直观:512 窗口因为有 33% 的重叠,实际送进编码器的 token 更多(11,001 对 8,785),但还是快了 5.7 倍。多算了四分之一的 token,总时间只有五分之一,差距全在注意力的平方项上。

不过两千 token 以下,一次性长上下文是略快的,因为这时候窗口数本来就只有 1 到 2 个,切窗口的固定开销占了上风。交叉点在两千到四千 token 之间。

所以“切窗口会拖慢吞吐”这个担心,方向其实是反的。

跨窗口关联做不到

跨窗口用的是取 max,相当于“或”,不是“与”。所以任何需要“A 和 B 同时成立”、而且两者距离超过大约 510 个 token 的判断,这个架构都做不到。

现在这 23 个标签都能局部判断,判断一个值是不是手机号,看它和旁边的字段名就够了,不需要看两千 token 之外的东西,所以没影响。

但以后如果要做关系型标签(比如区分买家电话和卖家电话),就得改。一个便宜的办法是把全局上下文(路径、方法、关键字段)拼成三十来个 token 的前缀,每个窗口前面都放一份,代价大概 6%。

显存:一个改动省掉了 A100

训 2048 窗口的时候一直 OOM,22 GB 的卡放不下,开了梯度检查点也没用。

查下来原因是:训练时每篇文档会给每个出现的标签各挑一个最强窗口,最多 23 个不同的窗口,这些窗口的计算图全都同时留着,等一次反向传播。窗口长度翻四倍,注意力显存涨十六倍,23 份图同时在显存里,不爆才怪。

编码器内部的梯度检查点只省层与层之间的激活,省不掉“23 份图同时存在”这件事。

解决办法是窗口级的检查点:每个窗口只保留池化后的输出,反向传播时再把整个窗口重算一遍,代价是每个窗口多一次前向。

配方 峰值显存
2048 窗口,改之前 21.2 GiB → OOM(卡容量 21.99 GiB)
2048 窗口,改之后 8.14 GiB
512 窗口,原本 11.49 GiB

改完之后 2048 的显存比原来 512 的还低 29%,大卡用不着了。

(单位都是 GiB,和 nvidia-smi 报的 21.99 GiB 同一个口径。按十进制 GB 算是 8.74 / 12.33,比例不变。)

这个改动我写了测试确认不改结果:开关打开和关闭,前向结果和分类头梯度一致,误差 1e-6 以内,梯度非零。说是“只省显存不改结果”的优化,最好都配一个这样的测试。

发布前才发现的问题:标签之间会互相压制

这是整个项目里我印象最深的一个坑,因为指标一直看着没问题,模型其实有洞。

准备对外发布的时候,我拿一个虚构的订单响应体做演示,里面有姓名、手机号、地址、邮箱、用户 ID、商品、掩码银行卡号、IP,一共八类。模型只报了五个,邮箱、邮编、商品数据、IP、银行卡号都漏了。

我第一反应是“手写的短 JSON 属于分布外”,还写进了文档里。后来做了对照实验,发现不是这么回事。

单独测每一个漏掉的值,全是满分:

  • 银行卡号:掩码的 4111 **** **** 1234、完整号码、只留后四位,三种都是 1.000
  • IP:client_ip、ip、remote_addr、X-Forwarded-For 等七种字段名,甚至裸值、放在句子里,都是 1.000
  • 邮箱:后面追加 300 条无关记录、切成 25 个窗口,还是 1.000

所以跟字段名、掩码、长度稀释都没关系。

然后我把字段一个一个加回去,看分数怎么掉:

文档内容 邮箱 IP 银行卡号 邮编
只有 email 1.000 0.000 0.000 0.000
加 client_ip 0.999 1.000 0.000 0.000
加 card_no 0.997 1.000 0.121 0.000
加 post_code 0.997 0.999 0.221 0.991
加姓名、手机、地址 0.988 0.787 0.008 0.791
再套两层嵌套 0.961 0.777 0.038 0.149

值没变、字段名没变、长度也没变,银行卡号从 1.000 掉到 0.008,邮编从 0.991 掉到 0.149。

原因

训练是多实例学习,每个标签只从自己最强的窗口回传梯度,但所有标签读的是同一个池化向量。一篇文档里同时有七八个正例标签的时候,编码器要在一个 768 维的向量里同时装下这些信号,容量是有限的。高频标签(姓名、手机号、地址,训练集里各有几百上千个正例)会挤掉稀有标签的表示空间。银行卡号在流量数据里只有 83 个正例,所以第一个塌。

为什么一直没发现

因为我一直看的是宏平均 AP。银行卡号的 AP 是 0.823,看着完全能用。但 AP 是在整个验证集上算的,验证集里含银行卡号的文档,大多数并不会同时有七八个其他类别。

聚合指标会把这种有条件的失效完全盖住。应该按“文档里同时有几个正例标签”分层看每个标签的召回,我之前从来没这么看过。

多标签模型成绩表里的每一行都是平均意义上的表现。如果你的真实数据在某个维度上(标签密度、文档长度、嵌套深度)比验证集更极端,那一行数字就不一定适用。发布前拿一个刻意构造的难样本手动跑一遍,比再多跑十组超参数有用。我这次就是这么发现的,而且是在已经准备发布之后。

最终成绩和适用范围

gate 的 14 个标签宏平均 AP 0.9297 / F1 0.8906,起点是 0.7055 / 0.6497。

这里有两个数容易搞混:14 个标签有成绩(验证集正例和负例都够 10 个),13 个标签的阈值是拟合过的(标定集正例够 10 个)。

“阈值没拟合”也不等于“不能用”,这个我一开始也搞混了。实测那 10 个用默认 0.5 的标签:IP、出生日期、年龄三个满分,银行卡号 F1 0.880,密码 0.667 漏了一半。真正坏的只有生物识别标识和门牌号两个(各自只有 1 到 2 个验证正例,本来也测不准)。另外护照号、社会保障号、车牌号三个验证集里一个正例都没有,完全不知道效果。

算下来就是:23 个标签里 2 个坏、3 个未知、1 个召回偏低,剩下 17 个能用。这些我都写进模型卡了。

另外还要加上上一节说的:就算是这 17 个能用的,在标签很密集的文档上也可能接近失效,聚合指标测不出来。

适用范围也要说清楚。训练和标定都是在 API 流量上做的,我拿七种非流量文本测过:CSV、SQL dump、日志、纯中文段落、邮件、Markdown 简历。高频标签(姓名、手机号、地址、邮箱)迁移得出乎意料地好,分数都在 0.99 以上;稀有标签基本不行。

自由文本上的误报要分版本说,因为最后一轮修掉了:20 条完全没有个人信息的句子,早期模型误报了 6 条姓名(当时姓名阈值只有 0.20),最终交付的模型是 0 条。原因是改完标签口径之后重新标定,姓名阈值从 0.20 升到了 0.96。所以校准尺子不光改变了成绩数字,也改变了模型在分布外的表现。

还有个挺有意思的现象:孤立的值反而识别不出来。单独给一个手机号,什么都不报;把同一个号码放进一句话里,1.00。模型学的是“值 + 上下文”,不是纯粹的格式匹配。

我的几点经验(偏主观)

  1. 先校准标签,再调模型。我在超参数上花的时间远远超过它的收益,两轮标签复核带来的提升比所有超参数改动加起来还多。新项目应该先抽样做一次标注一致性检查。
  2. 任何改动至少跑两个种子。单种子的结论在 0.02 以内都不可信。
  3. 切分按业务分组,不要按行随机。每次改划分都查一下和历史模型训练集的交集。
  4. 阈值标定和成绩报告用不同的数据。
  5. 失败的尝试也要记。我这轮有三个明确没用、一个明确有害的改动,这些结论跟成功的一样有用。
  6. 小心不报错的失败。静默过滤、配置字段被基类覆盖、审核指南和队列对不上,这些都不会抛异常,只能靠核对中间的数量发现。
  7. 别只看聚合指标。我的宏平均 AP 从 0.71 涨到 0.93,一直到发布前手动构造了一个难样本,才发现标签之间会互相压制。
  8. 发布前自己当一次读者。我准备发的材料里有三处会被当场质疑的错:速度表的基准代码在长上下文那列跑了两次前向却按一次算、把某一档的均值当成了中位数延迟、显存单位 GB 和 GiB 混用。都是自己重跑才查出来的。对外的数字,发之前自己再跑一遍。

开源的部分

窗口化推理这套代码和 PII 这个任务没关系,只要是“长文档 + 多标签 + 编码器”的场景都能用。我单独整理成了一个仓库:

https://github.com/godzeo/sliding-window-transformers

三个模块,八个测试:

  • windows:切重叠窗口,记录每个窗口属于哪篇文档
  • aggregate:跨窗口聚合,max / mean / top-k / log-mean-exp 四种,各自适合什么场景、有什么坑都写在文档里了
  • checkpoint:训练时的窗口级梯度检查点,就是前面把 21.2 GiB 降到 8.14 GiB 的那个改动

README 里有这篇的实测数字:准确率、速度、显存对比。

模型本身也打成了包,detect(任意长度文本) 直接返回标签,窗口对调用方是透明的。标签是英文的(phone_number、person_name、street_address 这种),中文名在配置里能映射回去。

模型卡里我放的是一个失败的例子,没放好看的:就是上面那个订单响应体,漏了五个标签,我把漏报原样列出来,也写了标签压制的原因。反正用的人迟早会拿自己的样本去测,与其让他们测出问题觉得这模型不行,不如先说清楚什么情况下不行、为什么不行。23 个标签里哪两个坏了、哪三个没测过、哪一个召回只有一半,也都标出来了。

还没解决的

  1. 标签压制目前只是发现了、量化了,还没修。可能的方向是按标签密度做训练采样,或者让稀有标签有自己的池化,还没试。
  2. 延迟长尾。21% 的流量超过 8k token,p99 到了 73 秒,真要实际用的话得先做截断或者分级处理。

最后

模型已经能用了,但离“放心用”还有一段距离,后面再慢慢补。