通用 Tokenizer 评估方案——从项目专用到任务无关
引言
数语觅类(我的第二个项目,数据库列语义分类)里写了一个 verify 函数,用来评估 tokenizer 的词表大小是否合理。核心逻辑是对样本做编码,统计平均 token 数,然后给建议:
# 评估建议(基于样本)
if avg_length > 20:
print(f"⚠️ 建议: 单项平均 {avg_length:.1f} tokens,考虑增大词表")
elif avg_length < 5:
print(f"⚠️ 建议: 单项平均 {avg_length:.1f} tokens,词表可能过大")
else:
print(f"✓ 词表大小合理 (单项平均 {avg_length:.1f} tokens)")
这个 5-20 的阈值是拍脑门定的,但在数语觅类的场景下工作得还不错——数据库列名本来就短,编码后几个到十几个 token,数字落在范围里,建议也说得通。
到了衔言渡意(第三个项目,中英法三语翻译,180 万对句子级样本),直接复用这个函数,平均 token 数飙到四五十。按旧逻辑,它会告诉我“考虑增大词表”——但问题不在词表,翻译任务的句子天然就比数据库列名长得多。
旧指标为什么失效
“平均 token 数”这个指标,实际上混合了两个东西:数据本身的长度,和 tokenizer 的编码效率。数据库列名短,token 数低;句子长,token 数高——这里面有多少是“数据就是这么长”,有多少是“tokenizer 编码效率不行”?分不开。
换句话说,这个指标和任务绑死了。在数语觅类里,我对数据长度有直觉,所以拍出来的阈值恰好能用。但换一个任务,数据长度的分布完全不同,阈值就失去了意义。问题不在 5 和 20 这两个数字,而在“平均 token 数”这个指标本身不具备跨任务的可比性。
我需要一个指标,纯粹反映 tokenizer 的编码效率,和输入数据是长是短无关。
压缩率:一个任务无关的指标
答案是压缩率——每个 token 平均承载多少个字符,即 chars/token。计算方式就是总字符数除以总 token 数,没有任何花哨的东西。
一个 tokenizer 把“联合国大会”编码成 3 个 token,压缩率是 5/3 ≈ 1.67;把“United Nations”编码成 4 个 token,压缩率是 14/4 = 3.5。这个数字和原文是一句话还是一整篇文档没有关系——它只反映 tokenizer 把字符压缩成 token 的效率。
压缩率的健康范围取决于书写系统,而非任务类型。中文字符本身信息密度高,一个字往往就是一个语素,合理的压缩率大约在 1.5-2.0 chars/token;拉丁语系靠字母组合成词,合理范围大约在 3.0-5.0 chars/token。不管你是做翻译还是做分类,中文数据的压缩率都应该落在中文的范围里。
光看压缩率还不够。一个极端情况:词表大小 10 万,但数据里实际只用到了 5000 个 token,压缩率可能很好看,但 95% 的词表是浪费。所以需要第二个指标:词表使用率(used_tokens / vocab_size)。两个指标配合看——压缩率判断编码效率,词表使用率判断词表规模是否合理。
拿衔言渡意的实际数据验证一下:中文部分压缩率 2.03,落在中文参考范围 1.5-2.0 内;英文 4.89,落在拉丁语系 3.0-5.0 内。三语合计词表使用率 85.82%,没有严重冗余,也没挤爆。这两个数字和数据是什么任务没有关系——完整的分语言评估报告在后面展开。
采样量:10000 条够不够
数语觅类有 96 万条样本,评估时取了 10%,用 96000 条跑的。到了衔言渡意,180 万条,下意识的想法是“依然取 10%,18 万条”——但我之前在设计模型架构时就吃过“凭直觉定参数”的亏,所以这次去翻了点统计学,然后发现“按比例采样”这个直觉本身就是错的。
中心极限定理说:样本均值的标准误差是 σ/√n。精度只取决于样本量 n,公式里根本没有总体大小 N 的位置。不管数据集是 96 万条还是 1800 万条,抽 10000 条得到的估计精度是一样的。
这非常反直觉。96 万里抽 1 万是 1%,1800 万里抽 1 万是 0.05%——抽取范围之外的部分连看都没看,凭什么说一样准?剩下的 99.95% 里万一藏着完全不同的分布呢?
关键在于:采样的前提是数据来自同一个分布。如果这个前提成立,那每一条样本都是从这个分布里独立抽取的,它携带的信息量不会因为“总共有多少条”而改变。10000 条样本能把分布的均值和方差估计到什么精度,取决于分布本身的离散程度(σ)和你抽了多少条(n),和池子里还剩多少条没抽无关。
严格来说,有限总体有一个修正因子。完整的标准误差公式是:
SE = (σ/√n) × √((N-n)/(N-1))
后面这个 √((N-n)/(N-1)) 就是有限总体修正因子(FPC)。当 n 相对 N 很小时,(N-n)/(N-1) 接近 1,修正因子约等于没有。比如从 180 万里抽 1 万,n/N ≈ 0.56%,FPC ≈ 0.997——和 1 几乎没有区别。而当 n/N 较大时,FPC 反而会小于 1,让标准误差变小——也就是说抽样比例越高,估计只会更精确,不会更差。
所以结论很简单:采样量定死 10000。数据不够 10000 就全用。不需要按数据集大小调整,不需要例外。
通用化设计
指标和采样策略都确定了,剩下的问题是:怎么把这个函数从“衔言渡意专用”变成“任何项目都能用”。
计算逻辑是通用的——压缩率永远是总字符数除以总 token 数,词表使用率永远是已使用 token 数除以词表大小。但数据的提取方式不通用。数语觅类的样本是一行一个列名,直接用;衔言渡意的样本是 source + target 的配对,需要按语言标签拆开。每个项目的数据格式不同,没法写一个万能的解析逻辑。
方案是把变化的部分交给调用方:计算和报告抽成公共逻辑,提取逻辑通过 extractors 参数传入。每个 extractor 是一个 (名称, 筛选函数) 对,筛选函数接收完整数据,返回要评估的子集。不传就用默认配置——把全部数据当作一组,覆盖最简单的场景。
报告设计
单 extractor 时,子报告就是全貌,词表使用率的参考范围直接附在子报告里。多 extractor 时,子报告只展示各组数据,参考范围统一放在汇总报告中——因为单组的词表使用率天然是片面的,给参考范围反而会误导。
衔言渡意的完整输出:
============================================================
[en] 词表评估报告
============================================================
词表大小: 48000
测试样本数: 10000
------------------------------------------------------------
压缩率 (chars/token): 4.89
参考: 中文 ~1.5-2.0 | 拉丁语系 ~3.0-5.0
本组词表使用率: 28.52% (13690/48000)
============================================================
============================================================
[zh] 词表评估报告
============================================================
词表大小: 48000
测试样本数: 10000
------------------------------------------------------------
压缩率 (chars/token): 2.03
参考: 中文 ~1.5-2.0 | 拉丁语系 ~3.0-5.0
本组词表使用率: 37.81% (18151/48000)
============================================================
============================================================
[fr] 词表评估报告
============================================================
词表大小: 48000
测试样本数: 10000
------------------------------------------------------------
压缩率 (chars/token): 4.54
参考: 中文 ~1.5-2.0 | 拉丁语系 ~3.0-5.0
本组词表使用率: 31.66% (15196/48000)
============================================================
============================================================
汇总
============================================================
词表总使用率: 85.82% (41196/48000)
参考: 小于 50% 可能冗余 | 超出 95% 可能不够
============================================================
压缩率方面,三组都落在各自书写系统的参考范围内。词表使用率方面,衔言渡意的训练数据是三语均匀分布的(每方向 30 万对,共 180 万对),所以各组大致占三分之一符合预期。汇总 85.82%,词表整体利用充分。
完整实现
def verify(
full_sample: list[str],
tokenizer: spm.SentencePieceProcessor,
vocab_size: int,
extractors: list[tuple[str, Callable[[list[str]], list[str]]]] | None = None,
):
"""
Tokenizer分析,评估分词器编码效率
:param full_sample: 全部样本数据
:param tokenizer: Tokenizer
:param vocab_size: 词表大小
:param extractors: 多组(名称, 数据筛选器)对,筛选器从完整数据中按条件过滤子集,再由sample_size控制采样量
"""
if extractors == []:
raise ValueError("extractors不能为空列表,传入None以使用默认配置")
if extractors is None:
extractors = [("默认数据集", lambda data: data)]
sample_size = 10000
all_used_token_ids = set()
for name, extractor in extractors:
filtered_sample = extractor(full_sample)
actual_size = min(len(filtered_sample), sample_size)
test_cases = random.sample(filtered_sample, k=actual_size)
char_lengths = []
token_lengths = []
used_token_ids = set()
for test_case in test_cases:
encoded = tokenizer.Encode(test_case)
token_lengths.append(len(encoded))
char_lengths.append(len(test_case))
used_token_ids.update(encoded)
all_used_token_ids.update(used_token_ids)
vocab_usage = len(used_token_ids) / vocab_size * 100
zip_ratio = sum(char_lengths) / sum(token_lengths)
print()
print(f"{'=' * 60}")
print(f" [{name}] 词表评估报告")
print(f"{'=' * 60}")
print(f" 词表大小: {vocab_size}")
print(f" 测试样本数: {len(test_cases)}")
print(f"-" * 60)
print(f" 压缩率 (chars/token): {zip_ratio:.2f}")
print(f" 参考: 中文 ~1.5-2.0 | 拉丁语系 ~3.0-5.0")
print(f" 本组词表使用率: {vocab_usage:.2f}% ({len(used_token_ids)}/{vocab_size})")
if len(extractors) == 1:
print(f" 参考: 小于 50% 可能冗余 | 超出 95% 可能不够")
print(f"{'=' * 60}")
if len(extractors) > 1:
overall_usage = len(all_used_token_ids) / vocab_size * 100
print()
print(f"{'=' * 60}")
print(f" 汇总")
print(f"{'=' * 60}")
print(f" 词表总使用率: {overall_usage:.2f}% ({len(all_used_token_ids)}/{vocab_size})")
print(f" 参考: 小于 50% 可能冗余 | 超出 95% 可能不够")
print(f"{'=' * 60}")
调用侧长这样:
def extract_target_with_locale(locale: str, data: list[str]) -> list[str]:
targets: list[str] = list(map(lambda line: line.split("\n")[1], data))
return list(filter(lambda line: line.startswith(locale), targets))
verify(
full_sample,
tokenizer,
VOCAB_SIZE,
[
("en", lambda data: extract_target_with_locale("<en>", data)),
("zh", lambda data: extract_target_with_locale("<zh>", data)),
("fr", lambda data: extract_target_with_locale("<fr>", data)),
]
)
提取逻辑完全是衔言渡意自己的事——数据格式怎么拆、按什么条件过滤,调用方自己定义。换一个项目,换一组 extractors 就行,verify 本身不需要改。