多语言敏感信息检测模型训练日志
这篇文章记录一个多语言敏感信息识别项目的完整训练日志。它关注的是工程路径本身:原始 AI 合成语料如何被清洗成可训练数据,哪些增强真正提高了监督质量,哪些架构在日志和代码块场景下看起来合理却会系统性失效,以及最终为什么是分层架构加数据侧 hard sample 方案收敛下来。
项目目标是多语言敏感信息识别——一个比通用 NER 更窄、条件更苛刻的场景。输入既包括自然语言为主的技术方案、故障报告或者聊天记录,也包括日志、代码块、连接串、配置片段和混合文本。训练数据覆盖 15 种语言,实体既包含通用 PII(个人姓名、邮箱、电话、密码等),也包含云资源标识(Instance ID、SecretId/SecretKey)、腾讯云专属标识(UIN、APPID、STAFF_ID),以及代码日志容器实体(CODE_BLOCK、LOG_BLOCK、INLINE_CODE)。系统要同时满足三个要求:多语言泛化、嵌套识别、困难边界鲁棒性。
这里的困难边界有两类。一类是 hard positive,例如值本身很弱(一个普通的数字串、一段普通文本),但上下文明确说明它就是密码、验证码或证件号;另一类是 hard negative,例如字符串格式很像 PII(长随机串、带前缀的标识符),但它只是示例值、占位符、公开测试凭据、文档样例或日志中的非敏感标识。
上游数据是大规模 LLM 合成语料。规模足够大,语言覆盖也足够广,但问题同样集中:标签本体漂移、占位符污染、演示值高频复用、模板固化严重,而且最上游只有 \((value, type)\) 对,没有字符级 span。原始语料总量为 2,603,742 条,实体总数超过 7200 万,类型却膨胀到 442 种,其中相当一部分是模型幻觉出来的错误标签。
这个约束直接决定了后续数据工程的设计。训练管线不在中间文件里长期保存 offset(字符偏移量),而是统一保留 \(\{type, value\}\),在构建训练集时再通过 string matching 动态恢复 span。这样做的收益非常实际:只要保证 \(value \in text\),就可以安全地做变长替换、前缀删除、书写变体转换和语言地域扩展,而不需要在每个中间阶段维护一条脆弱的 offset 传播链。
清洗聚焦两个目标:
- 标签本体收敛,把上游噪声类型压回稳定实体集合。例如删除 IP 父类,只保留 IPV4 和 IPV6;把 URL 统一改写为 URI,再从 URI 中拆出 URN;对 OTP、 JWT、 DOMAIN、 POSTALCODE 这类高度依赖格式的实体施加更严格的正则和上下文条件。
- 通用噪声清洗,包括占位符过滤、空值过滤、重复实体去重和异常值剔除。
这一步的意义超出了数据清洗本身——它在定义模型将来到底学什么。举例来说, DOMAIN 只保留裸域名,出现 @、 :// 或 / 一律丢弃; OTP 只接受 4 到 8 位纯数字,并要求近邻上下文出现验证码语义; JWT 要同时满足长度、三段式和正则结构要求。这样得到的训练标签虽然更少,但语义边界更清楚。
原始语料的另一个问题是示例值复用。项目内部统计显示,多类高风险实体都存在明显的 prompt-level 模板固化——同一批 LLM 生成的数据里,同一类实体反复使用极少数固定值。如果不处理,模型会记住这些值的字面内容("遇到 AKID1234567890 就标为 SECRET_ID"),学不到它们的结构规律("AKID 加 32 位字母数字 → SECRET_ID")。高频值随机化的目的是迫使模型从字符串结构学习,打破对演示值的死记硬背。
| 类型 | 问题表现 | 随机化约束 |
| SECRET_ID | 公开示例前缀和固定长度值大量重复 | 保留厂商前缀(AKID、AKIA、AIza、ghp_、sk-、ak-)、长度和字符集 |
| SECRET_KEY | hex 或 base62 样例高频复用 | 保留长度和字符集类别(hex/base62/base64) |
| APPID / UIN | 固定数字段反复出现 | 保留长度和数字结构 |
| IPV4 / IPV6 | 公开演示地址高度集中 | 保留 private、loopback、documentation 等语义类别 |
| CLOUD_INS_ID | 云资源前缀模式高度重复 | 保留资源前缀(ins-、cdb-、lb-)和整体长度 |
| UUID / URN | 标准示例串集中 | 保留版本位或 urn:<nid>: 结构 |
随机化采用 record 内一致、跨 record 独立的策略。同一条记录里同一个原值无论出现在实体还是正文复述里,都必须映射到同一个新值,否则上下文一致性会被破坏。这一点对日志和代码场景尤其重要,因为敏感值经常在同一条文本里被多次复述——比如一条日志里 SecretKey 出现三次(配置、报错消息、堆栈变量),它们必须是同一个替换值。
训练集不是清洗完就直接开训的——它经过了多轮定向扩展。增强流水线覆盖国家变体扩展、locale-sensitive 重采样、银行与证件子类型补齐、云计算生命周期语料、中文书写变体、腾讯云专属增强、繁体中文转换、日志代码注入和 hard sample 生成。每个增强模块解决一个具体的覆盖缺口,单纯堆数据量没有意义。
| 增强模块 | 目的 | 规模 |
| locale-sensitive 重采样 | 把相同语言拆到不同国家格式,修复电话、地址、邮编等区域依赖 | 134,141 条 |
| 银行与证件子类型补齐 | 补入 SWIFT、CVV、routing number、MRN、insurance policy 等弱覆盖子类 | 748,150 条 |
| 云计算生命周期语料 | 生成真实云运维工单、故障排查、配置片段和厂商交叉场景 | 3,926,300 条 |
| 中文书写变体 | 补齐全角数字、全角括号和中文金额写法 | 107,032 条 |
| 腾讯云专属增强 | 把特定场景中的 PERSON 替换为 STAFF_ID,补齐内部标识分布 | 112,352 条,替换 126,164 个实体 |
| 繁体中文转换 | 扩展 zh-TW、zh-HK、zh-MO 书写与本地化格式 | 209,435 条 |
| 日志代码注入 | 补齐 LOG_BLOCK、 CODE_BLOCK、 INLINE_CODE 及内部嵌套 PII | 新增 1,500,000 条,合并后总量 9,232,932 条 |
| hard samples | 补充 hard positive、hard negative、mixed difficulty 样本 | 合并后总量 10,455,978 条 |
这里最关键的两个模块是日志代码注入和 hard samples。前者解决"代码和日志监督信号太弱"的问题——如果训练集里代码块只来自模板生成的干净示例,模型会把带 fenced marker 的模板块当成默认形态,遇到没有 fence 的裸日志就失效。后者解决"模型看过很多正样本,但没有真正看过边界附近的对照样本"的问题——一个 git commit hash 很像 SECRET_KEY,一个 k8s pod name 很像 CLOUD_INS_ID,但它们不是 PII。如果没有这两个模块,模型很容易在普通 prose 上看起来不错,但一遇到 stack trace、配置块和示例值就开始误报。
这个项目后期最大的进展来自多次关键 Bug 修复——它们把数据一致性拉回可控状态,效果超过任何新模型。最典型的问题是 val_not_in_text:28,591 个实体的 value 字段在对应的 text 中找不到。如果 entity.value 和 text 使用了不同的随机值,或者正文已经转换成新书写形式但实体值没有同步更新,模型学到的就是错误标注本身——它会试图在文本中找到一个根本不存在的字符串。
几次关键修复包括:为随机化后的数据加上最终 value in text 过滤(直接丢弃 value 不在 text 中的实体);修复中文书写变体增强中 text 和 entity.value 不同步的问题;在 anti-bias 重随机化时同步更新容器型实体内部嵌套的 EMAIL、 DOMAIN、 URI 子串(例如 user@old-domain.com 如果 domain 被替换了,entity value 也必须更新);以及彻底移除中间文件中的 start/end,把 span 统一留到训练集构建阶段恢复。整个数据管线的设计原则是让 bug 无法向下游复制,而非追求零 bug。
当前真正落地并持续迭代的只有这一条模型线,而不是三条彼此独立的训练方向。它把多语言泛化、代码日志场景建模和行业专属增强合并到同一个训练系统里,目标是在一套数据分布和一组分层模型上同时解决语言差异、文本形态差异和行业实体差异。
- 多语言部分覆盖 15 种语言。locale-sensitive 重采样额外补入 134,141 条国家格式差异样本,中文书写变体补入 107,032 条,繁体中文转换再补 209,435 条,目的是让同一实体类型在不同国家格式、不同脚本和不同标点系统下仍能保持稳定边界。
- 代码日志增强直接决定 chunker 是否可用。日志代码注入一次性补入 1,500,000 条样本,合并后训练集扩大到 9,232,932 条;再叠加 hard samples 后,总量达到 10,455,978 条。真正提升效果的不是总量,而是分布被拉回真实线上形态:代码块不再只有干净模板,日志不再只有短句示例,hard negative 也开始系统覆盖 hash、资源名、公开测试凭据和占位符。
- 行业增强主要覆盖云计算与企业内部标识。云计算生命周期语料扩到 3,926,300 条,把工单、故障排查、配置片段和跨厂商运维上下文一起拉进来;银行与证件子类型补齐 748,150 条,补上 SWIFT、CVV、MRN、insurance policy 等弱覆盖类型;腾讯云专属增强新增 112,352 条样本,并执行 126,164 次 PERSON 到 STAFF_ID 的定向替换,让内部员工标识在真实业务上下文中形成稳定分布。
当前版本的训练系统拆成三层,其中 L3 已经完成设计,但尚未开始训练:
- L1 只做 chunk 定位,用来找到代码块和日志块的精确边界。
- L2 只处理 prose(自然语言正文)中的 PII span。
- L3 处理 code/log 内部的高风险实体,当前处于架构设计完成、训练未启动状态。
这么拆是因为长连续区域检测、短 span 抽取和块内高频实体识别的统计结构并不相同,不应共享同一个问题框架:
| 层级 | 目标 | 典型 span 长度 | 正负比 | 适合的框架 |
| L1 — Chunker | CODE_BLOCK / LOG_BLOCK 边界 | 50-500 tokens | 21% 正 | 语义分割(per-token inside/outside) |
| L2 — Prose PII | 32 种 PII 实体 | 1-12 tokens | 有 chunk 的 token ~5-15% | span extraction(start/end scoring) |
| L3 — Inblock PII | 代码/日志内的 14 种高频实体 | 1-8 tokens | ~10-20% 有实体 | 从 L2 继续微调 |
Backbone 也因此采用异构组合。L1 使用 ModernBERT(mmBERT,307M 参数),因为它原生支持 8192 token 上下文,并且预训练语料包含代码和日志,对长上下文的代码块边界检测更友好。L2 使用基于 mDeBERTa-v3 的 GLiNER(278M 参数),因为它处理的是更短、更密集的 token 级抽取任务,mDeBERTa 的 disentangled attention(解耦注意力)机制对 token 表征精度更优。L3 的设计也是从 L2 best checkpoint 继续微调 GLiNER,但训练计划尚未执行。
下面按时间顺序复盘 L1 Chunker 的四次架构迭代。这是整个项目中最波折的部分——一个"肉眼就能区分代码块和正文"的任务,用 300M 参数的预训练模型训了三天才真正解决。故事值得完整展开。
最早的 L1 方案是经典的序列标注:mmBERT 编码器输出每个 token 的向量表示,接一个线性分类头把每个 token 分为 B(Begin,实体起始)、I(Inside,实体内部)、O(Outside,非实体)三类标签,最后用 CRF(条件随机场)层做解码,确保输出的标签序列满足 BIO 合法转移约束(比如 B 后面只能跟 I 或 O,不能跟另一个 B)。辅助损失使用 Focal Loss 放大稀疏 B-tag 的梯度。
训练配置是 3 个 epoch、LR 3e-5、warmup 5%,主指标是 chunk F1。训练日志显示,模型在 token 级别很快学会了 inside/outside 分类(token F1 长时间维持在 0.95 以上),但 chunk 级结果始终不稳定。best chunk F1@IoU≥0.8 停在 0.6159(对应 step 21K);训练终止时已经跑到 step 103,150 / 304,083,LR 衰减到 1e-6 floor,loss 仍在 38K 到 45K 区间震荡。
这个结果揭示了 BIO+CRF 在长块检测中的结构性缺陷:
| 问题 | 描述 |
| 信号溺没 | 一个 500-token 的代码块,BIO 标注为 1 个 B + 498 个 I + 1 个 O。边界信号(B-tag)在 500:1 的 inside token 海洋中被稀释到几乎不可见。 |
| CRF 容量有限 | CRF 的转移矩阵只有 7x7 = 49 个参数(BIO 三种标签 × 2 种实体类型 + O),它只能建模相邻标签之间的转移概率。"B 之后需要跟多长的连续 I"这种长距离依赖,CRF 没有记忆机制来学习。 |
| Viterbi 局部性 | CRF 的解码算法(Viterbi)虽然是全局最优解码,但每步只看前一个标签。"一个 B 后面应该跟多少个 I"的决策被拆解成逐步累加的局部决策,边界误差会逐步累积。 |
| LR 敏感 | CRF 转移矩阵在高学习率下极不稳定——LR 在 warmup 期间偏高时,chunk F1 从 0.43 暴跌到 0.10 再弹回 0.36,而 token F1 基本不变。这说明 encoder 没退化,是 CRF 的 49 个转移参数被震坏了。 |
结论:CRF 对短实体 NER 有价值(1-5 token 的实体,B-tag 比例合理,约 1:3-1:5),但当目标是 50-500 token 的代码块时,BIO 框架本身就不适合了。
既然 BIO 不适合长 span,随后改用了 EfficientGlobalPointer——一种直接预测 \((start, end)\) 边界对的架构。GlobalPointer 的核心思想很简单:对序列中每一对 (token_i, token_j) 计算一个"这对 token 是否构成某类实体的起止点"的分数,然后选择分数高的作为预测结果。具体来说,它把 encoder 输出分成"query"和"key"两组向量,再用旋转位置编码(RoPE)注入相对位置信息,最终通过内积打分:
\[s_c(i,j) = \mathrm{RoPE}(q_i^c)^\top \mathrm{RoPE}(k_j^c)\]其中 \(c\) 是实体类型,\(i, j\) 分别是候选的起始和结束 token 位置。EfficientGlobalPointer 通过将 head_dim 分解为低维投影来避免显式构造 \([B, T, T, C]\) 的全尺寸矩阵,将内存从 \(O(T^2 \times C)\) 降到 \(O(T \times d)\)。
为了增强对长 span 的感知能力,我们在 encoder 和 GlobalPointer 之间插入了一个 1D U-Net 解码器做多尺度特征融合。训练用的是 GlobalPointer 标准的 Circle Loss(一种将正/负样本分数推向不同方向的 metric learning loss)。
训练跑了 50K steps,loss 从约 100 降到约 18,表面上看模型在学。但独立分析揭示了更糟的问题:best checkpoint 的 chunk F1@IoU≥0.8 只有 0.447(step 30K)。对模型输出做 threshold sweep 时发现,正样本分数的中位数约为 -0.36,负样本分数的中位数约为 -0.34——两者几乎完全重叠,模型根本没有学会区分正负 span。
根因分析指向三个结构性问题:
- 长 span 的 start 和 end 语义耦合极弱。GlobalPointer 通过 start token 和 end token 的向量内积来判断它们是否构成同一实体。对于短实体,start 和 end 的上下文几乎相同,内积自然高;但一个 300-token 的代码块,起点和终点的上下文几乎没有可比性,内积和随机配对没有本质区别。
- 候选空间爆炸且极端稀疏。一个 512-token 窗口里,所有合法的 \((start, end)\) 对有 \({512 \choose 2}=130816\) 个,但其中平均只有 0.07 个是正样本,因为 79% 的记录根本没有代码块。正负比接近 1:1,876,114,任何 loss 在这个稀疏度下都会被海量负样本主导,模型学到的唯一安全策略就是所有 span 都给低分。
- 问题框架本身错误。GlobalPointer 解决的是"哪对 \((start, end)\) 构成一个离散实体",这是 span extraction;代码块检测本质上是"哪些 token 落在同一连续区域内部",这是语义分割。用 span enumeration 框架解决 segmentation 问题,数学对象一开始就不对。
这个案例最有价值的教训是:代码块检测表面上像 NER,但其数学结构完全不同。NER 实体短、离散、一句话里有多个,span scoring 适合;代码块长、连续、极其稀疏,逐 token 分割才是正确框架。
认清问题框架后,方案继续向 per-token segmentation 改写。对每个 token \(x_i\),模型输出两个概率:
\[P(y_i=\mathrm{CODE}\mid x_i), \quad P(y_i=\mathrm{LOG}\mid x_i)\]它们分别用来判断当前位置是否落在代码块或日志块内部。这把 loss 的正负比从 1:1,876,114(全 span 枚举)降到了约 1:4(token 级别,21% 的 token 在 chunk 内)。同时保留 1D U-Net 做多尺度特征融合,新增一个 Boundary Head 预测起止边界位置。
这个框架已经是正确方向,但第一次实现仍然失败,主要卡在两个地方:
- BCE(Binary Cross-Entropy)在 7% 正样本占比下仍然会坍塌。BCE 对每个 token 计算交叉熵再取平均。当 93% 的 token 是负样本时,一个"全部预测为 outside"的模型能得到非常低的 loss(约 0.001),因为它在 93% 的 token 上损失为零。模型会发现,与其尝试预测正类,不如全部预测负类。这就是 majority class collapse。
- 训练评估函数存在严重 Bug。评估代码把 accuracy 错当成 token F1 报告给训练日志。在 93% 负样本的数据上,一个"全预测 O"的模型 accuracy 就有 0.93,加上一点随机波动就能显示 0.993。训练日志里的 token_f1=0.993 让团队误以为模型已经基本解决了问题,实际上独立验证发现真实 F1 只有 0.196。
对同一 checkpoint 做独立 precision/recall 分析:
| 指标 | 训练日志报告 | 独立验证真实值 |
| Token F1 | 0.993 | 0.196 |
| Precision | —(未拆分报告) | 0.126 |
| Recall | —(未拆分报告) | 0.443 |
这个阶段最大的收获是暴露了一个工程事实:错误的评估函数比错误的模型更危险,因为它会让团队长时间误以为模型在收敛,浪费 GPU 时间在空转上。
最终稳定下来的 L1 方案仍然是 token segmentation,关键修改有三项:
- 主损失从 BCE 换成 Dice Loss。
- 加入正负窗口平衡采样。
- 重写评估函数,独立报告 precision 和 recall。
Dice Loss 的数学形式是:
\[\mathrm{Dice}(P,G)=\frac{2|P \cap G|+\epsilon}{|P|+|G|+\epsilon}, \quad \mathcal{L}_{dice}=1-\mathrm{Dice}(P,G)\]其中 \(P\) 是模型预测为正的 token 集合,\(G\) 是 ground truth 中确实为正的 token 集合。Dice 系数衡量的是两个集合的重叠程度:分子 \(2|P \cap G|\) 是预测与真值共同覆盖的区域大小,分母 \(|P| + |G|\) 是双方总量之和。
关键性质:如果模型"全预测 outside"(\(|P| = 0\)),则 \(\mathrm{Dice} = \epsilon / (0 + |G| + \epsilon) \approx 0\),loss = 1.0——这是最大惩罚。换句话说,Dice Loss 在数学上阻止了 majority class collapse:你不可能通过"什么都不预测"来获得低 loss。它只看预测区域和真实区域的重叠,不关心负类有多少——哪怕 99% 的 token 是负样本,只要正类的重叠不好,loss 就不会低。
作为对比,BCE 在"全预测 O"时近似为:
\[\mathcal{L}_{BCE} \approx -\frac{1}{N}\sum_{i}\log(1-0)\cdot \mathbb{1}[y_i=0] = 0\]这几乎等于零,因为负样本占 93%,模型可以安全地躲在"全预测 O"的角落里。
实际训练中使用的是组合损失:
\[\mathcal{L} = \mathcal{L}_{dice} + 0.5 \times \mathcal{L}_{BCE}\]其中 BCE 分支的正类权重设为 10。Dice 负责阻止坍塌,加权 BCE 负责提供逐 token 的细粒度梯度,加速收敛。
正负窗口平衡采样通过 neg_subsample=0.27 参数实现。训练数据中 79% 的记录没有任何 chunk(纯负样本),只保留 27% 的纯负记录参与训练,使正负记录比例接近 50:50。计算方式为 21% 正 / (21% 正 + 79% × 0.27 负) ≈ 50%。这确保每个 mini-batch 中都有足够的正样本产生有意义的梯度。
评估函数则被重写为分别报告 precision(预测为正的 token 中确实为正的比例)和 recall(ground truth 为正的 token 中被正确预测的比例),不再使用任何综合指标掩盖真实状况。
训练使用 512 context、2 卡 DDP、30K steps。best checkpoint 出现在 step 8K——Dice Loss 让模型在 250 步就开始真正学习正类(vs BCE 训练了 60K 步都只学负类)。最终指标如下:
| 指标 | 数值 | 含义 |
| Token Precision | 0.996 | 模型预测"在块内"的 token,99.6% 确实在块内 |
| Token Recall | 0.999 | 真正在块内的 token,99.9% 被正确识别 |
| Token F1 | 0.997 | 逐 token 几乎完美 |
| Chunk F1 @ IoU≥0.5 | 0.974 | 97% 的代码块/日志块被检出(宽松匹配) |
| Chunk F1 @ IoU≥0.8 | 0.961 | 96% 的块与 ground truth 高度对齐 |
| Chunk Exact (≥0.95) | 0.928 | 93% 的块几乎完全匹配 |
| Precision @ IoU≥0.8 | 0.942 | 94% 的预测是正确的(低误报) |
| Recall @ IoU≥0.8 | 0.981 | 98% 的 ground truth 被找到(低漏检) |
后处理也非常简单:将输出概率的阈值设为 0.4(比默认的 0.5 更 aggressive,提高 recall),并过滤掉长度小于 10 个字符的预测块(消除噪声碎片)。最佳策略就是与训练目标一致的最小干预。模型本身已经学会了 chunk segmentation,后处理只是在修剪边角。
把四次 L1 架构迭代放在一起,可以看到一条清晰的认知演进路径:
| 版本 | IoU≥0.8 F1 | 问题框架 | 失败原因 | 教训 |
| v1: BIO+CRF | 0.62(不稳定) | 序列标注 | CRF 不适合长 span,LR 敏感 | CRF 是短实体工具 |
| v2: GlobalPointer | 0.45 | Span extraction | 正负比 1:1.8M,start/end 耦合弱 | 问题框架错误 |
| v3: Seg+BCE | 0.20(虚假报告 0.99) | Per-token 分割 | BCE majority collapse + eval bug | Loss 和 eval 都要验证 |
| v4: Seg+Dice | 0.961 | Per-token 分割 | — | 正确的 loss + 正确的 eval |
从 v1 到 v4,代码量变化不大(核心改动只有 loss function 和 eval function),但认知变化巨大。最终方案的代码比 GlobalPointer 版本更简单——没有 bilinear scoring matrix,没有 RoPE,没有 circle loss,只有逐 token 二分类 + Dice + 阈值后处理。真正困难的地方集中在三个判断:
- 识别出正确的问题框架,确认这是 segmentation 而不是 span extraction。
- 识别出 loss function 的坍塌模式,理解 BCE 允许"全预测 O"获得低 loss。
- 识别出 eval 在说谎,发现 accuracy 被伪装成 F1。
L2 当前采用 GLiNER 路线。GLiNER 的核心特性是用自然语言文本描述实体类型——训练时模型不仅学习"哪些 token 是实体",还学习"什么样的描述对应什么样的实体"。具体来说,它将类型标签(如"secret access key for cloud services")和输入文本拼接后送入 mDeBERTa 编码器,然后用 bilinear scoring 对所有候选 span 打分。这意味着推理阶段可以不重训就尝试新标签——只要提供一段自然语言描述。
L2 负责 prose 中的 32 类 PII。选择 GLiNER 的理由在于它的自然语言标签和运行时扩类能力——对于仍在快速迭代的 PII 类型定义,能不重训就试新类型是很实际的需求。准确性方面它未必最优,但系统级集成的灵活性胜出。
工程代价同样很清楚。L2 在 per-device batch size 为 8 时,在 step 587 即触发 CUDA OOM(原因是 span scoring 矩阵的内存开销);降到 4 之后才能稳定训练。更麻烦的是训练速度——GLiNER 的数据预处理需要对每个类型标签独立做 tokenize 和 span 矩阵构建,这是一个 Python GIL 下的串行循环,80 个 CPU 核只有 1 个在干活。训练速度约为 1.77 秒每步(4 GPU DDP),单轮完整评估接近 4 小时。
L2 训练从 checkpoint-25000 续训到 60K steps 时,出现了一个有趣的现象:HuggingFace Trainer 的 resume 机制重置了学习率 schedule,导致 LR 从 8.6e-6 跳回 2.15e-5。这等效于 SGDR(Stochastic Gradient Descent with Warm Restarts,Loshchilov 2017)中的 cosine warm restart——eval_loss 从 1.319 短暂跳到 2.879,但随着 LR 重新衰减,模型最终在 step 60K 收敛到 1.259(远优于 restart 前的 1.319)。这次意外的 warm restart 确实帮助模型跳出了局部最小值,找到了更优解。
对 checkpoint-55000 在 5000 条验证集上做 span-level 精确匹配评估(threshold=0.5),得到 micro 指标:
| Micro 指标 | 数值 |
| Precision | 0.9935 |
| Recall | 0.7610 |
| F1 | 0.8618 |
| TP / FP / FN | 34,650 / 228 / 10,881 |
Precision 近乎完美(99.4%),说明模型标出来的实体几乎全是对的。Recall 偏低(76.1%),意味着约 24% 的真实实体被漏掉了。下面的逐类型分析揭示了漏检的结构性原因。
各实体类型完整 P/R/F1:
| 实体类型 | P | R | F1 | TP | FP | FN |
| email address | 1.000 | 0.975 | 0.987 | 3451 | 1 | 88 |
| phone number | 0.997 | 0.960 | 0.978 | 2123 | 6 | 89 |
| postal code | 0.993 | 0.893 | 0.941 | 997 | 7 | 119 |
| person | 0.996 | 0.868 | 0.928 | 3387 | 14 | 515 |
| domain name | 1.000 | 0.857 | 0.923 | 1739 | 0 | 291 |
| user identification number | 0.997 | 0.850 | 0.918 | 1725 | 5 | 305 |
| ipv4 address | 0.999 | 0.848 | 0.917 | 1865 | 1 | 335 |
| application id | 0.996 | 0.840 | 0.911 | 1424 | 6 | 271 |
| bank account number | 0.988 | 0.842 | 0.909 | 1412 | 17 | 265 |
| uuid | 1.000 | 0.818 | 0.900 | 1651 | 0 | 368 |
| national id number | 0.987 | 0.805 | 0.887 | 1322 | 18 | 320 |
| identifier | 0.984 | 0.806 | 0.887 | 946 | 15 | 227 |
| staff id | 0.998 | 0.770 | 0.870 | 643 | 1 | 192 |
| monetary amount | 0.982 | 0.757 | 0.855 | 1020 | 19 | 327 |
| cloud instance id | 0.999 | 0.739 | 0.850 | 1770 | 1 | 625 |
| organization name | 0.970 | 0.745 | 0.842 | 1470 | 46 | 504 |
| api secret key | 0.998 | 0.723 | 0.838 | 1002 | 2 | 384 |
| urn | 1.000 | 0.693 | 0.818 | 888 | 0 | 394 |
| location | 0.944 | 0.716 | 0.814 | 836 | 50 | 331 |
| api secret id | 1.000 | 0.682 | 0.811 | 824 | 0 | 385 |
| json web token | 0.993 | 0.674 | 0.803 | 694 | 5 | 336 |
| one time password | 0.989 | 0.675 | 0.803 | 370 | 4 | 178 |
| password | 0.996 | 0.666 | 0.798 | 827 | 3 | 415 |
| address | 0.995 | 0.541 | 0.701 | 766 | 4 | 649 |
| uri | 0.999 | 0.404 | 0.576 | 1305 | 1 | 1922 |
| ipv6 address | 0.990 | 0.156 | 0.269 | 193 | 2 | 1046 |
IPV6(R=0.156)、URI(R=0.404)、Address(R=0.541)三个类型的 recall 明显偏低。排查后确认这完全是 GLiNER 的两个架构硬限制导致的,模型本身已经达到了理论上限。
限制一:max_width=12 的 span 宽度天花板。GLiNER 只枚举宽度 1 到 max_width 的候选 span,超过 12 个 token 的实体在数学上无法被预测。问题在于 tokenized_text 把标点拆成独立 token——一个标准 8 组 IPv6 地址(如 2001:db8:e23a:0:0:0:0:1)被切成 8 个 hex 组 + 7 个冒号 = 15 个 token,永远超出 max_width=12 的限制。类似地,长 URI 的 ://、 /、 . 各占一个 token 位置,3 段以上路径的 URL 就超过 12 个 token。77% 的 IPv6 和 49% 的 URI 被这个限制卡死。
限制二:max_length=384 截断。mDeBERTa 的位置编码上限是 512,但 GLiNER 需要把 32 个类型标签的自然语言描述也塞进同一个序列(占约 128 个 token),留给实际文本的空间只有 384 个 token。超出的部分对模型不可见。31% 的 IPv6 和 22% 的 URI 实体落在截断区域之外。
将两个限制叠加后计算理论上限,再与实际 recall 对比:
| 实体类型 | 理论 Recall 上限 | 实际 Recall | 模型效率 |
| ipv6 address | 15.5% | 15.6% | 100% |
| uri | 40.1% | 40.4% | 100% |
| address | 54.4% | 54.1% | 99.5% |
模型找到了每一个架构允许找到的实体。Precision 全部 >0.99。这不是模型质量问题,是 span enumeration 架构对长实体的先天缺陷。
解决方案有两条路:一是将 max_width 从 12 提到 16 重训(IPv6 覆盖率从 23% 提到接近 100%,显存增加 33%,bsz 需从 4 降到 3);二是对 IPv6、URI、EMAIL 等高度结构化类型直接叠加正则 fallback 层——零训练成本,recall 直接到 0.95+。两者可以同时做。
到当前版本,系统形态已经明确:
| 层 | 模型 | 状态 | 核心指标 |
| L1 — Chunker | mmBERT + 1D U-Net + Dice seg head | 完成 | Chunk F1@IoU≥0.8 = 0.961 |
| L2 — Prose PII | GLiNER (mDeBERTa-v3-base) | 完成(60K steps, 41h50m) | eval_loss: 2.879→1.259(持续下降至终点) |
| L3 — Inblock PII | GLiNER fine-tune from L2 | 已设计,未开训 | — |
真正让系统跨过可用门槛的是三件事同时成立:训练数据不再被模板值污染(随机化 + val_not_in_text 修复),hard negative 进入文本本身(在数据层面制造对照,而非仅靠 loss 层面的负采样),L1 的 loss 与任务结构终于对齐(Dice Loss 阻止坍塌 + per-token 框架匹配 segmentation 语义)。
L2 训练已完成,60K steps 共耗时 41 小时 50 分钟。eval_loss 从 LR reset 引发的 2.879 高点单调下降至 1.259,最终 LR 衰减至 8.6e-10(cosine schedule 自然到底)。完整 eval_loss 曲线:2.879 → 2.234 → 1.995 → 1.749 → 1.442 → 1.259。warm restart 效应(HF Trainer resume 重置 LR schedule)在这里实际起到了正面作用——模型在跳出旧局部最小值后找到了更低的 loss basin。
后续计划包括:实现正则 fallback 层覆盖 15 个结构化类型;评估是否需要以 max_width=16 重训 L2,以解决 JWT、URN、password 等中长实体的 recall;在此基础上启动 L3 训练,专攻代码和日志块内部 PII。
这篇训练日志会持续更新。下一篇更新将包含正则层实现细节、L3 训练结果,以及系统级推理 pipeline 的延迟和吞吐量基准测试。
如果大多数样本都写成"邮箱:xxx""Phone: xxx""SecretKey=xxx",模型就会优先学会前缀到类型的映射("看到'邮箱:'就标后面为 EMAIL"),跳过对值本身结构的学习("看到 xxx@yyy.zzz 格式就标为 EMAIL")。这条捷径在常规文本上表现很好(大量文本确实有这样的前缀),但遇到没有任何提示词的裸值就完全失效。去前缀和分隔符重塑的目的是系统性削弱这条最容易被滥用的捷径,迫使模型学习值本身的特征。
GLiNER 的 label-level negative sampling 只能制造 cross-class label negative("这条文本里没有 EMAIL 标签"),不能制造 within-class hard negative("这个看起来像 SECRET_KEY 的字符串其实是 git commit hash")。前者让模型学会"这条文本里没有某个标签",后者才决定模型能不能拒绝形似但非敏感的信息。真正提升 precision 的手段在文本层面:在训练数据里加入 git hash、k8s namespace、公开测试凭据、示例 token、配置占位符和文档样例,但不把它们标成 PII——让模型在文本级别学会区分。调 --num-negatives 参数改变的只是 label-level 分布,对这类区分帮助有限。
把训练 context 从 512 拉到 4096,并不会自动让模型更强。实验结果:512 训练 + 4096 推理得到 IoU≥0.8 = 0.963;直接用 4096 训练只得到 0.892。原因集中在以下两点:
- 4096 context 让正负比更极端。512 的滑动窗口经过代码块时,单个窗口内正 token 可以占 80% 以上;4096 单 pass 则把整篇文档压进一个 sample,正 token 只占 20%。BCE 在 per-sample 正负比 1:4 时仍然容易被负类梯度拖向坍塌。
- batch diversity 下降。受限于 GPU 显存,4096 context 只能用 bsz=4(vs 512 的 bsz=32)。同样 30K steps,512 训练看了 192 万篇文档的多样性,4096 训练只看了 24 万篇,多样性差 8 倍。对 segmentation 任务来说,数据多样性比完整上下文更重要。
结论:512 训练 + 4096 推理是甜蜜点——训练阶段用短窗口获得大 batch 多样性,推理阶段用长 context 获得完整上下文。
极端不平衡任务里,accuracy 没有解释力——一个"全预测 O"的模型在 93% 负样本数据上 accuracy 就有 0.93。precision 和 recall 必须拆开看,token 级和 chunk 级指标也必须拆开看。这个项目的一个直接教训是:如果训练日志只给出一个看起来很高的综合指标,而你没有独立验证评估代码(用完全不同的代码路径重新计算),那这个指标几乎不值得信任。我们在"token_f1=0.993"的虚假指标下浪费了超过 3 天 GPU 时间。
GLiNER 的自然语言标签和 zero-shot 扩类能力很有吸引力(不重训就能试新类型),代价是训练速度(串行 Python 循环,80 CPU 只 1 个在干活)、评估速度(4 小时一次完整 eval)和推理并行能力(350ms/text vs BIO 的 15ms/text)。一个适合研究验证的模型,放到高吞吐生产环境会遇到完全不同的瓶颈。如果最终 entity F1 不达标,备选方案是 mDeBERTa + BIO(65 个标签的 token classification),速度快 23 倍且在固定类型集上可能更准。
本节对项目中涉及的核心算法做完整技术说明,目标是让只有 AI 基础知识(了解 transformer、梯度下降、分类任务基本概念)的读者也能理解每个算法在做什么、为什么在这个场景下有效或无效。
标准交叉熵损失对每个样本一视同仁——不管模型对这个样本有多确信,贡献的梯度量级都差不多。Focal Loss(Lin et al., 2017)通过一个可调的衰减因子,让模型在"已经学会的简单样本"上产生更小的梯度,把学习资源集中到"困难样本"上:
\[\mathcal{L}_{focal} = -\alpha_t (1-p_t)^{\gamma}\log p_t\]其中 \(p_t\) 是模型对正确类别的预测概率。当模型已经很确信(\(p_t \to 1\)),\((1-p_t)^\gamma \to 0\),梯度几乎消失;当模型不确信(\(p_t\) 小),衰减因子接近 1,梯度保持正常量级。超参数 \(\gamma\) 控制衰减强度(\(\gamma=0\) 退化为标准交叉熵),\(\alpha_t\) 是类别权重。
在这个项目中,Focal Loss 用在 BIO 方案里放大稀疏 B-tag 的梯度(B-tag 是困难少数类),以及 boundary head 的起止点预测(起止点极其稀疏)。它能缓解类别不平衡带来的梯度问题,但无法从根本上改变"长块只有 1 个 B-tag"的信号结构——只能作为辅助工具。
CRF 是一种在序列标注任务中广泛使用的概率图模型。它的核心思想是:不仅考虑每个 token 独立的分类概率,还考虑相邻标签之间的转移概率。具体来说,CRF 维护一个转移矩阵 \(A_{y_{i-1}, y_i}\),表示"前一个 token 标签为 \(y_{i-1}\) 时,当前 token 标签为 \(y_i\) 的额外分数"。训练时通过 forward algorithm 计算所有可能序列的分数之和(归一化常数),推理时通过 Viterbi 算法找到全局最优标签序列。
CRF 特别适合短实体 NER:一个 3 token 的人名(B-PER I-PER I-PER),CRF 能学到"B-PER 后面大概率跟 I-PER"这样的转移规则,帮助解决 encoder 偶尔的单 token 错误分类。但对于 500-token 的代码块,CRF 的 7x7 转移矩阵只有 49 个参数,它无法编码"开始了一个 B-CODE 后需要持续 500 个 I-CODE"这种长距离依赖——每步解码只看前一个标签,长距离决策被拆解成逐步累积的短距离决策,边界误差会放大。
此外,CRF 的转移矩阵在学习率偏高时极不稳定——因为它只有 49 个参数,每次更新对其影响远大于对 100M 参数 encoder 的影响。这解释了训练中观察到的"chunk F1 从 0.43 暴跌到 0.10"现象:token 分类能力没变(encoder 稳定),但 CRF 转移约束被破坏了(小参数集波动大)。
GlobalPointer(Su et al., 2022)将命名实体识别重新定义为一个多标签分类问题:对序列中每一对 (i, j) 位置(其中 i ≤ j),判断 \((token_i, token_j)\) 是否构成某类实体的起止边界。scoring function 使用旋转位置编码(RoPE)注入相对距离信息:
\[s_c(i,j) = \mathrm{RoPE}(W_q h_i)^\top \mathrm{RoPE}(W_k h_j)\]其中 \(h_i, h_j\) 是 encoder 输出的 token 表示,\(W_q, W_k\) 是可学习的投影矩阵。RoPE 让模型天然感知两个 token 之间的距离,对 NER 中"实体通常只有几个 token 长"的归纳偏置很有用。
配套的 Circle Loss(Sun et al., 2020)是一种自适应的 metric learning loss。它把所有正 span(得分应该高)和负 span(得分应该低)分成两组,然后尝试拉大两组之间的间距:
\[\mathcal{L}_{circle} = \log\left[1 + \sum_{(i,j)\in\Omega^+} e^{-\gamma \cdot s(i,j)} \cdot \sum_{(i,j)\in\Omega^-} e^{\gamma \cdot s(i,j)}\right]\]当负样本数量是正样本的 187 万倍时,\(\sum_{\Omega^-} e^{\gamma \cdot s}\) 的梯度完全压倒了正样本项——模型学到的唯一安全策略是把所有分数压低(让负样本项变小),顺带也压低了正样本的分数。这就是为什么 threshold sweep 显示正负分数中位数只差 0.02 的根因。
Dice Loss 来自医学图像分割领域,其设计目标就是处理前景/背景极度不平衡的分割任务(例如在 CT 扫描中找到只占几个像素的肿瘤)。它的核心形式是:
\[\mathrm{Dice}(P,G)=\frac{2\sum_{i} p_i \cdot g_i + \epsilon}{\sum_{i} p_i + \sum_{i} g_i + \epsilon}, \quad \mathcal{L}_{dice}=1-\mathrm{Dice}(P,G)\]其中 \(p_i \in [0,1]\) 是模型对第 \(i\) 个 token 预测为正的概率(经过 sigmoid),\(g_i \in \{0,1\}\) 是 ground truth 标签。分子 \(2\sum p_i \cdot g_i\) 是预测与真值的"重叠量"(只在 \(g_i=1\) 的位置才有贡献),分母是预测总量加真值总量。\(\epsilon\) 是防止除零的小常数(通常取 1.0)。
直观理解:Dice 不关心"有多少负样本被正确拒绝"(这是 accuracy 关心的),它只问"你预测的正类区域和真实的正类区域有多大重叠"。因此负样本再多也不会影响 loss 的计算——1000 个正确拒绝的负样本对 Dice Loss 贡献为零。这正是它能在 93% 负样本场景下仍然迫使模型学习正类的原因。
项目中实际使用的是 per-type Dice(对 CODE_BLOCK 和 LOG_BLOCK 分别计算 Dice 再取平均),加上 0.5 倍的 pos-weighted BCE 作为辅助。BCE 提供逐 token 的精细梯度帮助模型学得更快,Dice 负责在全局层面阻止坍塌——两者互补。
U-Net 最初是为医学图像分割设计的编码器-解码器架构(Ronneberger et al., 2015)。它的核心特征是 skip connection(跳跃连接):编码器每一层下采样后的特征直接连到解码器对应层的上采样输出上,使网络同时拥有全局语义信息(深层,低分辨率)和局部细节信息(浅层,高分辨率)。
在本项目中,我们将 U-Net 从 2D 图像领域迁移到 1D 序列领域:encoder 输出的 \([B, T, 768]\) 表示经过 1D 卷积下采样(768→384→192),在 bottleneck 处感受野覆盖整个代码块区域("这里有一大片代码"的全局信号),然后上采样(192→384→768)并通过 skip connection 恢复精确的边界位置信息。
为什么这对代码块检测有价值:仅靠 encoder 的 self-attention,每个 token 的表示主要反映局部上下文(虽然理论感受野是全序列,但实际上 attention 分布集中在附近 token)。U-Net 的下采样路径通过 pooling 强制模型看到更大范围("我处在一个代码区域的中间"),上采样路径通过 skip connection 保留精细边界("第 127 个 token 是代码块结束的精确位置")。这种多尺度融合比单一 token head 更适合需要同时感知"整体区域"和"精确边界"的任务。
GLiNER(Zaratiana et al., 2023)是一种 zero-shot NER 模型,它的关键创新是用自然语言描述实体类型,而不是用固定的 label ID。工作流程如下:
- 将类型描述(如 "personal email address")和输入文本拼接: [type1] [SEP] [type2] [SEP] ... [SEP] input text
- 送入 mDeBERTa 编码器,得到每个 token 的表示。
- 用 bilinear scoring 对输入文本中的每个候选 span \((i, j)\) 和每个类型标签 \(c\) 打分。
- 选择分数超过阈值的 span 作为预测实体。
对应的打分函数为:
\[s(i, j, c) = h_i^T W h_j + h_c^T V h_i + h_c^T U h_j\]这种设计让模型在推理时能理解任何自然语言描述的类型——因为 encoder 同时编码了类型语义和输入文本,两者在同一向量空间中交互。但代价是:每个类型都需要和输入文本一起送入 encoder,32 个类型 = 计算量乘以 32(实际通过 batch 化缓解,但仍然远慢于固定 head 的 BIO 模型)。
mDeBERTa(He et al., 2021)是 GLiNER 的 backbone 选择,其核心优势是 disentangled attention:将 content 和 position 信息分开计算 attention score(\(A = A_{c2c} + A_{c2p} + A_{p2c}\)),比标准 BERT 的混合 attention 在 token-level 任务上表现更好。这对 NER 很重要,因为 NER 需要精确到单个 token 的边界判断——content-position 分离让模型更好地区分"这个 token 是什么"和"这个 token 在哪"。
Leave a Reply