1. 为什么每个做BERT的人都要先学会Tokenizer
先问一个很实际的问题:你在跑BERT的时候,是不是直接把一句话丢给model,然后拿到输出就完事了?我见过太多新人这么干,结果换了个预训练模型就报错,或者稍微改一下输入格式结果就完全不对,最后Debug一整天,发现坑全在Tokenizer上。
Tokenizer是BERT的门卫。你给模型的所有文本,都要经过它变成ID序列;模型吐出来的一切结果,也都要通过它重新翻译成人话。可以说,你对BERT的理解深度,很大程度上取决于你对BertTokenizer的理解深度。
HuggingFace的BertTokenizer并不是一个单一的实现,它是对几种不同分词算法的统一封装。我在不同项目里用过的就有BertTokenizer、BertWordPieceTokenizer、BertTokenizerFast。很多教程只讲一个,实际工程里换一个就懵了。所以这篇打算一次讲透:加载、分词、编码、解码、特殊Token、长度控制、batch处理、中文场景,最后再把我踩过的几个坑都列出来,给你留个底。
这篇内容适合所有正在用BERT做NLP项目的人,不管你是刚入门、还是已经跑通了一些基础任务但搞不懂细节,都不亏。
2. 从加载到分词:最快跑通一遍基础API
2.1 下载与加载:别直接用字符串路径,先走from_pretrained
from 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"变成了小写"i","Hugging"变成了"hugging","Face"变成了"face"。这是因为bert-base-uncased默认做了小写化处理。如果你不想让英文单词被转小写,要选bert-base-cased或者在加载时传入do_lower_case=False。
再比如一个词被拆分的例子:
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_tokens=True, max_length=12, padding="max_length", truncation=True, return_tensors="pt" ) print(encoded.keys()) # 输出: dict_keys(['input_ids', 'token_type_ids', 'attention_mask'])这个return_tensors="pt"很关键,直接返回PyTorch张量,省得手动转换。如果是TensorFlow用户,传"tf"。如果只是想看list,就不传或者传None。
3. return_tensors、input_ids与attention_mask:编码输出的三个核心概念
新手最容易在这里迷糊:为什么模型输入不能直接给字符串?为什么编码出来的是一堆数字?为什么还要一个attention_mask?
3.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:告诉模型哪些位置是真实token
attention_mask的作用是告诉模型:哪些位置是真实的文本token,哪些位置是padding补上的。
encoded = tokenizer.encode_plus( "I love NLP", padding="max_length", max_length=10, truncation=True ) 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_tokens=True ) 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和padding
4.1 truncation策略:截断到底截哪里
BERT有最大输入长度限制,bert-base是512个token。超了就必须截断。但截断策略有讲究:
# 从尾部截断(默认) encoded = tokenizer.encode_plus( long_text, truncation=True, max_length=128 ) # 只截断第二个句子(句子对任务常用) encoded = tokenizer.encode_plus( sent1, sent2, truncation="only_second", max_length=128 ) # 第一个句子截断 encoded = tokenizer.encode_plus( sent1, sent2, truncation="only_first", max_length=128 )默认的truncation=True其实等价于truncation="longest_first",它会从两个句子中较长的那一个开始截,保证两个句子的内容尽可能平衡。在做句子对任务时,很多人不知道这个行为,以为它只会截第二句,结果处理出来的数据分布不对,模型效果偏差。
实际的建议是:如果第一句是query、第二句是document,那第一句通常不能丢太多关键信息,可以考虑only_second截断;如果两个句子等权重,用longest_first就行。
4.2 padding策略:不要无脑padding到model_max_length
# 自动padding到本batch内最长句(训练时比较高效) encoded = tokenizer.encode_plus( texts, padding=True, truncation=True, max_length=128, return_tensors="pt" ) # 统一padding到固定长度(批处理比较方便,但浪费算力) encoded = tokenizer.encode_plus( texts, padding="max_length", max_length=128, truncation=True, return_tensors="pt" )很多人图省事,一上来就padding="max_length",把每个样本都padding到512。这么干一个batch里全是无效计算,训练速度会慢不少,尤其是序列平均长度只有几十的时候。
更优的做法是padding=True,只pad到batch内最长句,配合DataLoader里的动态padding。这样每个batch的序列长度接近实际需要,不会浪费算力。
我自己的习惯是:训练阶段用padding=True+collate_fn动态padding;推理阶段如果对时延要求不高,可以用padding="max_length",因为推理时的shape固定,方便批量处理。
4.3 动态batch padding的实际代码
网上教程很少讲这个,但实际项目中这是最常用的写法。给你一个可以直接抄的collate_fn:
import 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, padding=True, truncation=True, max_length=128, return_tensors="pt" ) return { "input_ids": encoded["input_ids"], "attention_mask": encoded["attention_mask"], "token_type_ids": encoded["token_type_ids"], "labels": labels }用的时候直接丢给DataLoader:
from torch.utils.data import DataLoader dataloader = DataLoader(dataset, batch_size=32, collate_fn=collate_fn)这样可以保证每个batch的输入都是一样长的,而且不会无脑padding到512。
5. 从ID到文本:decode、skip_special_tokens与batch_decode
5.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_tokens=True,就把[CLS]和[SEP]去掉了:
decoded = tokenizer.decode(encoded, skip_special_tokens=True) print(decoded) # 输出: unhappiness注意,decode不是简单地把token拼起来,它会做一些后处理,把##前缀的token和前面的token合并在一起,还会处理单词间的空格。这也是为什么你自己查表拼接的结果和decode对不上的原因。
5.2 batch_decode:批处理时的高效解码函数
处理一批结果时不要用for循环一个个decode,直接用batch_decode:
batch_ids = [ [101, 1045, 2293, 102], [101, 2023, 2003, 1037, 2817, 102] ] decoded_texts = tokenizer.batch_decode(batch_ids, skip_special_tokens=True) print(decoded_texts) # 输出: ['i love', 'the is a cat']批处理时,batch_decode内部会做一些优化,省去你写循环的功夫,代码也更干净。此外它内部的skip_special_tokens参数默认是True,但我建议你显式写出来,让别人读代码的时候不用去猜默认值。
5.3 生成任务中decode的实际应用
在文本生成任务中,模型输出的是整个词汇表上的概率分布,你需要先argmax或采样得到token ID序列,然后再decode:
generated_ids = model.generate( input_ids, max_length=50, do_sample=True, top_k=50, top_p=0.95, num_return_sequences=3 ) # generated_ids shape: (3, seq_len) for i, ids in enumerate(generated_ids): text = tokenizer.decode(ids, skip_special_tokens=True) print(f"生成结果 {i+1}: {text}")这里有个小经验:生成结果的input_ids是包含[CLS]和[SEP]的,如果你不做任何处理直接拼接,最终文本中间会出现[CLS]和[SEP]这样的特殊Token字符串。所以生成任务里基本都要加skip_special_tokens=True。
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加进去没有?直接看它的ID:
print(tokenizer.convert_tokens_to_ids("[E1]")) # 输出: 30522 (假设原来的词表是30522,那新token就是从这开始编号)这个技巧在做关系抽取、实体识别时特别有用,很多论文里的实体标记就是这么实现的。
6.2 处理英文缩写和标点
英文里don't、can't这类单词,WordPiece有自己的一套逻辑:
tokens = tokenizer.tokenize("I can't do it") print(tokens) # 输出: ['i', 'can', "'", 't', 'do', 'it']注意它会把can't拆成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)的词表是基于字的,而不是基于词的。这意味着每个中文字符一般就是一个token:
tokenizer_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_len=510, stride=128): """滑窗切分长文本,返回多个token序列""" encoded = tokenizer.encode(text, add_special_tokens=False) 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"第{i+1}段长度: {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导致的“静默截断”
有次我用的数据里有文本特别长,truncation=False,结果模型遇到超过512的序列时直接报错。后面我设置truncation=True,但忘传max_length,它默认截断到tokenizer的model_max_length=512。这个行为不是报错也不是警告,属于静默截断。等你发现数据分布不对时,可能已经跑了很多轮了。
所以我的习惯是每次编码都显式传入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_dir=cache_dir)这样更换模型时,不用到处改代码,只需要改一个映射配置。同时也方便团队其他成员统一使用。
9.2 预处理时就把文本清洗干净,而不是让Tokenizer来兜底
Tokenizer的设计目标是高效地把文本转成ID,不是用来清洗脏数据的。把文本清洗(去掉乱码、统一换行、修正编码问题)做在前面,能让Tokenizer的行为更可预测,排查问题也更方便。
我第一次做新闻分类的时候,用爬虫抓的数据里有大量\u3000全角空格、 、\xa0这些肉眼看不到的字符,结果分词结果总有一些奇怪的空token,后来发现是清洗不彻底造成的。
9.3 用tokenzier的返回结果来诊断数据质量
其实Tokenizer本身也能当数据质量检查工具用。比如有一个batch里token长度总是不对劲,你可以把input_ids解码回字符串,看看里面到底是什么:
decoded = tokenizer.decode(encoded["input_ids"][0], skip_special_tokens=True) print(decoded)这个办法看着笨,但比任何统计都直观。尤其是遇到“模型效果突然下降”这种问题,先把进模型的文本还原出来看一看,大多数问题一眼就能发现。
最后再分享一个很实用的小技巧:做完padding之后,如果你发现attention_mask里0的位置对应的input_ids不是0,那说明你的数据管线某个环节出问题了——正常情况下pad token的ID就是0(即[PAD]对应的ID)。这种不一致往往是手写collate_fn时漏了处理导致的。用这个办法排查,比一行行打断点快得多。