1. 为什么每个做BERT的人都要先学会Tokenizer先问一个很实际的问题你在跑BERT的时候是不是直接把一句话丢给model然后拿到输出就完事了我见过太多新人这么干结果换了个预训练模型就报错或者稍微改一下输入格式结果就完全不对最后Debug一整天发现坑全在Tokenizer上。Tokenizer是BERT的门卫。你给模型的所有文本都要经过它变成ID序列模型吐出来的一切结果也都要通过它重新翻译成人话。可以说你对BERT的理解深度很大程度上取决于你对BertTokenizer的理解深度。HuggingFace的BertTokenizer并不是一个单一的实现它是对几种不同分词算法的统一封装。我在不同项目里用过的就有BertTokenizer、BertWordPieceTokenizer、BertTokenizerFast。很多教程只讲一个实际工程里换一个就懵了。所以这篇打算一次讲透加载、分词、编码、解码、特殊Token、长度控制、batch处理、中文场景最后再把我踩过的几个坑都列出来给你留个底。这篇内容适合所有正在用BERT做NLP项目的人不管你是刚入门、还是已经跑通了一些基础任务但搞不懂细节都不亏。2. 从加载到分词最快跑通一遍基础API2.1 下载与加载别直接用字符串路径先走from_pretrainedfrom transformers import BertTokenizer # 正确姿势 tokenizer BertTokenizer.from_pretrained(bert-base-uncased) # 或者本地路径 tokenizer BertTokenizer.from_pretrained(./my_bert_model/)from_pretrained会自动帮你处理三件事从模型仓库下载配置文件、加载vocab.txt词汇表、初始化分词器内部状态。这些事如果全都让你自己干光下载和路径管理就能折腾一晚上。加载之后务必验证一下词表大小和特殊Token是否正常print(词表大小:, tokenizer.vocab_size) # 输出: 词表大小: 30522 print(特殊Token映射:, tokenizer.all_special_tokens) # 输出: [[UNK], [SEP], [PAD], [CLS], [MASK]]注意vocab_size和词表文件的行数不一定严格相等这是正常的因为有些特殊Token可能不在vocab.txt中。别在这上面纠结。2.2 核心分词操作tokenize与convert_tokens_to_ids很多教程直接教tokenizer.encode()但如果你想真正理解原理必须先从底层API开始。text I love Hugging Face! tokens tokenizer.tokenize(text) print(tokens) # 输出: [i, love, hugging, face, !] ids tokenizer.convert_tokens_to_ids(tokens) print(ids) # 输出: [1045, 2293, 17662, 2436, 999]看到没I变成了小写iHugging变成了huggingFace变成了face。这是因为bert-base-uncased默认做了小写化处理。如果你不想让英文单词被转小写要选bert-base-cased或者在加载时传入do_lower_caseFalse。再比如一个词被拆分的例子text unhappiness tokens tokenizer.tokenize(text) print(tokens) # 输出: [un, ##happiness]这就是WordPiece分词。unhappiness被拆成了un和##happiness其中##表示这个词片断不是一个完整单词的开头而是接在前面的片断后面。这是BERT能处理OOV词表外单词问题的核心机制——再长的生僻词只要能被拆成词表里的子词就能正常编码。顺带说一句如果你用的tokenizer打印出来的是PreTrainedTokenizerFast类型也别慌它的行为和我们这里讲的基本一致只是底层用Rust实现速度更快。2.3 一条龙encode与encode_plus的区别tokenize只是分词encode一步到位返回ID列表encode_plus则额外返回attention_mask和token_type_ids。# 只返回token IDs ids tokenizer.encode(Hello, world!) print(ids) # 输出: [101, 7592, 1010, 2088, 999, 102] # 返回完整编码结果 encoded tokenizer.encode_plus( Hello, world!, add_special_tokensTrue, max_length12, paddingmax_length, truncationTrue, return_tensorspt ) print(encoded.keys()) # 输出: dict_keys([input_ids, token_type_ids, attention_mask])这个return_tensorspt很关键直接返回PyTorch张量省得手动转换。如果是TensorFlow用户传tf。如果只是想看list就不传或者传None。3. return_tensors、input_ids与attention_mask编码输出的三个核心概念新手最容易在这里迷糊为什么模型输入不能直接给字符串为什么编码出来的是一堆数字为什么还要一个attention_mask3.1 input_ids理解数字背后的含义input_ids是每个token在词表中的索引。BERT的词表是固定大小的每个词片断都有一个唯一编号。这个编号不仅是用来查询词向量还承担了让模型能够区分不同token的作用。比如text I love NLP encoded tokenizer.encode_plus(text) print(encoded[input_ids]) # 输出: [101, 1045, 2293, 17953, 102]101是[CLS]分类标记放在句子开头。对于BERT来说这个位置的最终隐藏状态通常被当作整个句子的汇总表示用来做分类任务。102是[SEP]分隔标记放在句子末尾。有两个句子时也用来分隔两个句子。中间的1045、2293、17953分别是i、love、nlp三个词的ID。务必记住[CLS]和[SEP]的ID因为你在做自定义feature拼接时经常需要手动操作它们或者在debug时一眼看出特殊情况。3.2 attention_mask告诉模型哪些位置是真实tokenattention_mask的作用是告诉模型哪些位置是真实的文本token哪些位置是padding补上的。encoded tokenizer.encode_plus( I love NLP, paddingmax_length, max_length10, truncationTrue ) print(encoded[input_ids]) # 输出: [101, 1045, 2293, 17953, 102, 0, 0, 0, 0, 0] print(encoded[attention_mask]) # 输出: [1, 1, 1, 1, 1, 0, 0, 0, 0, 0]padding时填充的0在词表中对应[PAD]它的位置是0。attention_mask中对应的位置是0表示模型在计算注意力时“不要看这些位置”。我记得有个实习生第一次做BERT的时候没有传attention_mask给模型结果batch里边长短不一的样本全部按最大长度算注意力训练损失一直在震荡。后来加上mask第二天就正常了。这个细节是真的会让模型训不出来的。3.3 token_type_ids在多句子任务中的作用token_type_ids用0和1区分第一句和第二句。单句任务中它全是0不必太关心。但在句子对任务如文本蕴含、问答、句子相似度中它很重要encoded tokenizer.encode_plus( I love NLP, It is amazing, add_special_tokensTrue ) print(encoded[token_type_ids]) # 输出: [0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1][CLS]和第一句属于类型0[SEP]和token_type_ids会区分。第二句属于类型1。模型正是靠这个信息来区分两个句子的边界和归属的。4. 长度控制三件套max_length、truncation和padding4.1 truncation策略截断到底截哪里BERT有最大输入长度限制bert-base是512个token。超了就必须截断。但截断策略有讲究# 从尾部截断默认 encoded tokenizer.encode_plus( long_text, truncationTrue, max_length128 ) # 只截断第二个句子句子对任务常用 encoded tokenizer.encode_plus( sent1, sent2, truncationonly_second, max_length128 ) # 第一个句子截断 encoded tokenizer.encode_plus( sent1, sent2, truncationonly_first, max_length128 )默认的truncationTrue其实等价于truncationlongest_first它会从两个句子中较长的那一个开始截保证两个句子的内容尽可能平衡。在做句子对任务时很多人不知道这个行为以为它只会截第二句结果处理出来的数据分布不对模型效果偏差。实际的建议是如果第一句是query、第二句是document那第一句通常不能丢太多关键信息可以考虑only_second截断如果两个句子等权重用longest_first就行。4.2 padding策略不要无脑padding到model_max_length# 自动padding到本batch内最长句训练时比较高效 encoded tokenizer.encode_plus( texts, paddingTrue, truncationTrue, max_length128, return_tensorspt ) # 统一padding到固定长度批处理比较方便但浪费算力 encoded tokenizer.encode_plus( texts, paddingmax_length, max_length128, truncationTrue, return_tensorspt )很多人图省事一上来就paddingmax_length把每个样本都padding到512。这么干一个batch里全是无效计算训练速度会慢不少尤其是序列平均长度只有几十的时候。更优的做法是paddingTrue只pad到batch内最长句配合DataLoader里的动态padding。这样每个batch的序列长度接近实际需要不会浪费算力。我自己的习惯是训练阶段用paddingTruecollate_fn动态padding推理阶段如果对时延要求不高可以用paddingmax_length因为推理时的shape固定方便批量处理。4.3 动态batch padding的实际代码网上教程很少讲这个但实际项目中这是最常用的写法。给你一个可以直接抄的collate_fnimport torch from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(bert-base-uncased) def collate_fn(batch): texts [item[text] for item in batch] labels torch.tensor([item[label] for item in batch]) encoded tokenizer( texts, paddingTrue, truncationTrue, max_length128, return_tensorspt ) return { input_ids: encoded[input_ids], attention_mask: encoded[attention_mask], token_type_ids: encoded[token_type_ids], labels: labels }用的时候直接丢给DataLoaderfrom torch.utils.data import DataLoader dataloader DataLoader(dataset, batch_size32, collate_fncollate_fn)这样可以保证每个batch的输入都是一样长的而且不会无脑padding到512。5. 从ID到文本decode、skip_special_tokens与batch_decode5.1 decode还原而不是简单拼接编码的反向操作是decode。最常见的坑是把input_ids直接拿去查词表然后字符串拼接结果##符号到处都是。正确做法是encoded tokenizer.encode(unhappiness) # encoded: [101, 2107, 14658, 102] decoded tokenizer.decode(encoded) print(decoded) # 输出: [CLS] unhappiness [SEP]如果用skip_special_tokensTrue就把[CLS]和[SEP]去掉了decoded tokenizer.decode(encoded, skip_special_tokensTrue) print(decoded) # 输出: unhappiness注意decode不是简单地把token拼起来它会做一些后处理把##前缀的token和前面的token合并在一起还会处理单词间的空格。这也是为什么你自己查表拼接的结果和decode对不上的原因。5.2 batch_decode批处理时的高效解码函数处理一批结果时不要用for循环一个个decode直接用batch_decodebatch_ids [ [101, 1045, 2293, 102], [101, 2023, 2003, 1037, 2817, 102] ] decoded_texts tokenizer.batch_decode(batch_ids, skip_special_tokensTrue) print(decoded_texts) # 输出: [i love, the is a cat]批处理时batch_decode内部会做一些优化省去你写循环的功夫代码也更干净。此外它内部的skip_special_tokens参数默认是True但我建议你显式写出来让别人读代码的时候不用去猜默认值。5.3 生成任务中decode的实际应用在文本生成任务中模型输出的是整个词汇表上的概率分布你需要先argmax或采样得到token ID序列然后再decodegenerated_ids model.generate( input_ids, max_length50, do_sampleTrue, top_k50, top_p0.95, num_return_sequences3 ) # generated_ids shape: (3, seq_len) for i, ids in enumerate(generated_ids): text tokenizer.decode(ids, skip_special_tokensTrue) print(f生成结果 {i1}: {text})这里有个小经验生成结果的input_ids是包含[CLS]和[SEP]的如果你不做任何处理直接拼接最终文本中间会出现[CLS]和[SEP]这样的特殊Token字符串。所以生成任务里基本都要加skip_special_tokensTrue。6. 特殊token与分词细节大写的坑6.1 手动添加特殊Token的场景有时候你需要往输入里插入一些自定义标记比如在实体识别任务里你可能会用[E1]、[E2]标记实体的起止位置。这时候你需要先把它加到tokenizer的词汇表里再使用。new_tokens [[E1], [E2], [REL]] tokenizer.add_special_tokens({additional_special_tokens: new_tokens}) # 重新调整模型embedding大小 model.resize_token_embeddings(len(tokenizer))注意add_special_tokens之后必须调用model.resize_token_embeddings(len(tokenizer))否则模型embedding矩阵的维度还是旧的下一步就会报错或者静默出错。怎么验证新Token加进去没有直接看它的IDprint(tokenizer.convert_tokens_to_ids([E1])) # 输出: 30522 假设原来的词表是30522那新token就是从这开始编号这个技巧在做关系抽取、实体识别时特别有用很多论文里的实体标记就是这么实现的。6.2 处理英文缩写和标点英文里dont、cant这类单词WordPiece有自己的一套逻辑tokens tokenizer.tokenize(I cant do it) print(tokens) # 输出: [i, can, , t, do, it]注意它会把cant拆成can、、t三段。这是HuggingFace分词器的规则——标点符号单独成token。如果你在自定义数据处理时没有考虑到这一点可能会在复原文本时出错。还有一个常见情况是数字的处理tokens tokenizer.tokenize(In 2024, I read 3 books) print(tokens) # 输出: [in, 2024, ,, i, read, 3, books]数字一般不会继续拆但年份、电话号码这些长数字串不会做特殊处理。如果你在做文本清洗时把数字当作一个整体处理结果可能会和WordPiece不一致导致编码解码对不上。6.3 大小写敏感模型与uncased模型的区别BERT有多种预训练版本其中bert-base-uncased和bert-base-cased是使用最多的两个。它们的区别不只是是否小写化预训练语料和词表也不同。tokenizer_uncased BertTokenizer.from_pretrained(bert-base-uncased) tokenizer_cased BertTokenizer.from_pretrained(bert-base-cased) print(tokenizer_uncased.tokenize(Hello)) # 输出: [hello] print(tokenizer_cased.tokenize(Hello)) # 输出: [Hello]如果你处理的是英文语料且涉及专有名词如人名、地名、产品名一般用cased效果更好因为大小写包含语义信息。如果语料中大小写噪音很大或任务本身对大小写不敏感uncased更省心。中文没有大小写这个问题但中文BERT用的是单独的中文词表后面专门讲。7. 中文场景的实战套路空格、繁体、自定义词典7.1 中文BERT的加载方式和行为中文BERT如bert-base-chinese的词表是基于字的而不是基于词的。这意味着每个中文字符一般就是一个tokentokenizer_cn BertTokenizer.from_pretrained(bert-base-chinese) tokens tokenizer_cn.tokenize(我爱自然语言处理) print(tokens) # 输出: [我, 爱, 自, 然, 语, 言, 处, 理]这跟很多人以为的不一样——中文BERT不是先分词再编码的。它把每个汉字当作一个token。好处是不依赖外部分词工具坏处是token长度远比英文长一句话可能几十个token需要考虑512长度限制。7.2 中文文本中的空格问题不少人在处理中文时会遇到一个诡异的问题把文本清理成没有空格的连续字符串再输入中文BERT和原本带空格的字符串输入得到的token结果是一样的吗答案一样但前提是你用的是bert-base-chinese。这个分词器会忽略中文间的空格tokens1 tokenizer_cn.tokenize(我 爱 自然语言处理) print(tokens1) # 输出: [我, 爱, 自, 然, 语, 言, 处, 理] tokens2 tokenizer_cn.tokenize(我爱自然语言处理) print(tokens2) # 输出: [我, 爱, 自, 然, 语, 言, 处, 理]但是在处理混合中英文文本时要注意中英文之间的空格不会被忽略掉tokens tokenizer_cn.tokenize(我爱NLP也爱深度学习) print(tokens) # 输出: [我, 爱, n, ##l, ##p, , 也, 爱, 深, 度, 学, 习]看到没有NLP这种全大写英文在bert-base-chinese中也被小写化了而且被拆成了n、##l、##p。中文BERT的默认行为是把英文按字符拆开不是按单词拆。这就导致英文单词在中文BERT里的token数量明显变多。如果你的文本里英文很多建议要么先用bert-base-multilingual-cased要么单独处理英文部分。这个选择会直接影响模型输入长度和效果。7.3 自定义词典与实体标记中文任务中经常需要加入自定义词典。比如你做医疗领域可能有一些专业词汇虽然字级别编码已经能cover但你想让它们成为独立的token方便模型学习。这时候可以用tokenizer_cn.add_tokens([糖尿病, 高血压, 冠心病]) print(tokenizer_cn.convert_tokens_to_ids([糖尿病, 高血压, 冠心病])) # 输出可以看到给它们分配的ID # 注意让模型适配新词表大小 model.resize_token_embeddings(len(tokenizer_cn))这个操作本质上是在词表后面追加了几个整词token。但是要提醒一句加了新token之后新的embedding是随机初始化的模型需要时间学习才能用好它们。所以加了自定义词典之后最好微调一段时间而不是直接拿来推理。7.4 中文长文本的切分策略中文BERT最长512个token如果一篇文章有几万字你不可能一次性塞进去。常见的做法是滑窗切分def split_long_text(text, tokenizer, max_len510, stride128): 滑窗切分长文本返回多个token序列 encoded tokenizer.encode(text, add_special_tokensFalse) chunks [] start 0 while start len(encoded): end min(start max_len, len(encoded)) chunk encoded[start:end] chunks.append(chunk) if end len(encoded): break start end - stride return chunks # 测试 chunks split_long_text(long_text, tokenizer_cn) for i, chunk in enumerate(chunks[:3]): print(f第{i1}段长度: {len(chunk)})滑窗的stride步长让相邻两个chunk有一部分重叠避免关键信息刚好在边界处被切掉。stride一般设为128左右具体调参看你的数据分布。8. 常见报错与行为异常的排查记录8.1 TypeError: text input must of type str (single example)这个报错通常出现在你把一个list传给了encode_plus而不是batch_encode_plus# 错误 tokenizer.encode_plus([hello, world]) # 正确单条文本用str多条用list tokenizer.encode_plus(hello world) tokenizer.batch_encode_plus([hello, world])这也是为什么实际项目中我更推荐直接用tokenizer()因为它内部会自动判断是单条还是多条。8.2 文本中的特殊字符导致token数量暴涨比如你处理的是爬虫抓来的网页文本里面可能带了大量HTML实体、emoji、全角符号这些都会被拆成独立的token白白占用长度。我的处理经验是清洗阶段先把这些特殊字符替换掉没有必要让模型在它们上面浪费注意力。尤其是emoji在一个字一个字编码的中文BERT里一个emoji可能变成好几个token直接导致长度爆炸。8.3 手动设置max_length导致的“静默截断”有次我用的数据里有文本特别长truncationFalse结果模型遇到超过512的序列时直接报错。后面我设置truncationTrue但忘传max_length它默认截断到tokenizer的model_max_length512。这个行为不是报错也不是警告属于静默截断。等你发现数据分布不对时可能已经跑了很多轮了。所以我的习惯是每次编码都显式传入max_length不依赖默认值。8.4 使用save_pretrained持久化分词器在部署、复现的时候千万不要只是save模型权重Tokenizer也要一起保存tokenizer.save_pretrained(./my_bert_tokenizer/) # 之后加载 tokenizer BertTokenizer.from_pretrained(./my_bert_tokenizer/)保存后会生成vocab.txt、tokenizer_config.json、special_tokens_map.json等文件。重新加载的时候所有特殊Token、自定义Token、大小写设置都会正确恢复。这个习惯能让你避免很多版本不一致导致的诡异问题。9. 我自己在项目中的几个实用技巧做NLP这么久Tokenizer相关的代码反反复复写过很多版本总结三个我觉得最值得分享的经验。9.1 写一个全局可复用的tokenizer工厂项目里模型换了又换数据处理代码最容易被绑死在某一个tokenizer上。我的做法是写一个工厂函数统一管理TOKENIZER_MAP { bert: bert-base-uncased, roberta: roberta-base, chinese: bert-base-chinese, } def get_tokenizer(model_type, cache_dir./cache): name TOKENIZER_MAP[model_type] return BertTokenizer.from_pretrained(name, cache_dircache_dir)这样更换模型时不用到处改代码只需要改一个映射配置。同时也方便团队其他成员统一使用。9.2 预处理时就把文本清洗干净而不是让Tokenizer来兜底Tokenizer的设计目标是高效地把文本转成ID不是用来清洗脏数据的。把文本清洗去掉乱码、统一换行、修正编码问题做在前面能让Tokenizer的行为更可预测排查问题也更方便。我第一次做新闻分类的时候用爬虫抓的数据里有大量\u3000全角空格、nbsp;、\xa0这些肉眼看不到的字符结果分词结果总有一些奇怪的空token后来发现是清洗不彻底造成的。9.3 用tokenzier的返回结果来诊断数据质量其实Tokenizer本身也能当数据质量检查工具用。比如有一个batch里token长度总是不对劲你可以把input_ids解码回字符串看看里面到底是什么decoded tokenizer.decode(encoded[input_ids][0], skip_special_tokensTrue) print(decoded)这个办法看着笨但比任何统计都直观。尤其是遇到“模型效果突然下降”这种问题先把进模型的文本还原出来看一看大多数问题一眼就能发现。最后再分享一个很实用的小技巧做完padding之后如果你发现attention_mask里0的位置对应的input_ids不是0那说明你的数据管线某个环节出问题了——正常情况下pad token的ID就是0即[PAD]对应的ID。这种不一致往往是手写collate_fn时漏了处理导致的。用这个办法排查比一行行打断点快得多。
阅读完成 · 觉得有帮助?