Menu

  • Home
  • Work
    • AI
    • Cloud
      • Virtualization
      • IaaS
      • PaaS
    • Architecture
    • BigData
    • Python
    • Java
    • Go
    • C
    • C++
    • JavaScript
    • PHP
    • Others
      • Assembly
      • Ruby
      • Perl
      • Lua
      • Rust
      • XML
      • Network
      • IoT
      • GIS
      • Algorithm
      • Math
      • RE
      • Graphic
    • OS
      • Linux
      • Windows
      • Mac OS X
    • Database
      • MySQL
      • Oracle
    • Mobile
      • Android
      • IOS
    • Web
      • HTML
      • CSS
  • Life
    • Cooking
    • Travel
    • Gardening
  • Gallery
  • Video
  • Music
  • Essay
  • Home
  • Work
    • AI
    • Cloud
      • Virtualization
      • IaaS
      • PaaS
    • Architecture
    • BigData
    • Python
    • Java
    • Go
    • C
    • C++
    • JavaScript
    • PHP
    • Others
      • Assembly
      • Ruby
      • Perl
      • Lua
      • Rust
      • XML
      • Network
      • IoT
      • GIS
      • Algorithm
      • Math
      • RE
      • Graphic
    • OS
      • Linux
      • Windows
      • Mac OS X
    • Database
      • MySQL
      • Oracle
    • Mobile
      • Android
      • IOS
    • Web
      • HTML
      • CSS
  • Life
    • Cooking
    • Travel
    • Gardening
  • Gallery
  • Video
  • Music
  • Essay

多语言敏感信息检测模型训练日志

12
Apr
2026

多语言敏感信息检测模型训练日志

By Alex
/ in AI
0 Comments

这篇文章记录一个多语言敏感信息识别项目的完整训练日志。它关注的是工程路径本身:原始 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 种,其中相当一部分是模型幻觉出来的错误标签。

原始语料在语言和类型上的分布并不均匀。经过清洗后保留的 33 种目标 PII 类型中,26 种覆盖全部 15 语言,但时间日期类(DATE/TIME/DATETIME/DURATION)和代码容器类(CODE_BLOCK/LOG_BLOCK/INLINE_CODE)只存在于最初 5 种语言(TH/ZH/EN/JA/KO)中——后续扩展的 10 种语言(DE/PT/MS/VI/AR/FR/IT/ES/ID/RU)完全缺失这些类型。此外原始数据还包含大量非 PII 类型(物理量、序数、百分比等 30+ 种),在清洗阶段被整体丢弃。下面分两张表列出完整分布。

表一:目标 PII 类型在各语言中的实体数量。表中主体来自清洗后保留训练的原始目标类型;表尾列出增强阶段补入的类型。DE 列代表后续 10 语言的典型值(10 语言分布接近均匀):

类型 EN ZH JA KO TH DE 合计
URI 387,883 349,389 390,999 339,851 693,880 410,388 4,466,898
CLOUD_INS_ID 283,533 242,802 367,257 252,022 509,929 368,778 3,735,939
PERSON 212,646 325,730 217,676 209,245 349,541 207,095 2,487,734
DOMAIN 152,698 186,063 236,761 191,800 345,094 228,744 2,413,169
EMAIL 248,641 277,704 255,245 132,291 309,493 201,823 2,359,932
MONEY 168,414 226,927 187,213 209,319 372,540 200,372 2,305,095
PHONE 191,430 237,496 158,231 219,458 269,242 195,888 2,183,633
URN 161,729 229,942 154,351 179,484 281,721 208,288 2,179,318
ORGANIZATION 227,370 179,335 191,091 105,233 367,839 193,617 2,167,473
IDENTITY_NO 163,612 231,503 195,137 102,268 350,170 185,876 2,089,905
UIN 218,803 152,552 167,653 175,918 265,579 191,112 2,058,029
POSTALCODE 142,523 143,112 208,761 180,231 281,140 195,469 2,052,777
BANK_ACCOUNT 118,530 192,442 131,166 205,920 304,679 193,221 2,047,318
APPID 172,826 149,433 224,049 118,162 299,671 191,709 2,044,752
ADDRESS 196,957 164,760 132,139 111,700 327,005 194,940 2,029,092
LOCATION 100,284 227,909 105,304 217,984 243,906 197,961 2,006,311
IDENTIFIER 149,252 126,753 201,609 116,106 295,020 191,710 1,969,249
IPV6 104,400 203,402 145,297 118,439 300,745 193,064 1,959,565
SECRET_ID 158,332 142,449 152,872 113,392 310,071 190,504 1,951,102
PASSWORD 126,724 124,234 116,733 208,397 289,102 190,641 1,940,054
IPV4 113,337 216,102 138,522 119,426 251,519 192,883 1,925,626
SECRET_KEY 125,412 141,893 111,673 190,335 257,271 190,540 1,900,775
UUID 107,779 108,012 166,378 108,289 291,329 192,313 1,865,750
JWT 144,593 163,137 123,627 137,445 245,499 151,338 1,676,111
OTP 65,142 67,604 46,067 77,421 145,422 79,454 857,889
STAFF_ID 6 10 — — 102,417 190,895 1,178,587
DURATION 83,683 175,280 52,401 55,535 85,104 — 452,003
DATETIME 157,811 103,947 58,123 37,649 50,971 — 408,501
DATE 37,152 103,714 31,153 36,932 72,102 — 281,053
TIME 25,666 104,683 122,543 29,665 79,488 — 362,045
LOG_BLOCK 80,849 40,877 54,028 26,933 119,414 — 322,101
CODE_BLOCK 33,024 112,207 85,891 23,968 53,943 — 309,033
INLINE_CODE 12,297 106,495 24,482 109,057 21,995 — 274,326
PRIVATE_KEY — — — — — — (增补)
FILE_PATH — — — — — — (增补)
GPS_COORDINATE — — — — — — (增补)
CRYPTO_ADDRESS — — — — — — (增补)
MAC_ADDRESS — — — — — — (增补)

表二:非目标类型(清洗阶段丢弃,不进入训练)。这些类型只存在于前 5 语言,后 10 语言实体数均为零:

类型 EN ZH JA KO TH 合计 丢弃原因
FREQUENCY 74,471 55,900 162,098 138,315 128,312 559,096 物理量,非 PII
AREA 156,728 53,776 162,177 96,009 37,480 506,170 物理量
CAPACITY 56,635 125,315 74,919 145,521 94,523 496,913 物理量
AGE 102,469 92,037 75,824 105,101 92,345 467,776 弱隐私属性
ACCELERATION 97,765 135,816 31,327 116,780 34,789 416,477 物理量
ORDINAL 36,216 94,322 115,581 52,075 111,645 409,839 序数,非 PII
WORK_OF_ART 131,285 144,898 72,954 26,236 22,335 397,708 通用 NER 类型
LENGTH 54,028 163,368 29,056 72,175 74,062 392,689 物理量
DECIMAL 62,384 37,307 103,543 98,891 72,432 374,557 纯数字格式
SPEED 78,809 37,935 86,214 57,938 111,034 371,930 物理量
TEMPERATURE 43,485 63,224 26,631 145,604 76,863 355,807 物理量
SKILL 60,718 25,598 130,131 45,153 85,406 347,006 通用 NER 类型
QUANTITY 116,955 56,222 25,735 44,057 100,723 343,692 通用数量
ANGLE 36,985 36,529 101,937 65,373 93,459 334,283 物理量
PERCENT 119,493 46,407 27,644 62,370 75,313 331,227 百分比格式
FRACTION 76,397 21,956 87,863 72,583 51,991 310,790 分数格式
EVENT 37,411 41,263 46,269 107,225 69,006 301,174 通用 NER 类型
WEIGHT 22,156 52,280 38,563 61,136 120,538 294,673 物理量
LAW 102,408 24,297 45,241 53,965 66,774 292,685 通用 NER 类型
PERSON_TYPE 90,839 47,429 9,738 106,170 36,889 291,065 弱隐私属性
LANGUAGE 11,745 47,445 13,686 64,815 140,707 278,398 通用 NER 类型
INTEGER 21,622 99,995 70,826 61,526 23,383 277,352 纯数字
DATE_TIME 24,327 147,784 64,141 18,477 20,359 275,088 合并入 DATETIME
MEASURE 52,606 109,849 33,144 46,141 16,585 258,325 物理量
NORP 28,300 35,066 104,129 48,041 32,354 247,890 通用 NER 类型
PRODUCT 66,307 48,332 18,125 84,378 26,761 243,903 通用 NER 类型
RATE 109,598 23,567 19,022 24,113 26,940 203,240 物理量
CARDINAL 80,895 50,448 28,044 8,410 19,158 186,955 基数,非 PII
TELEX 27,760 37,635 51,593 42,574 16,915 176,477 电报号格式

此外还有 OTHERS (277K)、INTEGER (277K)、MEASURE (258K) 等约 30 种长尾类型,均只存在于前 5 语言且不属于 PII 范畴,全部在清洗阶段丢弃。

表中 "—" 标记表示该语言完全缺失该类型。这些缺口需要在数据增强阶段补齐。

这个约束直接决定了后续数据工程的设计。训练管线不在中间文件里长期保存 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、SECRET_KEY、PRIVATE_KEY、JWT、PASSWORD、OTP 保留厂商前缀、PEM block 标签、JWT 三段式、OTP 长度和密码/密钥字符集;随机化后只改变字面值,不改变类型可识别结构
云资源与内部标识 APPID、UIN、CLOUD_INS_ID、STAFF_ID、IDENTIFIER、UUID、URN 保留云资源前缀、UUID 版本/大小写/花括号、URN namespace 和内部工号池分布;可变长字段在合法格式内重新采样
网络地址与端点 IPV4、IPV6、MAC_ADDRESS、DOMAIN、URI IP 在 loopback、private、documentation、public 等语义类别内随机化;MAC 保留分隔符风格;域名和 URI 按语言/场景权重重采样
个人、组织与地点 PERSON、ADDRESS、ORGANIZATION、COMPANY、LOCATION 使用多语言姓名、地址、公司和地点池;按 lang 映射国家/地区格式,避免英文 Faker 名称污染非英语样本
通信与金融账号 EMAIL、PHONE_NUMBER、BANK_ACCOUNT_NO(含 SWIFT、CVV、routing number 子形态) 保留邮箱结构、国家电话格式、银行卡/IBAN/Luhn 校验,以及 BANK_ACCOUNT_NO 内部的 SWIFT、CVV、routing number 区域结构;同一语言优先生成本地化格式
证件、医疗与保险编号 IDENTITY_NO(含 MRN、insurance policy、certificate number 子形态)、POSTALCODE 按国家证件和邮编格式生成,能计算校验位的类型保留校验规则;MRN、保险和证书编号最终映射为 IDENTITY_NO,保留前缀、分隔符和长度族
时间、时长与金额 DATE、TIME、DATETIME、DATE_TIME、DURATION、MONEY 日期时间按语言和书写习惯采样,时长覆盖缩写与自然语言表达,金额覆盖币种符号、ISO code、千分位、小数和中文大小写金额
文件路径、坐标与加密资产 FILE_PATH、GPS_COORDINATE、CRYPTO_ADDRESS 文件路径覆盖 Linux、Windows、macOS、Kubernetes 和配置文件路径;GPS 保留坐标表达形态;加密地址按 Bitcoin、Ethereum、Tron 等链格式生成

当前随机化 registry 已经从早期的 17 类扩展到 43 类 active randomizer,其中 18 类会读取 lang 做本地化格式选择。随机化沿用 record 内一致、跨 record 独立的策略。同一条记录里同一个原值无论出现在实体还是正文复述里,都一定要映射到同一个新值,否则上下文一致性会被破坏。这一点对日志和代码场景尤其重要,因为敏感值经常在同一条文本里被多次复述—比方说一条日志里 SecretKey 出现三次(配置、报错消息、堆栈变量),它们一定要是同一个替换值。

增强

训练集不是清洗完就直接开训的—它经过了多轮定向扩展。增强流水线覆盖国家变体扩展、locale-sensitive 重采样、银行与证件子类型补齐、云计算生命周期语料、中文书写变体、云厂商专属增强、繁体中文转换、日志代码注入和 hard sample 生成。每个增强模块解决一个具体的覆盖缺口,单纯堆数据量没有意义。

增强模块 目的 规模
locale-sensitive 重采样 把相同语言拆到不同国家格式,修复电话、地址、邮编等区域依赖 134,141 条
银行与证件子类型补齐 补入 SWIFT、CVV、routing number、MRN、insurance policy 等弱覆盖子类 299,260 条
云计算生命周期语料 模拟云运维工单、故障排查、配置片段和厂商交叉场景;覆盖 AWS、GCP、Azure、阿里云、腾讯云、华为云、OCI、IBM Cloud(低资源语言按 tier 缩小厂商集合) 1,570,520 条
中文书写变体 补齐全角数字、全角括号和中文金额写法 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 条
组织名语料池 从 Wikidata、LLM 枚举和企业数据库汇集 15 语言真实组织名,替代 Faker 随机名 181,368 条,覆盖 15 语言 × 6 组织类型
密钥模式全覆盖 分离 SECRET_ID 与 SECRET_KEY:云 SDK 的 access key id / SecretId / AccessKeyId 归 SECRET_ID;secret、token、PAT、API secret 归 SECRET_KEY 覆盖主流云厂商 id+key 组合,以及 token-only 认证形态;空输入会随机选择合法形状
全类型多语言云场景模板 动态交叉云厂商、产品组合、场景和 15 语言,GPT-5-mini 生成带占位符模板后随机展开。覆盖原 33 类 PII,重点补齐 DURATION、DATE、TIME、STAFF_ID 在后 10 语言的缺口 当前完成 5 厂商:50,122 个 cross-points,974,644 个模板,已展开 1,564,960 条;配置目标为 ×5 复用,满量约 4,873,220 条
新 PII 类型多语言模板 补齐 PRIVATE_KEY、FILE_PATH、GPS_COORDINATE、CRYPTO_ADDRESS、MONEY(BTC) 的多语言安全场景。模板覆盖 SOC/SIEM 告警、DFIR 取证、客户工单、war-room 聊天、法务合规、链上分析、云审计和 postmortem 15 语言 × 每语言 1,000 模板 = 15,000 模板;×20 复用 = 300,000 条
繁体中文覆盖增强 作为增强阶段最后一步,从前序Stages的 zh-CN 增强记录中采样,使用 OpenCC 转换为 zh-TW、zh-HK、zh-MO,补齐繁体中文在金融、云运维、连接串、新 PII 类型等增强语料中的覆盖 默认采样 30% zh-CN;目标分布 zh-TW 40% / zh-HK 40% / zh-MO 20%;输出并入主增强合并

这里最关键的两个模块是日志代码注入和 hard samples。前者解决"代码和日志监督信号太弱"的问题—如果训练集里代码块只来自模板生成的干净示例,模型会把带 fenced marker 的模板块当成默认形态,遇到没有 fence 的裸日志就失效。后者攻克"模型看过很多正样本,但没有真正看过边界附近的对照样本"的难题—一个 git commit hash 很像 SECRET_KEY,一个 k8s pod name 很像 CLOUD_INS_ID,但它们不是 PII。如果没有这两个模块,模型很容易在普通 prose 上看起来不错,但一遇到 stack trace、配置块和示例值就开始误报。

类型 难正样本 难负样本
PASSWORD 弱密码、短 PIN、口令别名、Base64 后的密码,放进登录、工单、扫描上下文中标正。 脱敏星号、文档占位符、禁用弱密码说明、示例密码,不标为真实密码。
OTP 4/6/8 位验证码、前导 0、短信验证码和 2FA code,在验证语境中标正。 HTTP 状态码、房间号、正则说明、普通短数字,防止所有短数字都被标成验证码。
SECRET_ID / SECRET_KEY 多云 access key id、SecretId、AccessKeyId、Base64 secret、拆分拼接 key,在凭证语境中标正。 masked key、官方示例 key、git commit hash、Docker digest、证书序列号和占位符。
JWT 三段式 token 出现在 Bearer、session、auth 上下文中标正。 JWT header 示例、截断 token、结构说明和文档占位 token。
EMAIL 极短邮箱、别名邮箱、URL 编码邮箱、defanged 邮箱(at/dot 变体)和多语言字段关键词。 example.com、user@domain.com、your_email@provider.com 等 RFC、文档、表单提示样例。
PHONE_NUMBER 本地格式、括号、空格、全角符号、URL 编码和拆空格号码;不同语言使用各自电话字段词,中文繁体从 zh-CN 后处理扩展到 zh-TW、zh-HK、zh-MO。 +CC 模板、1-800 示例号、版本号、端口号和普通数字。
IPV4 / IPV6 客户端 IP、源 IP、攻击 IP、服务器地址等语义;包含 defanged IP、空格 IP、私网/公网/loopback。 192.0.2.0/24、198.51.100.0/24、203.0.113.0/24 文档保留地址、版本号和章节号。
DOMAIN 客户域名、侵权域名、子域名、defanged 域名和 Punycode/IDN;Punycode 专门覆盖国际化域名边界。 example.com、localhost、公共云 endpoint、官方文档域名和通配符示例。
URI / URN 回调地址、投诉链接、控制台 URL、连接串中的 URI。 API 文档路径、localhost 调试地址、file path、BASE_URL 模板、官方帮助中心链接。
CLOUD_INS_ID 实例、安全组、磁盘、网卡等云资源 ID。 region、zone、SKU、API action、Kubernetes namespace、pod name、镜像名和公共 endpoint。
APPID / UIN 账号、应用、主账号、用户 ID 上下文中的纯数字。 版本号、构建号、订单号、群号、房间号、普通 cardinal。
IDENTITY_NO 身份证、护照、CPF、NIK、MRN、insurance、certificate number 等多地区证件语义;地区字段词按 15 种语言配置。 ICD/CPT/NDC 医疗编码、示例 SSN、模板编号和普通数字。
BANK_ACCOUNT_NO 银行卡号、银行账号、IBAN、routing、SWIFT、CVV,在真实支付/银行上下文中标正。 测试卡号、BIN、ISIN、股票代码、masked card 和格式说明。
POSTALCODE 地址、收件、邮寄上下文中的邮编;核心是与地址语义的局部绑定。 HTTP 状态码、错误码、房间号、年份和短数字。
MAC_ADDRESS 网卡、设备、路由器、客户端上报场景中的 MAC。 OUI 文档示例、十六进制 digest 分组和 Cisco 格式说明。
UUID / IDENTIFIER request id、trace id、session id、device id、业务流水号。 nil UUID、namespace UUID 示例、git commit、sha256 digest、Docker image、k8s namespace、函数名、变量名和文件名。
STAFF_ID 工号、处理人、操作者、企业微信 ID、agent ID 上下文中的短 username。 系统用户名示例、git author 占位符、普通英文单词和 IAM role。
PERSON locale-aware 人名随机器覆盖中文、日文、韩文、泰文、俄文、阿拉伯文等脚本;部分非拉丁姓名加入罗马化括注。 John Doe、张三、Max Mustermann、Fulano de Tal、产品名、库名和函数名。
ORGANIZATION 真实公司、机构缩写和多语言组织名。 Acme Corp、Example Inc、示例公司、官方云厂商名、开源组织示例和产品名。
LOCATION / ADDRESS 区分城市、区域、机房位置和详细住址;中文、韩文、日文地址用数字加地址量词规则修正 location/address 边界。 云 region、zone、可用区代码、机房机架位、示例地址。
MONEY / 时间日期类 金额、日期、时间、生日、注册时间、到期时间依赖上下文标正。 容量、百分比、费率、版本日期、日志时间戳和示例 ISO 格式。
去前缀

早期前缀分析显示,70.7% 的实体值前面带有格式标记("email: xxx"、"tel: xxx"、"密码:xxx");最近一次完整前缀分布验收中,这个高前缀比例在新合并数据上升到 77.8%,说明后续增强语料仍然延续了"类型标记 + 值"的写法。这些类型的值没有固有结构("45",可以是年龄也可以是金额),去掉上下文标记后无法识别。去前缀阶段按类型强度分三档处理:

  • FORMAT_STRONG(17 类,并包含 Stage 08 新增的 PRIVATE_KEY、FILE_PATH、GPS_COORDINATE、CRYPTO_ADDRESS):50% 的实体删除前缀标记,10% 重排分隔符到末尾。删除后模型一定要从值本身的格式学习。
  • FORMAT_WEAK(25 类:PERSON、ORG、ADDRESS、MONEY、DURATION 等有上下文但格式较弱或歧义较高的类型):25% 删除前缀,10% 重排,10% 替换关键词。比例更保守,因为这些类型确实需要一定上下文。
  • CONTEXT_ONLY(28 类):不删除前缀,仅做安全的分隔符重排或关键词替换。

如果不处理,模型会学到最省力的捷径—"看到'email:'就标后面为 EMAIL"—遇到没有提示词的裸值就失效。

最近一次带完整前缀分布的验收结果是:prefix_with_sep 从 77.8% 降到 70.9%,no_prefix 从 12.0% 升到 19.2%,全量修改率 86.3%。2026-05-18 针对新合并数据的重跑处理 5,352,285 条记录,修改 5,038,990 条,全量修改率 94.15%,其中 remove_prefix 23,899,061 次、reshape_sep 8,119,969 次、replace_keyword 2,170,250 次。

修复

这个项目后期最大的进展来自多次关键 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。

训练后评估阶段暴露了两个标注缺陷,均已修复并持久化到数据构建流水线(data_fixes.py,作为 build_layer2_dataset.py 的后处理 hook 自动执行):

日期时间标签分裂。上游数据存在 DATETIME 和 DATE_TIME 两种源类型,在 label_map.py 中分别映射成了"date and time"和"date time"两个不同的自然语言标签。模型被迫学两个语义相同的类别,信号被稀释。验证集中 85%(192K)的日期时间实体使用"date and time",15%(34K)使用"date time"。修复:统一映射到"date time",全量数据已 patch(train 107 万条、val/test 各 13.5 万条记录受影响)。同时"date time"仅覆盖 5 种语言(ZH/EN/JA/KO/TH),其余 10 种语言的时间日期数据需在后续增强阶段补齐。

CJK location/address 标注越界。评估矩阵显示中文 location F1 只有 0.82(其它语言均 > 0.93),韩语也偏低(0.90)。调查发现原因是标注边界不一致:中文 21.1%、韩语 30.5% 的"location"实体实际带有门牌号,属于详细地址。对比之下,address 标签 99.3% 都是标准地址格式,标注很干净。模型已经学会区分两者(address P=0.998),但 location 里混入的地址造成它犹豫不决。修复规则:实体文本包含"数字 + 地址量词"(中文:号/楼/层/室/栋/弄;韩语:호/층/번지;日语:号/丁目/番地/階)则从 location 转标为 address。验证集 ZH 转移 6,292 条、KO 转移 5,802 条,JA 转移 720 条。这个规则持久化后,下次从源数据重建 dataset 时会自动执行。

多样性

LLM 合成数据最隐蔽的问题是值重复。即便经过随机化处理,某些类型的实体值池仍然很浅——模型可能记住了有限几百个固定值,而非学到结构规律。衡量方式:对训练集采样 200 万条记录,按(类型 × 语言)统计 unique 实体值 / 总实体数的比率。比率越低,说明同一个值被反复使用,模型越容易死记硬背。

第一轮评估暴露了严重的多样性缺口:DATE/TIME 完全没有随机化器(15-34%),PERSON ZH 池只有 64 个名字(15%),JWT/DOMAIN/URI 的生成空间过窄(11-52%)。经过完整修复后(详见下方原因分析和修复措施),当前训练集的多样性矩阵如下:

类型 AR DE EN ES FR ID IT JA KO MS PT RU TH VI ZH
person 53 — 92 — — 52 — 33 42 28 — — 22 57 92
location 24 13 72 23 15 13 14 24 22 8 26 42 21 29 27
organization 31 30 28 32 35 39 33 30 32 29 30 28 13 28 31
date 3 2 59 3 4 3 4 47 51 4 3 4 67 4 70
time 11 13 69 11 12 12 14 64 82 13 10 14 81 12 53
date time 2 2 76 2 2 2 3 63 44 2 2 2 51 2 80
domain name 95 95 95 95 95 95 95 96 96 94 95 95 95 95 96
email 59 64 75 65 66 70 66 65 71 66 66 67 59 70 73
uri 91 95 94 92 92 88 90 95 94 90 92 91 96 90 94
jwt 94 97 96 94 95 94 94 96 96 94 95 95 98 94 97
phone 96 98 98 97 97 94 96 97 97 95 97 97 97 96 98
password 86 86 86 86 86 86 86 87 85 85 86 86 85 86 86
secret id 99 100 98 99 99 100 100 100 100 99 99 100 100 100 99
uuid 93 96 95 94 94 89 93 94 93 91 94 93 96 91 94
ipv4 94 96 95 95 95 93 95 95 95 93 95 95 95 95 95
national id 99 100 99 99 99 100 100 100 100 99 99 99 100 100 99

从矩阵中可以分离出三个梯队:

  • 结构化类型(diversity > 90%):secret id/key、national id、domain、JWT、URI、phone、uuid、ipv4。修复后这批类型几乎全绿——domain 从 29-52% 跃升到 94-96%,JWT 从 11-48% 跃升到 94-98%,URI 从 29-67% 跃升到 88-96%。模型面对的每个值几乎都是唯一的,无法死记硬背。
  • 半结构化类型(50-90%):email(59-75%)、password(85-87%)、person EN/ZH(92%)。person ZH 从 15% 提升到 92%——旧池只有 64 名,现在从 staff.json 抽取 1,259 姓 × 114K 名的组合空间,在 200 万样本中几乎不重复。
  • 语义型和日期型(< 50%):location(8-72%)、organization(13-39%)、date/time 在非 CJK 语言下仍偏低。date 在 EN/JA/KO/TH/ZH 达到 47-70%,但在 AR/DE/ES 等语言只有 2-4%——这是因为这些语言的日期多为 ISO 格式(2024-03-15),同一日期值被多语言共用导致计数偏低。time 类似,CJK 语言有各自独特的时间表达(中文"下午3点"、日文"14時30分"、韩文年月日格式),多样性更高。

对低多样性类型的根因分析和修复措施:

类型 修复前 修复后 修复措施
DATE / TIME / DATETIME DATE 15-19%;TIME 17-30%;DATETIME 47% DATE 80%;TIME 94%;DATETIME 100% 新建 datetime_gen.py 随机化器。覆盖 15 种日期格式和 14 种时间格式,并按 locale 控制农历、时辰、令和、佛历等脚本和地区特有表达。
LOCATION 10-46% 32%+ 新建 location_gen.py + location_pools.json,按语言从训练数据和地名池抽样。location 天然是有限集合,修复目标是避免极少数固定城市被重复记忆,并不追求覆盖率逼近 100%。
PERSON ZH 15% 100% 中文姓名从 64 个固定名扩展到 1,133 个姓氏 × 114K 个名,组合空间足够覆盖大规模采样;非拉丁姓名还叠加低概率罗马化括注。
JWT 11-48% 100% 扩展 claim 池到 65+,payload 结构和 claim 数动态变化,timestamp 改为 2015-2030 随机值,避免 token 只围绕少数固定时间戳聚集。
DOMAIN 29-52% 99.8% SLD 词池从 32 扩到 200+,增加连字符、随机字母、缩写和后缀变体等 6 种模式,并扩大数字后缀范围。
URI 29-67% 100% 路径生成改为组合式:51 个资源词 × 30 个动作词 × 动态 ID 段,查询参数随机生成,打散原来的 16 条固定路径。
PRIVATE_KEY - 300,000 条;15 语言各 20,000 条 Stage 08 新增 PEM private key 场景,覆盖 RSA/EC/OpenSSH 等边界、长 base64 主体、配置泄露、CI/CD secret 和事故响应上下文。因为原始数据没有该类型,修复前不填基线。
FILE_PATH - 300,100 条;15 语言各 20,000 条 Stage 08 新增文件路径实体,覆盖 Linux、Windows、容器挂载、Kubernetes secret、证书路径、日志路径和取证路径。路径值本身具有强结构,因此也接入 FORMAT_STRONG 去前缀策略。
GPS_COORDINATE - 300,120 条;15 语言各 20,000 条 Stage 08 新增经纬度实体,覆盖 decimal、DMS、带方向字母、地图链接、设备上报和安全事件定位语境。修复前无原始基线。
CRYPTO_ADDRESS - 300,120 条;15 语言各 20,000 条 Stage 08 新增链上地址实体,覆盖 BTC、ETH、TRON 等不同前缀和字符集,并放入链上分析、欺诈调查、客户工单和安全告警语境。
MONEY(BTC) - 300,040 条;15 语言各 20,000 条 Stage 08 补齐加密货币金额表达,覆盖 BTC/ETH/USDT 等币种、链上转账、钱包余额和事件响应上下文。MONEY 原类型已有数据,但这类链上金额场景没有可用基线。
DURATION - 562,648 个实体;多语言回归门槛 ≥80% 新增 duration 随机化覆盖小时、分钟、秒、自然语言时长和多语言单位表达;回归测试要求最终标签在 en/zh/fr/pt/th 等语言下去重率不低于 80%。

修复后的整体格局:结构化类型全面 90%+,格式型类型(JWT/DOMAIN/URI/PHONE)从重灾区跃升为最安全的类别。剩余的低多样性区域集中在语义型实体(location/organization)和非 CJK 语言的日期时间。前者受限于真实地名本身的有限性——全球城市数万个,训练集出现几万次,重复不可避免。后者的数值低是因为 ISO 格式日期跨语言共享同一值:14 种语言中有 11 种使用 YYYY-MM-DD 格式,一个随机日期值会在多语言中分别计入,拉低了单语言的去重比率。这不构成训练风险——模型需要学的是日期格式 pattern 而非记忆具体日期,跨语言共用值反而强化了格式泛化能力。

模型:代码日志云行业增强多语言

当前落地并持续更新的只有这一条模型线。它把多语言泛化、代码日志场景建模和行业专属增强合并到同一个训练框架里,目标是在一套数据分布和一组分层模型上同时攻克语言差异、文本形态差异和行业实体差异。

  • 多语言部分覆盖 15 种语言。locale-sensitive 重采样额外补入 134,141 条国家格式差异样本,中文书写变体补入 107,032 条,繁体中文转换再补 209,435 条,目的是让同一实体类型在不同国家格式、不同脚本和不同标点框架下仍能保持稳定边界。
  • 代码日志增强直接决定 chunker 是否可用。日志代码注入一次性补入 1,500,000 条样本,合并后训练集扩大到 9,232,932 条,再叠加 hard samples 后,总量达到 10,455,978 条。总量增长本身意义有限,关键是分布被拉回线上形态:代码块不再只有干净模板,日志不再只有短句示例,hard negative 开始系统覆盖 hash、资源名、公开测试凭据和占位符。
  • 行业增强主要覆盖云计算与企业内部标识。云计算生命周期语料在模板复用降到 20 次后保留 1,570,520 条,把模拟工单、故障排查、配置片段和跨厂商运维上下文一起拉进来。银行与证件子类型补齐 299,260 条。补上 SWIFT、CVV、MRN、insurance policy 等弱覆盖类型,云厂商专属增强新增 112,352 条样本,并执行 126,164 次 PERSON 到 STAFF_ID 的定向替换,让内部员工标识在模拟业务上下文中形成稳定分布。

L1、L2、L3 全部从同一个顶层 split 派生,不分别从原始 PII span 各自独立抽样。当前 2026-05-18 本地最新顶层 split 来自 hardsamples + logcode + deprefix + augment + cleaning 的全量合并结果,总量 8,078,953 条:train 6,463,135 条、val 807,862 条、test 807,956 条。旧的 L1 BIO 派生目录仍保留 2026-05-02 的历史产物(train 8,362,344 条、val 1,045,270 条、test 1,045,341 条),这些数字只能说明早期 chunker 数据口径,不应再当作最新源数据口径;下一次训练 L1 前需要从 2026-05-18 顶层 split 重新派生。

层级 构建方式 当前可追溯数据量 代码日志块处理
L1 chunker 从顶层 split 投影出 CODE_BLOCK、LOG_BLOCK、INLINE_CODE 这类 codelog region,文本保持原样,非 codelog PII 全部丢弃,空 chunk 记录保留为负样本。 当前落盘 L1 BIO 派生集是 2026-05-02 旧产物:train 8,362,344、val 1,045,270、test 1,045,341;其中 train 纯负 6,614,486 条。当前最新顶层 split 已变成 6,463,135 / 807,862 / 807,956,需要重建 L1 才能得到新的 chunk 计数。 BIO 旧版三类都标;后续 GlobalPointer 和 Segmentation 只把 CODE_BLOCK / LOG_BLOCK 作为长块边界任务,INLINE_CODE 不再和长块混在同一个 L1 目标里。
L2 main GLiNER / BIO 从顶层 split 生成 prose PII 数据。构建时先找出 CODE_BLOCK、LOG_BLOCK、INLINE_CODE 区间,再把这些区间从正文中物理删除,剩余文本重新计算 token offset 和 PII 标签。 当前 2026-05-18 可追溯 train 6,363,180 条、val 795,360 条;test 文件存在但时间早于本轮 train/val,应在正式报告前重建并补齐同一口径统计。 L2 对代码块采用有意删除策略。CODE_BLOCK / LOG_BLOCK / INLINE_CODE 三类标签也从 L2 label set 中排除,避免长代码日志块干扰 prose span 抽取。
L3 inblock GLiNER 与 L2 在同一次构建中产生。每个含 PII 的 CODE_BLOCK、LOG_BLOCK、INLINE_CODE region 单独切成一条 inblock record,region 内 PII 的 offset 相对块内文本重新计算。 当前 2026-05-18 可追溯 train 1,199,644 条、val 150,165 条;test 文件存在但也是旧口径,需随 L2 test 一起重建。train 中 region 来源为 LOG_BLOCK 584,018、CODE_BLOCK 568,571、INLINE_CODE 47,055。 块内 PII 有标签,不是丢失监督。train 中包含 api secret key 293,469、json web token 214,683、api secret id 207,638、password 202,802、ipv4 address 198,827 等高风险块内实体。

因此,L2 GLiNER 不应保留未标注的代码块或日志块。保留但不标注会把块内 SECRET_KEY、JWT、PASSWORD、IP、DOMAIN 等真实 PII 变成训练时的假负例,同时还会让 prose 抽取模型承受长代码、日志行、stack trace 的分布噪声。当前更好的切法是:L1 只负责找长块边界,L2 只学 prose PII,L3 专门学 code/log/inline 内部的 PII。只有在能够完整标注块内所有 PII、并且模型上下文和采样策略专门为代码日志优化时,才值得把块文本合回 L2;否则三层拆分更稳。

模型架构

当前版本的训练系统拆成三层,其中 L3 已完成数据派生和训练脚本准备,但本地 checkpoint 目录还没有实际训练产物:

  • L1 只做 chunk 定位,用来找到代码块和日志块的精确边界。
  • L2 只处理 prose(自然语言正文)中的 PII span。
  • L3 处理 code/log 内部的高风险实体,当前处于数据已派生、训练未落盘完成的状态。

这么拆是因为长连续区域检测、短 span 抽取和块内高频实体识别的统计结构并不相同。不应共享同一种任务建模方式:

层级 目标 典型 span 长度 正负比 适合的框架
L1 — Chunker CODE_BLOCK / LOG_BLOCK 长块边界;INLINE_CODE 不再作为最新长块目标 50-500 tokens,部分长块超过 1024 字符 旧 BIO 派生集约 21% 记录含 chunk;最新源 split 需重建后确认 语义分割(per-token inside/outside)
L2 — Prose PII prose 中的敏感 PII;当前 train 统计实际出现 35 个 main labels 1-12 tokens 代码/日志/inline 区间已物理删除,只保留 prose 分布 span extraction(start/end scoring)
L3 — Inblock PII CODE_BLOCK / LOG_BLOCK / INLINE_CODE 内部的 PII;当前 train 统计出现 30 个 inblock labels 1-8 tokens,secret/token/path/URI 可更长 只保留含块内 PII 的 region,不训练空块 从 L2 GLiNER 继续微调

Backbone 也故采用异构组合。L1 使用 ModernBERT(mmBERT,307M 参数),因为它原生支持 8192 token 上下文,并且预训练语料包含代码和日志,对长上下文的代码块边界检测更友好。L2 使用基于 mDeBERTa-v3 的 GLiNER(278M 参数),因为它处理的是更短、更密集的 token 级抽取任务,mDeBERTa 的 disentangled attention(解耦注意力)机制对 token 表征精度更优。L3 的设计也是从 L2 best checkpoint 继续微调 GLiNER,但训练计划尚未执行。

L1架构演进

下面按时间顺序回顾 L1 Chunker 的六次架构改进。这是整个项目中最波折的部分—一个"肉眼就能区分代码块和正文"的任务,用 300M 参数的预训练模型训了三天才真正处置。版本名按真实训练记录对齐:早期 BIO+CRF 与 GlobalPointer 是废弃路线,最终生产 checkpoint 是 seg-v4-dice-20260503-224549-best,v5/v6 是围绕 4096 context 的后续验证实验。

BIO + CRF

最早的 L1 方案是经典的序列标注:mmBERT 编码器输出每个 token 的向量表示。接一个线性分类头把每个 token 分为 B(Begin,实体起始)、I(Inside,实体内部)、O(Outside,非实体)三类标签,末了用 CRF(条件随机场)层做解码,确保输出的标签序列满足 BIO 合法转移约束(像 B 后面只能跟 I 或 O,不能跟另一个 B)。辅助损失使用 Focal Loss 放大稀疏 B-tag 的梯度。

真实训练配置是 2 卡 DDP、per-GPU batch size 32、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 102K 仍大幅震荡;eval_limit=500 也带来明显采样方差。这个版本最终废弃。

这个结局揭示了 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 框架本身就不适合了。

GlobalPointer + 1D U-Net

既然 BIO 不适合长 span,随后改用了 EfficientGlobalPointer—一种直接预测 \((start, end)\) 边界对的架构。对序列中每一对 (token_i, token_j) 计算"这对 token 是否构成某类实体的起止点"的分数,选择分数高的作为预测。EfficientGlobalPointer 通过低维投影共享参数,把内存从 \(O(T^2 \times C)\) 降到 \(O(T \times d + T^2)\)。

为了增强对长 span 的感知能力,我们在 encoder 和 GlobalPointer 之间插入了一个 1D U-Net 解码器做多尺度特征融合。训练用的是 GlobalPointer 标准的 Circle 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 分割才是正确框架。

Segmentation + BCE

认清任务建模方式后,方案继续向 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。

对同一 checkpoint 做独立 precision/recall 分析:

指标 训练日志报告 独立验证真实值
Token F1 0.993 0.196
Precision —(未拆分报告) 0.126
Recall —(未拆分报告) 0.443

这个阶段最大的收获是暴露了一个工程事实:错误的评估函数比错误的模型更危险。因为它会让团队长时间误以为模型在收敛,浪费 GPU 时间在空转上。

Segmentation + Dice

最终稳定下来的 L1 方案仍然是 token segmentation,关键修改有三项:

  1. 主损失从 BCE 换成 Dice Loss。
  2. 加入正负窗口平衡采样。
  3. 重写评估函数,独立报告 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 步都只学负类)。训练日志里的 raw chunk F1@IoU≥0.8 为 0.7216;进一步做 post-processing sweep 后,最终生产口径达到约 0.96。指标如下:

指标 数值 含义
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.963 96% 的块与 ground truth 高度统一
Chunk Exact (≥0.95) 0.929 93% 的块几乎完全匹配
Precision @ IoU≥0.8 0.942 94% 的预测是正确的(低误报)
Recall @ IoU≥0.8 0.981 98% 的 ground truth 被找到(低漏检)

后处理也非常简单:将输出概率的阈值设为 0.4(比默认的 0.5 更 aggressive,提升 recall),并过滤掉长度小于 10 个字符的预测块(消除噪声碎片)。post-processing sweep 中 minlen10_t0.4 是最佳策略;在 4096 single-pass 推理口径下,IoU≥0.8 F1 约为 0.963,Exact 约为 0.929。模型本身已经学会了 chunk segmentation,后处理只是在修剪边角。

完整路径回顾

把 L1 架构升级放在一起,可以看到一条清晰的认知演进路径:

版本 IoU≥0.8 F1 IoU≥0.8 F1
PostProcess
说明
v1: BIO+CRF 0.616
不稳定
未采用 任务建模方式:序列标注
参数:mmBERT + Linear + CRF;2 GPU DDP;bsz=32/GPU;LR=3e-5;3 epochs
结果:step 21K 达峰,后续震荡
教训:CRF 是短实体工具
v2: GlobalPointer 0.447 未采用 任务建模方式:Span extraction
参数:mmBERT + 1D U-Net + EfficientGlobalPointer;512-token window;50K steps;Circle Loss
结果:正负 span 分数高度重叠
教训:任务建模方式错误
v3: Seg+BCE 最高约 0.59
不稳定
未采用 任务建模方式:Per-token 分割
参数:mmBERT + 1D U-Net + Seg Head + Boundary Head;BCE;512/4096 两组 context 都不稳定
结果:BCE majority collapse
教训:极端类别不平衡下,BCE 容易把模型推向全负类预测;需要换成更适合分割重叠度的 loss
v4: Seg+Dice 0.722 raw 0.963 任务建模方式:Per-token 分割
参数:max_length=512;stride=128;bsz=32/GPU;2 GPU DDP;30K steps;LR=3e-5;4096 single-pass 推理
结果:生产版本,checkpoint 为 seg-v4-dice best
教训:短窗口训练保留更高样本多样性,长上下文推理减少跨窗口拼接
v5: 4096 fine-tune from v4 0.571 raw 未采用 任务建模方式:Per-token 分割
参数:from v4 best checkpoint;max_length=4096;stride=2048;bsz=4/GPU;grad_accum=8;LR=5e-6;8K steps
结果:把 v4 的 512-window checkpoint 继续微调到 4096-window,收益反降
教训:4096 训练窗口会减少固定训练步数内的窗口暴露量;从 v4 继续微调也没有补回这个损失
v6: Seg+Dice 4096 from scratch 0.736 raw 0.892 任务建模方式:Per-token 分割
参数:从头训练;max_length=4096;stride=2048;bsz=4/GPU;grad_accum=8;neg_subsample=0.27;Segmentation Head + Boundary Head;Dice + weighted BCE
结果:raw 略高于 v4,但 post-processing 后显著低于 v4
教训:原始记录数没有变;变少的是 4096 窗口展开后的训练窗口数,以及固定 logged steps 内模型实际看到的不同窗口数。完整 context 提供更多单样本上下文,但没有抵消窗口覆盖量下降带来的边界泛化损失

从 v1 到 v6,代码量变化不大(核心改动集中在 loss function、context 长度和后处理),但认知变化巨大。最终方案的代码比 GlobalPointer 版本更简单—没有 bilinear scoring matrix,没有 RoPE,没有 circle loss,只有逐 token 二分类 + Dice + 阈值后处理。真正困难的地方集中在两个判断:

  1. 识别出正确的任务建模方式,确认任务本质属于 segmentation,span extraction 框架并不适用。
  2. 识别出 loss function 的坍塌模式,理解 BCE 允许"全预测 O"获得低 loss。
L2架构演进

L2 当前采用 GLiNER 路线。GLiNER 的核心特性是用自然语言文本描述实体类型—训练时模型,不仅学习"哪些 token 是实体",还学习"什么样的描述对应什么样的实体"。细说,它将类型标签(如"secret access key for cloud services")和输入文本拼接后送入 mDeBERTa 编码器,随后用 bilinear scoring 对所有候选 span 打分。这意味着推理阶段,可以不重训就尝试新标签—只要带来一段自然语言描述。

L2 负责 prose 中的 PII span。当前 label_map.py 暴露 33 个正标签;v5 评估文件中实际出现 31 类,原因是部分标签在评估集里没有样本或被清洗映射合并。选定 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 客观上帮助模型跳出了局部最小值,找到了更优解。

L2 Entity-Level 评估

下面是早期 v2 诊断口径:对 checkpoint-55000 在 5000 条验证集上做 span-level 精确匹配评估(threshold=0.5),得到 micro 指标。它用于定位 recall 瓶颈,不代表后续 v5 生产指标。

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
低 Recall 根因:架构硬限制

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 需要把类型标签的自然语言描述也塞进同一个序列(当前正标签集合为 33 个,占约 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 的训练速度瓶颈在数据预处理阶段。80 个 CPU 核只有 1 个在干活。实测这部分占总步时间的 56%(~1.0s/1.77s)。每一步训练中,data_collator 需要对 batch 内每条记录做 mDeBERTa tokenize + span 矩阵构建 + label 映射,全部在 Python 主线程串行执行(GIL 限制,DataLoader 的 num_workers 无法帮上忙,因为重活在 collate_fn 里)。

化解办法是离线预处理:训练前一次性用 80 CPU 并行把所有记录的 text tokenization 和 span indices 预计算为 Arrow 文件。训练时 DataLoader 只做轻量的 label sampling + prompt 拼接 + padding。预处理 8.2M records 耗时约 74 分钟,产出 135GB Arrow 文件。

实测结局超出预期—除了速度提升,GPU 显存占用也大幅下降:

配置 Step time GPU 显存 ETA (60K steps)
原始 pipeline, bsz=3 2.30 s/it 22 GB / 24 GB 38h
离线预处理, bsz=3 1.56 s/it 6-10 GB / 24 GB 26h
离线预处理。 bsz=8 0.68 s/it 13-16 GB / 24 GB 11h
离线预处理, bsz=10 ~0.55 s/it(预估) ~18 GB / 24 GB ~9h

显存下降的原因是:原始 pipeline 在 collate_fn 内部调用 mDeBERTa tokenizer,这会在 GPU 所在进程中创建大量临时 Python 对象和 tokenizer 内部缓冲区(token embedding lookup table、attention mask 构建的中间张量等),这些内存由 PyTorch 的 CUDA allocator 管理,即使释放后也会保留在 reserved pool 中不归还操作框架。离线预处理后,collate_fn 只做轻量的 tensor 拼接和 padding,tokenizer 完全不参与训练循环—进程的 CUDA memory footprint 大幅缩小。

这个意外收获让我们把 batch size 从 3(显存紧张时的保守值)一路提到 10—相同 effective batch 下 grad_accum 从 6 降到 2,GPU 空转的"等待梯度累积"时间减少,实际计算效率更高。最终训练时间从原始 38h 压缩到约 9h,提速 4.2 倍。

max_width=16 重训方案

max_width 从 12 提到 16,span representation 矩阵从 [B, L, 12, D] 变为 [B, L, 16, D],显存增加 33%。max_width 越大,候选 span 数量线性增长,但正样本数不变—负正比(score 级别,含 ×C=33 types)从 3,849:1(width=12)涨到 6,651:1(width=16)。训练速度因 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 而非更大值的考量:在 24GB 的 4090 上 bsz 从 4 降到 3(配合 grad_accum 从 4 调到 6 维持等效 batch size)。16 恰好覆盖 8 组 IPv6(15 token),超过 16 的 PII 实体极少(<3%)。更大 K 值的正负比恶化问题见下方"max_width=36 重训方案(构思)"章节的分析。

v3(w16)历史分项表如下。当前本地可追溯的 eval_results_v3_step60k.json micro 指标为 P=0.9956、R=0.8057、F1=0.8907;旧表中的部分分项值来自早期评估口径,不能再作为总表口径使用。

实体类型 P R F1 TP FP FN
phone number 0.999 0.983 0.991 894 1 15
email address 0.999 0.981 0.990 1424 1 27
person 0.999 0.934 0.965 1438 2 102
domain name 0.999 0.919 0.957 759 1 67
postal code 0.993 0.914 0.952 413 3 39
user identification number 1.000 0.901 0.948 703 0 77
application id 0.998 0.899 0.946 607 1 68
ipv4 address 0.997 0.882 0.936 780 2 104
date and time 0.947 0.922 0.934 71 4 6
bank account number 0.991 0.879 0.931 550 5 76
uuid 1.000 0.855 0.922 697 0 118
organization name 0.994 0.854 0.919 679 4 116
identifier 0.997 0.846 0.916 369 1 67
national id number 0.994 0.847 0.915 531 3 96
cloud instance id 1.000 0.821 0.902 796 0 173
urn 1.000 0.793 0.885 383 0 100
api secret key 0.995 0.796 0.885 430 2 110
monetary amount 0.990 0.795 0.882 412 4 106
api secret id 1.000 0.778 0.875 368 0 105
staff id 1.000 0.778 0.875 249 0 71
date 0.936 0.815 0.871 44 3 10
ipv6 address 1.000 0.761 0.864 363 0 114
one time password 0.988 0.756 0.856 161 2 52
password 0.989 0.745 0.850 348 4 119
time 0.894 0.808 0.849 59 7 14
json web token 0.994 0.728 0.840 318 2 119
location 0.960 0.743 0.837 332 14 115
date time 0.848 0.796 0.821 39 7 10
address 1.000 0.647 0.785 355 0 194
mac address 1.000 1.000 1.000 49 0 0

v5(w16)评估成果(75K checkpoint,micro P=0.9957, R=0.9700, F1=0.9827,50K test records;65K checkpoint 的 F1=0.9835,略高于 75K):

实体类型 P R F1 TP FP FN
mac address 1.000 1.000 1.000 892 0 0
ipv4 address 0.999 0.996 0.998 19936 24 77
cloud instance id 1.000 0.993 0.996 20630 3 148
application id 0.999 0.994 0.996 15873 22 95
ipv6 address 0.999 0.993 0.996 10416 8 72
user identification number 0.999 0.994 0.996 18230 22 116
staff id 1.000 0.993 0.996 8394 4 61
phone number 0.999 0.993 0.996 19605 26 134
email address 0.999 0.992 0.996 32913 32 266
person 0.998 0.992 0.995 37954 59 299
uuid 1.000 0.991 0.995 18567 2 173
api secret id 1.000 0.990 0.995 10158 1 104
postal code 0.996 0.992 0.994 9751 39 75
bank account number 0.997 0.990 0.993 14264 44 152
urn 1.000 0.986 0.993 10745 4 157
identifier 0.997 0.988 0.993 9704 31 115
api secret key 0.998 0.987 0.992 12891 30 173
monetary amount 0.997 0.985 0.991 13168 35 200
domain name 0.999 0.982 0.991 17748 16 318
national id number 0.994 0.986 0.990 13853 87 199
organization name 0.992 0.987 0.989 19037 151 259
one time password 0.987 0.979 0.983 4490 59 95
password 0.993 0.968 0.980 10524 72 353
address 0.999 0.953 0.975 11713 12 582
date 0.986 0.955 0.970 5114 74 242
date and time 0.979 0.960 0.969 7786 170 325
location 0.965 0.935 0.950 9735 350 674
uri 0.999 0.826 0.904 25253 32 5322
json web token 0.995 0.817 0.898 7979 39 1782
time 0.916 0.875 0.895 1903 174 272
date time 0.867 0.877 0.872 1176 181 165

v5(w16)分语言 × 分类型矩阵(F1 P R):

类型 ID DE FR MS IT RU AR VI ES PT EN TH KO JA ZH
mac address 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00
ipv4 address 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 0.99 0.99 1.00 0.98
cloud instance id 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.98 1.00 0.97 0.97 1.00 0.94
application id 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 0.99 0.99 1.00 0.99 0.99 1.00 0.99 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 0.99 0.99 1.00 0.98
ipv6 address 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 0.98 0.98 1.00 0.95
user identification number 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 0.99 1.00 1.00 0.99 1.00 1.00 1.00 1.00 1.00 0.99 0.99 1.00 0.99 0.98 1.00 0.97
staff id 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 0.98 0.99 1.00 0.98 0.97 1.00 0.93 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 0.96 1.00 0.91
phone number 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 0.98 1.00 1.00 0.99 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 0.98 0.98 0.99 0.97
email address 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 1.00 1.00 1.00 1.00 0.99 0.99 1.00 0.99 0.99 1.00 0.99 1.00 1.00 1.00 0.99 1.00 0.99 0.98 1.00 0.97
person 1.00 1.00 1.00 1.00 1.00 0.99 1.00 1.00 0.99 1.00 1.00 1.00 1.00 1.00 0.99 1.00 1.00 0.99 1.00 1.00 0.99 1.00 1.00 1.00 0.99 1.00 0.99 0.99 1.00 0.99 1.00 1.00 0.99 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 0.98 1.00 0.97
uuid 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 0.94 1.00 0.89
api secret id 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.98 1.00 0.97 0.95 1.00 0.91
postal code 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 0.99 1.00 1.00 0.99 0.99 0.99 0.98 0.99 0.99 0.99 0.98 0.99 0.96
bank account number 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 0.99 0.99 1.00 0.99 1.00 0.98 0.99 0.97 0.96 0.99 0.93
urn 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 0.98 0.94 1.00 0.89
identifier 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 0.99 0.99 0.99 0.99 1.00 0.99 0.99 0.99 0.99 0.93 0.98 0.88
api secret key 1.00 1.00 0.99 1.00 1.00 0.99 1.00 1.00 0.99 0.99 1.00 0.99 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 0.99 0.98 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 0.99 1.00 0.99 0.99 1.00 0.99 0.97 0.99 0.96 0.96 1.00 0.93
monetary amount 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 0.98 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 0.99 0.99 1.00 1.00 1.00 1.00 1.00 1.00 0.98 1.00 0.97 0.94 0.99 0.89
domain name 1.00 1.00 0.99 1.00 1.00 1.00 1.00 1.00 0.99 1.00 1.00 0.99 1.00 1.00 0.99 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 0.99 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 0.98 0.99 1.00 0.97 0.99 1.00 0.99 0.98 1.00 0.97 0.96 1.00 0.92
national id number 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 0.99 0.99 0.99 0.99 0.99 1.00 0.99 0.99 0.99 0.98 0.94 0.97 0.92
organization name 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 0.99 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.97 0.97 0.97 0.99 0.99 0.99 0.99 1.00 0.99 0.98 0.99 0.97 0.97 0.98 0.96
one time password 1.00 1.00 1.00 1.00 1.00 1.00 0.99 0.99 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 0.99 0.99 0.99 0.99 0.98 0.99 0.99 0.99 0.99 0.99 0.99 1.00 0.99 0.99 0.99 0.99 0.99 0.99 0.99 0.99 0.99 0.96 0.99 0.94 0.90 0.94 0.86
password 0.98 0.99 0.98 0.99 0.99 0.98 0.99 1.00 0.98 0.99 1.00 0.98 0.99 1.00 0.98 1.00 1.00 0.99 0.99 1.00 0.98 0.99 1.00 0.98 0.98 0.99 0.96 0.98 0.99 0.98 0.98 0.99 0.97 0.98 0.99 0.97 0.98 0.99 0.98 0.98 0.99 0.96 0.92 0.99 0.85
address 0.99 1.00 0.99 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.93 1.00 0.86 1.00 1.00 1.00 0.94 1.00 0.89 0.98 1.00 0.97 0.98 1.00 0.96 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.87 1.00 0.78 0.90 1.00 0.83
date 0.99 1.00 0.98 0.99 1.00 0.98 1.00 1.00 1.00 0.99 1.00 0.98 1.00 1.00 1.00 0.99 1.00 0.98 0.97 1.00 0.94 0.98 1.00 0.96 0.99 1.00 0.98 1.00 1.00 1.00 0.97 0.98 0.95 0.93 0.96 0.91 0.96 0.98 0.94 0.94 0.98 0.90 0.95 0.97 0.94
date and time 0.99 0.99 1.00 1.00 0.99 1.00 1.00 1.00 1.00 0.99 0.99 1.00 1.00 1.00 1.00 0.99 0.99 1.00 1.00 1.00 0.99 0.95 0.99 0.92 0.98 0.99 0.96 0.98 1.00 0.97 0.98 0.98 0.97 0.96 0.97 0.96 0.98 0.97 0.98 0.95 0.95 0.94 0.89 0.94 0.85
location 0.99 0.99 0.98 0.98 0.98 0.98 0.98 0.98 0.97 0.99 1.00 0.99 0.97 0.98 0.96 0.98 0.97 0.98 0.98 0.98 0.98 0.97 1.00 0.95 0.99 0.99 0.99 0.99 0.99 0.98 0.97 0.98 0.96 0.96 0.97 0.95 0.90 0.92 0.88 0.93 0.96 0.90 0.82 0.88 0.77
uri 0.91 1.00 0.84 0.91 1.00 0.84 0.92 1.00 0.86 0.92 1.00 0.86 0.92 1.00 0.85 0.92 1.00 0.85 0.91 1.00 0.84 0.91 1.00 0.83 0.91 1.00 0.84 0.91 1.00 0.83 0.91 1.00 0.83 0.90 0.99 0.82 0.91 1.00 0.83 0.88 1.00 0.79 0.86 0.99 0.76
json web token 0.88 1.00 0.79 0.91 1.00 0.84 0.85 1.00 0.73 0.85 1.00 0.73 0.78 1.00 0.63 0.89 1.00 0.80 0.84 1.00 0.72 0.91 1.00 0.84 0.81 1.00 0.68 0.80 1.00 0.66 0.96 0.99 0.93 0.94 0.99 0.90 0.92 1.00 0.86 0.95 0.99 0.91 0.93 0.99 0.88
time 0.93 1.00 0.86 0.94 1.00 0.89 1.00 1.00 1.00 0.92 0.97 0.88 1.00 1.00 1.00 0.90 0.93 0.87 0.98 1.00 0.96 0.90 1.00 0.82 0.90 0.92 0.88 0.99 1.00 0.97 0.89 0.89 0.89 0.89 0.91 0.87 0.86 0.91 0.81 0.91 0.91 0.91 0.87 0.90 0.84
date time — — — — — — — — — — 0.88 0.88 0.88 0.77 0.75 0.79 0.86 0.79 0.95 0.84 0.85 0.84 0.89 0.89 0.89
推理时滑动窗口

max_width 化解的是"单个实体太宽装不进候选空间"的问题,而 max_length=384 截断攻克的是另一类难题:"实体出现在文本靠后的位置,整段文本被截掉了"。技术文档和工单的典型结构是先描述问题,后面才贴具体地址、链接和配置—所以 IPv6、URI 这类实体倾向于出现在 word 384 之后的位置(31% 的 IPv6 和 22% 的 URI 落在截断区域外)。这样任何位置的实体都至少被一个窗口完整覆盖。重叠区域内如果同一个 span 被多个窗口检出,取分数最高的。

滑动窗口在推理阶段可以化解这个麻烦:把一条长文本切成多个重叠窗口(window=384, stride=256),每个窗口独立送入模型,之后合并预测结局。

滑动窗口不需要重训,纯推理阶段改动。代价是推理时间乘以窗口数(通常 1.3-2 倍,视具体情况文本长度分布)。对于 84% 的记录(text ≤ 384 words),只跑一个窗口,无额外开销。只有 16% 的长文本需要多窗口。

滑动窗口对 max_width 难题完全无效—一个 15 token 宽的 IPv6 不管在哪个窗口里都是 15 token,max_width=12 的模型看到它也无能为力。两个限制少不了各自独立的解决方案:max_width=16 重训攻克"实体太宽",滑动窗口解决"实体位置太靠后"。

在 v3 checkpoint 上实测滑动窗口(window=384, stride=256)的效果:

推理方式 Precision Recall F1
单次截断(标准推理) 0.9956 0.8057 0.8907
滑动窗口 (w=384, s=256) 0.9903 0.9556 0.9726
Δ −0.0053 +0.1499 +0.0819

Recall 从 80.6% 跳到 95.6%,提升约 15 个百分点,precision 只下降约 0.5 个百分点。这说明 v3 模型的大量漏检来自截断,模型本身在可见区域内的预测质量较高。

受益最大的类型恰好是之前诊断为"被截断影响"的那些:

实体类型 单次 Recall 滑窗 Recall 提升
json web token 0.728 0.973 +24.5%
ipv6 address 0.761 1.000 +23.9%
password 0.745 0.976 +23.1%
api secret id 0.778 1.000 +22.2%
staff id 0.778 0.997 +21.9%
one time password 0.756 0.967 +21.1%
urn 0.793 0.998 +20.5%
api secret key 0.796 0.994 +19.8%
cloud instance id 0.821 0.999 +17.8%

IPv6 达到 recall 1.000(满分)—max_width=16 化解了"太宽"的问题,滑动窗口解决了"太远"的麻烦,两者叠加后 IPv6 的架构盲区被彻底消除。JWT 是另一个典型受益者:JWT 通常出现在配置文件的中后部(token 位置 400-800),单次截断只能看到不到一半的 JWT,滑动窗口让模型覆盖到完整文本。

URI 的提升相对温和(+19% vs 其他类型 +20-24%)。URI 的瓶颈不完全来自截断—部分 URI 本身宽度仍然超过 max_width=16(如带路径的长 URL),属于 max_width 而非 max_length 的限制。滑动窗口早就成为 L2 推理 pipeline 的标准配置。

v3(w16)滑动窗口推理后的完整评估结局(本地 eval_results_v3_sliding_window.json 口径:micro P=0.9903, R=0.9556, F1=0.9726):

实体类型 P R F1 TP FP FN
api secret id 1.000 1.000 1.000 473 0 0
mac address 1.000 1.000 1.000 49 0 0
uuid 0.998 1.000 0.999 815 2 0
cloud instance id 0.998 0.999 0.998 968 2 1
urn 0.998 0.998 0.998 482 1 1
ipv4 address 0.995 1.000 0.998 884 4 0
email address 0.997 0.995 0.996 1444 4 7
user identification number 0.995 0.997 0.996 778 4 2
phone number 0.996 0.997 0.996 906 4 3
ipv6 address 0.990 1.000 0.995 477 5 0
application id 0.990 0.999 0.994 674 7 1
staff id 0.991 0.997 0.994 319 3 1
api secret key 0.991 0.994 0.993 537 5 3
monetary amount 0.990 0.992 0.991 514 5 4
postal code 0.991 0.989 0.990 447 4 5
identifier 0.982 0.995 0.989 434 8 2
bank account number 0.986 0.989 0.987 619 9 7
domain name 0.988 0.982 0.985 811 10 15
national id number 0.990 0.976 0.983 612 6 15
json web token 0.993 0.973 0.983 425 3 12
password 0.979 0.976 0.977 456 10 11
one time password 0.967 0.967 0.967 206 7 7
person 0.992 0.943 0.967 1452 11 88
date and time 0.959 0.922 0.940 71 3 6
organization name 0.962 0.865 0.911 688 27 107
uri 0.994 0.836 0.908 1077 6 211
date 0.889 0.889 0.889 48 6 6
date time 0.843 0.878 0.860 43 8 6
location 0.934 0.790 0.856 353 25 94
time 0.847 0.836 0.841 61 11 12
address 0.994 0.650 0.786 357 2 192

这个结果的工程含义:推理时只需在文本 tokenize 后加一层窗口切分逻辑(~20 行代码),无需重训任何模型,就能在 v3 口径下获得约 8.2 个 F1 点的提升。address 类型几乎没有从滑动窗口获益(+0.6%),因为地址通常出现在文本前部且较短。

v5 更新:分词修复后滑动窗口的增益从 v3 阶段的约 +8.2 个 F1 点降到约 +0.2 个 F1 点(v5 single-pass F1=0.9827, 滑窗 F1=0.9847)。原因是旧 splitter 把 TH/AR/RU/VI 文本逐字符拆碎导致 token 数膨胀,大量实体被推到 384 token 截断区域外——修复 splitter 后 token 数回归正常,绝大多数实体已落在单窗口覆盖范围内。滑动窗口仍有必要(对超长文档的尾部实体仍有边际收益),但已不再是关键提升手段。

v5(w16)滑动窗口评估(micro P=0.9934, R=0.9761, F1=0.9847,w=384 s=256,50K test records):

实体类型 P R F1
uuid 0.999 0.999 0.999
staff id 0.999 0.999 0.999
urn 0.999 0.999 0.999
api secret id 1.000 0.998 0.999
cloud instance id 0.999 0.998 0.998
ipv4 address 0.997 0.999 0.998
phone number 0.997 0.999 0.998
user identification number 0.998 0.997 0.998
ipv6 address 0.995 0.999 0.997
email address 0.999 0.995 0.997
application id 0.995 0.998 0.996
monetary amount 0.995 0.997 0.996
person 0.995 0.997 0.996
mac address 0.991 1.000 0.996
bank account number 0.997 0.994 0.995
identifier 0.994 0.995 0.995
postal code 0.993 0.996 0.994
api secret key 0.996 0.993 0.994
national id number 0.992 0.993 0.993
domain name 0.995 0.988 0.991
organization name 0.987 0.989 0.988
one time password 0.985 0.988 0.987
password 0.990 0.978 0.984
address 0.998 0.955 0.976
date and time 0.976 0.974 0.975
date 0.983 0.965 0.974
location 0.959 0.941 0.950
json web token 0.994 0.835 0.908
uri 0.997 0.832 0.907
time 0.897 0.895 0.896
date time 0.859 0.894 0.876

v5(w16)滑动窗口 分语言 × 分类型矩阵(F1 P R):

类型 ID DE FR MS AR RU IT VI ES PT EN TH KO JA ZH
uuid 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00
staff id 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00
urn 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00
api secret id 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 0.97 1.00 1.00 1.00
cloud instance id 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.98 1.00 0.97 1.00 1.00 1.00
ipv4 address 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 0.99 0.99 0.99 0.99 1.00
phone number 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 0.99 0.99 0.99 0.98 1.00
user identification number 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 0.99 0.99 0.99 0.99 0.99 0.99
ipv6 address 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 0.99 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.98 0.98 0.99 0.99 0.99 1.00
email address 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 1.00 0.99 0.99 1.00 0.99 1.00 1.00 1.00 0.99 1.00 0.99 0.99 0.99 0.99
application id 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 0.98 0.99 0.98 0.97 1.00
monetary amount 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 0.99 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.98 0.97 0.98
person 1.00 1.00 1.00 1.00 1.00 0.99 0.99 1.00 0.99 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 1.00 0.99 1.00 1.00 1.00 1.00 1.00 0.99 1.00 1.00 0.99 1.00 0.99 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 0.97 1.00
mac address 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 0.98 1.00 0.99 0.98 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.98 0.96 1.00 0.98 0.96 1.00
bank account number 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 0.99 0.99 1.00 0.99 1.00 0.98 0.99 0.97 0.99 0.99 0.98
identifier 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 0.99 0.99 0.99 0.99 1.00 0.99 0.99 0.98 0.99 0.97 0.96 0.97
postal code 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 1.00 1.00 0.99 0.99 0.99 0.98 0.98 0.97 0.99 0.98 0.97 0.99
api secret key 1.00 1.00 0.99 0.99 0.99 0.99 0.99 1.00 0.99 0.99 1.00 0.99 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 0.99 0.98 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 0.99 0.99 1.00 0.99 0.98 0.99 0.96 0.99 0.99 1.00
national id number 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 0.99 0.99 0.99 0.99 0.99 1.00 0.99 0.99 0.99 0.98 0.97 0.96 0.98
domain name 1.00 1.00 0.99 1.00 1.00 1.00 1.00 1.00 0.99 1.00 1.00 0.99 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 0.99 0.98 0.99 1.00 0.97 0.99 1.00 0.99 0.98 0.99 0.97 0.97 0.97 0.97
organization name 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.99 1.00 1.00 1.00 0.99 0.99 0.99 1.00 1.00 1.00 1.00 1.00 1.00 0.99 0.99 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.97 0.97 0.97 0.99 0.99 0.99 0.99 1.00 0.99 0.98 0.98 0.98 0.96 0.94 0.97
one time password 1.00 1.00 1.00 1.00 1.00 1.00 0.99 0.99 1.00 1.00 1.00 1.00 0.99 0.99 0.99 1.00 0.99 1.00 1.00 1.00 1.00 0.99 0.99 0.99 0.99 0.99 0.99 0.99 0.99 1.00 0.99 0.99 0.99 0.99 0.99 0.99 0.99 0.99 0.99 0.96 0.98 0.94 0.95 0.93 0.96
password 0.98 0.99 0.98 0.98 0.99 0.98 0.99 0.99 0.98 0.99 1.00 0.98 0.99 0.99 0.98 0.99 1.00 0.99 0.99 0.99 0.98 0.98 0.98 0.98 0.97 0.98 0.96 0.98 0.99 0.98 0.98 0.99 0.97 0.98 0.99 0.97 0.98 0.99 0.98 0.98 0.98 0.97 0.98 0.97 0.98
address 0.99 1.00 0.99 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.93 1.00 0.86 1.00 1.00 1.00 0.94 1.00 0.89 0.98 1.00 0.97 0.98 1.00 0.96 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.88 1.00 0.80 0.91 0.99 0.84
date and time 0.99 0.99 1.00 1.00 0.99 1.00 1.00 1.00 1.00 0.99 0.99 1.00 1.00 1.00 1.00 0.99 0.99 1.00 1.00 1.00 1.00 0.99 0.98 1.00 0.99 0.99 0.99 1.00 1.00 1.00 0.98 0.98 0.98 0.96 0.97 0.96 0.98 0.97 0.98 0.95 0.95 0.94 0.91 0.93 0.89
date 0.99 1.00 0.98 0.99 1.00 0.98 1.00 1.00 1.00 0.99 1.00 0.98 1.00 1.00 1.00 0.99 1.00 0.98 1.00 1.00 1.00 0.99 1.00 0.98 0.99 1.00 0.98 1.00 1.00 1.00 0.97 0.98 0.95 0.93 0.96 0.91 0.96 0.98 0.94 0.96 0.97 0.96 0.95 0.95 0.95
location 0.99 0.99 0.98 0.98 0.98 0.98 0.97 0.98 0.97 0.99 1.00 0.99 0.98 0.98 0.98 0.98 0.97 0.98 0.97 0.98 0.96 0.97 0.99 0.96 0.99 0.99 0.99 0.99 0.99 0.98 0.97 0.98 0.96 0.96 0.97 0.95 0.90 0.92 0.88 0.93 0.95 0.92 0.83 0.85 0.81
json web token 0.88 1.00 0.79 0.91 1.00 0.84 0.87 1.00 0.77 0.85 1.00 0.74 0.84 1.00 0.73 0.89 1.00 0.80 0.78 1.00 0.64 0.92 0.99 0.86 0.82 1.00 0.69 0.82 1.00 0.69 0.96 0.99 0.93 0.94 0.99 0.90 0.93 1.00 0.86 0.96 0.99 0.94 0.98 0.99 0.97
uri 0.91 1.00 0.84 0.91 1.00 0.84 0.92 1.00 0.86 0.92 1.00 0.86 0.91 1.00 0.84 0.92 1.00 0.85 0.92 1.00 0.85 0.91 1.00 0.84 0.91 1.00 0.84 0.91 1.00 0.83 0.91 1.00 0.83 0.90 0.99 0.82 0.91 1.00 0.83 0.88 0.99 0.79 0.89 0.98 0.82
time 0.93 1.00 0.86 0.94 1.00 0.89 1.00 1.00 1.00 0.92 0.97 0.88 0.98 1.00 0.96 0.90 0.93 0.87 1.00 1.00 1.00 0.90 1.00 0.82 0.90 0.92 0.88 0.99 1.00 0.97 0.89 0.88 0.90 0.89 0.91 0.87 0.86 0.91 0.81 0.91 0.90 0.92 0.88 0.86 0.90
date time — — — — — — — — — — 0.88 0.89 0.88 0.78 0.76 0.79 0.87 0.80 0.95 0.85 0.86 0.84 0.89 0.87 0.92
分词修复

GLiNER 的 span width 由上层 word splitter 决定——把原文切成 word 序列后,max_width=16 意味着最多跨 16 个 word。这一层必须满足一个硬约束:训练数据构建、评估代码和线上推理使用同一个确定性 splitter,不能依赖 gold entity 的 char offset。推理时不知道实体边界,所以训练时也不能用标注答案参与切词。

最终状态:当前修复已经从“Unicode word 粗修复”升级为“严格脚本边界切分”。tokenizer 按 15 语言的字符集合边界切分:Han 逐字,Hiragana/Katakana、Hangul、Thai、Arabic、Cyrillic、Latin 各自成 run,Latin 可和 ASCII digit / underscore 组成云资源 ID 或密钥 ID,纯数字独立成 run,标点和符号独立成 token。原则是:跨语言/跨脚本边界宁可切细,不切粗;切细最多增加 token 数和候选 span,切粗会让 gold span 和推理 span 都无法表达真实实体边界。

修复后,下面两个侧面保持一致:

训练数据构建侧

build_layer2_dataset.py 只用脚本感知 regex 生成 token,再把 char span 映射成 token span。这里不再做任何基于实体标注的二次切分,避免 train/infer skew。下表覆盖全部 15 种语言:前几类是会被旧 Unicode-word 粗切分影响的脚本混排场景,后几类是正常 Latin/Han 切分路径。

语言 影响 示例 最终 tokenization
JA 受影响 PL07148365189572651930223503または6606509に PL07148365189572651930223503 / または / 6606509 / に
KO 受影响 VPN포털HTTPS에서 VPN / 포털 / HTTPS / 에서
TH 受影响 AKIAIOSFODNN7EXAMPLEและ6606509 AKIAIOSFODNN7EXAMPLE / และ / 6606509
AR 受影响 AKIAIOSFODNN7EXAMPLEأو6606509 AKIAIOSFODNN7EXAMPLE / أو / 6606509
RU 受影响 IDABC123номер42 IDABC123 / номер / 42
ZH 正常路径 账号AKIAIOSFODNN7EXAMPLE已启用 账 / 号 / AKIAIOSFODNN7EXAMPLE / 已 / 启 / 用
EN 正常路径 userId AKIAIOSFODNN7EXAMPLE active userId / AKIAIOSFODNN7EXAMPLE / active
DE 正常路径 MünchenKonto42 aktiv MünchenKonto42 / aktiv
FR 正常路径 françaisClient77 actif françaisClient77 / actif
ES 正常路径 EspañaCuenta88 activa EspañaCuenta88 / activa
PT 正常路径 clienteSãoPauloID123 ativo clienteSãoPauloID123 / ativo
IT 正常路径 cittàAccount99 attivo cittàAccount99 / attivo
VI 正常路径 ĐàNẵngUser123 hoạtđộng ĐàNẵngUser123 / hoạtđộng
MS 正常路径 pelangganID123 aktif pelangganID123 / aktif
ID 正常路径 akunID123 aktif akunID123 / aktif

最终实现(用于下一轮重建 L2/L3 dataset 和 Arrow):

build_layer2_dataset.py — 当前版本
Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import regex as _regex
TOKEN_RE = _regex.compile(
    r'[\p{Script=Han}]'
    r'|[\p{Script=Hiragana}\p{Script=Inherited}\u30FC]+'
    r'|[\p{Script=Katakana}\p{Script=Inherited}\u30FC]+'
    r'|[\p{Script=Hangul}\p{Script=Inherited}]+'
    r'|[\p{Script=Thai}]+'
    r'|[\p{Script=Arabic}\p{Script=Inherited}]+'
    r'|[\p{Script=Cyrillic}\p{Script=Inherited}]+'
    r'|[\p{Script=Latin}][\p{Script=Latin}0-9_]*'
    r'|[0-9]+'
    r'|[\p{N}]+'
    r'|[^\s\p{L}\p{M}\p{N}_]',
    flags=_regex.UNICODE
)

这个 regex 就是完整策略。训练集构建、评估、滑动窗口评估、pipeline 推理和 reasoning benchmark 都必须替换到同一个 MultilingualTokenSplitter。任何基于 gold offset 的切词都会制造训练和推理不一致。

推理侧

推理侧没有 gold entity 边界,因此只能使用同一套确定性脚本切分。 word_splitter.py 的 MultilingualTokenSplitter 已同步到同一份 regex,保证训练、评估、reasoning benchmark 和生产推理的 token 粒度一致。

Python
1
2
3
from word_splitter import MultilingualTokenSplitter
model = GLiNER.from_pretrained(checkpoint_dir, local_files_only=True)
model.data_processor.words_splitter = MultilingualTokenSplitter()

这个修复不会挽救已经用旧 Arrow 训练出来的 v8 checkpoint;必须重新运行 build_layer2_dataset.py、 preprocess_offline.py,再重新训练 GLiNER / BIO。

Ensemble 方案(进行中)

GLiNER 在短 span 语义实体(PERSON、ORG、PASSWORD、OTP 等需要上下文判断的类型)上表现很好,但对长实体有先天缺陷(max_width 的硬限制)。滑动窗口解决了截断问题,但超宽实体仍然无法识别。对剩余 FN 做根因分析后,低 recall 类型的问题归因如下。

需要注意的是,即使当前 max_width=16 已经覆盖了绝大多数实体,现实中超长实体始终可能出现——多语言地址、带深路径的 URL、嵌套组织名称等,长度分布是长尾的,没有确定上界。我们不能无限制地堆高 max_width:每增加 K,候选 span 数线性增长,负正比恶化(K=16 时 6,651:1,K=36 时 13,587:1),显存和训练时间同步上涨,而覆盖率的边际收益递减。正确的策略是在 GLiNER 端选一个性价比最高的 K 值,剩余的超宽长尾交给 BIO 模型兜底——BIO 没有 span 宽度限制,天然覆盖任意长度实体。

BIO模型训练摘要

当前情况可以概括为:BIO 线已经把 0-10 版最核心的坑踩明白,但还没有像 GLiNER 那样形成“默认稳定可复现”的训练路径。早期版本的问题主要集中在四类:

  • 数值路径不稳定。CRF 前向里的 logsumexp 和 logpartition 对长序列、极端 emission、激进 transition 分数非常敏感,warmup 阶段容易直接 NaN,或者在 torch-struct 路线里转成数据相关 OOM。
  • 标签路径容易被细节破坏。BIO 对 subword 对齐要求极高,只要 continuation subword 贴错,gold path 就会出现大量非法 O→I-X 转移,模型会把正确标签序列本身当成低质量路径。
  • loss guard 很难调。只做梯度裁剪不够,spike guard、absolute ceiling、EMA recovery、per-token normalization 这些保护必须一起上;少一个,训练就可能在 5k-10k 这段历史危险区再次发散。
  • 工程诊断成本高。很多问题表面上都表现为 “loss 很高” 或 “F1 不学”,但根因可能分别来自 CRF 数值范围、subword 标签传播、loss reduction 口径、CRF LR 过高,定位链路比 GLiNER 长得多。

BIO 比 GLiNER 更不稳定,关键差异来自训练目标的数值结构。GLiNER 虽然慢,但主干仍是标准 transformer 编码 + span scorer,loss 面更接近常规深度学习训练;BIO+CRF 额外引入了一层链式归一化,整条 384/512 token 序列上的标签路径要一起参与 partition 归一化,任何一个位置的异常分数都可能沿动态规划被放大。再加上 BIO 的监督是离散标签路径,不是 span 打分,subword 贴标一旦出错,错误会直接污染 gold sequence 本身。换句话说,GLiNER 的主要矛盾是算得慢、候选空间有限;BIO 的主要矛盾是数值路径和标注路径都更脆弱。

待更新:w16 / w36 v5 评估完成后,更新下表中 w36 列的 F1 和 FN 数据。

类型 w16 F1 w16 FN w36 F1 w36 FN 根因
address 0.786 192 — — 100% 超宽(多语言地址平均 15.7 token,31% 超过 max_width=16)
json web token 0.983 12 — — 滑窗后已基本解决(R=0.973),残余 FN 来自语义判断失败
uri 0.908 211 — — 100% 超宽(长 URL 15% 超过 16 token)
location 0.856 94 — — 66% 超宽 + 34% 语义模糊
organization name 0.911 107 — — 79% 超宽 + 21% 语义模糊

超宽问题集中在逐字符 tokenize 的语言(泰语、阿拉伯语、越南语、俄语)。英文 "Aurora Solutions Co., Ltd." = 7 tokens,泰语 "บริษัทซันไชน์ดาต้าเทคโนโลยีจำกัด" = 32 tokens——同样一个公司名,因为 tokenizer 差异导致 token 数膨胀 4-5 倍。

最初考虑用正则 fallback 解决,但正则只能覆盖 URI 和 JWT 这两种有固定格式的类型,对 address/location/organization 无能为力。而 BIO token classification 模型能一次性解决所有超宽类型——它对每个 token 独立打 B/I/O 标签,没有宽度限制,100 token 宽的地址也能完整识别。

最终确定的 Ensemble 方案是 GLiNER + BIO 双模型并行:

  • GLiNER(mDeBERTa, 滑窗推理):负责全部 26 类型的基线检测。语义理解能力强,短实体 precision 极高(99.5%),支持 zero-shot 扩类。
  • BIO(mDeBERTa, 滑窗推理):同样覆盖全部候选类型。无 max_width 限制,可以补上 GLiNER 漏掉的所有超宽实体。全类型覆盖不会让弱项变差——BIO 的 per-token 分类是独立的,不同类型之间不竞争资源(不像 GLiNER 需要把全部 L2 type prompt 塞进同一个 512 窗口)。

合并逻辑:两路取并集,同一 span 同一 type 保留高分。两个模型的强项互补(GLiNER 强在语义判断和 zero-shot,BIO 强在长实体和速度),冲突很少。

Backbone 选择 mDeBERTa(而非 mmBERT 8192 ctx)的原因:NER 是 token-level 精细任务,disentangled attention 在边界判断上 consistently 更优;滑窗代码已有,不需要为了免滑窗而换 backbone。BIO 训练成本约 8-10h(标准 HuggingFace TokenClassification,无 GLiNER 的串行预处理瓶颈)。

max_width=36 重训方案(构思)

分词修复后实测数据(9.5M 训练集)显示,超过 max_width=16 的实体仅占 1.4%(121K/8.68M),但这 1.4% 集中在高价值类型:URI 有 16.4% 的实体超宽,ADDRESS 4.3%,JWT 2.1%。既然分词修复已经把泰语/阿拉伯语的 span 膨胀问题解决(ADDRESS width 从 31→5),那现在 width=36 应该能覆盖 99.9% 的实体。

直接把 --max-width 从 16 改到 36,不改架构、不改 loss 函数,只重新预处理 + 重训。代价很低:

  • 显存:从 12.5 GB → ~15.3 GB/GPU(+2.8 GB),B=8 不需要降(24 GB 卡仍有余量)
  • 训练速度:实测 ~1.35 it/s(K=16 为 1.6 it/s),慢 16%(13h → ~15h)
  • 推理:单条 +12 MB,延迟 +20%(spanRep 部分占总推理 15-20%)

潜在 concern:正负比恶化。K=16→36 时 score 矩阵从 57K 元素膨胀到 117K,但正样本数不变。实测正负比从 6,651:1 恶化到 13,587:1(翻倍)。新增的宽度 17-36 的 span 候选中 99.99% 是 negative——它们对短实体类型(PHONE、EMAIL、OTP 等)是纯噪声。

这是否会导致训练信号稀释、收敛变慢或精度下降?理论上有风险,但尚未实证。有利因素:

  • Focal loss γ=2 会把 easy negative 的梯度压到接近 0:模型很快学会"宽 span 不太可能是 PHONE",之后这些格子贡献的梯度就可以忽略
  • 训练初期(模型还分不清 easy/hard)可能有几百步的收敛延迟,但 warmup 阶段 lr 本来就小
  • 可以用 --negatives 0.5 --masking global 随机丢弃 50% 负样本,直接把有效 neg:pos 拉回 6,800:1

不利因素:

  • GLiNER 默认用 sum reduction,大量 easy neg 的微小 loss 累积后仍可能在总 loss 中占主导
  • GlobalPointer 在更极端的正负比(1:1.8M)下观察到过训练崩溃,虽然 loss 函数不同,但机制可能类似

验证计划:K=16 的 v5 基线已经训练到 75K,并完成 single-pass 与 sliding-window entity-level 评估。下一步应补齐 K=36(v6)的 entity-level F1 评估,才能判断宽 span 方案是否真正优于 v5。如果 K=36 在不调 negatives 参数的情况下就能收敛到相近或更优的 F1,则确认 focal loss 足以应对 13,587:1 的正负比;如果收敛明显变慢或 F1 下降,再考虑双矩阵方案或调低 negatives 采样率。

当前本地状态:K=36(v6)已有训练到 30K steps 的 checkpoint,trainer state 显示 eval_loss=1.455;但当前目录没有 v6 的 span-level 评估 JSON。因此只能确认训练过程没有立刻崩溃,不能确认 v6 在最终实体 F1 上优于 v5。

双矩阵方案(构思)

思路:把 33 个 entity type 按典型 span 宽度分成短/长两组,各用不同 max_width 生成候选 span,独立打分。期望收益有两个——节约显存、提升长实体覆盖率。逐一验证。

问题一:能否节约显存?

GLiNER forward 路径中跟 max_width(K)相关的两个核心张量(gliner/modeling/base.py:399-410):

Python
1
2
3
4
5
span_rep = self.span_rep_layer(words_embedding, span_idx)
# shape: [B, L, K, D=768]  ← D=hidden_dim,跟类型数 C 无关
 
scores = torch.einsum("BLKD,BCD->BLKC", span_rep, prompts_embedding)
# shape: [B, L, K, C=33]   ← C=类型数

内存占比实测(B=8, L=384, bf16,含前向+梯度): span_rep 形状 [B,L,K,D=768],不含 C、只随 K 变化,K=12 时 113 MB、K=16 时 151 MB、K=36 时 340 MB; scores 形状 [B,L,K,C=33],含 C 但体积很小,K=16 时只有 6.4 MB。两者相加, span_rep 占 K 相关内存的 95.9%, scores 只占 4.1%。

双矩阵要建两个 span_rep([B,L,12,768] + [B,L,36,768]),加起来 453 MB;单矩阵 K=36 只要一个 [B,L,36,768] = 340 MB。类型分组只能压缩 scores tensor(4%),压不到 span_rep(96%)。结论:不省显存,反而多花 33%。

问题二:能否提升识别率?——通过优化正负比。

GLiNER 的 loss 在 score tensor [B, L×K, C] 上逐元素做 binary focal loss。K 增大时候选 span 线性增长但正样本不变,正负比恶化:

max_width K score 元素数 正比率 neg:pos 备注
K=12 33,150 0.026% 3,849:1 —
K=16 57,288 0.015% 6,651:1 当前基线
K=36 117,018 0.0074% 13,587:1 翻倍恶化

双矩阵把 loss 拆成两路独立计算——短路径只对 25 个短类型的窄候选打分,长路径只对 6 个长类型的宽候选打分。短路径 K=12、25 类型、平均 6.68 ents/record,score 元素 33,150、neg:pos 4,963:1,比 K=16 单矩阵基线还好;长路径 K=36、6 类型、平均 1.93 ents/record,score 元素 21,276、neg:pos 11,003:1。两路相加,总 score 元素 54,426,整体 neg:pos 约 6,319:1——和 K=16 基线持平,但覆盖率从 98.6% 提到 99.9%。

对比表:

方案 score 元素 neg:pos 覆盖率 额外显存
单矩阵 K=16(当前) 57,288 6,651:1 98.6% 基线
单矩阵 K=36 117,018 13,587:1 99.9% +2.8 GB
双矩阵 K=12+K=36 54,426 6,319:1 99.9% +3.4 GB

短路径不受宽 span 噪声干扰(93.6% 的 entity 在 width≤12),长路径的宽候选对 ADDRESS/URI/ORG 是合理的 hard negative 而非纯噪声。

但这个正负比收益是否真的转化为 F1 提升,尚未验证。如果 focal loss γ=2 本身就能有效压制 K=36 下的 easy negatives(模型很快学会"宽 span 不可能是 PHONE"),那单矩阵 K=36 就够了,双矩阵的工程复杂度不值当。待 K=16 vs K=36 对比实验完成后决定。

完整路径回顾

L2 经历了多次版本迭代,每次解决一个具体瓶颈。具体如下表:

版本 micro F1 micro F1
(滑窗)
说明
v1 — — 配置:max_width=12;25K steps;2GPU
解决的问题:首次训练,验证 GLiNER 可行性
v2 0.862 未评估 配置:max_width=12;60K steps;4GPU;从 v1 warm restart
解决的问题:扩 GPU + 延长训练
v3 0.8907 0.972 配置:max_width=16;60K steps;离线预处理
解决的问题:IPv6 覆盖 + 训练提速 3.2×
v5 0.9827 0.9847 配置:max_width=16;75K steps;分词修复;全语言数据
解决的问题:TH/AR/RU/VI recall fix + 云行业多场景全语言;65K checkpoint 的 F1=0.9835,75K checkpoint 的 F1=0.9827
v6 作废 作废 配置:max_width=16;本地 checkpoint 到 ~36K steps;eval_loss=0.571
解决的问题:URI/ADDRESS 超宽覆盖验证;当前目录缺少 v6 的 span-level 评估 JSON,结论应保留为待核验
训练失败原因:cosine LR schedule 在 step ~1300 就到顶(warmup 比预期短),之后 34K 步持续下坡,训练后期 LR 近乎为零(step 35750 时 lr=2.45e-8); num_steps=30000(config)与实际 max_steps=35766(trainer 按数据量计算)不匹配,schedule 超出设计范围后 LR 已耗尽。eval loss 在 step 33000 到达最低(0.5697),之后微幅反弹,这个拐点未被捕获,无对应 checkpoint 保存。
v7(训练受损) 作废 作废 配置:max_width=16;bsz=8;3 epochs;max_steps≈214K;started 2026-05-15(第二次,eval 逻辑重构后重启)
eval 设计:HF Trainer 每 3000 steps 在 1/10 val 子集上计算 eval_loss(原先全量 eval 要 20 分钟,缩减至约 2 分钟);同时挂载 EvalCallback,每 3000 steps 随机抽 2000 条 val 样本计算 entity-level micro F1/P/R,并在每个 epoch 结束时额外触发一次(即使 epoch 边界不在 3000 steps 整倍数上);F1 eval 始终从完整 val 集采样,不受 1/10 子集限制;seed = 12345 + global_step,每次采样不同记录。
训练过程:epoch 1–2 正常收敛,最佳 eval_loss=0.430(step 132k)、最佳 F1=0.9536(step 144k)、epoch 2 末尾 F1=0.9451(step 186k,eloss=0.436)。epoch 3 期间节点先后在 step ~189k 和 step ~193k 两次崩溃,均从最新 checkpoint 热恢复,但每次重启都触发梯度尖刺(首步 loss 64→17→8);在 LR=2–5e-06 的训练末尾阶段,Adam 已无足够学习率抹平尖刺造成的权重偏移,epoch 3 的 F1 未能恢复至 epoch 2 水平(step 204k F1=0.8848 vs 186k F1=0.9451)。可用最佳点:checkpoint-186000(epoch 3 第一次崩溃前),已被 save_total_limit=5 的滚动窗口覆盖删除,无法恢复。
v8(失败) 作废 作废 配置:沿用 L2 GLiNER 主结构,数据升级到 v8 顶层 split 派生的 L2 main;train 6,363,180 条、val 795,360 条,CODE_BLOCK / LOG_BLOCK / INLINE_CODE 在构建时物理删除,块内 PII 同步拆到 L3。训练代码侧加入 hard-negative bias union,默认 8 个负标签中约 50% 来自当前样本正类对应的难负类;支持离线 Arrow 预处理 fast path; save_total_limit 从 5 提升到 15,并新增 epoch-boundary 永久 checkpoint。
失败原因:L2 tokenizer policy 仍把不同脚本的连续 Letter/Mark/Number 合并为一个 token,15 语言中的日文、韩文、泰文、阿拉伯文等弱空格文本会出现 Latin/digit 与本地脚本粘连,例如账号、ID、假名连接词被压成同一个 token。GLiNER 的 gold span 只能落在 token 边界上,因此实体边界被污染;训练中止,需修复 tokenizer 并重建 L2/L3 dataset 与 Arrow。

关键类型的 recall 演进(列=版本,行=类型):

实体类型 v2 (K=12) v3 (K=16) v3+滑窗 v5 (K=16)
ipv6 address 0.156 0.697 0.999 0.993
json web token — 0.489 0.802 0.817
uri 0.404 0.658 0.851 0.826
address 0.541 0.680 0.686 0.953
person 0.868 0.934 0.948 0.992
organization 0.745 0.829 0.842 0.987
phone number 0.948 0.983 0.993 0.993
email address 0.975 0.981 0.993 0.992
现状与后续

到当前版本,框架形态已明确:

层 模型 状态 核心指标
L1 — Chunker mmBERT + 1D U-Net + Dice seg head 完成 Chunk F1@IoU≥0.8 = 0.963(含 post-processing)
L2 — Prose PII GLiNER (mDeBERTa-v3-base) v5 完成(75K F1=0.9827) v5 single-pass F1=0.9827, 滑窗 F1=0.9847
L3 — Inblock PII GLiNER fine-tune from L2 数据与脚本已准备 train 1,057,587 / val 132,371 / test 132,271;本地无 checkpoint

后续计划:补齐 v6(K=36)的 entity-level 评估 JSON,确认宽 span 方案是否真正优于 v5;随后启动 L3 训练,专攻代码/日志块内部 PII。

这篇训练日志会持续更新。

推理优化

训练完成的模型需要部署到真实环境中运行。对于 PII 检测场景,部署约束比通用 NLP 更严格——敏感数据理想状态下不应离开用户自己的机器(box)。这意味着推理方案必须支持无 GPU 环境(大量开发机和服务器没有独立显卡),支持多操作系统(Linux 服务器、macOS 开发机),并且延迟要控制在可接受范围内。本节整理当前推理链路的硬件兼容性和优化路径。

当前推理链路的硬件依赖

当前 pipeline 的 device 选择逻辑已经内置了 CPU fallback:

Python
1
device = "cuda" if torch.cuda.is_available() else "cpu"

三个层的具体行为:

  • L1(mmBERT + Dice seg head):torch.load(..., map_location=device) 加载权重,model.to(device).eval() 移动到目标设备。CRF 的 Viterbi 解码本身就是 CPU 操作,不存在 GPU 依赖。
  • L2/L3(GLiNER, mDeBERTa):if "cuda" in device: model = model.to(device),否则模型留在 CPU。

也就是说,当前代码在没有 GPU 的机器上已经能运行,不需要改一行代码。但问题是速度——PyTorch 原生 CPU 推理在 280M 参数的 mDeBERTa 上非常慢。

Linux GPU 推理延迟

同一台 Xeon 机器上插的 L40S(48GB)做了完整的 GPU 侧跑分,torch-CUDA fp32 和 ONNX Runtime 的 CUDAExecutionProvider 两条路径都覆盖到了。L2 分 short / medium / long 三档 fixture,L1 分 T=64 / 256 / 512 三档序列长度,bench 脚本另外跑了 CPU↔GPU 数值一致性和 batch 吞吐扫描。

层 / 场景 torch-cuda fp32 (p50) onnx-cuda fp32 (p50) CPU↔GPU 一致性
L2 short (~30-80 char) 15.4 ms 10.0 ms (1.54×) 5/5 实体集合完全一致 (text+label)
L2 medium (~100-300 char) 17.1 ms 16.1 ms (1.06×)
L2 long (~1.5-2KB, 触发滑窗) 23.4 ms 20.9 ms (1.12×)
L1 T=64 b=1 10.0 ms — 9/9 cosine=1.000000, max-abs ≤ 5e-4
L1 T=256 b=1 10.2 ms 4.85 ms (2.1×)
L1 T=512 b=1 10.1 ms —

直接推翻了之前在部署矩阵里给 GPU 行填的 "~350ms" 估算——这是凭感觉写的上限,真实数字比它低 15-35 倍。几条值得记下来的发现:

  • L1 在 GPU 上是 launch-bound:T=64/256/512 三档 torch-CUDA 延迟几乎不变(10.0 / 10.2 / 10.1 ms),说明 kernel 执行时间远小于 CUDA launch overhead,序列长度在这个区间里不是瓶颈。
  • ONNX CUDA EP 不是白装的:L1 T=256 b=1 从 torch 的 10.17 ms 降到 4.85 ms(2.1×),L2 short 从 15.45 ms 降到 10.02 ms(1.54×)。ORT 的 graph 优化(融合 LayerNorm / MatMul+Add / attention fusion)+ CUDA kernel 选择比 eager PyTorch 快。
  • batch 推理是真能省钱:L2 batch_predict_entities(n=32) 单次 72.8 ms,折合 2.28 ms/text,吞吐 434 txt/s;顺序循环调用 predict_entities() 只有约 65 txt/s——6.7× 提速。L1 类似:torch-cuda b=1/8/32 吞吐 = 99 / 383 / 428 txt/s;ONNX-CUDA b=1/8/32 = 207 / 502 / 368 txt/s,最佳档是 ONNX-CUDA b=8。在线推理路径应该在 API 层做 micro-batch。
  • CPU↔GPU 数值等价:L2 在同一批 fixture 上 CPU 和 GPU 给出的实体集合 5/5 完全相同;L1 三档 × 三个 head = 9 项全部 cosine=1.000000,最大绝对差在 5e-4 以内(fp32 的正常数值噪声)。迁移到 GPU 不会带来精度回归。
  • 修掉了一个 L1 ONNX 的 batch export bug:现象是 b=1 跑得好好的 session 换 b=8/32 立刻报 Shape mismatch: {1,...} != {B,...}。一开始以为是 mmBERT 内部某个 reshape 把 batch 维烤成了常量、 dynamic_axes 没覆盖到,但用 onnx.load 静态扫了一遍图才看清真正的根因——graph 里没有任何显式 Reshape [1,...] 常量,是 dynamo exporter 的 shape inference 拿例子里的 batch=1 烤进了输出层( seg_logits 等三个 graph output 的 dim 0 写成了字面量 1),同时 1115 个中间 value_info 也跟着烤成了 1。ORT-CUDA 的 buffer-reuse 优化把这些 dim_value=1 当 hard constraint,于是 b>1 的请求过来就被拒;CPU EP 的 buffer-reuse 路径不走这一步所以原 CPU verify 一直没暴露。修法是 export 脚本 (1) 默认 example batch 改成 ≥2,让 shape inference 拿到的初始 batch 就不是退化值;(2) 导出后再走一遍 _make_batch_dim_dynamic,把 input/output/value_info 里所有 dim 0 是字面量 1 的统一改回 dim_param='batch'(纯 metadata 编辑,不动权重);(3) 默认 opset 从 17 提到 18,dynamo emit 的 Split 带 num_outputs 属性 17 不识别。verify 脚本里加了"同一个 session 跑 b=1/8/32 全部断言通过"的回归。修复后单 session 同时服务三档,b=8 直接顶到 502 txt/s,比原来"只能跑 b=1 的 206 txt/s"提升 2.4×。

onnxruntime-gpu 1.24 wheel 带 CUDA 12 runtime, pip install onnxruntime-gpu 和原来的 onnxruntime 是同名冲突包,安装时后者会被覆盖——部署脚本需要在 GPU 镜像里显式装 gpu 版,CPU 镜像保持默认。

结论:原来给 GPU 行写的 "~350ms" 可以丢了。实际部署到有 GPU 的 Linux 服务器上,L2 单条推理 10-23 ms,批量 2-3 ms/text;L1 是稳定的 10 ms,或者走 ONNX CUDA EP 4.85 ms。bench 产物保存在 reasoning/artifacts/bench_results/l2_bench_gpu_*.json 和 l1_bench_gpu_*.json,脚本在 reasoning/oo_reasoning/bench/{bench_l2_gpu,verify_l1_gpu}.py。

Linux CPU 推理延迟

在 Intel Xeon Platinum 8576C 的 Linux 主机上、CUDA_VISIBLE_DEVICES="" + 4 线程 CPU 预算下实测的 p50 延迟:

层 模型 参数量 torch-cpu fp32 onnx-cpu fp32 onnx-cpu int8
L1 mmBERT-base + UNet + seg/boundary ~312M 142ms (T=256) 75ms (1.88×) ~70ms (1.1×)
L2 (short ~30-80 char) mDeBERTa-v3-base (GLiNER) ~289M 116ms 52ms (2.2×) 41ms (2.8×, 但精度崩溃)
L2 (medium ~400 char) mDeBERTa-v3-base (GLiNER) ~289M 190ms 89ms (2.2×) 73ms (2.6×, 但精度崩溃)
L3 未训练 - - - -

关键修正之前的预估:

  • torch-cpu 比预估快得多——L2 medium 只要 190ms,不是预估的 2-5s。原因是 Xeon Platinum 即使限制 4 线程,IPC 仍然非常高;之前的 "vanilla CPU 2-5s" 是按笔记本/低端服务器估算的,对服务器级 Xeon 偏保守。
  • ONNX fp32 已经是甜蜜点——L1 加速 1.88×,L2 加速 2.2×。这部分加速主要来自 ORT 的图优化、算子融合和没有 Python 调度开销,跟量化无关。
  • INT8 在 L2 上的额外加速只有 30%,不是想象的 2×。增量小是因为 Intel AVX-512 没启用 VNNI,且嵌入层和 LayerNorm 没量化。
  • 真正的杀手是 L2 INT8 的精度问题,见下文专门讨论。
ONNX 导出
是否必须导 ONNX

训练管线本身就是一套完整的推理路径——L1 就是 torch.load(state_dict) + model.eval(),L2/L3 就是 GLiNER.from_pretrained(ckpt_dir),调 predict_entities() 即可。理论上只用 PyTorch 这一条栈也能上线。实测数字也支持这种选择:Linux + GPU 上 torch-CUDA fp32 跑 L2 short 是 15.4 ms,跑 L1 是 10.0 ms,已经在可接受区间。所以在"训练管线 = 推理管线"这一选项上确实不存在技术阻塞。

但在做了 ONNX 导出后,几个数字让"必须导"变成了一个偏向积极的选择,不再是中立选项:

  • 同硬件下 ONNX-CUDA 比 torch-CUDA 多 1.5-2.1× 加速(L2 short 15.4 → 10.0 ms,L1 T=256 是 10.2 → 4.85 ms)。这部分加速来自 ORT 的 graph 优化和 kernel 选择,是免费午餐——前提是导出能跑通。
  • CPU 路径上的 2.2× 加速几乎全部来自 ONNX(L2 PyTorch fp32 → ONNX fp32),INT8 量化的增量贡献很小。换言之 CPU 部署如果只用 torch,单条 L2 medium 会落在 396 ms,达不到 180 ms 级别的 SLA。这条结论跟"上 INT8 才能让 CPU 推理快"的直觉相反,已经在前文记过。
  • 依赖体积差距明显。torch + CUDA wheel 全量大约 2-3 GB;onnxruntime(CPU 版)约 50 MB,onnxruntime-gpu 大约 250 MB(含自带 CUDA 12 runtime)。如果未来要打 docker 镜像、要塞进 sidecar、要在 macOS 客户端起一个本地推理进程,差出一个数量级的依赖大小直接影响发布形态。
  • 异构后端只走 ONNX 一条路就能覆盖。Linux GPU = ONNX-CUDA EP;Linux CPU = ONNX-CPU;macOS = ONNX-CoreML EP(已规划,等 Mac 实测);Windows / 嵌入式机 = ONNX-CPU 或 DirectML EP。如果坚持用 torch,至少 macOS / Windows 客户端这条线就需要再装 PyTorch 全套,CoreML 加速也用不上。
  • ONNX 不是没成本。导出本身能踩到坑:L1 batch>1 的 export bug 就是这次踩到再修掉的(dynamo 把 example batch=1 烤进了 graph output 元数据,已通过 example batch≥2 + 后处理改 dim_param 解决,详见上文);L2 INT8 dynamic 在 mDeBERTa 上直接召回崩溃;GLiNER 的 predict_entities() 内部要分 onnx / torch 两条 dispatch 分支(虽然对调用方透明,但库版本升级时是潜在故障点)。导出脚本现在还得维护,sliding-window 兼容性、tokenizer 文件分发都是新增运维面。

当前的判断是:L1 + L2 fp32 ONNX 双路并行——线上推理走 ONNX(CPU 或 CUDA EP),但保留 torch 加载路径作为 fallback 和 debug 入口。 PIIPipeline.load(backend="torch" | "onnx") 这层抽象在 04-pipeline-refactor.md 里已经设计好。理由是:导出的边际工作量已经付了(脚本、bench、bug 修复都做完),收益(CPU 2.2×、GPU 1.5-2.1×、依赖小一个量级、跨平台只一条路径)覆盖维护成本;同时保留 torch 路径,方便训练侧改动后的线上验证,因此 backend 切换保留为部署期决策,不做代码硬编码。

L1 ONNX 导出

L1 是自定义架构(mmBERT encoder + 1D UNet + seg head + boundary head),没有现成的 export_to_onnx,需要走 torch.onnx.export。包了一层 wrapper 把字典输出展平为元组:

Python
1
2
3
4
5
6
7
8
class L1ExportWrapper(nn.Module):
    def forward(self, input_ids, attention_mask):
        enc_out = self.inner.encoder(
            input_ids=input_ids, attention_mask=attention_mask, return_dict=True)
        hidden = self.inner.unet(enc_out.last_hidden_state)
        seg_logits = self.inner.seg_head(hidden)
        start_logits, end_logits = self.inner.boundary_head(hidden)
        return seg_logits, start_logits, end_logits

需要注意 torch 2.11 的 dynamo exporter 把权重存成 sidecar(model.onnx graph + model.onnx.data 二进制权重),ORT 加载时自动从同目录拼起来。L1 fp32 实测:

  • graph + sidecar 总大小 1.19 GB。
  • vs PyTorch:seg / start / end 三个头 cosine = 1.000000,max abs diff ~1e-5——逐 bit 等价。
  • T=256 batch=1 推理:torch-cpu 142 ms → onnx-cpu 75 ms(1.88× 加速)。
  • opset 必须用 18;dynamo 生成的 Split 算子带 num_outputs 属性,opset 17 不识别。

L1 INT8 dynamic 也跑通了,但价值有限:决策一致率 89.7-97.6%(mmBERT 比 mDeBERTa 容忍度高很多,但仍有小幅退化),加速从 1.88× 只涨到 ~2.0×。L1 推荐方案是 fp32 ONNX。

L2 ONNX 导出

GLiNER 库原生支持 ONNX export,代码路径已经在项目 venv 中就绪(gliner/onnx/model.py)。导出和加载流程如下:

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from gliner import GLiNER
 
# Step 1: 导出 ONNX (fp32 + INT8 动态量化一起产出)
model = GLiNER.from_pretrained("your_l2_checkpoint")
model.export_to_onnx(
    save_dir="./l2_onnx",
    quantize=True,    # 同时产出 INT8 版本
    opset=19,
)
# 实测产出:
#   l2_onnx/fp32/model.onnx           1103.5 MB  ← 可部署
#   l2_onnx/int8/model_quantized.onnx  332.9 MB  ← 不可部署(精度崩溃)
 
# Step 2: 加载 ONNX 推理
model = GLiNER.from_pretrained(
    "./l2_onnx/fp32",
    load_onnx_model=True,
    onnx_model_file="model.onnx",
    map_location="cpu",
)
# 直接调用 predict_entities,API 完全一样,滑动窗口逻辑透明
preds = model.predict_entities(text, labels, threshold=0.5)

L2 实测文件大小与延迟(Xeon Platinum 8576C Linux、CPU only、4 线程):

配置 short p50 medium p50 模型文件 质量
PyTorch fp32 CPU 116ms 190ms 1117 MB ckpt baseline
ONNX fp32 CPU 52ms 89ms 1103 MB 逐字段一致
ONNX INT8 dynamic CPU 41ms 73ms 333 MB 召回崩溃

修正几个之前错估的数字:

  • fp32ONNX 实测 1103 MB,不是预估的 660 MB。mDeBERTa-v3-base encoder ~280M params × 4 byte ≈ 1.1 GB,再加上 GLiNER 的 span representation 和 entity-type encoder。
  • INT8 实测 333 MB,不是预估的 165 MB(缩小比例 30%,不是 25%)——LayerNorm 和 Embedding 没量化,占了大部分残余体积。
  • fp32 ONNX 加速 2.2×,不是预估的 3-8×——Xeon Platinum 上 PyTorch CPU 已经不慢,ORT 的相对收益没那么夸张。
L2 INT8 动态量化(否决)

章节早期推断 mDeBERTa 的 disentangled attention 对动态量化敏感(详见 reasoning/02-quantization-risks-mdeberta.md),实测完全证实了这个担忧。5 个 multilingual 短样本对比三 backend:

样本 torch-cpu onnx-fp32 onnx-int8
User john.doe@example.com signed in from 203.0.113.42 email + ip email + ip (none)
사용자 김민수 (kim@example.kr) 가 192.168.1.100 person + email + ip person + email + ip only ip @0.684
El usuario maria.gonzalez ... 10.0.0.5 staff_id + ip staff_id + ip (none)
Người dùng tran.van.a@example.vn email email (none)

3 个长样本(~1.8KB,触发滑动窗口):torch 和 ONNX-fp32 都给出 2 / 3 / 0 个实体;ONNX-INT8 给出 0 / 0 / 0。

结论:L2 INT8 动态量化召回从 ~90% 跌到 ~10-20%,章节里 "INT8 准确率损失 < 1%" 的引用数据(基于 BERT/DeBERTa classification)在 mDeBERTa 的 disentangled attention 上完全不适用。根因是相对位置 embedding 和 content embedding 通过分别投影矩阵交叉相乘(\(Q_c \cdot K_p + Q_p \cdot K_c + Q_c \cdot K_c\)),三项 fp32→int8 的量化噪声在 attention score 上累加,把低分实体压到阈值以下。

L2 上 INT8 dynamic 不可部署。要继续走 INT8 路线,得换 static quantization + calibration(用 200-500 条真实 PII 样本喂 quantize_static)或 QAT(重训 1 epoch with FakeQuant)。这两条路已列入 P2,本轮不实现。

L2 ONNX 滑动窗口兼容性

之前章节担心过 "ONNX 模式可能与滑动窗口不兼容"。实测证伪:GLiNER 的 predict_entities() 内部根据 self.onnx_model 标志位自动 dispatch 到 ONNX session 还是 PyTorch forward,滑动窗口逻辑跟 backend 无关。长文本测试中 fp32 ONNX 与 torch-cpu 在每条文本、每个实体上完全一致(2/3/0 vs 2/3/0)。

L1-L3 管线合并导出(构思)

"合并"在这里有两种相差很远的形态,ONNX 路径在两条路径下的成立条件也不一样。

形态一:容器/镜像级合并——三个独立 ONNX 模型打进同一个镜像,按 pipeline 串行调用。这是当前 PIIPipeline 已经在做的事,也是工程上最现实的下一步。镜像里同时驻留 L1 / L2 / L3 三个 ORT session,请求进来后 L1 出 chunk 边界 → 路由到 L2(散文区)或 L3(代码/日志区),三个 session 各自跑各自的 forward。ONNX 在这条路径上完全没问题——session 之间没有图层耦合,每个模型保留独立的 input/output 签名,dynamic_axes 各管各的,CUDA EP / CPU EP / CoreML EP 三种后端的切换粒度保持在 per-session 级别,与 per-image 无关。这条路真正要做的工程在 pipeline 抽象层( 04-pipeline-refactor.md 已设计),不在 ONNX 导出本身。镜像体积上,三个 fp32 ONNX 大约 1.2 GB(L1)+ 1.1 GB(L2)+ 1.1 GB(L3)≈ 3.4 GB,再加 ~250 MB onnxruntime-gpu 和 tokenizer 文件,整体落在 4 GB 左右,比"三套独立 PyTorch 服务各占一台 GPU"省得多。

形态二:模型层合并——重训一个端到端模型,同时输出 chunk 边界 + entity span + entity type。这条路当前不存在 checkpoint,是未来研究项;能不能导 ONNX 取决于这个未来模型长什么样,分情况讨论:

  • 如果合并模型仍是 mDeBERTa / mmBERT / DeBERTa-v3 这一类标准 transformer encoder + 多个 head(一个 BIO head 出 chunk、一个 span head 出 entity)——这是最有可能的形态——ONNX 导出基本无障碍。当前 L1(mmBERT + UNet head)和 L2(mDeBERTa + GLiNER span head)都已经走通了 ONNX 路径,多 head 只是把 forward 的输出从一个 tensor 变成 N 个 tensor, torch.onnx.export 的 output_names 列表加几项即可。CUDA EP 和 CoreML EP 都支持多输出。
  • 如果合并方式是 GLiNER 的扩展——把 L1 的 chunker 任务也建模成 zero-shot span prediction,全部走 GLiNER 的统一 head——ONNX 也可以走。GLiNER 自己的 export_to_onnx 已经被本项目实测验证可用,单 backbone + 单 head 比当前 L1+L2 更简单,导出难度只会下降。
  • 如果合并模型引入某些已知 ONNX 兼容性差的结构——这是真正需要警惕的——例如 Universal Transformer / 动态层数 / 自适应计算(涉及循环和 break 条件, torch.onnx.export 在 dynamo 之前的 tracer 模式下基本无法处理;dynamo 模式下也很挑剔),或者带显式 KV cache 的 decoder-style 推理(cache 形状每步在变,opset 要求高,CoreML 长期不支持),那 ONNX 路径会从"导出"问题变成"重写部分模型 / 退回 torch"。但这种架构对 NER 任务收益不明显,从 PII 项目的研究目标看不会优先去碰。
  • 如果合并模型直接换成 LLM 抽取(Qwen / Llama / 内部 LLM 做 NER)——这是另一种"合并",相当于一个模型解决全部三层——ONNX 路径在 GPU 上仍然成立(onnxruntime-gpu + CUDA EP 跑 7B-30B 没问题,社区有充分案例),但 CPU 部署基本不可行;macOS 端取决于模型大小和 CoreML 对该 LLM 的支持度,一般要走 MLX 或 llama.cpp,ONNX 不会是默认路径。这条路在本项目的优先级里靠后,主因是它和"三层小模型各自做擅长的事"这个设计原则相冲突,不是 ONNX 走不通。

用一句话回答:形态一(镜像级合并)今天就能上,ONNX 三 session 是默认形态;形态二(模型层合并)只要不踩 dynamic-control-flow 或 KV-cache 这种历史已知雷区,仍然是最稳的部署选项。从工程角度,下一个版本的合并模型在选型时应该把"ONNX 可导出 + CoreML EP 兼容"作为一项 P1 验收标准,前置到选型阶段——前面 mDeBERTa INT8 的教训就是这么来的(事后发现 disentangled attention 在 dynamic INT8 下崩溃)。具体做法是:候选架构定下来之后,先用一个 1/10 大小的 toy checkpoint 跑通 torch.onnx.export + onnxruntime CPUExecutionProvider + CoreMLExecutionProvider 三件套,10 行代码、半小时内出结论;通过之后再训完整 checkpoint。

macOS 支持(脚本已就绪,等 Mac 实测)

PII 数据不该离开用户的 box——这个原则在 macOS 开发机上同样成立。Linux 这台机器没有 MPS 也没有 CoreML EP,所以下面这部分只能"导出+打包"在 Linux 上完成,"实测"必须在真机上跑。bundle 和脚本已经写完打好(reasoning/oo_reasoning/macos/),3.1 GB tar.gz 等 rsync 到 Mac。

macOS Intel (x86_64)

路径和 Linux CPU 一样:onnxruntime 有 macOS x86 wheel,CPU 推理走 MLAS/Eigen 后端。L2 fp32 ONNX 在 Linux Xeon 上 89ms (medium),Mac Intel 上单核更弱、应该慢一些,估算 200-500ms。需要在真机上确认。

macOS Apple Silicon (M1/M2/M3/M4)

M 系列芯片有三条候选路径,bundle 里的 bench_macos_l2.py / bench_macos_l1.py 会把 5 个 backend 全部跑一遍:

方案 说明 Linux 侧能否预判
ONNX + CoreML EP onnxruntime 的 CoreMLExecutionProvider,自动调度到 Apple Neural Engine,不支持的算子回退 CPU 不能——mDeBERTa 的 disentangled attention 含 gather/relative-pos,已知 CoreML 编译可能 fall back 大量层到 CPU
PyTorch MPS torch 2.x 支持 device="mps",Apple GPU 加速 预期可用,需要 Mac 上原始 PyTorch ckpt(bundle 默认不带,省 4GB)
ONNX CPU (ARM) 纯 CPU + NEON,最稳的兜底 稳定可用,性能上限受 CPU 单核制约

推荐顺序是先试 CoreML EP(最快但风险最高),不行回退到 MPS,最后兜底 ARM CPU。CoreML EP 加载示例:

Python
1
2
3
import onnxruntime as ort
providers = ["CoreMLExecutionProvider", "CPUExecutionProvider"]
session = ort.InferenceSession("model.onnx", providers=providers)

如果选 PyTorch MPS 路径,当前代码的 device 判断需要小幅修改——目前只认 "cuda":

Python
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 改前
device = "cuda" if torch.cuda.is_available() else "cpu"
if "cuda" in device:
    l2_model = l2_model.to(device)
 
# 改后
def _auto_device() -> str:
    if torch.cuda.is_available():
        return "cuda"
    if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
        return "mps"
    return "cpu"
 
device = _auto_device()
if device != "cpu":
    l2_model = l2_model.to(device)
部署场景矩阵

Linux + GPU / Linux CPU 两条路径已经填好实测数字,标 "Mac" 的几条等真机数据:

部署目标 推荐方案 L2 模型大小 L2 延迟 (p50) 精度损失
Linux 服务器 (有 GPU) PyTorch CUDA / ONNX CUDA EP 1117 MB ckpt 15-23 ms (torch) / 10-21 ms (ONNX-CUDA) 0%
Linux 服务器 (无 GPU) ONNX fp32 1103 MB 52-89ms (4 thread) 0%
Mac (M 系列) ONNX fp32 + CoreML EP(最快候选) 1103 MB 等真机数据 0% (fp32)
Mac (M 系列, 兜底) ONNX fp32 + ARM CPU 1103 MB 等真机数据 0% (fp32)
Mac (Intel) ONNX fp32 1103 MB 等真机数据 0% (fp32)
桌面端 (打包分发) ONNX fp32 + PyInstaller/electron 1103 MB ~等同 ARM CPU 0%
超轻量场景 L1 (seg only) + 正则 fallback ~15MB(L1 head only) ~50-100ms 5-10% F1 降

修正之前的核心结论:原来希望"INT8 量化把模型从 660MB 压到 165MB"是双重误判——fp32 实测就是 1103 MB(不是 660 MB),INT8 dynamic 在 mDeBERTa 上召回崩溃(不是 "<0.5% F1 损失")。真正能落地的核心收益是 ONNX fp32 自身:CPU 上 2.2× 加速、零精度损失、模型体积反而比训练 ckpt 略小。"PII 检测完全在用户本地完成"的目标 Linux CPU 已经达成(<100ms),Mac 路径等真机验证。

落地步骤

实施分三个阶段:

  • Phase 1: ONNX 导出 + bench(已完成)——L1 / L2 都已经导出 fp32 + INT8 ONNX,CPU bench 数据落盘到 reasoning/artifacts/bench_results/。L2 INT8 否决,fp32 接受为主推路径。L3 暂未训练,等 ckpt 后同样流程。
  • Phase 2: Pipeline 适配(待做)——infer.py 增加 --onnx 参数自动检测 device(cuda/mps/cpu),ONNX 模型加载路径,Mac MPS fallback 支持。前置依赖 macOS 真机选定最优 backend。
  • Phase 3: 打包分发(按需)——pip install 方式(最简单)、Docker image(Linux server)、或 PyInstaller 打包 standalone binary(桌面端)。

Phase 1 在训练 GPU 占满的情况下用 CPU 全程跑通,未影响训练。Phase 3 视部署需求决定是否执行。

关键洞察
Batch size 与学习动力学

提升 batch size 不只是"一次处理更多样本"—它会扭转优化器的行为。梯度是对 loss landscape 的采样估计:batch 越大,每步梯度越接近"真实梯度"(全量数据的方向),噪声越小,batch 越小,每步梯度的随机性越大,噪声越多。

这两种状态各有利弊:

  • 大 batch(低噪声):收敛路径平滑,训练更稳定,但容易陷入 sharp minima(泛化性差的尖锐极小值)。在极端局面下,模型可能"过于平滑地"滑到一个训练 loss 很低但 val loss 偏高的位置。
  • 小 batch(高噪声):梯度的随机抖动,可以帮模型跳出 sharp minima,倾向于落入 flat minima(泛化性更好的平坦极小值)。但训练不稳定,收敛慢。

经验法则:当 effective batch 变化不超过 2 倍时(我们从 64 调到 80),学习动力学变化很小,通常不需要调 LR。如果 effective batch 变化超过 4 倍,LR 需要线性缩放(linear scaling rule:batch 翻倍 → LR 翻倍)。我们的调整幅度(64→80)在安全范围内。

一次不成功的加速尝试:在 4×4090D 上把 per-device batch size 从 8 提到 16,显存占用从 50% 涨到 77%(18.8G/24.6G),看起来"用满了GPU"。total steps 从 71K 降到 36K(同一个 epoch),单次训练从 ~20h 缩短到 ~16h。但 eval 结果全面退步——micro F1 从 0.983 掉到 0.899。

退步的根因是 cosine LR schedule 被 total_steps 锁死。bsz=16 把 total_steps 压到 36K,warmup 仅 1K 步,剩下 35K 步跑完整个余弦衰减——模型在 epoch 尾部 LR 已经接近零,eval loss 刚开始 plateau(0.570 → 0.571)就没了学习率继续优化。bsz=8 的对照组跑到同样的数据量时(75K/214K 步),LR 还在 peak 的 67%,loss 还在下降(0.39)。

教训:大 batch 缩短 step 数不等于缩短训练时间——如果 LR schedule 按 step 规划而非按 epoch 规划,缩短 step 就等于提前耗尽学习率预算。正确做法:如果要用大 batch,必须同步延长 epochs(或改用 linear schedule 带 min_lr floor),保证模型在高 LR 区间有足够的更新次数。9.16M 数据集在 bsz=8、2 epoch(~143K steps)的配置下,cosine 有充分的展开空间。

捷径学习

如果大多数样本都写成"邮箱: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/GPU(vs 512 的 bsz=32/GPU)。训练脚本里的 step 是 micro-step,grad_accum 只延后 optimizer step,不会增加每个 micro-step 看到的不同窗口数。因此同样 30K logged steps,512 训练约看了 192 万个窗口,4096 训练约看了 24 万个窗口,窗口多样性差 8 倍。这里的"变少"指训练窗口暴露量下降,原始 JSONL 记录数没有变。对这个 segmentation 任务来说,当前实验支持的判断是:短窗口训练带来的窗口覆盖量,比完整训练上下文更影响边界泛化。

断案:512 训练 + 4096 推理是甜蜜点—训练阶段用短窗口获得大 batch 多样性,推理阶段用长 context 获得完整上下文。

评估可信度

极端不平衡任务里,accuracy 没有解释力—一个"全预测 O"的模型在 93% 负样本数据上 accuracy 就有 0.93。precision 和 recall 一定要拆开看,token 级和 chunk 级指标也一定要拆开看。这个项目的一个直接教训是:训练日志只给一个很高的综合指标时,必须用独立代码路径重新计算 precision、recall 和 F1,尤其要确认指标名和实现完全一致。

灵活性的代价

GLiNER 的自然语言标签和 zero-shot 扩类能力很有吸引力(不重训就能试新类型),代价是训练速度(串行 Python 循环,80 CPU 只 1 个在干活)、评估速度(4 小时一次完整 eval)和推理并行能力(CPU 单条 ~116 ms,GPU 单条 ~17 ms vs BIO 的 ~15 ms/text)。一个适合探究验证的模型,放到高吞吐生产情境会遇到完全不同的瓶颈。如果最终 entity F1 不达标,备选方案是 mDeBERTa + BIO(65 个标签的 token classification),CPU 上速度优势更明显,且在固定类型集上可能更准。

调参无法修复架构盲区

当模型对某些实体的 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/数据量的调参都是徒劳的。

推理优化的杠杆大于调参

本地可追溯口径下,v3 模型从 v2 的 F1=0.862 提升到 0.8907;而在同一个 v3 模型上开启滑动窗口推理,F1 从 0.8907 跳到 0.9726。这里真正的大头收益来自推理阶段扩大可见范围,继续堆训练步数的收益相对有限。

这个对比说明:当模型的可见区域内已做到接近满分(precision 99.5%),剩余错误集中在"看不到的地方"时,投入更多训练资源(更多数据、更多 steps、更大模型)的收益递减。正确的动作是扩大可见区域—让每个实体都至少被一个推理窗口覆盖。滑动窗口、更长的 context window、更大的 max_width,本质上都是"让架构看到更多"而非"让模型判断更准"。

量化的"经验数字"不能跨架构搬运

"INT8 dynamic 准确率损失 < 1%"是 BERT/RoBERTa classification 任务上反复被引用的经验值,几乎成了量化的默认假设。但在 mDeBERTa 上实测,L2 的召回从 ~90% 跌到 ~10-20%,跌幅约 70 个点。根因来自 disentangled attention:它把 attention score 拆成 \(Q_c \cdot K_p + Q_p \cdot K_c + Q_c \cdot K_c\) 三项,fp32→int8 的量化噪声在这三项上独立产生再相加,最终在 score 这一层放大了 3 倍。任何"位置和内容耦合得更深"的注意力变体——DeBERTa 系列、ALiBi、RoPE 的某些实现——都需要把"这条经验在我的架构上重测"作为前置动作,不能直接套用 BERT 时代的结论。dynamic 路线否决之后,要继续走 INT8 还得换 static quantization + calibration 或 QAT,这是另一个量级的工程量。

CPU 加速的大头来自图优化

常见的心智模型是"想让 CPU 推理快,就上 INT8"——把量化当作 CPU 加速的主入口。实测把这个直觉打散了:L2 在 Linux Xeon 上,PyTorch fp32 → ONNX fp32 拿到 2.2× 加速,ONNX fp32 → ONNX INT8 dynamic 在此基础上只多 30%(医学统计意义上),且伴随召回崩溃。也就是说"换 runtime"贡献了大部分加速,"换数值精度"只贡献边角。原因是 ORT 的图优化、算子融合(QKV 合并、LayerNorm fuse)和摆脱 Python 调度开销本身就吃掉了大块开销,而 INT8 在没启用 VNNI 的 AVX-512 上、且 LayerNorm/Embedding 都不量化的情况下,理论上限本来就有限。工程含义:CPU 部署的第一动作应该是导 ONNX,跑通 fp32 baseline;量化不是首要步骤——后者风险高、收益小、debug 成本远高于 runtime 切换。

预先估算 vs 实测:差距大到改变结论

在写"推理优化"这一节之前,纸面预估是:vanilla CPU 2-5 秒、ONNX fp32 加速 3-8×、INT8 模型从 660MB 压到 165MB。实测下来全错:CPU fp32 medium 文本 190ms,远快于 2-5s;ONNX fp32 加速 2.2×,低于 3-8×;fp32 模型 1.1GB,高于 660MB;INT8 333MB,高于 165MB。错的方向各不相同——CPU 比想象快,加速比想象低,模型比想象大——说明这些差异并非某一个偏置造成,脱离实测的"目测推算"在这个尺度根本不靠谱。直接的代价是规划级别的:原来的部署故事是"必须靠量化把模型塞下去",实测之后变成"fp32 ONNX 在普通 Linux CPU 上已经

算法介绍

本节对项目中涉及的核心算法做完整技术说明,目标是让只有 AI 基础知识(了解 transformer、梯度下降、分类任务基本概念)的读者也能理解每个算法在做什么、为什么在这个场景下有效或无效。

Focal Loss

在类别不平衡的分类任务中,标准交叉熵有一个隐蔽的难题:大量"简单"负样本(模型已很确定它们是负的)产生的梯度累加起来,淹没了少量"困难"正样本的梯度。打个比方—一个班里 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

CRF(Conditional Random Field)是序列标注任务中的概率图模型。它在 encoder 独立分类的基础上,额外建模相邻标签之间的转移概率。CRF 维护一个转移矩阵 \(A_{y_{i-1}, y_i}\),表示"前一个 token 标签为 \(y_{i-1}\) 时,当前 token 标签为 \(y_i\) 的额外分数"。训练时借助 forward algorithm 计算所有可能序列的分数之和(归一化常数),推理时通过 Viterbi 算法找到全局最优标签序列。

转移矩阵的参数量为 \(|Y|^2\)(标签集大小的平方)。对于 BIO 标注,3 种标签 × 2 种实体类型 + O = 7 类,转移矩阵只有 49 个参数。这个极小的参数集只能编码相邻 token 之间的局部约束(如"B 后面大概率跟 I"),无法编码"B 后面需要跟多少个 I"这种长距离依赖——Viterbi 解码每步只看前一个标签,长距离决策被拆解成逐步累积的短距离决策。

GlobalPointer

传统 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}\):旋转位置编码—给向量施加一个和位置相关的旋转,使得两个向量的内积天然包含它们之间距离的信息。距离越近内积越大。
EfficientGlobalPointer

原始 GlobalPointer 对每个类型 \(c\) 独立维护一套 \(W_q^c, W_k^c\) 投影矩阵,参数量和类型数成正比。EfficientGlobalPointer 的改进是:所有类型共享同一套 \(W_q, W_k\),再通过一个低维的 per-type 分类头区分类型。这把 span scoring 的内存从 \(O(T^2 \times C)\) 降到 \(O(T \times d + T^2)\),在多类型场景下显存和计算量都大幅减少。本项目 L1 阶段使用的就是这个 Efficient 变体。

Circle Loss

Circle Loss(Sun et al., 2020)是 GlobalPointer 的配套训练损失。它的核心思想像一根弹簧:把正样本的分数往上拉(接近 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。

Circle Loss 的一个已知弱点是对正负比极端敏感。当 \(|\Omega^-| \gg |\Omega^+|\) 时,\(\sum_{\Omega^-} e^{\gamma \cdot s}\) 的梯度会压倒正样本项,模型倾向于把所有分数统一压低来最小化负样本项的贡献。

Dice Loss

Dice Loss 来自医学图像分割领域(Milletari et al., 2016),诞生的背景是:在 CT/MRI 扫描中找肿瘤时,前景(肿瘤)可能只占图像的 1-5%,其余 95-99% 是正常组织。标准 BCE loss 在这种极端不平衡下会让模型"摆烂"—直接预测"全是正常组织"就能得到很低的 loss。Dice Loss 关注的是“你圈出来的答案和标准答案重合了多少”(overlap),不再强调 accuracy 那种“你做对了多少题”的逐项计数方式。

数学形式:

\[\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 分。

1D U-Net

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

GLiNER(Zaratiana et al., 2023)是一类 zero-shot NER 模型,它的关键创新是用自然语言描述实体类型,替代固定的 label ID。工作流程如下:

  1. 将类型描述(如 "personal email address")和输入文本拼接:[type1] [SEP] [type2] [SEP] ... [SEP] input text
  2. 送入 mDeBERTa 编码器,得到每个 token 的表示。
  3. 用 bilinear scoring 对输入文本中的每个候选 span \((i, j)\) 和每个类型标签 \(c\) 打分。
  4. 选择分数超过阈值的 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。当前 L2 label map 暴露 33 个正标签,计算量随标签数线性增长(实际通过 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 在哪"。

推理时滑动窗口

Transformer 模型有一个硬性限制:输入序列不能超过预训练时的最大位置编码长度(mDeBERTa 为 512 tokens)。超出部分会被截断—模型直接看不到。对于 NER 任务,这意味着出现在文本后半部分的实体可能完全丢失。直觉上,这就像用一把只有 30 厘米长的尺子量一张 A0 纸—尺子够短,总有一大截纸测不到。

滑动窗口(Sliding Window)是处置这个麻烦的标准工程方案。核心思想:既然一把尺子量不完,就分多次量—每次往右挪一段距离,量完全纸后把所有测量成果合并。形式化描述:

给定一条长度为 \(N\) 的 token 序列、窗口大小 \(W\) 和步长 \(S\)(stride),将文本切分为多个重叠片段:

\[\text{windows} = \{(kS,\; kS + W - 1) \mid k = 0, 1, \ldots, \lceil(N-W)/S\rceil\}\]

每个窗口 \([kS, kS+W-1]\) 独立送入模型推理,得到一组 span 预测 \(\{(i_{local}, j_{local}, c, score)\}\)。将局部坐标转换为全局坐标后,合并所有窗口的预测:

\[(i_{global}, j_{global}) = (i_{local} + kS, \; j_{local} + kS)\]

合并策略对结果影响很大。两个窗口可能对同一个实体独立给出预测(因为重叠区域同时被两个窗口覆盖),需要去重。本项目使用的策略是 max-score deduplication:对于坐标和类型完全相同的预测,保留分数最高的那个,丢弃其余副本。数学表达:

\[\hat{y}(i, j, c) = \max_{k:\; kS \leq i,\; j \leq kS+W-1} \; score_k(i-kS,\; j-kS,\; c)\]

其中 \(score_k(\cdot)\) 是第 \(k\) 个窗口内模型对该 span 的打分。

关键参数的作用:

  • Window \(W\):等于模型的最大有效输入长度。本项目中 mDeBERTa 有 512 个位置编码,减去 ~128 token 的类型 prompt 前缀,实际文本窗口为 384 token。
  • Stride \(S\):控制相邻窗口的重叠程度。\(S = W\) 表示无重叠(相邻窗口紧邻),\(S < W\) 表示有重叠。重叠的意义是:让边界附近的实体至少被一个窗口"完整"覆盖—如果一个实体恰好骑在两个窗口的接缝处。没有重叠的话两个窗口各看到一半,都无法正确识别,有重叠的话,至少有一个窗口包含完整实体。
  • 重叠量 \(W - S\):本项目取 \(W = 384, S = 256\),重叠 128 token。这 128 token 的缓冲区要大于 max_width(16),确保任何合法实体在至少一个窗口中被完整覆盖(不会被截断到一半)。

为什么不直接用更大的模型/更长的 context:

  • 位置编码是训练时固定的。mDeBERTa 训练时只见过 512 以内的位置,直接输入 1024 token 会产生 out-of-distribution 的位置编码,效果崩溃。
  • 即使用支持 8192 context 的模型(如 mmBERT),推理显存和延迟也随序列长度平方增长(self-attention 的 \(O(N^2)\))。滑动窗口把一个 \(O(N^2)\) 的长序列问题拆成若干个 \(O(W^2)\) 的短序列问题,总计算量从 \(N^2\) 降到 \(\frac{N}{S} \cdot W^2\)。
  • 训练和推理可以解耦。模型在短窗口(384 token)上训练早已学到了足够好的 token 表示和 span 判断能力,推理时用多窗口覆盖只是让模型"看到更多的文本",不需要改变模型本身的任何参数。

性能特征:

  • Recall 提升显著。本项目实测:单次截断 R=0.806,滑动窗口 R=0.956(+15%)。所有被截断区域中的实,体现在都能被至少一个窗口覆盖。
  • Precision 微降。多窗口推理增加了 false positive 的机会(每个窗口都可能产生误报),但幅度极小(P 从 0.996 降到 0.990)。原因是模型的 precision 本身就极高—它很少"凭空发明"实体。
  • 速度代价可控。84% 的记录文本 ≤ 384 token,只需一个窗口(零额外开销)。16% 的长文本,需要 2-3 个窗口。平均推理时间增加 ~1.5×。
  • 不影响短实体。对于已在第一个窗口内被检出的实体(如 PERSON、PHONE),滑动窗口只是多跑几次,不会改变结果—因为 max-score dedup 保留的是最高分,第一个窗口给的分早就足够高。

常见的替代去重策略(本项目未使用但值得了解):

  • NMS(Non-Maximum Suppression):计算所有预测 span 之间的 IoU,IoU 超过阈值的只保留最高分。适合预测边界有微小偏差的场景(如目标检测),但对 NER 的精确匹配来说有点过度—NER 预测的 start/end 要么完全相同要么完全不同,不太需要 IoU 去重。
  • 加权投票:重叠区域内多个窗口对同一 span 的分数取加权平均而非取最大值。理论上更稳健,但实验中差异很小(因为同一个 span 在不同窗口中获得的分数几乎相同—模型对明确的实体都给高分)。
  • 中心优先:只信任每个窗口中间 \(S\) 个 token 范围内的预测(因为边缘 token 缺少右侧上下文),边缘区域的预测交给相邻窗口负责。这种手段完成更复杂但理论上更稳健,尤其适合窗口边缘 attention 质量下降的场景。

在这个项目中,滑动窗口的工程做到只需要约 20 行代码:对超过 \(W\) 长度的文本按 stride 切分,每个片段调用一次 model.predict_entities(),接着按全局坐标去重合并。它不修改模型权重、不需要重训、不变化训练数据—纯粹是推理阶段的"视野扩展"。在 v3 本地评估口径下,这种推理策略把 F1 从 0.8907 推到 0.9726,证明了在模型判断力已经足够的前提下,扩大可见范围比继续优化模型本身更有效率。

← DevPod on Kubernetes: turning devcontainer.json into a persistent remote workspace
人工智能知识 - 简介 →

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code class="" title="" data-url=""> <del datetime=""> <em> <i> <q cite=""> <strike> <strong> <pre class="" title="" data-url=""> <span class="" title="" data-url="">

Related Posts

  • OpenClaw: Architecture, Components, and Deployment Notes
  • Octave知识集锦
  • 人工智能知识 - 编程(一)
  • 人工智能知识 - 算法和机器学习
  • 人工智能知识 - 数学基础

Recent Posts

  • 人工智能知识 - 编程(二)
  • 人工智能知识 - 编程(一)
  • 人工智能知识 - 智能体
  • 人工智能知识 - Transformers和大模型
  • 人工智能知识 - 主要应用领域
ABOUT ME

汪震 | Alex Wong

江苏淮安人,现居北京。目前供职于腾讯云,专注国际化和AI落地。

GitHub:gmemcc

Git:git.gmem.cc

Email:gmemjunk@gmem.cc@me.com

ABOUT GMEM

绿色记忆是我的个人网站,域名gmem.cc中G是Green的简写,MEM是Memory的简写,CC则是我的小天使彩彩名字的简写。

我在这里记录自己的工作与生活,同时和大家分享一些编程方面的知识。

GMEM HISTORY
v2.00:微风
v1.03:单车旅行
v1.02:夏日版
v1.01:未完成
v0.10:彩虹天堂
v0.01:阳光海岸
MIRROR INFO
Meta
  • Log in
  • Entries RSS
  • Comments RSS
  • WordPress.org
Recent Posts
  • 人工智能知识 - 编程(二)
    这一篇承接人工智能知识 - 编程(一)。前一篇已经梳理 AI 训练与推理编程的横向工程栈;本篇进入重点框架详解与 ...
  • 人工智能知识 - 编程(一)
    这一篇专门处理 AI 训练、微调、推理与部署中的编程栈问题。前几篇分别讲了机器学习基础、任务版图、Transfo ...
  • 人工智能知识 - 智能体
    这一篇处理模型之外的系统层问题,包括上下文工程、Harness Engineering、检索增强生成(RAG)与 ...
  • 人工智能知识 - Transformers和大模型
    这一篇聚焦现代大模型主线,内容从 Transformer 架构出发,延伸到语言模型、多模态模型、预训练与微调,以 ...
  • 人工智能知识 - 主要应用领域
    这一篇从常用算法进入机器学习基础概念、经典机器学习与神经网络,重点讨论“模型如何被构造、训练、评估与正则化”。前 ...
  • 人工智能知识 - 算法和机器学习
    这一篇从常用算法进入机器学习基础概念、经典机器学习与神经网络,重点讨论“模型如何被构造、训练、评估与正则化”。前 ...
  • 人工智能知识 - 数学基础
    这一篇整理 AI 所需的数学基础,包括基础数学、线性代数、微积分与概率论统计。它回答的核心问题是:模型里的向量、 ...
  • 人工智能知识 - 简介
    这一篇作为整套 AI 总纲的导论,先回答更根本的问题,不急于进入公式和具体模型细节:什么叫智能,人工智能究竟在试 ...
  • 多语言敏感信息检测模型训练日志
    这篇文章记录一个多语言敏感信息识别项目的完整训练日志。它关注的是工程路径本身:原始 AI 合成语料如何被清洗成可 ...
  • DevPod on Kubernetes: turning devcontainer.json into a persistent remote workspace
    DevPod is an open source workspace manager ...
  • OpenClaw: Architecture, Components, and Deployment Notes
    Four Months, 343,000 Stars On November 24, 2025, ...
  • Replacing Docker Desktop with Colima on macOS
    Colima is one of the cleanest ways ...
  • Kubernetes GPU Sharing
    GPU sharing in Kubernetes depends on what ...
  • Investigating and Solving the Issue of Failed Certificate Request with ZeroSSL and Cert-Manager
    In this blog post, I will walk ...
  • A Comprehensive Study of Kotlin for Java Developers
    Introduction Purpose of the Study Understanding the Mo ...
  • LangChain: Architecture, LCEL, Agents, LangGraph, Retrieval, and Production Patterns
    LangChain is no longer best understood as ...
  • Kubernetes Migration
    Migrating a Kubernetes cluster from one cloud ...
  • Terraform: a practical guide to infrastructure as code
    Terraform is an infrastructure-as-code tool. You describ ...
TOPLINKS
  • Zitahli's blue 91 people like this
  • 梦中的婚礼 64 people like this
  • 汪静好 61 people like this
  • 那年我一岁 36 people like this
  • 为了爱 28 people like this
  • 小绿彩 26 people like this
  • 彩虹姐姐的笑脸 24 people like this
  • 杨梅坑 6 people like this
  • 亚龙湾之旅 1 people like this
  • 汪昌博 people like this
  • 2013年11月香山 10 people like this
  • 2013年7月秦皇岛 6 people like this
  • 2013年6月蓟县盘山 5 people like this
  • 2013年2月梅花山 2 people like this
  • 2013年淮阴自贡迎春灯会 3 people like this
  • 2012年镇江金山游 1 people like this
  • 2012年徽杭古道 9 people like this
  • 2011年清明节后扬州行 1 people like this
  • 2008年十一云龙公园 5 people like this
  • 2008年之秋忆 7 people like this
  • 老照片 13 people like this
  • 火一样的六月 16 people like this
  • 发黄的相片 3 people like this
  • Cesium学习笔记 90 people like this
  • IntelliJ IDEA知识集锦 59 people like this
  • Bazel学习笔记 38 people like this
  • 基于Kurento搭建WebRTC服务器 38 people like this
  • PhoneGap学习笔记 32 people like this
  • NaCl学习笔记 32 people like this
  • 使用Oracle Java Mission Control监控JVM运行状态 29 people like this
  • Ceph学习笔记 27 people like this
  • 基于Calico的CNI 27 people like this
Tag Cloud
ActiveMQ AspectJ CDT Ceph Chrome CNI Command Cordova Coroutine CXF Cygwin DNS Docker eBPF Eclipse ExtJS F7 FAQ Groovy Hibernate HTTP IntelliJ IO编程 IPVS JacksonJSON JMS JSON JVM K8S kernel LB libvirt Linux知识 Linux编程 LOG Maven MinGW Mock Monitoring Multimedia MVC MySQL netfs Netty Nginx NIO Node.js NoSQL Oracle PDT PHP Redis RPC Scheduler ServiceMesh SNMP Spring SSL svn Tomcat TSDB Ubuntu WebGL WebRTC WebService WebSocket wxWidgets XDebug XML XPath XRM ZooKeeper 亚龙湾 单元测试 学习笔记 实时处理 并发编程 彩姐 性能剖析 性能调优 文本处理 新特性 架构模式 系统编程 网络编程 视频监控 设计模式 远程调试 配置文件 齐塔莉
Recent Comments
  • xdemo on 人工智能知识 - 编程(二)
  • 杨松涛 on snmp4j学习笔记
  • kaka on Cilium学习笔记
  • JackZhouMine on Cesium学习笔记
  • 陈黎 on 通过自定义资源扩展Kubernetes
  • qg on Istio中的透明代理问题
  • heao on 基于本地gRPC的Go插件系统
  • 黄豆豆 on Ginkgo学习笔记
  • cloud on OpenStack学习笔记
  • 5dragoncon on Cilium学习笔记
  • Archeb on 重温iptables
  • C/C++编程:WebSocketpp(Linux + Clion + boostAsio) – 源码巴士 on 基于C/C++的WebSocket库
  • jerbin on eBPF学习笔记
  • point on Istio中的透明代理问题
  • G on Istio中的透明代理问题
  • 绿色记忆:Go语言单元测试和仿冒 on Ginkgo学习笔记
  • point on Istio中的透明代理问题
  • 【Maven】maven插件开发实战 – IT汇 on Maven插件开发
  • chenlx on eBPF学习笔记
  • Alex on eBPF学习笔记
  • CFC4N on eBPF学习笔记
  • 李运田 on 念爷爷
  • yongman on 记录一次KeyDB缓慢的定位过程
©2005-2026 Gmem.cc | Powered by WordPress | 京ICP备18007345号-2