【15】Transformers快速入门:添加自定义 Token
课程模块:扩展你的词汇表——添加自定义 Token
目标: 掌握如何向预训练模型的分词器和词表中添加新的 Token(包括普通 Token 和特殊 Token),理解其对模型的影响,并学会正确调整模型权重。
核心概念: 自定义 Token (Custom Tokens), 特殊 Token (Special Tokens), 词表 (Vocabulary), Token Embedding, add_tokens()
, add_special_tokens()
, resize_token_embeddings()
1. 为什么需要添加自定义 Token?—— 当模型“不认识”你的标记时
想象一下,你给模型一个任务:“找出文本里的汽车和隧道实体”。你想用特殊的标记把它们框起来,比如:
"Two [ENT_START] cars [ENT_END] collided in a [ENT_START] tunnel [ENT_END] this morning."
你期望模型看到 [ENT_START]
和 [ENT_END]
就知道:“哦!这里标记了一个实体!”
但现实很骨感:
from transformers import AutoTokenizertokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
sentence = 'Two [ENT_START] cars [ENT_END] collided in a [ENT_START] tunnel [ENT_END] this morning.'
print(tokenizer.tokenize(sentence))
# 输出:['two', '[', 'en', '##t', '_', 'start', ']', 'cars', '[', 'en', '##t', '_', 'end', ']', ...]
发生了什么?
- 分词器把
[ENT_START]
无情地切成了'[', 'en', '##t', '_', 'start', ']'
6个碎片! - 模型看到的是一堆毫无意义的字符片段,根本理解不了你想标记实体的意图。
- 模型词表里压根没有
[ENT_START]
和[ENT_END]
这两个 Token!
这就是问题所在: 预训练模型的词表是固定的。它不认识你自定义的特殊标记(如实体标记、领域术语、新表情符号等)。如果强行使用,这些标记会被错误地切分,导致模型无法理解你的意图。
添加自定义 Token 的必要性:
- 引入特殊功能标记: 如
[ENT_START]
,[ENT_END]
,[DOMAIN_SPECIFIC]
。 - 添加领域专业词汇: 如医学名词
"Deoxyribonucleic acid"
(DNA) 可能被切分,不如直接添加"DNA"
作为一个 Token(如果它足够高频且重要)。 - 处理新出现的词汇: 如网络新词、品牌名、产品名。
核心目标: 让分词器和模型“认识”并“理解”你的新 Token!
2. 如何添加自定义 Token?—— 两种武器:add_tokens
和 add_special_tokens
Transformers 库提供了两种主要方法:
-
武器一:
tokenizer.add_tokens(new_tokens_list)
- 作用: 向词表添加普通的新 Token。
- 参数: 一个包含新 Token 字符串的列表
["token1", "token2", ...]
。 - 特点:
- 新 Token 会被添加到词表末尾。
- 新 Token 会参与分词器的标准化(Normalization)流程(如小写化、Unicode 规范化等)。
- 适用于添加领域词汇、新名词等。
checkpoint = "bert-base-uncased" tokenizer = AutoTokenizer.from_pretrained(checkpoint)new_words = ["cybersecurity", "NFT", "metaverse"] # 假设这些不在原词表中 num_added = tokenizer.add_tokens(new_words) print(f"Added {num_added} new tokens!") # 输出:Added 3 new tokens!# 检查词表大小是否增加 print(f"Original vocab size: 30522, New vocab size: {len(tokenizer)}") # 输出:New vocab size: 30525
-
武器二:
tokenizer.add_special_tokens(special_tokens_dict)
- 作用: 向词表添加特殊的新 Token。
- 参数: 一个字典,键必须是预定义的特殊 Token 类型,值是对应的 Token 字符串。例如:
special_tokens_dict = {"cls_token": "[MY_CLS]", # 替换或添加 [CLS] Token"sep_token": "[MY_SEP]", # 替换或添加 [SEP] Token"pad_token": "[PAD]", # 通常已有,但可以确保或修改"unk_token": "[UNKNOWN]", # 通常已有,但可以确保或修改"mask_token": "[MASK]", # 通常已有,但可以确保或修改"additional_special_tokens": ["[ENT_START]", "[ENT_END]", "[DOMAIN]"] # 添加全新的特殊Token }
- 特点:
- 新 Token 会被添加到词表末尾。
- 新 Token 不会或较少参与分词器的标准化流程(例如,通常不会被小写化)。
- 添加后,可以通过属性直接访问这些 Token(如
tokenizer.cls_token
,tokenizer.additional_special_tokens
)。 - 适用于添加具有特殊功能的标记(如实体标记、任务控制标记)或替换/确保核心特殊 Token。
checkpoint = "bert-base-uncased" tokenizer = AutoTokenizer.from_pretrained(checkpoint)# 添加全新的实体标记作为 "additional_special_tokens" special_tokens_dict = {"additional_special_tokens": ["[ENT_START]", "[ENT_END]"]} num_added = tokenizer.add_special_tokens(special_tokens_dict) print(f"Added {num_added} special tokens!") # 输出:Added 2 special tokens!# 现在分词器认识它们了! sentence = 'Two [ENT_START] cars [ENT_END] collided in a [ENT_START] tunnel [ENT_END] this morning.' tokens = tokenizer.tokenize(sentence) print(tokens) # 输出:['two', '[ENT_START]', 'cars', '[ENT_END]', 'collided', 'in', 'a', '[ENT_START]', 'tunnel', '[ENT_END]', 'this', 'morning', '.']
关键区别:标准化 (Normalization) 行为!
add_tokens
(普通 Token): 会经过分词器的标准化流程(如小写化、清理空白、Unicode 处理等)。add_special_tokens
(特殊 Token): 通常会被视为“字面字符串”,标准化处理较少或没有。例如,在bert-base-uncased
(不区分大小写)分词器中:
结论: 对于你希望保持原样(尤其是大小写)的功能性标记(如# 使用 add_tokens 添加 tokenizer.add_tokens(["[NEW_tok1]"]) print(tokenizer.tokenize('[NEW_tok1]')) # 输出:['[new_tok1]'] (被小写了!)# 使用 add_special_tokens 添加 (作为 additional_special_tokens) tokenizer.add_special_tokens({"additional_special_tokens": ["[NEW_tok2]"]}) print(tokenizer.tokenize('[NEW_tok2]')) # 输出:['[NEW_tok2]'] (保持大写!)
[ENT_START]
),务必使用add_special_tokens
并放入additional_special_tokens
中!
避免重复添加:
new_tokens = ["[ENT_START]", "[ENT_END]"]
# 方法1:检查是否已在词表中
new_tokens = [tok for tok in new_tokens if tok not in tokenizer.get_vocab()]
# 方法2:使用 set 操作 (更简洁)
new_tokens = list(set(new_tokens) - set(tokenizer.vocab.keys()))if new_tokens:tokenizer.add_special_tokens({"additional_special_tokens": new_tokens})
3. 让模型“认识”新 Token —— 调整 Embedding 矩阵
重要警告:仅仅修改分词器是不够的!
- 分词器负责:文本 -> Token ID。
- 模型负责:Token ID -> Embedding Vector (词向量) -> 后续计算。
当你通过 add_tokens
或 add_special_tokens
增加了词表大小(比如从 30522 增加到 30524),模型的 Embedding 矩阵(model.embeddings.word_embeddings
)还是原来的大小(30522 x 768)。新 Token 的 ID(30523, 30524)在模型的 Embedding 矩阵里没有对应的位置!
解决方案:model.resize_token_embeddings(len(tokenizer))
from transformers import AutoTokenizer, AutoModelForSequenceClassificationcheckpoint = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
model = AutoModelForSequenceClassification.from_pretrained(checkpoint)# 1. 添加新 Token (这里是特殊Token)
new_special_tokens = ["[ENT_START]", "[ENT_END]"]
tokenizer.add_special_tokens({"additional_special_tokens": new_special_tokens})# 2. 关键一步:调整模型 Embedding 层大小!
old_embedding_size = model.get_input_embeddings().weight.size(0)
model.resize_token_embeddings(len(tokenizer)) # 传入新的词表大小
new_embedding_size = model.get_input_embeddings().weight.size(0)print(f"Embedding size changed from {old_embedding_size} to {new_embedding_size}")
# 输出:Embedding size changed from 30522 to 30524
resize_token_embeddings
做了什么?
- 扩容: 创建一个新的、更大的 Embedding 矩阵(例如 30524 x 768)。
- 复制: 将原来 30522 个 Token 的 Embedding 向量原封不动地复制到新矩阵的前 30522 行。
- 初始化: 为新添加的 Token(第 30523 和 30524 行)随机初始化 Embedding 向量(通常是从某个分布中采样,比如正态分布)。
- 替换: 将模型的旧 Embedding 层替换为这个新的、扩容后的 Embedding 层。
查看新 Token 的 Embedding:
# 获取新 Token 的 ID (通常在词表末尾)
ent_start_id = tokenizer.convert_tokens_to_ids("[ENT_START]")
ent_end_id = tokenizer.convert_tokens_to_ids("[ENT_END]")# 获取 Embedding 层权重
embeddings = model.get_input_embeddings().weight# 查看新 Token 的 Embedding (最后两行)
print("Embedding for [ENT_START]:", embeddings[ent_start_id, :5]) # 看前5维
print("Embedding for [ENT_END]:", embeddings[ent_end_id, :5])
# 输出示例 (每次运行值不同,因为是随机初始化的):
# Embedding for [ENT_START]: tensor([-0.0123, 0.0456, -0.0789, 0.1234, 0.0001], grad_fn=<SliceBackward>)
# Embedding for [ENT_END]: tensor([ 0.0987, -0.0321, 0.0567, -0.0456, 0.0123], grad_fn=<SliceBackward>)
重要提示:随机初始化意味着什么?
- 模型在初始时对这些新 Token 没有任何语义理解。它们的 Embedding 是随机的乱码。
- 要让模型理解这些新 Token 的含义,必须进行后续训练(微调)!
- 在微调过程中,模型会根据你标注的任务数据(例如,实体识别任务中标记了
[ENT_START] cars [ENT_END]
是实体),调整这些新 Token 的 Embedding,同时也调整模型的其他参数,最终让模型学会这些新 Token 的语义和功能。
4. 完整流程与最佳实践
添加自定义 Token 并调整模型的正确步骤:
- 加载分词器和模型:
tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased") model = AutoModelForSequenceClassification.from_pretrained("bert-base-uncased") # 或其他任务模型
- 定义要添加的新 Token:
- 普通词汇:
new_regular_tokens = ["cyberattack", "phishing"]
- 特殊标记:
new_special_tokens = ["[ENT_START]", "[ENT_END]", "[REVIEW]"]
- 普通词汇:
- 添加 Token 到分词器:
# 添加普通 Token tokenizer.add_tokens(new_regular_tokens) # 添加特殊 Token (强烈推荐这种方式添加功能标记) tokenizer.add_special_tokens({"additional_special_tokens": new_special_tokens})
- (可选但推荐) 调整模型 Embedding 层大小:
model.resize_token_embeddings(len(tokenizer))
- 注意: 对于
AutoModelFor...
类模型(如AutoModelForSequenceClassification
),直接调用resize_token_embeddings
是安全的,它会自动找到并调整输入 Embedding 层。对于自定义模型结构,需要确保调整了正确的 Embedding 层。
- 注意: 对于
- (必须) 保存修改后的分词器和模型 (如果需要后续使用):
tokenizer.save_pretrained("./my_custom_tokenizer/") model.save_pretrained("./my_custom_model/")
- (必须) 进行模型微调 (Fine-tuning):
- 使用包含新 Token 的训练数据对模型进行训练。
- 模型会在训练过程中学习新 Token 的 Embedding 表示及其在任务中的作用。
最佳实践总结:
- 优先使用
add_special_tokens
添加功能标记: 确保它们保持原样(如大小写),并通过additional_special_tokens
添加。 - 添加后务必
resize_token_embeddings
: 让模型为新 Token 腾出位置。 - 理解新 Token Embedding 是随机初始化的: 模型一开始不认识它们,必须通过后续训练(微调) 来赋予它们意义。
- 检查词表: 使用
tokenizer.vocab
或tokenizer.get_vocab()
查看 Token 是否成功添加及其 ID。 - 利用分词器属性: 对于通过
add_special_tokens
添加的核心特殊 Token(如自定义的cls_token
),使用tokenizer.cls_token
访问更安全。对于additional_special_tokens
,使用tokenizer.additional_special_tokens
。 - 考虑 Token 的重要性: 不要随意添加大量低频词。优先添加高频、关键的功能标记或核心领域术语。
课程总结:
- 问题: 预训练模型的词表固定,不认识自定义 Token(特殊标记、新词、术语)。
- 解决方案:
- 扩展分词器词表:
tokenizer.add_tokens(list)
:添加普通 Token(会标准化)。tokenizer.add_special_tokens(dict)
:添加特殊 Token(推荐用于功能标记,较少标准化)。
- 扩展模型 Embedding 层:
model.resize_token_embeddings(new_vocab_size)
(new_vocab_size = len(tokenizer)
)。
- 扩展分词器词表:
- 关键后果:
- 新 Token 的 Embedding 是随机初始化的。
- 必须通过后续微调 (Fine-tuning) 让模型学习新 Token 的含义和功能。
- 核心原则: 分词器和模型的词表大小必须同步更新!只改分词器不改模型(或反之)会导致错误。
提示:
- 实验对比: 加载一个 BERT 分词器。分别用
add_tokens
和add_special_tokens
(放入additional_special_tokens
) 添加同一个 Token (如"[TEST]"
)。调用tokenizer.tokenize('[TEST]')
观察输出有何不同?为什么? - 观察 Embedding: 完成添加 Token 和
resize_token_embeddings
后,多次运行代码,打印出新 Token 的 Embedding 向量(如取前5个值)。观察它们是否变化?这说明了什么? - 验证流程: 尝试只添加 Token 到分词器 (
add_special_tokens
),但不调用model.resize_token_embeddings
。然后用这个分词器编码一个包含新 Token 的句子,并尝试将input_ids
送入模型。会发生什么错误?(提示:IndexError) - 思考: 假设你要做一个医学文本分类任务,需要添加 100 个高频医学术语作为普通 Token。添加后直接使用模型进行预测(不微调),效果会好吗?为什么?如何改进?