OpenMed Indic 文本归一化实战:九种婆罗米系文字的确定性规范化与偏移安全重映射
【免费下载链接】openmedLocal-first healthcare AI: clinical NER & HIPAA PII de-identification that runs 100% on-device. 2,200+ medical models, 21 languages, Apple MLX + Python, no cloud, no patient data leaving your network. Apache-2.0项目地址: https://gitcode.com/GitHub_Trending/ope/openmed
IndicNormalizer是 OpenMed 中面向天城文(Devanagari)、孟加拉文(Bengali/Assamese)、泰米尔文(Tamil)、泰卢固文(Telugu)、卡纳达文(Kannada)、马拉雅拉姆文(Malayalam)、古吉拉特文(Gujarati)、古木基文(Gurmukhi)与奥里亚文(Odia)的本地化 Unicode 规范化器,在临床模型推理之前将同一种书写系统的各种编码变体收敛为确定性规范形式。它在纯 Python 标准库(unicodedata)之上实现,完全本地运行、无网络依赖,并且为每一次字符变换保留原始文本偏移映射,使模型输出的实体区间可以精确回溯到原始病历文本。读完本文,你将掌握IndicNormalizer的全部配置选项、偏移安全重映射机制、九种脚本的底层规则表,以及它在 OpenMed PII 检测管线中的实际调用方式。
IndicNormalizer 的定位与适用范围
IndicNormalizer处理以下九种源自婆罗米系(Brahmi-derived)的 Unicode 文字区块(定义于 openmed/processing/text.py 的INDIC_SCRIPTS):
| 脚本 | 名称(源码常量) | Unicode 区块 |
|---|---|---|
| 天城文 | Devanagari | U+0900–U+097F |
| 孟加拉文/阿萨姆文 | Bengali | U+0980–U+09FF |
| 古木基文 | Gurmukhi | U+0A00–U+0A7F |
| 古吉拉特文 | Gujarati | U+0A80–U+0AFF |
| 奥里亚文 | Odia | U+0B00–U+0B7F |
| 泰米尔文 | Tamil | U+0B80–U+0BFF |
| 泰卢固文 | Telugu | U+0C00–U+0C7F |
| 卡纳达文 | Kannada | U+0C80–U+0CFF |
| 马拉雅拉姆文 | Malayalam | U+0D00–U+0D7F |
从源码结构看,规范化器会在内部先按脚本切分文本,再对每个脚本运行段应用各自的规则表;非 Indic 脚本运行段(如拉丁文)仅做 NFC 归一化后原样通过。这意味着它可以安全地处理多脚本混排的临床文本,而不影响其他语种的内容。
快速上手:核心 API 与最小示例
IndicNormalizer从openmed.processing包导出(见 openmed/processing/init.py),无需任何第三方依赖即可使用:
from openmed.processing import IndicNormalizer normalizer = IndicNormalizer() canonical = normalizer.normalize("क़ासिम और अङ्क") mapped = normalizer.normalize_with_offsets("ID: ൻ രോഗി") raw_start, raw_end = mapped.remap_span(4, 5)normalize(text, script=None):返回规范化后的字符串。当script为None时,内部会调用openmed.core.script_detect.segment_by_script自动检测脚本运行段,逐段规范化后拼接(text.py#L474-L517);也可以显式传入脚本名(支持hi、bn、ta等别名,见_SCRIPT_ALIASES)以跳过脚本检测。normalize_with_offsets(text, script=None):返回一个IndicNormalization对象,包含规范化文本、原始文本长度、以及每个规范化码点对应的原始字符起止偏移(offset_starts/offset_ends)。remap_span(start, end):把规范化文本上的半开区间[start, end)映射回原始文本的字符边界,且做了越界钳制(text.py#L389-L406)。
IndicNormalizer是可复用对象,构造与规范化过程均为确定性、无状态的纯函数变换;同一输入在任何环境都会得到完全相同的输出,这保证了后续模型推理与审计的可复现性。
默认策略:一次调用完成的六类规范化
默认构造IndicNormalizer()即启用以下完整策略:
- NFC 归一化:先按"基字符 + 附加符号"簇为单位做 NFC(
_nfc_units,text.py#L617-L636),再进入脚本规则;这一步消除组合序列与预组合字符的差异。 - 保留 nukta:nukta 分解后默认不删除,因为 nukta 承载音位区分(见下文参数)。
- 剥离 ZWJ/ZWNJ 编码变体:默认移除零宽连接符 U+200D 与零宽不连接符 U+200C 造成的形式差异,使
क्\u200dष与क्\u200cष均收敛为规范形क्ष。 - 严格的显式鼻音簇转 anusvara:形如"鼻音 + 元音删除符(virama)+ 同组辅音"的显式拼写收敛为 anusvara 记法。
- chandrabindu / chandra 变体归一:将月亮点及其变体映射为规范 anusvara 与元音形式。
- 词尾保持不变:默认不插入显式词尾元音。
测试 tests/unit/processing/test_indic_normalizer.py 验证了这些变体在规范化后产生逐字节相同的文本:
normalizer.normalize("ऩ") == normalizer.normalize("न\u093c") # nukta 分解收敛 normalizer.normalize("अंक") == normalizer.normalize("अङ्क") # 鼻音簇收敛 normalizer.normalize("क्\u200dष") == normalizer.normalize("क्ष") # ZWJ 变体收敛 normalizer.normalize("क्\u200cष") == normalizer.normalize("क्ष") # ZWNJ 变体收敛这套默认行为的设计意图非常明确:编码变体常被用来绕过临床 PII(如患者姓名)的检测,而规范化把这些变体统一到同一形式,使检测模型只需要学习一种规范表示。
五个可调选项:何时开启、何时关闭
构造函数签名(text.py#L446-L467)暴露五个关键字参数,全部有安全默认值:
| 参数 | 默认值 | 取值 | 说明 |
|---|---|---|---|
remove_nuktas | False | bool | 是否在分解后删除 nukta。默认关闭,因为删除会抹掉音位区分(如क़与क);只有下游模型明确要求时才开启 |
nasals_mode | "to_anusvara" | "preserve"/"to_anusvara"/"to_anusvara_relaxed"/"to_nasal_consonants" | 鼻音簇的收敛策略;同时接受 Indic NLP Library 的旧拼写别名(do_nothing、to_anusvaara_strict、to_anusvaara_relaxed) |
normalize_chandra | True | bool | 是否把 chandrabindu/chandra 变体映射到规范 anusvara 与元音形式;设为False可保留月亮点形式 |
normalize_vowel_ending | False | bool | 是否为以辅音结尾的词插入脚本对应的显式词尾。默认关闭(opt-in),因为这会改变词形 |
joiner_policy | "strip" | "strip"/"preserve" | ZWJ/ZWNJ 处理策略。PII 推理之外若必须保留连接符区分(如马拉雅拉姆文 chillu 的显式拼写),可设为"preserve" |
参数校验采用fail-closed策略:传入未知的nasals_mode或非法的joiner_policy会立即抛出带可选值列表的ValueError,而不是静默降级(text.py#L455-L463)。测试 test_indic_normalizer.py 专门覆盖了这一行为。
选项行为对照(测试 test_indic_normalizer.py 验证):
default = IndicNormalizer() "़" in default.normalize("क़") # 默认保留 nukta IndicNormalizer(remove_nuktas=True).normalize("क़") == "क" # 显式开启删除 IndicNormalizer(joiner_policy="preserve").normalize("क्\u200dष") == "क्\u200dष" IndicNormalizer(normalize_chandra=False).normalize("अँ") == "अँ" IndicNormalizer(nasals_mode="preserve").normalize("अङ्क") == "अङ्क" IndicNormalizer(normalize_vowel_ending=True).normalize("नाम") == "नाम्"偏移安全重映射:审计元数据不存原文的关键机制
normalize_with_offsets()的核心价值在于:它为每个规范化码点返回一个原始字符区间。因此,模型在规范化文本上预测出的实体区间,可以通过remap_span()精确映射回原始文本,而无需在审计元数据中存储原文,也无需在原始文本上按字素簇(grapheme cluster)手工切片——后者在叠加符号复杂的婆罗米系文字中极易出错。
remap_span的映射语义(text.py#L389-L406):
- 区间起点映射为
offset_starts[start]; - 区间终点(开区间)映射为
offset_ends[end - 1]; - 对越界输入做钳制,保证返回的原始区间始终合法且起点不大于终点。
测试 test_indic_normalizer.py 用一个跨九种脚本的多语言句子验证了往返映射:对每个原始词条,先取其规范化形式在规范化文本中的位置,再remap_span回原始文本,结果与预期原始跨度逐一对齐。测试注释还特意说明了 Gurmukhi 附加符号 U+0A71 在 UAX #29 规则下的边界行为,证明偏移映射对"非边界起始"的复杂簇也保持正确。
九种脚本的底层规则表
IndicNormalizer的脚本特定规则全部以纯数据表形式内联在 text.py,逐项说明如下。
nukta 分解(Nukta Decomposition)
将预组合的 nukta 字符分解为"基字符 + nukta"序列,覆盖 Devanagari(11 组)、Bengali(3 组)、Gurmukhi(6 组)、Odia(2 组),例如天城文ऩ → ऩ、क़ → क़(分解后保留 nukta,除非显式开启删除)。源码中另有_NUKTA_OFFSETS表记录各脚本 nukta 的块内偏移(均为0x3C),供删除操作定位。
鼻音与 chandra 归一
_normalize_chandras按脚本块基址 + 块内偏移定义六组替换(text.py#L720-L741),将 chandrabindu/chandra 变体映射为规范 anusvara 与元音。_normalize_nasals实现了三种模式(text.py#L744-L814):to_anusvara:仅当"鼻音 + virama + 同组辅音"严格匹配签名表时才收敛为 anusvara;to_anusvara_relaxed:不要求后续辅音在签名范围内,直接收敛;to_nasal_consonants:反向把 anusvara 展开为"对应鼻音 + virama"。
显式鼻音簇与序列替换(_SEQUENCE_REPLACEMENTS)
针对各脚本的双部件元音符号与旧式拼写进行替换,例如:
- Gurmukhi:
ਅਾ → ਆ、ਅੈ → ਐ等 8 组(含 addak 相关收敛); - Odia:
ଅା → ଆ、ଏୗ → ଐ等 9 组; - Bengali:
ো → ো、ৌ → ৌ等 4 组; - Tamil:
ொ → ொ、ோ → ோ、ௌ → ௌ等 7 组; - Telugu:
ై → ై等 2 组; - Kannada:
ೀ → ೀ、ೇ → ೇ、ೈ → ೈ等 10 组; - Malayalam:
ൊ → ൊ、ോ → ോ、ൌ → ൗ及独立ൗ → ൗ共 7 组。
马拉雅拉姆文 chillu
_MALAYALAM_CHILLUS(text.py#L324-L331)把显式"辅音 + virama + ZWJ"序列收敛为预组合的 chillu 字符,如ന്\u200d → ൻ、ര്\u200d → ർ。注意该项处理发生在 joiner 剥离之前,因此即便joiner_policy="strip",chillu 也能先收敛为单字符。
Gurmukhi addak / tippi 与元音基座
_canonicalize_gurmukhi_addak(text.py#L697-L717)将ਅੱਕ → ਅੱਕ之类的 addak(U+0A71)后跟辅音的模式改写为"辅音 + virama + 辅音"的规范拼写;同时ਁ(tippi)映射为 anusvaraਂ。_SEQUENCE_REPLACEMENTS中还有针对ੲ/ੳ等独立元音基座的组合替换。
Odia 的 va/v 映射
Odia 规则把ଵ(U+0B35)与ଵ的变体ୱ(U+0B71)统一替换为ବ(text.py#L562-L565),测试中以ଵ → ବ验证(test_indic_normalizer.py#L100)。
标点与 virama 归一
- poorna virama:将各脚本块内专用的句号字符映射为天城文
।(U+0964)与॥(U+0965),例如 Bengali৴→।、Tamil→।;同时各脚本的 ASCII 竖线|统一替换为।(text.py#L579-L593)。 - 冒号转 visarga:当冒号
:出现在脚本块字符之后时,替换为该脚本的 visarga(如天城文ः、孟加拉文ঃ),测试以নাম: → নামঃ验证(text.py#L817-L830)。 - 词尾元音(仅
normalize_vowel_ending=True):对以辅音(块内偏移 0x15–0x39)结尾且后随空白或文本结尾的词,追加脚本对应的词尾——达罗毗荼系脚本(泰米尔、泰卢固、卡纳达、马拉雅拉姆)追加ಾ(0x3E),其余脚本追加 virama(text.py#L833-L849)。
与 PII 检测管线的集成方式
IndicNormalizer并非孤立工具,而是 OpenMed 对抗性 Unicode 防御的一部分。在 openmed/core/script_detect.py 的_normalize_unicode_for_pii_detection中:
- 先按脚本切分文本,对落在
INDIC_SCRIPTS的运行段调用indic_normalizer.normalize_with_offsets(run, script=script); - 将规范化输出的偏移累加回全文本坐标;
- 随后依次执行宽度折叠(
normalize_width)、Indic 数字折叠(fold_indic_digits)、剥离独立组合标记等步骤; - 全部结果汇总到
DetectionNormalization对象(script_detect.py#L567-L616),其remap_span与indic_changes、indic_scripts等字段供 PII 检测与审计使用。
DetectionNormalization同样被 openmed/core/pii.py 引用,作为实体解码与区间回溯的契约(见 openmed/core/decoding/spans.py 对normalization字段的说明)。也就是说,Indic 规范化产出的偏移映射会一路贯穿到模型预测的 span 解码层,保证脱敏区间落在原始病历的精确位置。
此外,对于历史遗留的 ISCII-1991 编码或视觉序(visual-order)旧字体文本,openmed.processing.text还提供了normalize_indic_text()(text.py#L882-L999),它只做天城文前置元音ि的重排与 nukta/virama 顺序修正,不做IndicNormalizer的广泛拼写规范化,从而保证 ISCII 往返字节一致;该函数与convert_legacy_encoding(见 openmed/processing/legacy_encoding.py)组合后,可把旧编码文本先转换再送入检测管线,偏移经DetectionNormalization重新合成回原始字节坐标(script_detect.py#L1410-L1459)。
安全性设计:两个刻意为之的差异
与上游 Indic NLP Library 相比,OpenMed 在两个安全领域刻意不同(docs/indic-normalization.md):
- 默认绝不删除脚本承载字符。所有规则要么保留原字符,要么替换为规范的脚本内等价字符;没有任何默认规则会把承载字母/符号的码点丢弃成空。测试 test_indic_normalizer.py 遍历九个脚本区块的全部字母类与标记类码点,逐一验证规范化后输出非空且仍落在脚本区块内。
- 每一次变换都携带原始偏移溯源。
IndicNormalization的changes、removed_joiners计数与逐码点偏移表使临床脱敏可审计——这正是文档所称"无需在审计元数据中存储文本"的工程基础。
同时,规范化是幂等的:测试验证对多脚本文本规范化两次结果一致(test_indic_normalizer.py#L24-L34),意味着重复调用不会引入新的差异,适合在批处理与流式管线中安全复用。
规则与许可溯源
IndicNormalizer的脚本规则行为以 Anoop Kunchukuttan 发布的Indic NLP Library归一化器为建模参考,该上游项目采用MIT License。OpenMed 对其中的 nukta 分解、鼻音与 chandra 归一、双部件元音符号、标点、马拉雅拉姆 chillu、Gurmukhi addak/tippi 与元音基座、Odia 元音/va 映射等,独立实现了紧凑的纯数据等价表,且不复制、不捆绑上游包体、上游资源或任何第三方模型权重(源码注释见text.py#L256-L258)。因此该模块可以安心用于本地化、离线、可审计的临床 PII 脱敏部署,无额外许可负担。
【免费下载链接】openmedLocal-first healthcare AI: clinical NER & HIPAA PII de-identification that runs 100% on-device. 2,200+ medical models, 21 languages, Apple MLX + Python, no cloud, no patient data leaving your network. Apache-2.0项目地址: https://gitcode.com/GitHub_Trending/ope/openmed
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考