1. 从一道报错说起:为什么BertTokenizer值得花时间搞懂
先讲个我自己的经历。有一次给一个文本分类项目做预处理,我直接拿Python的split方法把句子切成词,再查表转成id,结果模型训练完F1值死活上不去。后来排查了一圈才发现,问题出在分词上:“铁观音”被切成了“铁”和“观音”,“机器学习”被切成了“机器”和“学习”——词义完全变了。后来换成了BertTokenizer,同样一个句子,它能自动切成“铁观音”、“机器学习”这种完整语义单元,模型效果立刻上来了。
这就是BertTokenizer存在的意义:它不是为了把句子拆成一个个汉字或单词,而是要切出对语义理解最友好的词元(token)序列,然后把每个token映射成BERT模型能读懂的数值输入。
这篇博文我会从源码和实操两个层面,把BertTokenizer的完整用法讲透。不管你是刚接触NLP的新手,还是已经跑过几版BERT模型但一直没细究过分词细节的老手,这篇文章都能帮你省下不少查文档的时间。内容包含完整的代码示例、参数说明、踩坑记录,基本上照着抄就能用。
先给一个快速认知框架:BertTokenizer的核心功能可以拆成四件事——分词(tokenize)、转ID(convert_tokens_to_ids)、编码(encode)、批量编码(batch_encode_plus)。后面所有内容都会围绕这四个功能展开。
2. 动手前的必要准备:环境安装与基础概念
2.1 安装transformers库
BertTokenizer来自Hugging Face的transformers库,这是目前NLP领域事实上的标准工具库。安装很简单,直接用pip:
pip install transformers如果你还需要加载BERT模型本身,建议同时安装torch或者tensorflow,二选一即可。我日常习惯用PyTorch,所以装的是torch。要注意的是,transformers库更新频率很高,API偶尔会有小变动,建议安装后固定版本号。我当前用的版本是4.x,下面所有代码都基于这个版本验证过。
安装完成之后,一行代码就能加载BERT官方预训练好的分词器:
from transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained('bert-base-chinese')这里bert-base-chinese是Hugging Face模型仓库里的一个预训练模型名称,对应的是谷歌发布的中文BERT模型。如果你做英文任务,换成bert-base-uncased即可。第一次运行时会自动下载模型文件到本地缓存目录,大约需要几分钟,后续再运行就直接走缓存,速度很快。
2.2 几个必须搞清楚的核心概念
在正式写代码之前,有几个概念必须提前讲清楚,不然后面看代码会一头雾水。
Token(词元):BERT处理文本的最小单位。在中文模型里,一个字通常就是一个token,但一些词会被合并成一个token(比如“清华大学”可能整体是一个token)。在英文模型里,一个token可能是完整单词,也可能是单词的一部分(后面讲WordPiece时会细说)。
Input ID(输入ID):每个token在词表中的编号。BERT模型实际读取的是这些数字,而不是原始文本。
Attention Mask(注意力掩码):一个与input_ids等长的0/1序列,1表示对应位置是真实文本,0表示是padding填充的。这样模型在计算注意力时就不会被填充位置干扰。
Token Type IDs(token类型ID):用于区分句子A和句子B。在句子对任务(比如问答、自然语言推理)中,第一个句子的位置全部标0,第二个句子的位置全部标1。
Special Tokens(特殊token):BERT在序列首尾和一些特殊位置会插入专用的token,稍后细说。
2.3 磁盘上的文件:它到底加载了什么
第一次加载时,Hugging Face会从远程仓库把以下文件拉到本地:
vocab.txt:词表文件,一行一个词元,每一行的行号就是这个词元的ID。中文BERT模型的词表大小是21128,英文uncased模型是30522。tokenizer_config.json:分词器的配置文件,里面记录了模型名称、是否小写化、是否保留重音等参数。config.json:模型本身的配置。
这里重点说一下vocab.txt。你可以直接打开看看,前面几行永远是[PAD]、[UNK]、[CLS]、[SEP]、[MASK]这几个特殊token,然后才是一些常用汉字和词。这种顺序不是随机的,特殊token必须占据固定的ID位置,比如[PAD]永远在ID=0的位置,[UNK]在ID=100的位置,[CLS]在ID=101,[SEP]在ID=102,[MASK]在ID=103。搞清楚这个顺序,后面调试代码会轻松很多。
3. 核心原理:WordPiece分词算法与BERT的“最小语义单元”
3.1 为什么不能用简单的按空格切词
英文里“unbelievable”这个单词,如果按空格切,它是一个完整词。但在BERT的词表里,它可能被切成“un”、“believable”或者“un”、“believ”、“able”。为什么这么干?因为自然语言的形态变化太丰富了,“unhappy"、"unbelievable"、"unimportant”都带“un”前缀,如果每个完整词都单独进词表,词表会爆炸。
中文就更复杂了。中文没有天然的空格分隔符,按字切分虽然简单,但“北京大学”呢?它是“北京”+“大学”的组合,语义上是完整机构名。如果按字切分,模型需要额外学习字与字之间的组合关系;如果按词切分,又面临歧义切分问题(“研究生物科学”是“研究/生物/科学”还是“研究生/物/科学”)。
WordPiece算法就是来解决这个问题的。
3.2 WordPiece算法的核心思想
WordPiece是一种子词(subword)分词算法,它的核心思想是:优先用词表里最长的词元匹配文本,如果匹配不上,就逐级缩小范围,直到单个字符。
以bert-base-uncased为例,假设词表里有以下词元:
un ##bel ##ievable其中##前缀表示这个词元不是独立词,而是附着在前面的词元后面。当处理文本“unbelievable”时,算法先看完整词“unbelievable”是否在词表里——不在。然后看“unbelie”是否在——也不在。继续缩小到“un”——在,命中。接着处理剩余的“believable”:完整词不在,看“b”开头的词元“##bel”是否在——在,命中。继续处理剩余的“ievable”,命中“##ievable”。最终结果就是“un”、“##bel”、“##ievable”,还原成文本就是“unbelieveable”。
这个方法的好处非常明显:词表容量可以控制在几万个词元内,却几乎能覆盖所有文本。遇到生僻词时,最坏情况下退化成按字符切分,保证不出现“无法处理”的情况。
我再用一个更贴近生活的例子说明:中文里“貂蝉”这个词如果不在词表里,算法会退化成“貂”和“蝉”两个单字token;但如果词表里有“貂蝉”整体词元,它就会优先匹配整体。这就是为什么BERT在不同领域的迁移能力很强——词的子词组合可以泛化到没见过的复合词。
3.3 特殊token的作用与位置
BERT的输入格式是固定的,所有序列都要遵循这个格式:
[CLS] token1 token2 ... tokenN [SEP][CLS]:位于序列最开头,ID为101。它的输出向量被设计用来聚合整个序列的语义信息,分类任务中通常用它做最终预测。[SEP]:位于序列结尾,ID为102。在句子对任务中,它同时作为两个句子的分隔符。
中英文模型略有差异:英文uncased模型在处理英文时会先转成小写再分词;中文模型不区分大小写(中文也没有大小写概念),直接按字切分。
理解WordPiece算法之后,你会发现BertTokenizer的设计非常优雅——它不是简单地把一句话切成词,而是把一句话切成“词表里最长的可用词元序列”,这样既压缩了序列长度,又最大限度保留了语义完整性。
4. 核心API完全拆解:从tokenize到encode_plus
4.1 基础方法:tokenize与convert_tokens_to_ids
先用最底层的方法感受一下分词过程。tokenize方法把文本转成token列表,convert_tokens_to_ids把token列表转成ID列表:
from transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained('bert-base-chinese') text = "机器学习让生活更美好" tokens = tokenizer.tokenize(text) print(tokens) # ['机', '器', '学', '习', '让', '生', '活', '更', '美', '好'] ids = tokenizer.convert_tokens_to_ids(tokens) print(ids) # [3300, 2678, 2113, 2304, 1761, 2523, 2945, 2360, 3307, 1962]注意这里中文模型直接按字切分了。这是正常的,bert-base-chinese的词表本身就以单字为主,没有做进一步的中文分词。所以如果你用的是中文模型,tokenize的输出十个字就是十个token。如果你想按词切分,需要先自己用jieba等工具分词,然后以空格分隔传入,但这属于进阶用法,后面会讲。
再试一下英文模型:
from transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained('bert-base-uncased') text = "unbelievable" tokens = tokenizer.tokenize(text) print(tokens) # ['un', '##bel', '##ievable'] ids = tokenizer.convert_tokens_to_ids(tokens) print(ids) # [2100, 18810, 22180]看到##前缀了吧?这就是WordPiece算法把“unbelievable”拆成了三个子词。拆完之后,这三个子词在词表中都有独立ID,模型可以正常处理。
如果你有一个token列表或ID列表想还原回文本,可以用convert_ids_to_tokens和convert_tokens_to_string:
# ID列表转token token_list = tokenizer.convert_ids_to_tokens(ids) print(token_list) # ['un', '##bel', '##ievable'] # token列表转可读文本 text_restored = tokenizer.convert_tokens_to_string(token_list) print(text_restored) # 'unbelievable'这里有个细节:##前缀在还原时会自动处理,所以convert_tokens_to_string能还原为完整的“unbelievable”,而不是“un##bel##ievable”这种半成品。
4.2 最常用的核心方法:encode
tokenize+convert_tokens_to_ids的组合操作太多余了,实际开发中我们直接调用encode方法。encode就是分词+转ID+加特殊token+截断的集合:
from transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained('bert-base-chinese') text = "你好世界" ids = tokenizer.encode(text) print(ids) # [101, 704, 1744, 1921, 1920, 102]输出结果里,101是[CLS]的ID,102是[SEP]的ID,中间的704、1744、1921、1920分别是“你”、“好”、“世”、“界”的ID。也就是说,encode方法默认已经帮你把[CLS]和[SEP]加上了。
如果你想验证这一点,可以把ID转回token看一眼:
tokens = tokenizer.convert_ids_to_tokens(ids) print(tokens) # ['[CLS]', '你', '好', '世', '界', '[SEP]']encode方法的几个常用参数:
# add_special_tokens=False,不加CLS和SEP ids_no_special = tokenizer.encode(text, add_special_tokens=False) print(ids_no_special) # [704, 1744, 1921, 1920] # max_length设置最大长度,超过部分截断 ids_truncated = tokenizer.encode(text, max_length=3, truncation=True) print(ids_truncated) # [101, 704, 1744] # 返回PyTorch张量 ids_tensor = tokenizer.encode(text, return_tensors='pt') print(ids_tensor) # tensor([[ 101, 704, 1744, 1921, 1920, 102]])return_tensors='pt'返回的是一个形状为(1, 序列长度)的张量,第一个维度是batch size,方便直接喂给模型。pt对应PyTorch,tf对应TensorFlow。
4.3 终极方法:encode_plus
encode_plus是在encode基础上的一次全面升级,它一次性返回所有模型需要的字段。看个例子:
from transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained('bert-base-chinese') text = "你好世界" encoded = tokenizer.encode_plus( text, add_special_tokens=True, max_length=10, padding='max_length', truncation=True, return_tensors='pt' ) for key, value in encoded.items(): print(f"{key}: {value.tolist()}") # input_ids: [[101, 704, 1744, 1921, 1920, 102, 0, 0, 0, 0]] # token_type_ids: [[0, 0, 0, 0, 0, 0, 0, 0, 0, 0]] # attention_mask: [[1, 1, 1, 1, 1, 1, 0, 0, 0, 0]]结果里包含三个字段:
input_ids:就是encode方法返回的ID序列,长度被pad到了10,末尾补了4个0([PAD]的ID)。token_type_ids:全部是0,表示这一段序列属于“第一个句子”。attention_mask:前6个位置是1(真实文本),后4个位置是0(padding)。
这三个字段拼在一起,就是BERT模型前向传播时需要的完整输入。直接这样调用:
outputs = model(**encoded)非常优雅,不需要手动拼接任何东西。
encode_plus还支持一次传入两个句子,处理句子对任务:
encoded_pair = tokenizer.encode_plus( "你喜欢什么运动", "我喜欢篮球", max_length=20, padding='max_length', truncation=True, return_tensors='pt' ) token_ids = encoded_pair['input_ids'].tolist()[0] tokens = tokenizer.convert_ids_to_tokens(token_ids) print(tokens) # ['[CLS]', '你', '喜', '欢', '什', '么', '运', '动', '[SEP]', '我', '喜', '欢', '篮', '球', '[SEP]', '[PAD]', ...] print(encoded_pair['token_type_ids'].tolist()) # [[0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, ...]]注意token_type_ids,在[SEP]之前的位置全是0,[SEP]之后、第二个[SEP]之前的位置全是1,这样模型就能区分前后两个句子。
4.4 批量编码终极方案:batch_encode_plus
实际训练时,我们几乎不会一次只处理一条文本,都是一批一批地处理。每批文本长度天然不同,需要padding补齐,还要保证这批数据在训练过程中不会变化。batch_encode_plus就是干这个的。
from transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained('bert-base-chinese') texts = [ "机器学习真有趣", "今天天气不错适合出去走走", "深度学习改变世界" ] encoded_batch = tokenizer.batch_encode_plus( texts, max_length=16, padding='max_length', truncation=True, return_tensors='pt' ) print(encoded_batch['input_ids'].shape) # torch.Size([3, 16]) for ids in encoded_batch['input_ids']: tokens = tokenizer.convert_ids_to_tokens(ids.tolist()) print(tokens) # ['[CLS]', '机', '器', '学', '习', '真', '有', '趣', '[SEP]', '[PAD]', ...] # ['[CLS]', '今', '天', '天', '气', '不', '错', '适', '合', '出', '去', '走', '走', '[SEP]', '[PAD]', ...] # ['[CLS]', '深', '度', '学', '习', '改', '变', '世', '界', '[SEP]', '[PAD]', ...]默认情况下,长度不足的句子会统一补充[PAD]到16。但如果你希望batch内只pad到最长句子的长度而不是固定值,可以这样:
encoded_batch_dynamic = tokenizer.batch_encode_plus( texts, padding=True, truncation=True, return_tensors='pt' ) print(encoded_batch_dynamic['input_ids'].shape) # torch.Size([3, 14])padding=True表示动态计算本batch内最大长度并pad到该长度。第一条句子7个字补到14,第二条14个字正好,第三条9个字补到14。这在推理阶段能明显减少计算量,训练阶段则建议固定max_length,保证batch内张量形状一致。
还需要特别注意的是truncation=True这个参数。如果一条文本特别长,超过max_length,默认策略是直接从尾部截断。但BERT的[CLS]位于开头,[SEP]位于末尾,如果超长句子被截断,句尾信息会丢失。一些场景下可以配合truncation_strategy='only_first'或'longest_first'参数来做更精细的控制,这个后面会展开。
5. 从编码到解码:如何在模型输出和可读文本之间转换
训练模型时输入是ID,但分析和评估模型输出时,我们经常需要把ID重新转换成可读文本。这一节把反向转换的几个方法整理清楚。
5.1 单条样本解码
推荐使用decode方法,一步到位:
from transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained('bert-base-chinese') text = "我喜欢用BERT做文本分类" encoded = tokenizer.encode(text) # [101, 2821, 2304, 8013, 2099, 4638, 10196, 2190, 3199, 2378, 102] decoded = tokenizer.decode(encoded) print(decoded) # '我喜欢用BERT做文本分类'注意,decode默认会跳过[CLS]、[SEP]、[PAD]这些特殊token,直接输出可读文本。如果你想保留特殊token,可以传skip_special_tokens=False:
decoded_with_special = tokenizer.decode(encoded, skip_special_tokens=False) print(decoded_with_special) # '[CLS] 我 喜 欢 用 B E R T 做 文 本 分 类 [SEP]'5.2 批量解码与可视化
解码一批ID时,可以使用列表推导式:
texts = ["第一句话", "第二句话"] batch_encoded = tokenizer.batch_encode_plus( texts, max_length=8, padding='max_length', truncation=True ) for token_ids in batch_encoded['input_ids']: print(tokenizer.decode(token_ids)) # '第一句话' # '第二句话'调试阶段我还经常用convert_ids_to_tokens配合列表展示每个token的粒度,这样能看清BERT到底把一句话拆成了什么颗粒度:
token_ids = batch_encoded['input_ids'][0] tokens = tokenizer.convert_ids_to_tokens(token_ids) print(tokens) # ['[CLS]', '第', '一', '句', '话', '[SEP]', '[PAD]', '[PAD]']在分析和调试模型时,把input_ids还原成token列表往往比直接看数字直观得多。建议在代码里封装一个小工具函数,输出格式可以自定义,比如只保留非padding的部分。
def ids_to_text(tokenizer, ids): tokens = tokenizer.convert_ids_to_tokens(ids, skip_special_tokens=True) return tokenizer.convert_tokens_to_string(tokens)6. 进阶用法:两个句子同时编码与特殊场景处理
6.1 句子对输入:BERT处理两句话的标准姿势
做自然语言推理、问答、句子相似度这类任务时,模型需要同时接收两个句子。BERT原生的输入格式就是[CLS] 句子A [SEP] 句子B [SEP]。
from transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained('bert-base-chinese') sentence_a = "苹果和香蕉哪个更健康" sentence_b = "香蕉富含钾元素" encoded = tokenizer.encode_plus( sentence_a, sentence_b, max_length=32, padding='max_length', truncation=True, return_tensors='pt' ) tokens = tokenizer.convert_ids_to_tokens(encoded['input_ids'][0]) print(tokens) # ['[CLS]', '苹', '果', '和', '香', '蕉', '哪', '个', '更', '健', '康', '[SEP]', # '香', '蕉', '富', '含', '钾', '元', '素', '[SEP]', '[PAD]', ...] print(encoded['token_type_ids'][0]) # tensor([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, ...])token_type_ids在句子A和句子B之间的区分非常清晰:句子A区域全是0,句子B区域全是1,padding区域是0。这样模型就知道哪些位置属于哪一句话。
6.2 长文本处理策略:截断与分块
BERT的序列长度上限是512个token,超过512就必须截断或切分成多个片段。默认的truncation=True是从尾部截断,但这种方法对长文本来说会丢失太多尾部信息。
truncation_strategy参数可以改变截断策略。在encode_plus或batch_encode_plus中传入truncation_strategy时,需要同时设置truncation=True:
only_first:只截断句子A,保留句子B全部内容。适用于“短文+长上下文”的场景。only_second:只截断句子B。longest_first:从较长的句子开始逐token截断,直到总长度满足要求。这个策略尽量保留两个句子的内容,但会损失一部分长句信息。
举例说明:
from transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained('bert-base-chinese') sentence_a = "这是一段很长的文本,长度超过了限制,需要被截断处理" * 3 sentence_b = "短文本" encoded = tokenizer.encode_plus( sentence_a, sentence_b, max_length=20, truncation=True, truncation_strategy='only_first' ) tokens = tokenizer.convert_ids_to_tokens(encoded['input_ids']) print(len(tokens)) # 20 # 可以看到sentence_b完整保留,sentence_a被截断如果文本长度远超512,单纯截断会丢失大量语义,更稳妥的做法是分块:把长文本切成512token的窗口,分别编码后做结果聚合(比如取各窗口特征的平均值)。代码思路如下:
def encode_long_text(tokenizer, text, max_len=512, stride=128): """ 长文本分块编码,返回多个分块的input_ids列表 窗口之间重叠stride个token,避免切分位置截断语义 """ tokens = tokenizer.encode(text, add_special_tokens=False) chunks = [] for i in range(0, len(tokens), max_len - stride): chunk = tokens[i: i + max_len] chunks.append(chunk) if i + max_len >= len(tokens): break return chunks这个方法是我处理长文档时最常用的方案,尤其适合做长文本分类和阅读理解。
6.3 自定义词表与词汇扩展
有些场景下,默认词表里缺你要用的领域词。比如医学NLP里“心肌梗死”可能词表里没有,导致被拆成“心”“肌”“梗”“死”。一个办法是加载分词器后手动添加新词并重新保存:
tokenizer.add_tokens(["心肌梗死"]) tokenizer.save_pretrained("my_tokenizer")但只要添加了新词,词表大小就变了。如果你同时加载了预训练模型,需要同步调整模型的embedding层大小,否则会报维度不匹配:
from transformers import BertModel model = BertModel.from_pretrained('bert-base-chinese') model.resize_token_embeddings(len(tokenizer))resize_token_embeddings会根据新的词表大小调整embedding矩阵。新词对应的embedding会随机初始化,所以用之前建议在下游任务上微调一段时间,让模型学会新词的语义。
7. 不同模型间的差异与选择建议
7.1 中英文模型全家桶对比
用BertTokenizer时,最常遇到的困惑是“到底该用哪个预训练词表”。这里我列一个对比表:
| 模型名称 | 适用语言 | 词表大小 | 切分粒度 | 典型使用场景 |
|---|---|---|---|---|
bert-base-uncased | 英文 | 30522 | 单词+子词 | 通用英文NLP任务 |
bert-base-cased | 英文 | 28996 | 单词+子词 | 需要保留大小写信息的任务 |
bert-base-chinese | 中文 | 21128 | 单字为主 | 中文通用NLP任务 |
bert-base-multilingual-cased | 多语言 | 119547 | 子词 | 跨语言任务 |
uncased和cased的核心区别是:uncased会把所有字母转成小写再去掉重音符号,然后分词;cased保留原始大小写。大多数英文NLP任务用uncased就够了,因为模型对大小写不敏感时更容易学习语义。但如果是命名实体识别这类对大小写敏感的任务——比如区分人名“Apple”和水果“apple”——就要用cased。
中文模型的切分粒度是个容易让人误解的点:bert-base-chinese默认按字切分,不是按词切分。这反而是它的优势——避免了中文分词引入的错误传播。分词错误会导致后面的词向量表示全错,而按字切分虽然丢掉了词边界信息,但模型可以通过Self-Attention自己学到字与字的组合关系。实践证明,这种方式效果相当能打。
7.2 为什么词表大小不同、token切分粒度不同
很多人会问:为什么bert-base-chinese的词表只有21128个词元,而bert-base-multilingual-cased有119547个?因为中文字符总数也就两万多,常用汉字不到三千,所以按字切分只需很小词表就能覆盖几乎所有文本。多语言模型需要同时覆盖104种语言的字符,词表自然膨胀。
这里有一个实用经验:如果你的文本是纯英文或纯中文,用对应语言专用模型更好;如果文本混合了多种语言,再用多语言模型。专用模型在对应语言上的效果几乎总是优于多语言模型。
7.3 Fast版本的细微区别
transformers库里还有一个BertTokenizerFast,用法基本一致:
from transformers import BertTokenizerFast tokenizer = BertTokenizerFast.from_pretrained('bert-base-chinese')Fast版用Rust实现,速度更快,还额外提供tokenizer.backend属性用于判断。如果你只要做文本编码而不需要修改底层行为,直接上Fast版即可。但如果你调用了add_tokens等修改词表的方法,Fast版底层同步更新可能会有延迟,我在项目中遇到过Fast版修改词表后无法立即生效的坑,所以需要自定义词表时建议用普通版。
8. 避坑指南:我在这上面栽过的7个跟头
8.1 常见错误一:忘记设置padding或truncation
上手时最容易犯的错,就是直接对一批不同长度的文本调用encode_plus而不设置padding和truncation,结果返回的序列长度不一致,拼batch时报错:
RuntimeError: stack expects each tensor to be equal size解决办法很简单:batch_encode_plus一定要显式设置padding和truncation。训练阶段建议padding='max_length'固定长度;推理阶段用padding=True动态padding更高效。
8.2 常见错误二:token_type_ids全为0导致句子边界丢失
如果手动构造input_ids时忘记了token_type_ids,或者只用encode方法编码句子对,第二个句子的位置会错误地标成0,模型就无法区分句子A和句子B。这个问题不会报错,只会悄无声息地降低模型效果,排查起来特别麻烦。
8.3 常见错误三:batch内文本长度差距过大,算力浪费严重
一批文本里有长有短,固定max_length=512会造成大量padding计算。比如batch里大部分句子只有20个token,却pad到512,99%的计算量都在处理垃圾padding。我的经验是:先统计一下训练集文本长度的分布,把max_length设为能覆盖95%文本的长度,剩余5%直接用truncation截断,这样效率最高。
8.4 常见错误四:长文本直接截断导致关键信息丢失
BERT虽然有512长度限制,但这不代表长文本只能用前512个token。我之前做法律文书分类,判决书的案由经常出现在文末,直接截断就把最关键的分类信息丢了。后来改成按段落切分后分别编码,再对所有段落的[CLS]向量做平均池化,才解决这个问题。
8.5 常见错误五:中英文混合文本处理不当
中英文混合时,bert-base-uncased会把中文直接拆成两个[UNK]未知token,完全丢失语义;bert-base-chinese对纯英文的效果也一般,连续英文字母会被拆成单个字母。所以中英文混合场景下,要么用多语言模型,要么把中文和英文分两路编码再拼接。
8.6 常见错误六:词表修改后未同步embedding维度
如果你用add_tokens添加了新词,但没有调用model.resize_token_embeddings,加载模型做前向传播时会报类似embedding size mismatch的错误。哪怕是只加了一个token,embedding矩阵也要同步变大。
8.7 常见错误七:padding_token_id默认值问题
BERT把ID=0设为[PAD],这是模型预训练时约定好的。如果手动分词后自己指定了padding位置,务必保证用的ID是0。如果你把padding位填成[UNK]的100,模型会把这些位置当成真正的词去计算,输出严重失真。
8.8 问题排查速查表
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 模型输出全为0或NaN | input_ids有负数或超大值 | 检查词表ID映射 |
| batch拼接报形状错误 | 序列长度不一致 | batch_encode_plus设置padding |
| 效果远低于论文水平 | token_type_ids错误 | 检查句子对编码是否使用encode_plus |
| 推理速度极慢 | padding长度固定为512大面积浪费 | 改用动态padding |
| UNK token大量出现 | 词表不含该字符 | 换用多语言模型或扩展词表 |
| 长文本分类效果差 | 尾部信息被截断 | 改用分块聚合策略 |
9. 完整实操案例:文本分类任务预处理全流程
看完所有方法之后,我用一个完整的文本分类任务把整个流程串起来。场景是情感二分类,判断一条商品评论是好评还是差评。
9.1 数据准备与加载
from transformers import BertTokenizer import torch tokenizer = BertTokenizer.from_pretrained('bert-base-chinese', model_max_length=512) # 假设有训练集文本和标签 texts = [ "质量很好,物流很快,下次还会买", "用了两天就坏了,差评!", "整体还不错,就是价格有点贵", ] labels = [1, 0, 1] # 1好评,0差评9.2 批量编码训练集
encoded = tokenizer.batch_encode_plus( texts, max_length=128, padding='max_length', truncation=True, return_tensors='pt' # 返回PyTorch张量 ) # encoded包含input_ids、token_type_ids、attention_mask三个字段 # 标签也转成张量 label_tensor = torch.tensor(labels, dtype=torch.long) # 可以直接用来训练了 batch_input_ids = encoded['input_ids'] batch_token_type_ids = encoded['token_type_ids'] batch_attention_mask = encoded['attention_mask']9.3 构造自定义Dataset
实际项目里推荐把编码过程封装进Dataset类,避免每次迭代都重复编码:
from torch.utils.data import Dataset class ReviewDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_len=128): self.texts = texts self.labels = labels self.tokenizer = tokenizer self.max_len = max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): text = self.texts[idx] label = self.labels[idx] encoding = self.tokenizer.encode_plus( text, max_length=self.max_len, padding='max_length', truncation=True, return_tensors='pt' ) return { 'input_ids': encoding['input_ids'].squeeze(0), 'token_type_ids': encoding['token_type_ids'].squeeze(0), 'attention_mask': encoding['attention_mask'].squeeze(0), 'label': torch.tensor(label, dtype=torch.long) }注意这里用了squeeze(0)把(1, seq_len)的张量压缩成(seq_len,),否则DataLoader自动堆叠时会多出一个维度。这是我早期踩过的坑。
9.4 推理阶段动态padding
训练用固定长度,推理就不用严格固定了。动态padding能显著降低延迟:
def predict(model, tokenizer, text, device): encoding = tokenizer.encode_plus( text, padding=True, truncation=True, return_tensors='pt' ) encoding = {k: v.to(device) for k, v in encoding.items()} model.eval() with torch.no_grad(): outputs = model(**encoding) logits = outputs.logits pred = torch.argmax(logits, dim=-1).item() return pred用padding=True时,单条文本会自动截断到512以内,然后只pad到当前文本长度,省去了大量无效计算。
9.5 模型输入与前向传播的完整连接
最后展示一次完整前向传播:
from transformers import BertForSequenceClassification model = BertForSequenceClassification.from_pretrained( 'bert-base-chinese', num_labels=2 ) device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model.to(device) # 一个batch batch_input_ids = batch_input_ids.to(device) batch_token_type_ids = batch_token_type_ids.to(device) batch_attention_mask = batch_attention_mask.to(device) outputs = model( input_ids=batch_input_ids, token_type_ids=batch_token_type_ids, attention_mask=batch_attention_mask, labels=label_tensor.to(device) ) loss = outputs.loss logits = outputs.logits # 训练时用loss反向传播 loss.backward() # 推理时用logits做预测 preds = torch.argmax(logits, dim=-1)到这里,从原始文本到模型输入再到反向传播的完整链路就通了。
10. 我的最终建议:从会用到用对
BertTokenizer看起来简单,但想真正“用对”,需要在实践中反复打磨。我个人最深刻的体会是:不要把Tokenizer当成一个无脑的“文本清洗工具”,而要把它当成模型的一部分来理解。它决定了模型看到什么、看不到什么,也决定了训练和推理时的效率上限。
处理几个小点供参考:
- 正式训练之前,花十分钟统计一下自己数据的长度分布,再设定
max_length,这个投入回报非常高。 - 尽量用
batch_encode_plus而不是在循环里逐个encode_plus。前者底层做了C++层面的优化,速度快很多。 - 在保存模型时,Tokenizer要单独保存:
tokenizer.save_pretrained('model_dir')。加载时也一并加载,否则下游预测时的分词方式与训练时不一致,效果会打折扣。 - 调试阶段把
tokenizer.decode用起来,经常看看模型实际吃进去的token是什么,能发现很多隐蔽的数据问题。
如果你能把这篇文章里的方法消化掉,再遇到“分词不对”“padding报错”“效果上不去”这类问题,基本都能快速定位。后续我还想写一篇关于如何把BertTokenizer替换成其他分词器(比如BPE、Unigram算法)的对比分析,如果大家感兴趣,可以在评论区告诉我。