文本归一化与逆归一化避坑指南:用 WeTextProcessing 打通语音识别到 TTS 的最后一公里
深夜十二点,你的 ASR 结果还在"裸奔"
想象这样一个场景:你辛辛苦苦调好了语音识别模型,识别准确率终于上了 90%,结果拿到的文本长这样——
"用户余额为一千二百三十四点五六元,账户尾号幺二三四"
听起来挺通顺的,对吧?但当你把这行字塞进数据库、拿去搜索、或者交给下游 NER 系统时,问题全来了:"一千二百三十四点五六"不是数字,"幺二三四"不是尾号。你的系统根本无法直接消费这份文本。
反过来,如果你在做语音合成(TTS),输入里偏偏是 ¥299.99、2023-12-25、14:30 这种符号文本,模型大概率会读得磕磕绊绊,甚至把 14:30 念成"一四冒号三零"。
两个方向、两种痛点,指向同一个需求:文本归一化(Text Normalization,TN)与逆文本归一化(Inverse Text Normalization,ITN)。而今天要介绍的 WeTextProcessing,正是为这两个问题而生的开源工具包——它由 WeNet 语音团队维护,已经在真实生产环境中打磨多年。
为什么非它不可:这不是又一个"正则表达式全家桶"
你可能会问:数字转汉字、汉字转数字,我自己写几行正则不就行了?对于 123 → 一百二十三 这种简单映射,正则确实够用。但真实世界的文本远没这么善良:
12306要读成"幺二三零六"而不是"一万二千三百零六"2002/01/28、2002-01-28、2002.01.28三种写法都要归一化为"二零零二年一月二十八日"8:00 a.m.要变成"早上八点",78:96却是比分"七十八比九十六"10km/h要读作"每小时十公里",而"10km"是"十公里"CEO要拆成C E O,O2O要读成"O to O"- 更别提儿化音、全角半角、繁体简体、语气词"呃""啊"的清理
规则之间还互相纠缠:同样是"12",在时间语境下是"十二点",在数学语境下是"十二比"。正则写到最后就是一座没人敢动的意大利面条山。
WeTextProcessing 的解法完全不同:它把规则编译成有限状态转换器(FST),底层使用 OpenFST 与 Pynini。这带来三个正则方案给不了的优势:
- 确定性:同样的输入永远得到同样的输出,规则之间天然有序组合,不会互相打架;
- 高性能:FST 编译后接近 O(n) 的线性处理速度,在线推理毫无压力;
- 可扩展:每条规则是独立模块,改一个、加一个都不影响全局。
更重要的是,它把"标签(tagger)"和"口头化(verbalizer)"两个阶段拆开:tagger 只负责分类并保留原始字段,verbalizer 才做语义转换。这个设计让你能拿到输入到输出的精确映射——哪段原文变成了哪段结果,一清二楚,这正是下游系统做对齐、做调试时最稀缺的能力。
一看就懂的效果演示
先别管原理,直接看结果。这是项目 README 里真实可复现的输入输出:
| 类型 | 处理前 | 处理后 |
|---|---|---|
| 数字 | 共465篇,约315万字 | 共四百六十五篇,约三百一十五万字 |
| 分数 | 总量的1/5以上 | 总量的五分之一以上 |
| 百分比 | 同比增长6.3% | 同比增长百分之六点三 |
| 日期 | 2002/01/28 | 二零零二年一月二十八日 |
| 时间 | 我是5:02开始的 | 我是五点零二分开始的 |
| 数学 | 比分定格在78:96 | 比分定格在七十八比九十六 |
| 货币 | 价格是¥13.5 | 价格是十三点五元 |
| 度量 | 速度是10km/h | 速度是每小时十公里 |
| 号码 | 可以拨打12306来咨询 | 可以拨打幺二三零六来咨询 |
| 儿化音 | 我儿子喜欢这地儿 | 我儿子喜欢这地 |
| 白名单 | CEO / O2O | C E O / O to O |
而 ITN 方向就是上面表格的"倒放":二零零二年一月二十八日 → 2002/01/28,价格是十三点五元 → 价格是¥13.5,早上八点半准时开会 → 8:30a.m.准时开会。
一句话总结:TN 把"人写的符号文本"翻译成"人能读的发音文本",ITN 把"人说的话转成的文本"还原成"机器要的结构化文本"。两者合在一起,恰好覆盖了语音链路的两端。
关键能力拆解
🧩 能力一:中英日三语开箱即用
很多同类工具只支持单语言,而 WeTextProcessing 内置了中文、英文、日文三套完整规则,每套都独立维护数据词典与规则文件:
tn/chinese/rules/
├── cardinal.py # 数字
├── date.py # 日期
├── money.py # 货币
├── measure.py # 度量衡
├── time.py # 时间
├── char.py # 字符宽度/繁简
└── whitelist.py # 白名单
中文支持儿化音去除、繁转简、全角转半角、未登录词(OOV)标记;英文覆盖地址、电话、电子邮箱、罗马数字、序数词;日文则针对日语音读规则做了专项优化(比如 WeNet → ウェネット 的转写)。你要做的只是换一个类名。
🎯 能力二:精确到字符的映射追踪
这是最容易被忽略、但在生产里最救命的能力。普通工具给你一个转换后的字符串就完事了,WeTextProcessing 还能告诉你每一段转换的来龙去脉:
result = zh_tn_model.normalize_with_mapping("今天中午12点")
print(result.output_text) # 今天中午十二点
for mapping in result.mappings:
print(mapping.token_type, mapping.input_text, "=>", mapping.output_text)
# math 12 => 十二
它返回的不只是结果,还有每个 token 的类型(date、time、money、measure……)、原文区间、结果区间。这意味着你可以:
- 在下游系统里只对"被转换的部分"做特殊处理;
- 快速定位是哪个规则把某个 badcase 转错了;
- 把映射信息喂给对齐模块,做 ASR 训练数据的自动标注。
⚙️ 能力三:精确 n-best 联合解码
对"转换歧义"场景,比如"三点二十五分"到底是时间 3:25 还是数字 3.25,WeTextProcessing 支持tagger 与 verbalizer 联合排序的 n-best 输出:
outputs = zh_tn_model.normalize("输入文本", nbest=3)
# nbest=1 返回字符串;nbest>1 返回字符串列表
它用精确的唯一输出路径流做枚举,没有固定 beam 截断,把 top-K 个候选连同权重一起给你,方便上层做二次决策。
🚀 能力四:从 Python 到 C++ 的生产级部署
Python 版适合快速实验与规则调试;当你要上高并发服务时,项目还提供了完整的 C++ 运行时(runtime/ 目录),把 Python 里编译好的 tagger/verbalizer FST 图直接丢给 processor_main:
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build
./build/processor_main --tagger zh_tn_tagger.fst --verbalizer zh_tn_verbalizer.fst --text "2.5平方电线"
一套规则,Python 验证、C++ 上线,不用写两遍逻辑。Android 端也有对应的 JNI 绑定(runtime/bindings/android/),移动端集成有现成模板可参考。
从零上手的轻量教程:3 分钟跑通
最快看到效果的方式是直接安装发行包:
pip install WeTextProcessing
然后命令行就能用:
wetn --text "2.5平方电线"
# 输出: 二点五平方电线
weitn --text "二点五平方电线"
# 输出: 2.5平方电线
Python 调用同样简单:
from tn.chinese.normalizer import Normalizer
from itn.chinese.inverse_normalizer import InverseNormalizer
zh_tn_model = Normalizer(remove_erhua=True)
zh_itn_model = InverseNormalizer(enable_0_to_9=False)
print(zh_tn_model.normalize("你好 WeTextProcessing 1.0,简直666"))
# 你好 wetextprocessing 一点零,简直六六六
print(zh_itn_model.normalize("下午三点二十分开会"))
# 15:20开会
英文只要换 import 路径即可:tn.english.normalizer.Normalizer、itn.english.inverse_normalizer.InverseNormalizer。
进阶玩家的独门秘籍
秘籍一:别把 overwrite_cache 当默认值
很多人第一次用会把 overwrite_cache=True 随手写上,结果每次启动都重新编译 FST,白白等几十秒。事实上,项目的缓存是内容寻址的:缓存 key 包含了全部配置参数、规则源码指纹、TSV/FAR 数据与构建格式信息,任何一处改动都会自动触发重建并校验。日常使用保持默认 overwrite_cache=False 即可,它会自动命中正确缓存。
缓存默认落在系统用户缓存目录(Linux 下是 ~/.cache/wetextprocessing),不会污染你的源码树。想要完全内存运行、不落盘,传 cache_dir=False。
秘籍二:善用 language 参数切换语言与选项
命令行参数是按语言隔离的,先查帮助再下手:
python -m tn --language zh --no-remove-erhua --text "这儿"
python -m itn --language zh --enable-0-to-9 --text "一二三"
python -m itn --language ja --full-to-half True --text "12時"
布尔参数既支持 --option / --no-option 这种开关式,也兼容旧的 --option True|False 写法,迁移脚本时不用改习惯。
秘籍三:改规则修 badcase 的正确姿势
遇到识别结果不对,先定位是哪个 token 类型出了问题(用 normalize_with_mapping 看 token_type),再去对应规则文件改。想改规则需要克隆仓库本地开发:
git clone https://gitcode.com/gh_mirrors/we/WeTextProcessing
cd WeTextProcessing
pip install -r requirements.txt
python -m tn --text "2.5平方电线" --overwrite_cache
修改 tn/chinese/rules/xx.py 后带上 --overwrite_cache 强制重建。动手前强烈建议先读 docs/python-rule-architecture.md,里面有一条硬性约定:tagger 必须保留原始字段、不允许做不可逆改写,所有语义转换交给 verbalizer——违反这条,映射追踪功能就会失效。
秘籍四:从标准输入批量喂数据
命令行除了 --text,还支持 --file PATH 和标准输入。每行输入会输出两行:tagged 表示与 verbalized 结果,批量测试规则时极其顺手:
cat cases.txt | python -m tn --overwrite_cache
常见问题快问快答
Q:安装报错找不到 pynini? A:WeTextProcessing 依赖 pynini>=2.1.6,而 pynini 需要 OpenFST 原生库。在 Linux/macOS 上通常一条 pip install 就能搞定;Windows 用户建议优先装好预编译的 pynini wheel 再装本包。
Q:enable_0_to_9 到底是干嘛的? A:它控制"个位数的单独数字是否转换"。默认 False 时,"幸运一百"→"幸运100","九和六"保持汉字;设为 True 后个位数也会转成阿拉伯数字。语音识别场景一般保持默认,避免"六"被误转成数字导致歧义。
Q:中文 ITN 里"一年后"为什么没变成"1年后"? A:这是 exclue_one=True(默认开启)的保护逻辑:单独的"一"会被排除,避免把"一年后""一个人"里的量词误转。需要开启可以显式传参关闭该选项。
Q:为什么我的缓存目录里有一堆 .lock 文件? A:这是内容寻址缓存的正常元数据,不是残留的锁。真实锁由操作系统文件锁(POSIX 的 flock / Windows 的 msvcrt.locking)保证,进程崩溃会自动释放,不用手动清理。
Q:C++ 运行时能用 Python 缓存目录里的 FST 吗? A:不能直接混用。Python 的缓存 bundle 是内部格式,不是稳定的 C++ 运行时导出格式,需要单独导出兼容的 tagger/verbalizer 对再传给 processor_main。
结语:文本归一化不该是每个团队重复造的轮子
在语音识别、语音合成、文本分析这条链路上,TN/ITN 是绕不开的"最后一公里"。WeTextProcessing 的价值不在于它转得多快,而在于它把这条路上所有的坑——数字读法、日期格式、单位换算、儿化音、全半角、白名单——都替你填平了,还顺手送了你精确映射、n-best、多语言、C++ 部署这些生产级能力。
下次当你再遇到"余额为一千二百三十四点五六元"这种文本时,别再默默写正则了。装上它,跑通一次,你就回不去了。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



