ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

transformers模型加词实操:tokenizer与embedding同步扩展指南

transformers模型加词实操:tokenizer与embedding同步扩展指南 从“用不了新词”到“词表自由”一次给 transformers 模型加词典和 embedding 的完整记录做 NLP 这行最烦的其实不是模型结构多复杂而是你辛辛苦苦fine-tune一个 BERT、RoBERTa 或者 Qwen 系列上线前同事甩给你一句“咱们行业那些术语模型都不认识比如‘碳配额’‘转置卷积’‘RAG’这些词会被切得稀碎你处理一下。”这就是今天想聊的核心问题在 Pytorch 生态里用 Hugging Face 的 transformers 库时怎么往 tokenizer 的词汇表里添加新的词语同时正确地把模型的 embedding 层尺寸对齐避免训练直接报错或者效果崩掉。这篇文章不是贴一段add_tokens就完事而是把背后的原理、会遇到什么坑、embedding 初始化怎么做、不同模型系列Bert系列 / GPT系列 / T5系列有什么区别全部拆开揉碎讲清楚。适合两类人看一是刚入门、被“词表大小不一致”报错折磨的新手二是已经跑通但困惑“我加了新词效果怎么反而变差了”的进阶玩家。全程基于 transformers 4.x 和 Pytorch 2.x 实践可以直接抄作业。1. 为什么要给词汇表加词以及加词背后的核心机制1.1 一切从一个“被拆碎的行业术语”说起先别急着开代码我们来复现一个真实场景。假设你手上是一个中文 BERT 模型用BertTokenizer加载。然后你输入一句话“本文提出一种基于RAG架构的图神经网络方法用于解决小样本条件下的语义对齐问题。”标准分词器跑完之后tokenizer.tokenize()的输出大概率是长这个样子的[本, 文, 提, 出, 一, 种, 基, 于, ra, ##g, 架, 构, 的, 图, 神, 经, 网, 络, 方, 法, ...]看到 “ra” 和 “##g” 没好好的“RAG”被拆成了两个 token。这种拆法在通用语料里没什么问题因为 BERT 的 WordPiece 词表只有 21128 个词中文 BERT base 的 vocab_size不可能把全世界所有词都收进去。但在你的垂直领域——比如做科研论文审稿、法律文书解析、医疗病历结构化、游戏NPC对话——就会出问题专有名词被切开语义信息被打散每个 token 都要走一遍 attention序列变长训练变慢更关键的是fine-tune 时模型根本没有针对“RAG”这个完整概念的 embedding 做优化它只有 “ra” 和 “##g” 的 embedding。所以你需要做的就是让 tokenizer 认识这个新词并且让模型对“新词”有一个独立的向量表示。前者查表后者加 embedding两者必须同时完成只做一步都会报错或者白干。1.2 tokenizer 加的到底是“词”还是“token”很多教程把add_tokens叫做“加词”实际上更准确的说法是“加入新的 token”。对于 BERT 这种词表里存的是 WordPiece 子词单元你加的“新词”可能是一个完整词语也可能是你想固定住的短语对于 GPT-2 / LLaMA 这种 BPE 模型tokenizer.add_tokens()加入的是一个特殊的 byte-level token它会强行让 BPE 合并逻辑不去继续拆解这个词对于 T5 系列SentencePiece情况更特殊因为它用的是tokens_to_add这样的参数而且如果你的模型是t5而不是mt5加中文词要非常小心因为原始 T5 词表根本没有中文字符。但不管哪一类tokenizer 层面的操作本质是修改两个映射表token_to_id或类似的词→ID 字典id_to_token或类似的 ID→词 字典。Hugging Face 的PreTrainedTokenizerFast和PreTrainedTokenizer都封装好了这两层结构你不需要手工去改vocab.txt只需要调用接口。1.3 模型侧为什么必须同步改 embedding这一步就是新手最容易漏的。tokenizer 的词表大小变了从 21128 变成 21130假设加了两个词但模型的embedding.weight还停留在原来的[21128, hidden_size]。如果你直接拿这个模型跑 forwardPytorch 会直接抛出类似这样的错误RuntimeError: index out of range: 21128 is out of bounds for dimension 0 with size 21128更隐蔽的情况是你用的是Trainer训练它会在某个 step 才触发 embedding 的查表届时直接崩溃日志还不好排查。所以必须调用model.resize_token_embeddings(new_vocab_size)。这个函数做了两件事在nn.Embedding的weight末尾随机初始化新增的行返回一个新的Embedding对象并且在你的模型里把对应的层替换掉。大部分模型BERT、GPT-2、LLaMA 架构的 transformers 实现都支持这个操作。但也有例外比如某些自定义模型没有实现resize_token_embeddings那就需要手工改model.bert.embeddings.word_embeddings这类属性后面详细说。2. 动手实操从加载模型到完成词表扩充的完整代码2.1 环境说明和依赖版本先交代一下我测试时的环境避免你因为版本差异踩一些无关的坑python 3.10 pytorch 2.1.2 transformers 4.38.2 tokenizers 0.15.2实际上在我的经验里transformers 4.x 任意小版本4.20 以上基本都能跑通本文的代码但有少数几个 API 是旧版本没有的比如tokenizer.add_special_tokens返回值的差异我会在对应位置标注。模块导入import torch from transformers import ( AutoTokenizer, AutoModelForCausalLM, AutoModelForSequenceClassification, )这里有个很关键的细节你加词之前一定要先明确自己用的是“分词器”还是“快速分词器”。Hugging Face 现在默认很多模型会加载tokenizer_class对应的 fast 版本一般类名带Fast后缀。下面讲到的add_tokens在 fast 和 slow 版本上行为完全一致所以你不用太纠结这一点。但如果你是自己训练的 tokenizer 并保存成了tokenizer.json再手动加载就要确认用的是PreTrainedTokenizerFast否则某些方法会报错。2.2 第一步明确你的模型类型和加载方式为了照顾更多场景本文以三个主流模型家族为例模型家族加载用类典型词表大小分词算法BERT (bert-base-chinese)AutoTokenizerBertModel21128WordPieceGPT-2 (gpt2)AutoTokenizerGPT2LMHeadModel50257BPELLaMA (meta-llama/Llama-2-7b-hf)AutoTokenizerAutoModelForCausalLM32000SentencePiece / BPEmodel_name bert-base-chinese tokenizer AutoTokenizer.from_pretrained(model_name) model AutoModelForSequenceClassification.from_pretrained(model_name, num_labels2)注意这里我用了AutoModelForSequenceClassification因为分类任务在实际项目里需求最高。如果你用的是纯BertModel或者AutoModel后续操作完全一致只是最后输出层不同。2.3 第二步准备要加入的新词并区分两类 token要加入的词集合通常来自你的领域语料建议做一个词频统计把高频且被错误切分的词语挑出来。举个例子new_words [碳配额, 碳交易, 碳足迹, 绿电, CCER, VER]这时候要停下来想一个事情这些词是普通 token 还是 special token普通 token比如“碳配额”“绿电”这种它们应该参与 attention 计算在生成/分类时作为正常内容。special token比如[CLS]、[SEP]、[MASK]或者你要新加的[COMPANY]、[USER]这种控制符号它们一般不参与正常文本表示在代码里用add_special_tokens加入。如果你把普通词语用add_special_tokens加进去虽然也能用但会污染整个特殊 token 的语义空间尤其在生成任务里模型会以为这些词是控制符行为会变得非常诡异。正确姿势num_added_normal tokenizer.add_tokens(new_words) print(f新增普通 token 数量: {num_added_normal}) # 新增特殊 token比如自定义一个领域标记 num_added_special tokenizer.add_special_tokens( {additional_special_tokens: [[TRADE], [ENV]]} ) print(f新增特殊 token 数量: {num_added_special})两点提醒add_tokens的参数可以是List[str]也可以是Dict[str, str]。后者可以在加入 token 的同时给它们设定不同的属性但一般用不上。如果某个词已经在词表里add_tokens会自动跳过返回值里不会重复计数。所以放心地传入包含重复词的列表也没问题。2.4 第三步修改模型的 embedding 尺寸这一步是核心也是最容易出错的。old_vocab_size len(tokenizer) print(f旧词表大小: {old_vocab_size}) # 调用 resize一定要用新的 tokenizer 词表大小 model.resize_token_embeddings(old_vocab_size)等一下这里的old_vocab_size其实已经是加入新词之后的长度了。len(tokenizer)是在加入新词之后调用的所以它就是新词表大小。命名别被误导。仔细看源码的话resize_token_embeddings内部会做几步操作获取get_input_embeddings()拿到当前模型的输入 embedding比较当前 embedding 的num_embeddings和传入的new_num_tokens如果不一致构造一个全新的nn.Embedding(new_num_tokens, embedding_dim)把旧的 embedding 权重拷贝到新 embedding 的前min(old_num, new_num)行通过set_input_embeddings()替换模型的输入 embedding。如果模型有输出层比如lm_head它也会尝试同步 resize。但要注意resize 的输出层并不总是对的。比如 GPT-2 的lm_head和 embedding 是权重共享的resize_token_embeddings处理得很好。但某些模型比如 T5的lm_head不共享权重或者名字不叫lm_head这时候你要自己手动去处理model.lm_head的尺寸。怎么判断模型是否需要手动调整输出层最简单的办法是 debug 一下print(type(model.get_output_embeddings()))如果返回的不是None就说明有一个输出 embedding 层。这个时候你最好再手动检查一下它的out_featuresif model.get_output_embeddings() is not None: output_emb model.get_output_embeddings() print(LM head shape:, output_emb.weight.shape)2.5 第四步新词的 embedding 初始化策略重要这一部分才是决定效果的分水岭。resize_token_embeddings默认会对新增的行做随机初始化并且初始化方法遵从nn.Embedding的默认策略也就是标准正态分布乘以一个较小系数。这在大多数情况下是“能用但不够好”的。为什么不够好因为你随机初始化出来的向量跟词表里已有的其他词向量没有语义关系。假设“碳配额”被加入为新 token随机向量意味着模型一开始完全不知道该把它放在语义空间的哪个位置需要靠后续训练慢慢学。如果你的训练数据只有几万条根本学不充分模型可能退化成“记住了但不会用”。针对这个问题我实践下来比较好用的初始化方案有几种按推荐程度排序方案一平均已有子词向量比如“碳配额”在加入前会被切成“碳”“配”“额”三个 token或者“碳”“配额”。把这三个 token 的 embedding 取平均作为新 token 的初始向量。这个思路非常直观且效果稳定。def init_embedding_from_subwords(tokenizer, model, new_words): embed model.get_input_embeddings() with torch.no_grad(): for new_word in new_words: # 用原来的 tokenizer 切分新词 sub_tokens tokenizer.tokenize(new_word) if len(sub_tokens) 1 and sub_tokens[0] new_word: # 说明这个词本来就是一个 token不需要初始化 continue sub_ids tokenizer.convert_tokens_to_ids(sub_tokens) # 平均已有子词的 embedding avg_embedding embed.weight[sub_ids].mean(dim0) # 新词的 token id new_id tokenizer.convert_tokens_to_ids(new_word) embed.weight[new_id] avg_embedding有个细节这里必须用加入新词之前的 tokenizer 来切分。如果你在add_tokens之后再调用tokenize(碳配额)它会直接返回[碳配额]因为新词已经进词表了你反而拿不到子词信息了。所以严谨的做法是先保存一份旧 tokenizer 的切分结果再加新词。# 保存旧切分结果的字典 sub_token_map {} for new_word in new_words: sub_token_map[new_word] tokenizer.tokenize(new_word) # add_tokens 之前调用方案二使用相近词向量初始化如果你的新词在领域词典里有解释或者你能通过其他方式找到语义相近的已有 token可以采用这种方案。比如你加了“绿电”可以拿“风电”“光伏”“能源”这几个词的 embedding 加权平均作为初始值。这个方法效果好但需要人工参与不适合大规模批量加词。方案三直接用resize_token_embeddings的默认随机初始化适合两种情况一是你加的词数量很大几百上千个模型预训练本来就没见过随机初始化等于给模型一个自由探索的空间二是你后续会在大量领域语料上继续 pre-train模型能自己学好。除此之外不推荐。2.6 第五步验证一切是否对齐加完词和 embedding 后我习惯性做三件事来验证# 1. 新词能否被完整编码 test_text 今年公司的碳配额交易量显著上升 encoded tokenizer(test_text) print(tokenizer.convert_ids_to_tokens(encoded[input_ids])) # 期望看到 碳配额 作为一个独立 token而不是 碳 配 额 # 2. 词表大小是否一致 assert len(tokenizer) model.get_input_embeddings().num_embeddings # 3. 新 token 的 embedding 是否是有限值 new_ids tokenizer.convert_tokens_to_ids(new_words) embedding_weights model.get_input_embeddings().weight for wid in new_ids: assert torch.isfinite(embedding_weights[wid]).all()如果三个断言都通过你就可以放心去 fine-tune 了。2.7 第六步保存和离线加载训练完后tokenizer 要保存模型也要保存。注意两者要同步保存并且优先使用save_pretrained而不是直接torch.save。tokenizer.save_pretrained(./my_model_dir/) model.save_pretrained(./my_model_dir/)当别人或者未来的你需要加载这个模型时new_tokenizer AutoTokenizer.from_pretrained(./my_model_dir/) new_model AutoModelForSequenceClassification.from_pretrained(./my_model_dir/)这里有一个容易踩的坑如果你在原来基础上加载预训练权重后又 add_tokens、又 resize最后保存到同一个文件夹里会把新增行是随机初始化这个状态也保存进去。如果你训练得不够充分下次加载后模型表现可能会让你的老板怀疑人生。所以加词后至少要做几十步的 warmup 训练让新 embedding 融入整个表示空间再保存。如果你只是做推理、不打算训练那加新词几乎没有意义因为新 embedding 是随机的模型根本无法对它们给出合理预测。3. 进阶不同模型家族的特殊处理方式和避坑说明3.1 中英文 BERT 系列最稳但要留意大小写和繁体BERT 系的 tokenizer 通常有do_lower_case参数。中文模型一般不分大小写但英文模型默认会把所有字母转小写。如果你要加入“iPhone”这种词最好先确认tokenizer.do_lower_case是 True 还是 False否则你保存的 token 列表里可能会出现两个完全不同但其实应当合并的词。我在做英文金融领域模型时尝试加入 “ESG”“ESG rating”“green bond” 这些词。最开始没注意do_lower_caseTrue结果add_tokens([ESG])加了一个大写版本但实际输入文本 “esg” 还是会被切成 “es” “##g”。改正方式很简单——先对候选词做大小写归一化再根据分词器的处理方式决定加入哪个版本。另外中文词表还有一个“预分词器”的概念。BertTokenizer默认会对中文按字切分而BertTokenizerFast的行为在某些版本上略有差异。为了避免这个差异我一般会显式指定use_fastFalse或者干脆用PreTrainedTokenizerFast自己构建一个统一行为。当然如果你只是做 BERT 中文不需要太担心。3.2 GPT-2 和 LLaMA 系列BPE 合并要小心加完可能不生效GPT-2 和 LLaMA 用的是 BPE 分词算法。BPE 的核心是“合并规则”而不是单纯的词表。也就是说tokenizer.add_tokens([carboncredit])虽然在词表里加了一个词条但实际编码时BPE 会先走一遍 merge rules如果这个新词可以被已有的子词规则合并出来那它根本不会单独被识别为新 token。怎么验证很简单tokenizer AutoTokenizer.from_pretrained(gpt2) tokenizer.add_tokens([carboncredit]) # 如果不做额外操作下面这个输出可能仍然是 [carbon, credit] print(tokenizer.tokenize(carboncredit))这是因为分词器的“新增 token”机制在 fast tokenizer 实现里是把新词直接插到句子的“预分词”阶段而不是 merge rules 阶段。tokenizers库会把你 add 进去的 token 当作一个整体拼成一个“受保护”的词条防止 BPE 继续拆分。所以理论上你加了就有效。但是如果你用的不是 fast tokenizer而是 slow tokenizer比如某些AutoTokenizer在缺少tokenizers库时fallback到 perl 实现add_tokens的实现可能就没那么严谨。我遇到过一次在microsoft/DialoGPT-small上加词tokenize结果完全没变最后查代码发现是 slow tokenizer 的缓存问题。解决办法是强制加载 fast 版本tokenizer AutoTokenizer.from_pretrained(gpt2, use_fastTrue)如果在 LLaMA 系列上还要注意LLaMA 的 tokenizer 是用 SentencePiece 训练的fairseq和transformers都加了特殊的控制符号。手动add_tokens时要小心不要把bos_token、eos_token这些给覆盖了。另外LLaMA 词表默认是 32000你加词后变成 32005之前保存的模型如果是用别的框架比如 vLLM、TGI加载可能不支持动态词表推理服务需要重新编译。这一点在部署时要特别留意。3.3 T5 / mT5 系列坑最多建议慎重对待T5 的 tokenizer 是 SentencePiece它有一个很特殊的地方词表大小由vocab_size参数决定并且分词器里面有个sp_model对象直接add_tokens只能加 special token加普通词经常会报错或者被强制忽略。如果你加的是英文字母、符号SentencePiece 还算友好如果你要加中文词而原始模型是t5-base不含中文语料分词器可能根本不知道中文字符怎么映射因为它的vocab.txt里全是英文字母和特殊符号。我的建议是如果你的任务是中文为主直接用mt5或ByT5它们的词表和 SentencePiece 模型天然支持多语言。如果你确实要在t5上加中文词可以走“扩展 SentencePiece 词表 重训 sp_model”的路线但这已经超出了tokenizer.add_tokens的范畴需要动用sentencepiece库重新训练一个 sp model再把vocab_size调整到新模型大小。这个流程极其容易出问题不推荐在生产环境尝试除非你有一整天的排查时间。3.4 多任务模型和 LoRA 场景下的额外注意点做 LoRA 微调时很多人会问加了新词LoRA 能不能覆盖新 embedding答案是能但不能只靠 LoRA 的 adapter 实现。LoRA 默认只对q_proj、k_proj、v_proj、o_proj等线性层做低秩分解它不修改 embedding 层。所以如果你加了新词直接调用peft库加载 LoRAmodel.get_input_embeddings().weight在推理时是冻结的因为requires_gradFalse新词 embedding 依然是随机初始化状态。解决办法有两条路在 LoRA 之外单独设置 embedding 层可训练embedding_layer model.get_input_embeddings() for param in embedding_layer.parameters(): param.requires_grad True然后在设优化器时把 embedding 参数单独拎出来给一个相对较大的学习率。在加词后先做一小段“embedding warmup”训练只更新 embedding 层然后用save_pretrained保存。之后再加载这份权重去做 LoRA。这是我最推荐的做法相当于把加词和下游任务解耦。如果你用的是transformers.Trainer还可以通过optimizer_grouped_parameters分组把 embedding 参数和 transformer 参数的学习率分开。param_groups [ {params: [p for n, p in model.named_parameters() if word_embeddings in n], lr: 5e-4}, {params: [p for n, p in model.named_parameters() if word_embeddings not in n], lr: 2e-5}, ] optimizer torch.optim.AdamW(param_groups)4. 加词之后训练时容易踩的坑与排查实录4.1 训练时第一个 batch 就崩报CUDA error: device-side assert triggered这个错误几乎 99% 是因为有 token id 超出了 embedding 表长度。但你可能会很困惑明明我resize_token_embeddings了啊这时候要检查的不是 embedding 层而是labels。如果你在做序列标注或者生成任务模型输出的logits维度等于词表大小。如果你的labels里有 token id 超过新的vocab_size计算CrossEntropyLoss时就会触发设备侧断言错误。这种错误最坑的地方在于显存管理机制会让报错信息非常难查甚至你 kill 进程之后显卡显存还被占着。我的排查流程把所有 label 全局扫一遍看最大 id 是否小于len(tokenizer)检查数据预处理时是否用了tokenizer(..., paddingTrue)但没同步更新专门给 label 做的对齐把model.resize_token_embeddings(len(tokenizer))的返回值打印出来确认输出层的out_features也变了。如果是生成模型GPT、LLaMA还要检查model.lm_head.weight.shape是否和model.get_input_embeddings().weight.shape一致。如果一个是 32000 一个是 32005loss 计算一定炸。4.2 加了词但tokenize后还是被拆开这个问题在 fast tokenizer 里较少见因为 fast tokenizer 的add_tokens是立即生效的。但如果你用了 slow tokenizer而且是在同一个进程内反复加载旧权重可能要清缓存tokenizer AutoTokenizer.from_pretrained(model_name, use_fastTrue)如果还是被拆开检查你传的new_words里是不是包含了空格或标点。BPE 和 WordPiece 对空格很敏感carbon credit和carboncredit是两个完全不同的 token。在中文场景下空格不是问题但英文场景里非常常见。还有一个隐藏很深的坑大小写。BertTokenizer默认do_lower_caseTrue时add_tokens(ESG)等于啥也没加因为 tokenizer 内部会把输入先 normalize 成esg。但你打印tokenizer.vocab时又确实能看到ESG这个 key。这就导致一种诡异情况你检查词表里有 “ESG”但真实文本里的 “esg” 还是被拆开。解决方法用 tokenizer 的 normalize 逻辑先处理候选词或者统一用小写。4.3 resize 之后模型表现断崖式下降这是正常的因为你给 embedding 塞了一堆随机向量相当于在模型里引入了噪声。如果只是加了一两个词影响很微弱如果加了上千个词模型整体性能下降会非常明显。解决办法是“加热”准备一段领域语料几万条就够在原有任务上继续预训练或者至少做 Masked Language Modeling / Next Sentence Prediction 之类的自监督训练如果是生成模型用原始语料做因果语言建模时间紧张的话可以只训练 embedding 层固定 transformer 其他层训练若干个 epoch 后再解冻全部层做 fine-tune。我在实际项目里加 20 个金融专属词之后如果不做 warmup分类 F1 会从 0.91 掉到 0.87做了 20 个 epoch 的 embedding-only warmup学习率 1e-4batch size 32语料 3 万条F1 反而涨到了 0.925。这说明新词表不一定是负担只要初始化得当、训练充分它会变成模型的专业知识入口。4.4 多 GPU 分布式训练时的词表不同步如果你用DistributedDataParallel或者deepseed每个进程都会独立加载模型和 tokenizer。你必须在每个进程里都执行add_tokens和resize_token_embeddings不能只在主进程做了再广播。在transformers.Trainer中resize_token_embeddings会在training_args设置正确时自动同步但自定义训练循环中容易漏。漏掉的结果是主进程词表 32005从进程词表还是 32000梯度 allreduce 时 shape mismatch直接报错expected hidden_size 32000 but got 32005。稳妥做法在构造 DataLoader 之前确保每个进程的 tokenizer 和模型都以同样的方式扩展。最保险的方案是# 每个进程都执行这段逻辑 new_words load_my_new_words() tokenizer.add_tokens(new_words) model.resize_token_embeddings(len(tokenizer))不要试图把new_words拼进 config 文件里去动态读取除非你写好了严格有序的加载逻辑。4.5 在线推理服务的兼容性问题很多工业场景不会直接用 transformers 做推理而是导出 ONNX、TorchScript或者用 TensorRT、vLLM、TGI 这些高性能推理引擎。这时候原生的tokenizer.add_tokens和resize_token_embeddings只在训练阶段生效导出时会有各种意外ONNX 导出的词表大小是静态的加词后必须重新导出vLLM 对词表变动不友好有些版本甚至会直接拒绝加载“非原始词表”的 checkpoint如果你用的是 Pytorch 的torch.compileembedding 层尺寸变化可能导致缓存 key 变化重新编译。所以在做工程方案时我的建议是要不要加词应该在项目启动的第一天就决定。如果你觉得未来可能加某些专业词宁可一开始就把它们加进去并做好初始化也不要训练完再改。否则你还要承担换模型、重新量化、重新部署的一系列成本。5. 综合案例一个分类模型在 10 分钟内完成加词与微调光讲理论不落地等于白讲。我再总结一个可以直接复制的最小闭环帮你把前面的知识点串起来。任务是中文 BERT 情感分类需要在原词表基础上加入游戏领域黑话然后做微调。import torch from torch.utils.data import DataLoader from transformers import AutoTokenizer, AutoModelForSequenceClassification, AdamW # 1. 加载模型和 tokenizer model_name bert-base-chinese tokenizer AutoTokenizer.from_pretrained(model_name) model AutoModelForSequenceClassification.from_pretrained(model_name, num_labels2) # 2. 准备新词和旧切分映射 new_words [开黑, 上分, 连跪, 出装, 打野] sub_token_map {} for w in new_words: sub_token_map[w] tokenizer.tokenize(w) # 3. 加入 tokenizer tokenizer.add_tokens(new_words) # 4. resize embedding model.resize_token_embeddings(len(tokenizer)) # 5. 用子词平均初始化新 embedding with torch.no_grad(): embeddings model.get_input_embeddings().weight for w in new_words: sub_ids tokenizer.convert_tokens_to_ids(sub_token_map[w]) new_id tokenizer.convert_tokens_to_ids(w) embeddings[new_id] embeddings[sub_ids].mean(dim0) # 6. 验证 assert len(tokenizer) model.get_input_embeddings().num_embeddings print(tokenizer.tokenize(今天和兄弟开黑三连跪心态崩了)) # 7. 正常做微调 # 这里省略 dataset 构造细节只给出 dtype 层面的建议 inputs tokenizer(今天和兄弟开黑三连跪心态崩了, return_tensorspt) labels torch.tensor([1]).unsqueeze(0) outputs model(**inputs, labelslabels) loss outputs.loss loss.backward()注意第 5 步里先通过sub_token_map拿到了旧切分结果再在add_tokens之后通过tokenizer.convert_tokens_to_ids(w)获取新词的 id。因为新词 id 在add_tokens之后才存在。顺序不能乱。如果你一次性加几百个词建议把初始化函数好好封装一下并且加入日志监控。因为其中可能会有个别词在旧词表里找不到任何子词比如特殊符号这时候mean(dim0)会得到空张量需要跳过或者用随机初始化代替。我第一次跑批量加词时就因为这个空张量问题凌晨两点在服务器上排查了一个多小时最后发现有个词是纯 emojiWordPiece 完全切不了。后来我在代码里加了过滤逻辑如果sub_ids为空就用默认的随机初始化并打日志。这样就不会因为一个脏数据导致整个流程中断。6. 一些额外想跟你分享的经验上面这些内容基本覆盖了“在 Pytorch transformers 里给 tokenizer 加新词和 embedding”的全部关键点。最后分享几个我在多次实战后沉淀下来的个人习惯也许能帮你少走弯路。第一加词之前先看你的语料。不要凭空想象哪些词需要加最好跑一次词频统计把频繁被切成 3 段以上的词拎出来。有时候你会发现真正要加的不是那些很长的专业术语而是像“氪金”“白嫖”“真香”这种网络黑话它们切分后语义损失最严重。第二新词数量不要贪多。WordPiece / BPE 词表本身就是为了控制词表规模和低频词稀疏性而设计的如果你一口气加几千个词embedding 矩阵会显著变大显存占用上升、训练速度下降而且大量低频词占着 embedding 却学不好得不偿失。我的经验是单次增加不要超过词表原规模的 1%2%。如果真的有大量领域词汇建议用更底层的 tokenizer 重新训练词表而不是简单地add_tokens。第三embedding 初始化不是越复杂越好。平均子词向量在大多数情况下已经够用。我试过用 Word2Vec 或 BGE 这类外部模型给新词构造 embedding理论上更科学但实际上因为分布不一致效果反而不稳定。外部 embedding 是静态的和 transformer 内部的上下文表示空间存在领域偏移强行映射进去可能会干扰现有表示。倒不如老老实实用子词平均或者干脆随机初始化让模型自己学。第四提交代码时一定要把“加了哪些词”也提交进配置仓库。我见过太多项目模型训练完了代码里却没有记录词表扩充的逻辑结果几个月后要复现实验谁都不知道当初那 20 个新词是哪来的整个实验没法回归。最简单的做法是在项目里放一个domain_vocab.txt把人工挑选的词语全部写进去训练脚本统一从文件读取。第五学会看 tokenizer 的底层输出。很多人看到tokenizer.encode返回的是一串 id就不去关心中间过程了。但在加词这个场景下我强烈建议你把convert_ids_to_tokens(encode(text))的结果打印出来亲眼确认哪些词被整合了哪些没有。这比看任何文档都有用。某些模型在特殊输入下还会出现 token 重叠的奇怪现象只有把中间结果打出来才能发现。最后再分享一个调试技巧当你觉得一个词加了没效果时把它放到 tokenizer 的 vocab 里用tokenizer.tokenize单独测是一个方法但更让我觉得实用的是在模型 forward 过程中把 embedding 查表结果 dump 出来直接看这条文本经过 embedding 层之后的向量是不是稳定的、是不是和相似词向量的距离足够近。这样你可以非常明确地判断“词加成功了”和“词加得有用”是两码事。加词和 embedding 扩充这件事说难不算难但想真正用出效果、不踩坑还是要把底层机制和业务场景同时吃透。希望这篇长文能帮你省下几个通宵祝模型调参顺利。
返回列表