多语言敏感信息检测模型训练日志
这篇文章记录一个多语言敏感信息识别项目的完整训练日志。它关注的是工程路径本身:原始 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、配置块和示例值就开始误报。
清洗和增强之后,70.7% 的实体值前面带有格式标记("email: xxx"、"tel: xxx"、"密码:xxx")。如果不处理,模型会学到最省力的捷径——"看到'email:'就标后面为 EMAIL"——遇到没有提示词的裸值就失效。去前缀阶段按类型强度分三档处理:
- FORMAT_STRONG(13 类:EMAIL、PHONE、IPV4 等格式极强的类型):50% 的实体删除前缀标记,10% 重排分隔符到末尾。删除后模型必须从值本身的格式学习。
- FORMAT_WEAK(25 类:PERSON、ORG、ADDRESS 等有上下文但非固定格式):25% 删除前缀,10% 重排,10% 替换关键词。比例更保守,因为这些类型确实需要一定上下文。
- CONTEXT_ONLY(28 类:DATE、MONEY、TIME 等):不做任何修改。这些类型的值没有固有结构("45"可以是年龄也可以是金额),去掉上下文标记后无法识别。
处理后前缀比例从 78% 降到 71%,裸值比例从 12% 升到 19%。一个意外发现是去前缀的修改率在所有 15 种语言之间高度一致(84-87%),说明"类型标记 + 值"的写法是跨语言通用的模式,不是某种语言特有的。
数据增强完成后,cross-lingual QA 发现了一个隐蔽的偏差:中文文本中 EMAIL/DOMAIN/URI 实体的顶级域名高度集中在 .cn(占比 91%+),而英文文本分散在 .com/.co.uk/.ca 等多个域。这意味着模型可以不看实体结构,单凭周围文本的语言就猜出域名——中文上下文 → 预测 .cn,英文上下文 → 预测 .com。这是一条完全虚假的捷径。
修复方式是对 3725 万个 EMAIL/DOMAIN/URI 实体做 post-hoc 重随机化:用 80 CPU 并行扫描所有阶段的输出文件,对域名部分重新采样,打破语言→TLD 的相关性。处理后所有 TLD 的跨语言 spread 降到 15% 以下(最高 .com 为 10.9%),确认偏差已消除。
这个项目后期最大的进展来自多次关键 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 第一轮训练(25K steps, 2 GPU)结束后,我们将训练扩展到 4 GPU 并把 max_steps 从 25K 改为 60K 重新启动。由于修改了训练参数(GPU 数量、max_steps),HuggingFace Trainer 重新计算了 cosine LR 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 架构对长实体的先天缺陷。
为了进一步验证这个结论,我们做了 threshold sweep——如果漏检是因为模型"不够自信",那降低阈值应该能捞回一部分。结果:
| Threshold | Precision | Recall | F1 |
| 0.1 | 0.969 | 0.764 | 0.854 |
| 0.3 | 0.988 | 0.764 | 0.861 |
| 0.5(默认) | 0.994 | 0.762 | 0.863 |
| 0.7 | 0.997 | 0.759 | 0.862 |
从 0.7 降到 0.1,recall 只涨了 0.5%(0.759→0.764),precision 降了 2.8%。F1 几乎不变。这说明模型对那 24% 漏掉的实体压根没有给出任何分数——它们不在候选空间中,连 0.1 分都没有。漏检完全由架构限制决定,调 threshold 无法改善。
解决方案有两条路,可以同时做:
- 将 max_width 从 12 提到 16 重训。IPv6 覆盖率从 23% 提到接近 100%,显存增加 33%,bsz 需从 4 降到 3。
- 对 IPv6、URI、EMAIL 等高度结构化类型直接叠加正则 fallback 层。零训练成本,recall 直接到 0.95+。
GLiNER 的训练速度瓶颈在数据预处理阶段。每一步训练中,data_collator 需要对 batch 内每条记录做 mDeBERTa tokenize + span 矩阵构建 + label 映射,全部在 Python 主线程串行执行(GIL 限制,DataLoader 的 num_workers 无法帮上忙,因为重活在 collate_fn 里)。实测这部分占总步时间的 56%(~1.0s/1.77s)。80 个 CPU 核只有 1 个在干活。
解决办法是离线预处理:训练前一次性用 80 CPU 并行把所有记录的 text tokenization 和 span indices 预计算为 Arrow 文件。训练时 DataLoader 只做轻量的 label sampling + prompt 拼接 + padding。预处理 8.2M records 耗时约 10 分钟,产出约 130GB Arrow 文件。训练时 collate 时间从 ~1.0s 降到 ~0.04s,总步时间从 1.77s 降到 ~0.8s,训练速度提升约 2 倍。
max_width 从 12 提到 16,span representation 矩阵从 [B, L, 12, D] 变为 [B, L, 16, D],显存增加 33%。在 24GB 的 4090 上,bsz 从 4 降到 3(配合 grad_accum 从 4 调到 6 保持等效 batch size)。训练速度因 bsz 降低而慢 25%,但结合离线预处理的 2 倍提速,实际训练时间反而缩短。推理时 max_width 的显存增量可以忽略(单条推理仅多 5MB)。
覆盖率变化预估:
| 实体类型 | max_width=12 覆盖率 | max_width=16 覆盖率 |
| ipv6 address(15 tokens) | 23% | ~100% |
| uri(13+ tokens) | 51% | ~75% |
| address(13+ tokens) | 55% | ~70% |
| json web token(长 base64) | ~60% | ~85% |
选择 16 而非更大值是刻意的。max_width 越大,候选 span 数量线性增长,但正样本数不变——负正比从 1500:1(width=12)涨到 2000:1(width=16)、4000:1(width=32)。GLiNER 的 focal loss 在 2000:1 时仍能 hold 住收敛,但继续放大会导致正类梯度被海量负 span 稀释,收敛变慢甚至精度下降——和 L1 GlobalPointer 在 1:1.8M 正负比下崩溃是同一个机制。16 恰好覆盖 8 组 IPv6(15 token),超过 16 的 PII 实体极少(<3%),用正则 fallback 更合算。
max_width 解决的是"单个实体太宽装不进候选空间"的问题,而 max_length=384 截断解决的是另一类问题:"实体出现在文本靠后的位置,整段文本被截掉了"。技术文档和工单的典型结构是先描述问题,后面才贴具体地址、链接和配置——所以 IPv6、URI 这类实体倾向于出现在 word 384 之后的位置(31% 的 IPv6 和 22% 的 URI 落在截断区域外)。
滑动窗口在推理阶段可以解决这个问题:把一条长文本切成多个重叠窗口(例如 window=384, stride=256),每个窗口独立送入模型,然后合并预测结果。重叠区域内如果同一个 span 被多个窗口检出,取分数最高的。这样任何位置的实体都至少被一个窗口完整覆盖。
滑动窗口不需要重训,纯推理阶段改动。代价是推理时间乘以窗口数(通常 1.3-2 倍,取决于文本长度分布)。对于 84% 的记录(text ≤ 384 words),只跑一个窗口,无额外开销;只有 16% 的长文本需要多窗口。
值得注意的是,滑动窗口对 max_width 问题完全无效——一个 15 token 宽的 IPv6 不管在哪个窗口里都是 15 token,max_width=12 的模型看到它也无能为力。两个限制需要各自独立的解决方案:max_width=16 重训解决"实体太宽",滑动窗口解决"实体位置太靠后"。
GLiNER 在短 span 语义实体(PERSON、ORG、PASSWORD、OTP 等需要上下文判断的类型)上表现很好,但对长实体和结构化实体有先天缺陷。一个自然的思路是引入互补架构,各自覆盖对方的盲区:
- GLiNER 负责语义模糊的短实体。这类实体的识别依赖上下文("这个数字串是密码还是订单号"),GLiNER 的自然语言标签恰好提供了这种语义信号。
- BIO token classification 模型负责长实体。BIO 对每个 token 独立打 B/I/O 标签,没有 max_width 限制,任意长度的 URI、IPv6、JWT、ADDRESS 都能覆盖。可以用 mmBERT(8192 ctx)作为 backbone,同时解决 max_length=384 截断问题。速度也远快于 GLiNER(单次 forward 15ms vs 350ms)。
- 正则 fallback 负责高度结构化的类型。IPv6、EMAIL、PHONE、UUID 等有固定格式,正则 recall 直接到 0.98+,零训练成本。
合并逻辑很简单:三个来源取并集,同一 span 同一 type 被多个模块检出时保留 score 最高的,不同 type 冲突时也取高分。两个模型的强项几乎不重叠(GLiNER 强在语义判断,BIO 强在长序列覆盖),冲突很少。
这个双模型 ensemble 的预期效果是把 micro recall 从当前的 0.76 拉到 0.90 以上——正则层先解决结构化类型(+10%),BIO 模型再补上长实体(+5%),GLiNER 继续兜底语义类型。三者互补,各管一片。方案尚未实施,后续迭代中验证。
到当前版本,系统形态已经明确:
| 层 | 模型 | 状态 | 核心指标 |
| 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 从参数变更引发的 2.879 高点单调下降至 1.259,最终 LR 衰减至 8.6e-10(cosine schedule 自然到底)。完整 eval_loss 曲线:2.879 → 2.234 → 1.995 → 1.749 → 1.442 → 1.259。从 2 GPU 扩展到 4 GPU 并延长 max_steps 时触发的 LR 重置,客观上起到了 warm restart 的作用——模型在跳出旧局部最小值后找到了更低的 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 倍且在固定类型集上可能更准。
当模型对某些实体的 recall 偏低时,第一反应往往是降低检测阈值——"模型可能不够自信,放宽一点就能捞回来"。但 threshold sweep 给出了反直觉的结果:阈值从 0.7 降到 0.1,recall 只涨 0.5%,precision 降 2.8%。模型对那 24% 漏掉的实体没有给出任何分数,连 0.1 都没到。
原因是这些实体根本不在模型的候选空间里——要么超出了 span 宽度上限(15 token 的 IPv6 vs max_width=12),要么落在文本截断位置之后。对模型来说它们不存在,自然不可能给分。这个案例说明:在排查 recall 问题时,第一步应该确认漏检是"模型判断力不够"还是"架构看不到"。如果是后者,所有基于 threshold/loss/数据量的调参都是徒劳的。
本节对项目中涉及的核心算法做完整技术说明,目标是让只有 AI 基础知识(了解 transformer、梯度下降、分类任务基本概念)的读者也能理解每个算法在做什么、为什么在这个场景下有效或无效。
在类别不平衡的分类任务中,标准交叉熵有一个隐蔽的问题:大量"简单"负样本(模型已经很确定它们是负的)产生的梯度累加起来,淹没了少量"困难"正样本的梯度。打个比方——一个班里 95 个学生已经会做题了,只有 5 个还不会,但老师的精力被 95 个人的答案平均分走,没有集中辅导那 5 个。Focal Loss(Lin et al., 2017)就是这个问题的解决方案:让"已经会做的学生"自动减少对老师注意力的占用。
数学形式:
\[\mathcal{L}_{focal} = -\alpha_t (1-p_t)^{\gamma}\log p_t\]其中:
- \(p_t\):模型对正确类别的预测概率(0 到 1 之间)。如果真实标签是"正",\(p_t\) 就是模型预测为正的概率;如果真实标签是"负",\(p_t\) 就是模型预测为负的概率。
- \((1-p_t)^\gamma\):调制因子。模型越确信(\(p_t\) 接近 1),这个因子越接近 0,梯度越小——"已经学会的样本不再贡献梯度"。模型越不确信(\(p_t\) 小),因子接近 1,梯度保持正常——"困难样本继续贡献学习信号"。
- \(\gamma\):聚焦强度。\(\gamma=0\) 退化为标准交叉熵(不聚焦),\(\gamma=2\)(本项目使用值)表示中等聚焦,\(\gamma=5\) 表示极端聚焦(只学最难的样本)。
- \(\alpha_t\):类别权重,用来进一步提高少数类的权重(比如 B-tag 权重设为 3,O-tag 设为 1)。
在这个项目中,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 转移约束被破坏了(小参数集波动大)。
传统 NER 用序列标注(每个 token 打一个标签),GlobalPointer(Su et al., 2022)换了一个思路:直接对文本中所有可能的 (start, end) 位置对打分——分数高的就是实体。这相当于在一张二维表格上圈出实体,而非沿序列逐个标注。打分函数用旋转位置编码(RoPE)注入相对距离信息:
\[s_c(i,j) = \mathrm{RoPE}(W_q h_i)^\top \mathrm{RoPE}(W_k h_j)\]其中:
- \(s_c(i,j)\):位置 \(i\) 到位置 \(j\) 构成第 \(c\) 类实体的分数。
- \(h_i, h_j\):encoder 输出的 token 向量表示(768 维)。
- \(W_q, W_k\):可学习的投影矩阵,分别把 \(h\) 投影为"查询"和"键"向量。
- \(\mathrm{RoPE}\):旋转位置编码——给向量施加一个和位置相关的旋转,使得两个向量的内积天然包含它们之间距离的信息。距离越近内积越大。
配套的 Circle Loss(Sun et al., 2020)负责训练。它的核心思想像一根弹簧:把正样本的分数往上拉(接近 1),把负样本的分数往下压(接近 0),两组分数之间拉开间距。数学形式:
\[\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]\]其中:
- \(\Omega^+\):所有正 span(真正是实体的 (start, end) 对)的集合。
- \(\Omega^-\):所有负 span(不是实体的 (start, end) 对)的集合。
- \(\gamma\):温度参数(通常 64),控制分数区分的锐利程度。
- \(s(i,j)\):模型给 span (i,j) 打的分数。
直觉:正样本分数越低,\(e^{-\gamma \cdot s}\) 越大,loss 越高——惩罚"该高分的给了低分"。负样本分数越高,\(e^{\gamma \cdot s}\) 越大,loss 越高——惩罚"该低分的给了高分"。当正负分数完全分开时(正 > 0、负 < 0),两项都趋近 0,loss 趋近 log(1)=0。
为什么在我们的场景下崩溃:当负样本数量是正样本的 187 万倍时,\(\sum_{\Omega^-} e^{\gamma \cdot s}\) 的梯度完全压倒了正样本项——模型学到的唯一安全策略是把所有分数压低(让负样本项变小),顺带也压低了正样本的分数。这就是为什么 threshold sweep 显示正负分数中位数只差 0.02 的根因。
Dice Loss 来自医学图像分割领域(Milletari et al., 2016),诞生的背景是:在 CT/MRI 扫描中找肿瘤时,前景(肿瘤)可能只占图像的 1-5%,其余 95-99% 是正常组织。标准 BCE loss 在这种极端不平衡下会让模型"摆烂"——直接预测"全是正常组织"就能得到很低的 loss。Dice Loss 解决这个问题的方式很像一个考试评分标准的改变:不再问"你做对了多少题"(accuracy),而是问"你圈出来的答案和标准答案重合了多少"(overlap)。
数学形式:
\[\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 标签。1 = 确实在代码块内,0 = 不在。
- \(2\sum p_i \cdot g_i\)(分子):预测和真值的"重叠量"。只有在 \(g_i=1\)(真正为正)的位置,\(p_i\) 才对分子有贡献。如果模型什么都不预测(全部 \(p_i=0\)),分子 = 0。
- \(\sum p_i + \sum g_i\)(分母):预测的"体积"加上真值的"体积"。如果模型全预测为 0,\(\sum p_i = 0\),分母只剩 \(\sum g_i\)(真实正样本的数量)。
- \(\epsilon\):防止除零的小常数(通常取 1.0)。
关键性质用一句话概括:如果模型"什么都不预测"(全部 \(p_i=0\)),Dice = \(\epsilon / (\sum g_i + \epsilon) \approx 0\),loss = 1.0(最大惩罚)。模型不可能通过"摆烂"获得低 loss。而 BCE 在相同情况下 loss ≈ 0(因为 93% 的负样本都被"正确"分类了)。
通俗比喻:BCE 像一场选择题考试,100 道题 93 道答案是"否"——一个什么都选"否"的学生能拿 93 分。Dice 像一个画圈游戏——要求你在纸上圈出正确区域,评分标准是"你圈的面积和标准答案重合了多少"。什么都不圈 = 0 分。这就是为什么 Dice 能逼迫模型在严重不平衡数据上仍然学习正类。
项目中实际使用的是 per-type Dice(对 CODE_BLOCK 和 LOG_BLOCK 分别计算 Dice 再取平均),加上 0.5 倍的 pos-weighted BCE 作为辅助。BCE 提供逐 token 的精细梯度帮助模型学得更快,Dice 负责在全局层面阻止坍塌——两者互补。
U-Net 最初是为医学图像分割设计的编码器-解码器架构(Ronneberger et al., 2015)。它要解决的核心矛盾是:分割任务需要同时知道"大局"(这一整片区域是什么)和"细节"(精确到像素的边界在哪)。普通网络层数越深看得越远但丢失细节,层数越浅保留细节但看不到全局。U-Net 用 skip connection(跳跃连接)同时拥有两者:编码器每一层下采样后的特征直接连到解码器对应层的上采样输出上。
打个比方:阅读一篇混合了代码和正文的技术文档时,你需要两种视角——"缩小看"知道"第 3-8 段整体是代码块"(全局),"放大看"知道"第 3 段第 2 行的 ``` 是代码块开头"(边界细节)。U-Net 的下采样路径负责"缩小看",上采样路径负责"放大看",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\]其中:
- \(h_i, h_j\):文本中第 \(i\) 和第 \(j\) 个 token 的向量表示(来自 encoder 输出)。
- \(h_c\):第 \(c\) 个类型标签的向量表示(如"personal email address"经过 encoder 编码后的向量)。
- \(W, V, U\):可学习的投影矩阵。\(W\) 衡量 start 和 end token 之间的兼容性,\(V\) 衡量类型标签与 start token 的匹配度,\(U\) 衡量类型标签与 end token 的匹配度。
直觉:这个公式同时问三个问题——"这两个 token 像是同一个实体的首尾吗"(第一项)、"这个 start token 像是这类实体的起点吗"(第二项)、"这个 end token 像是这类实体的终点吗"(第三项)。三个分数加起来越高,模型越确信这个 span 属于该类型。
这种设计让模型在推理时能理解任何自然语言描述的类型——因为 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