1. 从一道报错说起为什么BertTokenizer值得花时间搞懂先讲个我自己的经历。有一次给一个文本分类项目做预处理我直接拿Python的split方法把句子切成词再查表转成id结果模型训练完F1值死活上不去。后来排查了一圈才发现问题出在分词上“铁观音”被切成了“铁”和“观音”“机器学习”被切成了“机器”和“学习”——词义完全变了。后来换成了BertTokenizer同样一个句子它能自动切成“铁观音”、“机器学习”这种完整语义单元模型效果立刻上来了。这就是BertTokenizer存在的意义它不是为了把句子拆成一个个汉字或单词而是要切出对语义理解最友好的词元token序列然后把每个token映射成BERT模型能读懂的数值输入。这篇博文我会从源码和实操两个层面把BertTokenizer的完整用法讲透。不管你是刚接触NLP的新手还是已经跑过几版BERT模型但一直没细究过分词细节的老手这篇文章都能帮你省下不少查文档的时间。内容包含完整的代码示例、参数说明、踩坑记录基本上照着抄就能用。先给一个快速认知框架BertTokenizer的核心功能可以拆成四件事——分词tokenize、转IDconvert_tokens_to_ids、编码encode、批量编码batch_encode_plus。后面所有内容都会围绕这四个功能展开。2. 动手前的必要准备环境安装与基础概念2.1 安装transformers库BertTokenizer来自Hugging Face的transformers库这是目前NLP领域事实上的标准工具库。安装很简单直接用pippip 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 IDstoken类型ID用于区分句子A和句子B。在句子对任务比如问答、自然语言推理中第一个句子的位置全部标0第二个句子的位置全部标1。Special Tokens特殊tokenBERT在序列首尾和一些特殊位置会插入专用的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]永远在ID0的位置[UNK]在ID100的位置[CLS]在ID101[SEP]在ID102[MASK]在ID103。搞清楚这个顺序后面调试代码会轻松很多。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_plus4.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 最常用的核心方法encodetokenizeconvert_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]的ID102是[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_tokensFalse不加CLS和SEP ids_no_special tokenizer.encode(text, add_special_tokensFalse) print(ids_no_special) # [704, 1744, 1921, 1920] # max_length设置最大长度超过部分截断 ids_truncated tokenizer.encode(text, max_length3, truncationTrue) print(ids_truncated) # [101, 704, 1744] # 返回PyTorch张量 ids_tensor tokenizer.encode(text, return_tensorspt) print(ids_tensor) # tensor([[ 101, 704, 1744, 1921, 1920, 102]])return_tensorspt返回的是一个形状为(1, 序列长度)的张量第一个维度是batch size方便直接喂给模型。pt对应PyTorchtf对应TensorFlow。4.3 终极方法encode_plusencode_plus是在encode基础上的一次全面升级它一次性返回所有模型需要的字段。看个例子from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(bert-base-chinese) text 你好世界 encoded tokenizer.encode_plus( text, add_special_tokensTrue, max_length10, paddingmax_length, truncationTrue, return_tensorspt ) 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个位置是0padding。这三个字段拼在一起就是BERT模型前向传播时需要的完整输入。直接这样调用outputs model(**encoded)非常优雅不需要手动拼接任何东西。encode_plus还支持一次传入两个句子处理句子对任务encoded_pair tokenizer.encode_plus( 你喜欢什么运动, 我喜欢篮球, max_length20, paddingmax_length, truncationTrue, return_tensorspt ) 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_length16, paddingmax_length, truncationTrue, return_tensorspt ) 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, paddingTrue, truncationTrue, return_tensorspt ) print(encoded_batch_dynamic[input_ids].shape) # torch.Size([3, 14])paddingTrue表示动态计算本batch内最大长度并pad到该长度。第一条句子7个字补到14第二条14个字正好第三条9个字补到14。这在推理阶段能明显减少计算量训练阶段则建议固定max_length保证batch内张量形状一致。还需要特别注意的是truncationTrue这个参数。如果一条文本特别长超过max_length默认策略是直接从尾部截断。但BERT的[CLS]位于开头[SEP]位于末尾如果超长句子被截断句尾信息会丢失。一些场景下可以配合truncation_strategyonly_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_tokensFalsedecoded_with_special tokenizer.decode(encoded, skip_special_tokensFalse) print(decoded_with_special) # [CLS] 我 喜 欢 用 B E R T 做 文 本 分 类 [SEP]5.2 批量解码与可视化解码一批ID时可以使用列表推导式texts [第一句话, 第二句话] batch_encoded tokenizer.batch_encode_plus( texts, max_length8, paddingmax_length, truncationTrue ) 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_tokensTrue) 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_length32, paddingmax_length, truncationTrue, return_tensorspt ) 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区域全是1padding区域是0。这样模型就知道哪些位置属于哪一句话。6.2 长文本处理策略截断与分块BERT的序列长度上限是512个token超过512就必须截断或切分成多个片段。默认的truncationTrue是从尾部截断但这种方法对长文本来说会丢失太多尾部信息。truncation_strategy参数可以改变截断策略。在encode_plus或batch_encode_plus中传入truncation_strategy时需要同时设置truncationTrueonly_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_length20, truncationTrue, truncation_strategyonly_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_len512, stride128): 长文本分块编码返回多个分块的input_ids列表 窗口之间重叠stride个token避免切分位置截断语义 tokens tokenizer.encode(text, add_special_tokensFalse) 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。训练阶段建议paddingmax_length固定长度推理阶段用paddingTrue动态padding更高效。8.2 常见错误二token_type_ids全为0导致句子边界丢失如果手动构造input_ids时忘记了token_type_ids或者只用encode方法编码句子对第二个句子的位置会错误地标成0模型就无法区分句子A和句子B。这个问题不会报错只会悄无声息地降低模型效果排查起来特别麻烦。8.3 常见错误三batch内文本长度差距过大算力浪费严重一批文本里有长有短固定max_length512会造成大量padding计算。比如batch里大部分句子只有20个token却pad到51299%的计算量都在处理垃圾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的错误。哪怕是只加了一个tokenembedding矩阵也要同步变大。8.7 常见错误七padding_token_id默认值问题BERT把ID0设为[PAD]这是模型预训练时约定好的。如果手动分词后自己指定了padding位置务必保证用的ID是0。如果你把padding位填成[UNK]的100模型会把这些位置当成真正的词去计算输出严重失真。8.8 问题排查速查表现象可能原因解决方式模型输出全为0或NaNinput_ids有负数或超大值检查词表ID映射batch拼接报形状错误序列长度不一致batch_encode_plus设置padding效果远低于论文水平token_type_ids错误检查句子对编码是否使用encode_plus推理速度极慢padding长度固定为512大面积浪费改用动态paddingUNK token大量出现词表不含该字符换用多语言模型或扩展词表长文本分类效果差尾部信息被截断改用分块聚合策略9. 完整实操案例文本分类任务预处理全流程看完所有方法之后我用一个完整的文本分类任务把整个流程串起来。场景是情感二分类判断一条商品评论是好评还是差评。9.1 数据准备与加载from transformers import BertTokenizer import torch tokenizer BertTokenizer.from_pretrained(bert-base-chinese, model_max_length512) # 假设有训练集文本和标签 texts [ 质量很好物流很快下次还会买, 用了两天就坏了差评, 整体还不错就是价格有点贵, ] labels [1, 0, 1] # 1好评0差评9.2 批量编码训练集encoded tokenizer.batch_encode_plus( texts, max_length128, paddingmax_length, truncationTrue, return_tensorspt # 返回PyTorch张量 ) # encoded包含input_ids、token_type_ids、attention_mask三个字段 # 标签也转成张量 label_tensor torch.tensor(labels, dtypetorch.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_len128): 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_lengthself.max_len, paddingmax_length, truncationTrue, return_tensorspt ) 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, dtypetorch.long) }注意这里用了squeeze(0)把(1, seq_len)的张量压缩成(seq_len,)否则DataLoader自动堆叠时会多出一个维度。这是我早期踩过的坑。9.4 推理阶段动态padding训练用固定长度推理就不用严格固定了。动态padding能显著降低延迟def predict(model, tokenizer, text, device): encoding tokenizer.encode_plus( text, paddingTrue, truncationTrue, return_tensorspt ) 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用paddingTrue时单条文本会自动截断到512以内然后只pad到当前文本长度省去了大量无效计算。9.5 模型输入与前向传播的完整连接最后展示一次完整前向传播from transformers import BertForSequenceClassification model BertForSequenceClassification.from_pretrained( bert-base-chinese, num_labels2 ) 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_idsbatch_input_ids, token_type_idsbatch_token_type_ids, attention_maskbatch_attention_mask, labelslabel_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算法的对比分析如果大家感兴趣可以在评论区告诉我。
阅读完成 · 觉得有帮助?