如果你突然接到一个任务,要用公司内部积累了多年的工单语料训练一个NLP模型来做智能客服、舆情分析或者知识检索,你大概率会遇到一个尴尬的局面:开源的中文RoBERTa预训练模型在通用语料上表现不错,但一旦面对满屏的行业黑话、产品缩写、特殊话术,效果就明显拉胯。这时候,真正管用的思路往往不是直接微调(fine-tuning),而是先在自有语料上做一轮Mask Language Model(MLM)预训练,让模型先“读懂”你这摊语料,再做下游任务。这篇文章我会完整记录我在这类项目里的实操路径,包括数据准备、模型选型、训练参数、代码实现和踩坑记录,给同样需要折腾领域预训练的同学一份可以直接上手的参考。
我默认你已经有基本的PyTorch和Transformers使用经验,能跑通一个简单的文本分类任务,但还没到能徒手改BERT源码的地步。这个定位下,下面的方案会更看重稳定、快速和容易复现,而不是把性能压榨到极致。
1. 整体思路与关键决策:为什么需要一份“领域专属”的MLM预训练
1.1 通用预训练模型和行业语料之间的巨大鸿沟
我先用一个实际例子说清楚问题。之前我在处理一批IT运维工单数据时,里面充满了“宕机”“限流”“CDN回源”“pod重启”“SLB健康检查”这类词。通用中文BERT模型见过“宕机”,也见过“CDN”,但在它的语义空间里,“pod”大概率被切分成了“po”和“d”这种没意义的片段,而“SLB”甚至可能被当成一个生僻词丢掉。
这就导致一个很常见的后果:你拿RoBERTa去微调工单分类任务,模型在训练集上能到95%的准确率,到了验证集就掉到82%,而且bad case分析下来几乎都集中在包含大量专有名词的样本上。原因很简单——模型没有在预训练阶段充分学习这些词的上下文分布,你硬让它在下游微调时“临时抱佛脚”,它当然学不稳。
自己做MLM预训练,本质上就是让模型在“你的语言环境”里重新学会“说话”。这不是锦上添花,而是很多垂直领域NLP项目的必经步骤。
1.2 两种常见路线:全量预训练 vs 继续预训练
很多人一听到“预训练”就以为是要从头训练一个BERT,甚至要复现RoBERTa的百万步训练流程。这是最容易被带偏的地方。对于绝大多数垂直领域场景,你根本不需要从头训练,需要的只是继续预训练(continued pretraining)。
- 全量预训练(Pre-training from scratch):从随机初始化参数开始,在超大语料上训练数百万步,需要几十张GPU跑几周。除非你要研究新模型结构,否则完全不推荐。
- 继续预训练(Continued Pre-training):在开源通用模型(如BERT-base、RoBERTa-base、中文MacBERT)的基础上,用你的领域语料继续做MLM任务。训练几万步,单卡或双卡就能完成。这是目前垂直领域NLP实践中最通用的方案。
继续预训练之所以好用,是因为它保留了通用语言知识,同时把领域知识“注入”到模型参数里。打个比方,一个读过很多书的通才,去医院实习三个月,他就懂医学术语和诊疗逻辑了,但没必要从零开始重新学识字和语法。同理,领域语料存量有限(一般几GB已经算很多),从零训练反而会因为数据不足而学崩。
1.3 什么情况下才值得做这一步
我见过很多团队盲目照搬“领域预训练”流程,结果训完模型,下游任务效果反而变差了。这里有一个前置判断标准:
| 场景 | 是否建议做MLM继续预训练 | 理由 |
|---|---|---|
| 语料<100MB,领域词汇与通用语料差异不大 | 不建议 | 数据量太少,MLM学不到稳定规律,反而可能遗忘通用知识 |
| 语料100MB~10GB,领域有较多专有词汇/句式 | 强烈建议 | 收益最明显的区间,能有效提升下游任务上限 |
| 语料>10GB,算力充足 | 建议,但考虑增量训练 | 数据量大时收益趋于饱和,可以结合增量训练方案 |
| 下游任务本身数据极少(小于1万条) | 谨慎 | 即使做了预训练,下游任务样本不足时,效果提升也比较有限 |
我个人的经验是,如果你手里的领域语料能达到“让一个人类读三个月才能读完”的量级,那做MLM继续预训练大概率是值得的。如果只有几万条短文本,那不如把精力花在数据增强和微调策略上。
1.4 整体流程概览
整个流程可以拆成四个阶段,后面每个章节都对应其中一个阶段:
- 数据准备:清洗、去重、切分、构建MLM训练样本。
- 模型与分词器准备:选择中文预训练模型,决定是否扩充词表。
- 训练实现:基于Transformers框架跑通MLM训练脚本,监控Loss。
- 评估与应用:用Perplexity和下游任务验证预训练效果,决定最终模型。
这个流程里,最容易翻车的其实不是训练代码,而是数据准备。Mask Language Model的训练目标很简单,难的是喂给它的数据质量。所以我先花大篇幅把数据这块讲透。
2. 技术底座:模型、分词器与MLM损失函数的底层逻辑
2.1 模型选型:BERT、RoBERTa还是Electra
继续预训练的第一步,是选一个“底子”模型。中文NLP里主流选择无非这几个:
- BERT-base-chinese:经典选择,分词方式是字粒度(Character-level),词表大小21128。优势是稳定、通用,大量中文模型都从它演进。缺点是静态Mask,训练效率略低。
- RoBERTa-wwm-ext / MacBERT:哈工大讯飞联合发布的模型,用了全词掩码(Whole Word Masking),在中文上效果明显优于原版BERT。我做中文MLM预训练时最常用这个作为底座。
- ELECTRA:用生成器-判别器结构,训练效率高,但由于它的预训练任务不是纯MLM,直接拿来做继续预训练需要额外适配,不太适合入门复现。
- 中文LLM(如ChatGLM、Baichuan):如果你做的是大规模生成任务,可能要考虑这些。但它们做MLM继续预训练的结构适配成本高,且显存消耗大,不在本次分享范围。
我的建议很简单:中文任务无脑选RoBERTa-wwm-ext,英文任务选RoBERTa-base。MacBERT也行,但MacBERT的预训练里引入了纠错任务,继续训练时如果你不改任务头,等价于只做了MLM部分,不够优雅。
从模型结构上看,你不需要修改任何代码——BERT为MLM设计的架构天然支持继续预训练。你只负责给模型喂语料,让它在原有的权重上继续更新。
2.2 中文里必须懂的“全词掩码(Whole Word Masking)”
继续预训练之前,我一直推荐检查分词器是否支持Whole Word Masking(WWM)。这个概念在中文里尤其重要。
标准的MLM做法是:以15%的概率选中一个token(中文里通常是一个字),然后80%概率替换为[MASK],10%概率替换为随机token,10%概率保持不变。但对中文来说,“字”不等于“词”。比如“宕机”是两个token(字),如果只把“宕”掩码住,模型可以通过旁边的“机”字轻易猜出来,因为“宕机”作为一个词共现频率极高。这就让预训练任务变得简单,模型学到的东西就打折扣。
全词掩码的做法是:如果某个字被选中,那它所在整个词的所有字都被掩码。比如“CDN回源”中选中了“回”,则“回源”两个字一起被掩码。这样模型必须真正根据上下文推断,而不是偷看同一词的相邻字。
好在HuggingFace的BertTokenizerFast配合DataCollatorForLanguageModeling,在wwm类模型下默认可以执行全词掩码逻辑。你不需要自己实现,但要在选模型时确认它带wwm标记,并且训练时不要误用了不匹配的Tokenizer。
2.3 MLM的损失函数:为什么只算被Mask位置的Loss
经常有人问我:继续预训练时,模型的损失函数是怎么定义的?是不是跟文本生成一样,每个位置都要算交叉熵?
不是。MLM只对被掩码(Masked)的位置计算交叉熵损失。每一条样本里,被掩码的token大概是15%,也就是说,80%的token在计算梯度时是完全不参与的。这是一个巨大的区别。
用公式来理解,对一个样本序列 ( x = [x_1, x_2, ..., x_n] ),掩码后的序列是 ( x_{masked} )。模型前向得到每个位置的预测分布 ( P(x_i | x_{masked}) ),但损失函数只考虑被掩码位置的集合 ( M ):
[ \mathcal{L}{MLM} = - \sum{i \in M} \log P(x_i | x_{masked}) ]
第一次跑MLM训练的同学,看到Loss很小不用惊讶——因为它只衡量被Mask的15%的位置的预测能力。这也解释了一个现象:MLM Loss一般在1.0~4.0之间波动,很难降到0.5以下。如果一个位置的预测难度低,整体Loss自然就小。比如高频词“的”被掩码时,模型轻松猜对,Loss贡献就很低;而掩码一个冷门机构名“中科寒武纪”时,Loss就会剧烈上升。
2.4 关键超参数:掩码概率与Mask/Replace/Keep的比例
训练脚本里最重要的超参数是mlm_probability,默认0.15。这个值不是拍脑袋定的,而是BERT论文里的遗产。0.15的含义是:每个token有15%的概率成为“候选掩码位置”,在候选位置上再按80/10/10的比例决定具体操作。
| 操作 | 概率 | 目的 |
|---|---|---|
| [MASK]替换 | 80% | 让模型学会根据上下文填空 |
| 随机token替换 | 10% | 让模型学会纠错,缓解[MASK]只在预训练出现、微调阶段消失的gap |
| 保持不变 | 10% | 让模型把注意力放在上下文编码上 |
你可能会问:为什么不让[ MASK ]的比例更高?如果100%都替换成[MASK],模型会过度依赖[MASK]标记,下游微调阶段没有[MASK]时它就“不习惯”。10%随机替换和10%保持不变是对抗这种分布偏移的trick。我们在数据处理里经常说“别把训练和推理的分布搞不一样”,这里就是典型例子。
我自己在垂直领域语料上做测试时,mlm_probability设成0.15是没问题的。只有当语料极短(比如平均长度小于20个词)时,我会升到0.2,因为短句里15%的选择样本太稀疏,模型更新信号不足。
2.5 学习率与优化器:继续预训练不是微调
继续预训练和下游微调有一个重要区别:学习率不能太大。
微调阶段我们常用2e-5到5e-5的学习率,因为此时模型已经收敛,只希望在小范围内调整参数。而继续预训练如果也用5e-5,很容易破坏通用语义表征,导致灾难性遗忘——模型把领域知识学进去了,但把通用能力丢了。
我的经验值:
- 继续预训练:峰值学习率1e-4到2e-4,配合Warmup。用AdamW优化器。
- 如果训练数据领域差异十分大(比如通用中文→古文),可以把学习率降到1e-4以下,减少对原有知识的冲击。
- 如果数据规模较大(>1GB),可以用2e-4,加速领域知识吸收。
这只是经验参考,更科学的方法是跑一个小规模实验,对比不同学习率在验证集Perplexity上的表现,选那个验证Loss最低的。
2.6 词表扩充:什么时候需要,什么时候不要
继续预训练里还有一个高频话题:要不要扩充词表?
开源的中文RoBERTa词表包含21128个token(字、常见词、特殊符号)。如果你的语料里全是专业术语,例如生物信息学里的“碱基对”“逆转录”“CRISPR”,原始词表很可能把这些术语切成一堆单字。模型虽然还是能通过单字组合学习语义,但学习效率和效果会打折扣。
此时你有两个选择:
- 不扩充词表:简单省事,模型通过字级上下文也能学会领域语义,只是路径更长。适合领域词占比不高的场景。
- 扩充词表:用分词器在领域语料上统计高频词片段,往词表里加新token,然后用新词表重新初始化embedding矩阵并继续预训练。收益可能更大,但工程复杂度显著更高。
我的建议:第一阶段先不扩充词表,直接用原始词表做继续预训练。如果下游任务效果始终上不去,再考虑扩容词表。扩充词表需要熟练掌握tokenizer.add_tokens()和模型resize_token_embeddings()的配合,容易踩坑,后面常见问题章节我会展开讲。
3. 数据准备:语料清洗、样本切分与Tokenization实操
3.1 准备好你的语料:格式与清洗规范
MLM预训练不需要标注数据,只需要纯文本。这个“纯”字很关键,我踩过的坑大多来自清洗不彻底。
以工单语料为例,原始数据可能是数据库导出的JSON,里面除了正文,还有工单号、时间戳、操作人、状态字段等。清洗的第一步是只保留正文,并且把HTML标签、URL、特殊符号、乱码全部清掉。如果里面还有客服和客户的对话标记,比如“用户:”“客服:”,我倾向于保留这些前缀——它们是真实业务场景的特征,模型需要学习两者的语言风格差异。
一个标准的数据清洗流程:
- 去重:用哈希或SimHash删除重复文本。重复语料会让模型过拟合到重复片段。
- 非文本内容清理:删除HTML标签、Markdown标记、URL、emoji(或替换为特殊token
<EMOJI>)。 - 噪声过滤:剔除过短的句子(小于5个字)、全数字或全符号的文本。
- 统一格式:全角转半角(中文标点保留)、连续空格压缩、规范化换行。
- 文档切分:如果语料本身就是长文档,建议按段落切割。BERT的最大序列长度通常是512,过长的段落需要截断,过短的段落可以拼接。
清洗规范做得越细,后面训练时模型学到的噪声就越少。我甚至会把“重复字符”也规范化,比如“哈哈哈”压缩成“哈”,因为重复字符的预测难度高,会拉偏Loss。
3.2 数据规模估算:多少语料才够“预训练”
很多第一次做继续预训练的同学会问:我只有500MB文本,够吗?
从经验来看,继续预训练对数据量的要求远低于从零预训练。一张通俗的对照表:
| 数据量 | 可支撑的训练步数 | 预期效果 |
|---|---|---|
| 50MB~200MB | 500~2000步 | 小幅提升,适合快速实验验证 |
| 200MB~1GB | 2000~10000步 | 中等提升,领域词汇有明显改善 |
| 1GB~10GB | 10000~50000步 | 显著提升,模型领域语义基本成型 |
| 10GB以上 | 更多 | 收益递减,需考虑算力成本 |
这里的“步数”还取决于batch size。一个通用公式:总token数 / (batch_size × seq_len) = 每个epoch的step数。通常训练2~3个epoch就够了,再多容易过拟合。
举个例子,你有1GB中文文本,约5亿个token。batch_size=32,seq_len=512,则每个batch包含32×512=16384个token。5亿/16384≈30517步一个epoch。训练1个epoch在当前消费级显卡上大约要跑几天,属于合理范围。如果你只有200MB文本,一个epoch大约6000步,训练量完全可以接受。
3.3 如何把语料“喂”给BERT:Tokenization与Block切分
Text文件不能直接喂给BERT,需要先转成token id序列,并按固定长度切成块。这里有两个常见的坑:
- BERT的
Tokenizer会把每个样本编码成input_ids和attention_mask,但如果你直接把每条文本编码后再单独padding到512,会产生大量无效计算。最优做法是先拼接所有文本,然后按512长度连续切块,不padding。 - 多文档拼接时,需要在两个文档之间加
[SEP]标记,避免模型认为所有文本来自同一篇文章。
使用HuggingFace的datasets库,可以用map函数批量tokenize。下面给一段核心代码:
from datasets import load_dataset from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("hfl/rbt3") dataset = load_dataset("text", data_files="corpus.txt", split="train") def tokenize_function(examples): return tokenizer(examples["text"], truncation=False, add_special_tokens=True) tokenized_dataset = dataset.map( tokenize_function, batched=True, remove_columns=["text"], num_proc=8, ) # 将token序列按block_size=512切分 block_size = 512 def group_texts(examples): concatenated_examples = {k: sum(examples[k], []) for k in examples.keys()} total_length = len(concatenated_examples["input_ids"]) total_length = (total_length // block_size) * block_size result = { k: [t[i : i + block_size] for i in range(0, total_length, block_size)] for k, t in concatenated_examples.items() } return result lm_dataset = tokenized_dataset.map(group_texts, batched=True, num_proc=8)这段代码的核心逻辑是:先把语料全部tokenize成id序列,然后切块。group_texts函数会在一个batch内部把所有样本的token id“拍扁”再切成固定长度块,确保每个样本长度都是512,不需要padding,训练效率最高。
要注意的是,add_special_tokens=True会在每段文本首尾加上[CLS]和[SEP],多个文档拼接时模型能感知边界。如果你用的是group_texts的方式,不同文档的边界信息会被块切分模糊化——这其实是BERT预训练的标准做法,模型会逐渐学会[SEP]位置的语义,不必过度担心。
3.4 动态掩码(Dynamic Masking)为什么对继续预训练更重要
原版BERT在数据预处理阶段就固定了掩码位置,训练时每个epoch看到的掩码方式一模一样,这被称为静态掩码(Static Masking)。静态掩码的缺点是模型容易“背题”——同一个位置的掩码反复出现,模型记住了答案而没学到上下文。
RoBERTa引入的改进是动态掩码(Dynamic Masking):每次喂给模型数据时,重新随机生成掩码位置。这样同一个句子在多个epoch中会以不同的掩码形式出现,相当于数据量变多了。
在HuggingFace里,动态掩码不是通过数据集预处理实现的,而是通过DataCollatorForLanguageModeling在每batch生成时动态执行。所以后续训练脚本里,data_collator是MLM预训练的“灵魂组件”。如果你遗漏了它,模型训练时会因为没有掩码标签而直接报错。
4. 实操过程:用Transformers在自己的语料上跑通MLM预训练
4.1 环境准备与依赖安装
开始之前,先准备好环境。我的建议版本组合(避免踩兼容性坑):
transformers>=4.30.0 datasets>=2.12.0 tokenizers>=0.13.0 torch>=2.0.0 accelerate>=0.20.0安装命令很简单:
pip install transformers datasets tokenizers accelerate如果你有GPU,请确保CUDA可用:
import torch print(torch.cuda.is_available()) # True print(torch.cuda.get_device_name(0))没有GPU也可以跑通,但速度会非常慢。MLM继续预训练哪怕只跑几千步,也强烈建议用GPU。如果你只有CPU,可以先把数据量缩小到50MB做流程验证,再提交到GPU服务器上跑正式版。
4.2 构建DataCollator:MLM预训练的心脏
接下来是训练前最重要的一步——构建DataCollatorForLanguageModeling。它会在每个batch里动态做mask,并生成对应的labels。
from transformers import DataCollatorForLanguageModeling data_collator = DataCollatorForLanguageModeling( tokenizer=tokenizer, mlm=True, mlm_probability=0.15, )这段代码会让collator对每个batch的input_ids做以下操作:
- 随机选15%的token作为候选掩码位置。
- 其中80%替换为
tokenizer.mask_token_id。 - 10%替换为词表中的随机token id。
- 10%保持原id不变。
- 生成
labels张量,被掩码位置记录原始token id,其余位置为-100(交叉熵损失自动忽略)。
labels里用-100填充非掩码位置,是因为PyTorch的CrossEntropyLoss默认忽略ignore_index=-100。这一机制让你不需要手动写mask loss函数。
如果你用的是BertTokenizerFast且模型是wwm类型,DataCollatorForLanguageModeling会自动执行全词掩码,无需额外配置。这里再强调一次:确保tokenizer和model来自同一个模型版本,别搞混。用RoBERTa-wwm的tokenizer配BERT的模型,词表id对不上,Loss会直接飞掉。
4.3 加载预训练模型与配置训练参数
选择底座模型后,加载方式如下。这里以rbt3(RoBERTa-wwm-ext-small)为例,适合快速验证;如果你的语料量大且算力充足,可以换成hfl/chinese-roberta-wwm-ext(base版)。
from transformers import AutoModelForMaskedLM, AutoTokenizer model_name = "hfl/rbt3" # 小模型,适合验证流程 tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForMaskedLM.from_pretrained(model_name) # 查看参数量 total_params = sum(p.numel() for p in model.parameters()) print(f"Total parameters: {total_params / 1e6:.2f}M")AutoModelForMaskedLM会自动加载BERT的MLM head(一个带权重的分类层),这个head在预训练时是必须的。如果你加载的是AutoModel而不是AutoModelForMaskedLM,你会发现模型没有lm_head,没办法计算MLM损失——这是新手最常见的错误。
4.4 训练脚本:使用Trainer实现完整的MLM预训练
HuggingFace的Trainer把训练循环封装得很干净,适合大多数场景。下面给一份完整可跑的脚本:
from transformers import Trainer, TrainingArguments training_args = TrainingArguments( output_dir="./mlm_output", overwrite_output_dir=True, num_train_epochs=2, per_device_train_batch_size=16, per_device_eval_batch_size=16, gradient_accumulation_steps=2, learning_rate=1.5e-4, warmup_steps=1000, weight_decay=0.01, logging_dir="./logs", logging_steps=100, save_steps=2000, eval_steps=1000, evaluation_strategy="steps", save_total_limit=2, fp16=True, # 仅在GPU支持时开启 dataloader_num_workers=4, ) trainer = Trainer( model=model, args=training_args, train_dataset=lm_dataset["train"], eval_dataset=lm_dataset["validation"], data_collator=data_collator, tokenizer=tokenizer, ) trainer.train()参数解释:
per_device_train_batch_size=16:如果显存不足(12GB以下),降到8或4。gradient_accumulation_steps=2:相当于把实际batch size翻倍到32,稳定训练。learning_rate=1.5e-4:继续预训练经验值,不建议一上来就设2e-4。warmup_steps=1000:前1000步学习率线性增长,避免训练初期剧烈震荡。eval_steps=1000:每1000步跑一次验证集,监控Loss。save_total_limit=2:只保留最新两个checkpoint,避免磁盘爆掉。
有一点值得说明:MLM继续预训练的参数量虽然和微调一样,但它的优化目标更“简单”——只需要让模型在掩码位置输出正确的词。所以收敛速度远快于从头预训练。你往往能在几千步内看到验证Loss明显下降。
4.5 想用自定义掩码策略?看这里
虽然DataCollatorForLanguageModeling够用,但如果你有特殊需求(比如不想掩码某些特殊字符、或者想让某些领域关键词拥有更高掩码概率),可以继承类改写。
比如,我曾在工单语料中希望提高产品名的掩码概率,因为产品名是下游分类的关键特征。做法是自定义一个“加权掩码”的collator:
import torch from transformers import DataCollatorForLanguageModeling class WeightedDataCollatorForLanguageModeling(DataCollatorForLanguageModeling): def __init__(self, tokenizer, mlm_probability=0.15, high_freq_ids=None): super().__init__(tokenizer=tokenizer, mlm_probability=mlm_probability) self.high_freq_ids = high_freq_ids or [] def torch_mask_tokens(self, inputs, special_tokens_mask=None): labels = inputs.clone() probability_matrix = torch.full(labels.shape, self.mlm_probability) # 提高关键token的掩码概率 for token_id in self.high_freq_ids: probability_matrix[inputs == token_id] = min(self.mlm_probability * 3, 0.6) # 其余逻辑与父类一致 masked_indices = torch.bernoulli(probability_matrix).bool() ...这种做法适合领域特征极为集中的语料。实操中我建议先在标准mlm_probability=0.15上跑通,再考虑加权方案,避免一开始就引入太多变量。
4.6 训练时间估算与checkpoint选择
在我常用的单卡V100(32GB)上,训练一个小型rbt3模型,batch_size=16、seq_len=512,速度大概是每秒4~5步。如果语料对应6000步一个epoch,训练2个epoch需要约3000秒,一个小时内搞定,非常轻量。
如果是bert-base级别(参数量约110M),同样卡上每秒约2~3步,同样的量需要2~3小时。如果你的资源很紧张,可以先拿rbt3跑通全流程,确认效果方向正确后再用base模型做正式实验。
保存checkpoint时,我建议保留训练完成后的最终模型即可。Trainer会自动保存最后一个epoch的权重,你可以在output_dir里找到pytorch_model.bin和config.json。
4.7 生成训练集时的Poisoning风险:别让模型看到Future Labels
这里有一个容易被忽略的细节:如果你是在有大字段的数据库里导出的语料,比如“订单标题+商品描述+售后结论”,你在清洗时如果不小心把“售后结论”也当成正文包含进去,模型预训练时就会“看到”未来信息(结论),这不仅不影响MLM训练本身,反而会制造一种假象——下游任务(比如预测退款风险)场景下,模型在测试时拿不到结论字段,效果会崩得很难看。
所以清洗时一定要保留字段边界:只保留模型下游使用时“已知”的信息。比如做工单分类,正文和用户描述可以喂给MLM,但“最终处理结果”绝不能喂进去。这个原则叫作“不要让你的预训练语料泄露未来标签”。
5. 评估与调优:从Loss到下游任务效果的全链路验证
5.1 如何判断预训练是否有效:验证集Loss与Perplexity
训练过程中的Loss曲线是最直接的反馈。MLM Loss在继续预训练场景下,通常会在前几百步快速下降,然后进入缓慢下降的平台期。如果你发现Loss不降反升,那说明学习率可能过大,或者数据清洗有问题。
有了验证集Loss,就可以算Perplexity(PPL),直觉理解是“模型预测答案时的困惑程度”:PPL越低,说明模型对语料的掌握越好。
[ PPL = \exp(\text{Loss}) ]
举个例子,Loss=2.0时,PPL≈7.4,意味着模型在候选词中选择正确答案的平均困难度是7.4选1。Loss=1.5时,PPL≈4.5,显著优于随机猜测(中文词表是21128,随机猜的PPL约等于词表大小)。继续预训练的目标是把PPL从初始值(比如30~50)压到5以下,理想情况接近领域内语言模型的正常水平。
不过别盲目追求极低Loss。如果你的Loss降到0.8以下,PPL≈2.2,那可能是模型“背”下了语料中的常见搭配,对下游任务反而不一定有利。预训练的目标是学到可泛化的语义,不是记住语料。
下面是一份我在工单语料上跑出来的典型曲线参考:
| 训练步数 | Loss(验证集) | PPL | 备注 |
|---|---|---|---|
| 0 | 4.2 | 66.7 | 初始通用模型在领域语料上的表现 |
| 500 | 2.8 | 16.4 | 快速学习阶段 |
| 1500 | 2.1 | 8.2 | 领域词开始被“理解” |
| 3000 | 1.8 | 6.0 | 语言规律趋于稳定 |
| 6000 | 1.6 | 5.0 | 平台期,继续训练收益变小 |
这类曲线只能在“领域语料”上计算。如果在通用语料上做验证,Loss通常比这低很多,因为通用模型本来就在那类数据上训练过。
5.2 下游任务验证:预训练效果到底有没有用
Loss下降还不够,你最终要回答的问题是:训练出的模型在真实任务上有没有变强。
最稳妥的验证方式是选一两个下游任务做A/B对比:
- Baseline模型:直接用原始预训练权重做微调,得到指标A。
- 继续预训练模型:用你的MLM checkpoint做微调,得到指标B。
- 对比指标A和B:如果B显著优于A,说明继续预训练有价值;如果差距不大,说明领域语料与通用语料差异不大,或者数据量不够。
我做过一次实验:在IT工单多分类任务上,原始RoBERTa的F1是82.3,MLM继续预训练后F1到85.1,提升了近3个点。在情感分析任务上,提升相对小,只有0.8个点,因为情感表达与领域差异相关性弱。
建议你固定种子(例如seed=42),多次运行取平均,避免把随机波动当成真实提升。微调阶段用同样的超参数和epoch数,否则无法归因。
5.3 检查模型真的学到了什么:用Fill-Mask做定性验证
定量指标之外,还有一个非常直观的定性验证方法——直接用模型的fill-mask能力测试领域知识。
from transformers import pipeline fill_mask = pipeline("fill-mask", model="./mlm_output", tokenizer="./mlm_output") # 工单场景 result = fill_mask("服务器 [MASK] 导致服务不可用,需要立即处理。") for item in result: print(f"{item['token_str']}: {item['score']:.4f}")如果模型在[MASK]位置预测出“宕机”“死机”“重启”等高相关词,说明领域语义确实被吸收了。如果还是预测出“故障”“问题”这种通用词,说明领域词汇还没充分学习,可以继续训练或增加数据。
5.4 训练日志分析:一个案例拆解
我在训练一次电力行业语料时,把训练日志拉出来看,发现一段有意思的过程:
- 第200步时,验证Loss是3.4,PPL约30。
- 第600步时,Loss降到2.4,模型明显在“拼命学”行业词。
- 第1200步后,Loss降速变慢,进入平台期。
这提示我:文本里大量电力专业词汇(“继电保护”“变电站”“负荷预测”)正在被模型消化。之后我用fill-mask测试,输入“线路[MASK]导致跳闸”,模型预测“故障”概率0.72,“过载”概率0.11,“短路”概率0.08,已经能给出行业相关性很高的结果。
从这类日志里,你能很直观感觉到“模型正在进入你的领域”,这种反馈比任何指标都安慰人。
6. 常见问题与排查技巧实录
6.1 问题速查表
我把实操中遇到的典型问题整理成一个速查表,方便你对照排错。
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| Loss不下降或升高 | 学习率过大 / 数据清洗不干净 / 模型与tokenizer不匹配 | 降低学习率至5e-5,检查语料是否有大量重复噪声 |
| 训练时出现NaN | 学习率过高 / 梯度爆炸 / fp16精度问题 | 调低学习率,关闭fp16,或增加梯度裁剪(max_grad_norm=1.0) |
| 显存不够(OOM) | batch_size过大 / seq_len过大 | 降低batch_size,开启gradient accumulation,或缩短seq_len到256 |
| Tokenizer编码乱码 | 全角半角混用 / 未清洗特殊字符 | 清洗时统一转半角,过滤无法解码的bytes |
| 下游任务反而变差 | 过度训练导致灾难性遗忘 / 微调数据量太少 | 减少预训练步数,采用更小学习率,或跳过后直接微调 |
| 模型输出的[MASK]位置全是unk | 词表没有覆盖领域词,且未扩充 | 考虑扩充词表,或用原始tokenizer不额外训练 |
| 验证集PPL很低但下游没提升 | 模型“死记硬背”了语料常见搭配,语义泛化不足 | 增加语料多样性,加强清洗去重,降低训练epoch |
6.2 坑一:忘记给[PAD]之外的token做掩码保护
某些token不应该参与掩码,比如[CLS]、[SEP]、[PAD]。如果你在自定义collator时没有拒绝这些特殊token,模型可能会把[CLS]掩码掉,导致预训练目标包含了无意义的内容。
DataCollatorForLanguageModeling内部已经通过special_tokens_mask处理了这个问题,但如果你自己写collator,务必记得:
special_tokens_mask = [ tokenizer.get_special_tokens_mask(val, already_has_special_tokens=True) for val in labels.tolist() ]然后把special_tokens_mask对应的位置排除在掩码候选之外。
6.3 坑二:block_size设置太大导致长文本语义割裂
如果语料以短文本为主(比如工单日志、对话片段),把block_size设为512会导致多个文本被拼接进同一个块,模型很难区分不同样本的边界,也可能学到“跨样本”的错误依赖。
解决思路:根据语料平均长度动态调整block_size。如果平均长度只有128,那就用128作为块大小,保证每个块大致对应一个完整样本。短文本拼接时,多利用[SEP]做边界标记,效果会更好。
6.4 坑三:扩充词表后忘记resize_token_embeddings
如果你按2.6节的思路扩充了词表,调用了tokenizer.add_tokens(new_tokens)之后,必须同步调整模型embedding层:
model.resize_token_embeddings(len(tokenizer))如果不做这一步,模型输出层的维度(vocab_size)与词表长度不一致,前向传播时直接报维度不匹配错误。而且新加的token embedding是随机初始化的,需要额外训练一段时间才能“适应”模型,所以扩充词表后训练步数要适当增加。
6.5 坑四:数据文件名或格式不一致
用datasets读取语料时,如果文本文件编码是GBK,而load_dataset("text")默认按UTF-8读取,会直接报错或读出乱码。正确做法是提前统一编码为UTF-8:
iconv -f GBK -t UTF-8 corpus_raw.txt > corpus_utf8.txt另外,如果语料文件很大(超过几个GB),建议load_dataset时用num_proc并行读取,避免单进程耗时太久。
6.6 坑五:用fp16训练时Loss出现诡异波动
开启fp16=True可以在V100/A100上加速训练,但半精度训练在某些显卡和数据分布下会出现Loss震荡或NaN。如果你观察到Loss曲线剧烈抖动,先关掉fp16跑几百步看是否恢复稳定。如果确认是fp16导致的,可以考虑用bf16=True(Ampere架构及以上支持),bf16的动态范围比fp16大,稳定性更好。
6.7 训练完无法加载checkpoint
如果你保存的checkpoint目录里只有pytorch_model.bin、config.json和optimizer.pt,但缺少tokenizer文件,当你用AutoModelForMaskedLM.from_pretrained(checkpoint_dir)加载时,模型能加载,但tokenizer必须单独加载。
解决办法:保存时最好把tokenizer也存到同一目录:
model.save_pretrained("./mlm_output") tokenizer.save_pretrained("./mlm_output")之后加载:
model = AutoModelForMaskedLM.from_pretrained("./mlm_output") tokenizer = AutoTokenizer.from_pretrained("./mlm_output")这条经验很基础,但我在多个项目里看到同事栽在这上面。
7. 进阶扩展:从MLM到更多自监督信号的迁移路径
跑通MLM继续预训练后,你可能会发现一个自然的追问:除了MLM,还有没有其他自监督任务可以在领域语料上继续训练?
有。最常见的两个方向是:
- Sentence Order Prediction(SOP):ALBERT使用的任务,交换句子顺序让模型判断是否打乱。对长文档语义理解更有帮助。
- Token-level Contrastive Learning:类似SimCSE的思路,让模型在一个batch内区分相似与不相似的token表示。这需要额外构造正负样本,实现成本更高。
但从实践性价比来看,MLM仍然是垂直领域继续预训练的第一选择,因为它实现简单,效果稳定,且和下游微调的适配度最高。如果你把MLM跑到平台期,效果依然不够,再考虑叠加其他损失。
另外,如果你最终要做的是生成任务,也可以考虑在通用LLM上做“增量预训练”,方式与MLM类似,只是损失函数换成了自回归交叉熵。两者的数据准备流程高度重合——清洗、切块、去重这些经验完全能复用。
我想强调的是:自监督预训练本质上是在“压缩语料中的信息”。你提供的语料质量越高、领域特性越清晰,MLM能压缩出的有效知识就越多。这也是为什么我一直强调数据清洗比模型调参更重要的原因。
8. 写在最后:关于一套可复用的判断框架
跑过完整的MLM继续预训练流程之后,我现在拿到一个新领域项目,会先用这套判断框架快速决策:
- 通用预训练模型在这个领域语料上的PPL是否明显偏高(比如大于20)?如果是,继续预训练大概率有收益。
- 领域语料是否有100MB以上的干净文本?如果没有,先去做数据积累,别急着训练。
- 下游任务是否对领域词汇敏感?分类、NER、语义相似度、检索这些任务都高度依赖领域语义,继续预训练收益明显;纯情感打分类任务收益相对有限。
- 算力是否允许跑至少3000步?如果只够跑500步,效果可能不明显,不如直接微调。
这套框架帮我避免了很多“为了训练而训练”的无效工作。希望你读完这篇文章后,不仅掌握了MLM继续预训练的代码细节,也能建立起一个更清醒的判断标准:预训练是手段,不是目的;领域语料是燃料,但也要用之有度。
最后再分享一个小技巧:训练完成后,别急着把模型部署到生产环境。先用一个小型的下游任务评测集(几百条标注样例就够)对比原始模型和继续预训练模型在真实场景上的差异。这个“最小可行性验证”只要半天时间,却能帮你避免把一个大但无效的模型推到线上。毕竟,模型参数再多,不如在真实数据上“懂行”来得重要。