先说个很多人都有的困惑:辛辛苦苦看了几天迁移学习的理论,什么源域、目标域、特征分布都背得滚瓜烂熟,结果一打开 IDE,看着pip install transformers之后那堆报错,人直接傻掉。尤其是当我看到“迁移学习的python代码”“哪个版本的pytorch和cuda支持transformers==3.4.0”这种搜索词频繁出现在技术社区里时,我就知道,真正卡住大家的不是原理,而是落地那一哆嗦。
这篇文章我就以 Transformers 库为主线,把“预训练+微调”这条最主流的迁移学习落地路线彻底讲透。不绕弯子,不堆术语,从环境版本怎么配、三种微调方式怎么选、到文本分类的完整可跑代码,再到调试过程中那些让人抓狂的坑,挨个过一遍。适合刚接触 NLP 迁移学习的学生、想快速验证效果的算法工程师,以及准备在业务里引入预训练模型但被环境劝退的朋友。看完之后你至少能独立跑通一个微调实验,并且知道每一步在干什么、为什么这么干。
1. 先搞懂迁移学习到底在解决什么问题
1.1 从零训练和迁移训练的核心差异
很多人第一次接触迁移学习的时候,脑子里冒出的第一个问题是:我直接拿数据训练一个模型不就行了吗?为什么非要搞“预训练+微调”这套?这个问题问得非常关键,它决定了你是否真的理解迁移学习的价值。
从零训练一个深度模型,意味着所有的知识都只能从你手头这批标注数据里学。假设你要做一个中文评论情感分类任务,攒了 5 万条标注数据,这听起来已经不少了吧?但对模型来说,它要从零开始学习汉字怎么写、词语怎么组合、句子结构长什么样,更要理解“这部电影让我哭笑不得”这种复杂情绪的微妙之处——5 万条数据根本不够学,模型很快会过拟合,在测试集上的表现惨不忍睹。
而迁移学习的思路则完全不同。我们先用海量无标注文本(比如几十 GB 的网页、书籍、百科)预训练一个语言模型,这个模型已经学会了词法、句法、语义甚至一部分世界知识。然后在下游任务上,我们只需要在这个“基础能力很强的大模型”基础上,用小规模标注数据做“定向训练”,也就是微调,把手头的任务知识接上就行。预训练相当于读了十二年书,微调相当于入职前一个月的岗位培训,两者完全不是一个量级的工作量。
这里我还想补充一个概念辨析,因为最近的搜索热词里总有人把“直推式迁移学习”和常规微调混在一起。经典的微调属于“归纳式迁移学习”,源域和目标域任务不同但数据分布相似,模型学到的是泛化能力。而直推式迁移学习更典型的是领域自适应场景,源域有标注、目标域只有无标注样本,模型需要在测试时对目标域样本特别处理。在 Transformers 库语境下,我们日常接触到的“微调”基本都是归纳式迁移学习的落地方案,这个定位先清楚,后面才不会迷糊。
1.2 为什么“预训练+微调”成了事实标准
其实在 Transformers 库出现之前,NLP 领域也尝试过不少迁移方案,比如 ELMo 那种只拿预训练词向量做特征输入的方案,再比如把预训练模型的输出拼接到下游模型上的做法。但都有一点“隔着靴子挠痒痒”的感觉,因为预训练模型内部那些蕴含丰富语义的层次化特征,并没有真正参与下游任务的训练。
BERT 这类基于 Transformer 架构的模型出现后,情况彻底变了。Transformer 的 encoder 结构天然适合做各种 NLP 任务的底座,它的每一层 self-attention 都能捕捉到不同粒度的语言特征,从词法到句法再到语义。于是 BertForSequenceClassification、BertForQuestionAnswering 这种“模型主体 + 任务头”的组合方式就成了标准范式——把预训练模型当作骨架,在它上面挂一个适合具体任务的输出层,微调的时候整个骨架和输出层一起训练。Transformers 库把这些封装得极其利落,三五行代码就能加载一个预训练模型,再配一个 Trainer 类,数据处理、训练循环、评估流程全都标准化了。
有人担心“整个模型一起训练”会不会让预训练能力被冲掉,这正是微调这个动作的精妙之处。实际训练中,下游数据量相对于预训练语料来说很小,学习率也压得很低(通常 2e-5 这个量级),模型只会做“温和的调整”,不会发生灾难性遗忘。这就是迁移学习落地中最核心的哲学:用大规模通用知识做底座,用小规模领域知识做修正,两边都不耽误。
2. 环境选型:PyTorch、CUDA 与 Transformers 版本的真·匹配指南
2.1 3.4.0 这个经典版本的前世今生
“哪个版本的pytorch和cuda支持transformers==3.4.0”,看到这个搜索词我一下就乐了。如果你是从网上找了一份老教程,大概率会碰上这个版本。Transformers 3.4.0 是 2020 年末发布的版本,当时对应的生态大致是 Python 3.6-3.8、PyTorch 1.5-1.6、CUDA 10.1-10.2。后来 PyTorch 升级到 1.8+、CUDA 升级到 11.x 之后,老版本 Transformers 的某些依赖约束开始出现兼容性问题,最典型的就是tokenizers库的编译版本对不上。
我个人的建议是:如果是全新开始的项目,没必要死守 3.4.0。直接装新版的 transformers,比如 4.x 系列,接口更规范、bug 修复更多、社区支持也更好。但如果你确实需要复现一个基于 3.4.0 的旧项目,那么环境组合我的经验是:
| 组件 | 我的推荐版本组合 |
|---|---|
| Python | 3.7 或 3.8 |
| PyTorch | 1.6.0 |
| CUDA Toolkit | 10.2 |
| 显卡驱动 | 440.33 以上 |
这个组合是我实测过最稳的,transformers==3.4.0 在 requirements 里写的torch>=1.0.0,理论上 1.6 完全满足,而 CUDA 10.2 对 20 系显卡的支持非常成熟,兼容性和稳定性都好。如果你的显卡是 30 系及以上,就得换个思路了,因为 30 系显卡需要 CUDA 11.x 以上,这时候要么升级 transformers 版本到 4.4.0 以上,配合 PyTorch 1.8.1 + CUDA 11.1,要么就得找别的替代方案。
2.2 手把手锁定一套不出错的环境
在这里我直接推荐一套“大众化、踩坑最少”的组合,适用于绝大多数文本分类、命名实体识别、文本匹配等微调任务:
- Python 3.9 或 3.10
- PyTorch 2.0.1(对应 CUDA 11.7 的预编译包)
- transformers 4.31.0
- datasets 2.14.3
- accelerate 0.21.0
- peft 0.5.0(后面讲 LoRA 微调要用)
安装命令我习惯这么写,因为它把依赖顺序和版本都限制住了:
conda create -n finetune python=3.9 -y conda activate finetune # 安装对应 CUDA 11.7 的 PyTorch pip install torch==2.0.1 torchvision==0.15.2 torchaudio==2.0.1 --index-url https://download.pytorch.org/whl/cu117 pip install transformers==4.31.0 datasets==2.14.3 accelerate==0.21.0 peft==0.5.0 # 可选,用于指标计算 pip install scikit-learn evaluate装完之后,一定要在 Python 里跑一次这个测试,确认 GPU 真正可用:
import torch print(torch.__version__) print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0))如果torch.cuda.is_available()返回 False,先别急着去改 transformers,大概率是 PyTorch 装成了 CPU 版本,或者 CUDA 驱动对不上。可以用nvidia-smi看驱动支持的 CUDA 版本,然后去 PyTorch 官网挑对应的安装命令。这一步搞不定,后面跑再好的代码都是纸上谈兵。
2.3 版本组合背后的依赖逻辑
我在帮别人排查环境问题的时候发现,很多人其实是被“哪个版本的pytorch和cuda支持transformers==3.4.0”这种问题带偏了思路。版本兼容不是看某个库单独要求什么,而是看依赖树上所有包的综合约束。比如说 transformers 3.4.0 底层依赖tokenizers==0.10.1,这个版本的 tokenizers 在编译时对absl-py、protobuf等库有隐性要求,如果你装了一个过新的 protobuf(比如 4.x),就会出现“import tokenizers 时直接段错误”这种让人崩溃的问题。
所以锁环境的时候,我的习惯是:先锁 PyTorch 和 CUDA,再装 transformers,然后让 pip 自动解析依赖,最后再单独固定训练相关的包版本。顺序反了或者中间乱插版本,很容易把依赖树搅成一锅粥。另外,强烈建议用 conda 管理 Python 环境,每次实验都新建独立环境,脏了直接删掉重建,比在那里折腾回滚强一百倍。
3. 三种主流微调方案对比:全量微调、Freeze 微调与 LoRA 微调
3.1 从“改全部”到“改一丢丢”的思路演进
“全量微调、freeze微调以及lora微调”这三个词最近在社区里讨论度极高,因为大模型时代显存就是金钱,谁都不想为了一个简单任务租 8 张 A100。我把这三种方式用一个生活化的类比讲清楚。
全量微调就像你要装修整间屋子,承重墙、水管、电路全都要重新弄一遍,效果最彻底,但工程量和成本也最大——它的显存开销包括模型参数、梯度、优化器状态(Adam 会额外存一阶和二阶动量),再加上前向传播的激活值,一个 1.5B 参数的模型光用 fp16 训,显存就往 20GB 上走了。
Freeze 微调则是把整栋楼的主体结构锁死,只装修某一层。实际操作中,我们把 BERT 的 embedding 层和前 11 层 transformer 层的参数全部冻结,只训练最后一层和分类头,这样反向传播时被冻结层无需计算梯度,显存占用直接砍半以上,训练速度也明显加快。缺点也明显:底层特征完全不动,如果下游任务和预训练领域差异较大,效果会打折扣。
LoRA 微调就更有意思了,它不动原模型的任何参数,而是在某些层旁边加装一个“小型旁路模块”——用两个低秩矩阵来模拟参数的更新量。训练的时候只更新这个旁路,原模型参数连梯度都不算。用数学语言说,原本需要更新的参数量是 d×d,LoRA 把它拆成 d×r 和 r×d 两个矩阵,当 r 远小于 d 时,训练参数量缩减成原来的几十分之一甚至几百分之一。一个 7B 模型的 LoRA 微调,可能只需要不到 1% 的参数。
3.2 显存占用量化估算与对比
为了让这个对比更直观,我以 BERT-base(1.1 亿参数,约 440MB 存储)为例,给一个工程上的估算:
| 微调方式 | 可训练参数量 | 训练阶段显存需求(batch size 8,序列长度 128) | 消费级显卡能否训练 |
|---|---|---|---|
| 全量微调 | 110M | 约 8-10GB | 需要 16GB 或以上 |
| Freeze(冻结前 11 层) | 约 7.7M | 约 4-6GB | 8GB 可以 |
| LoRA(rank=8) | 约 0.5M | 约 3-5GB | 6GB 可以 |
这里要说明一下,显存估算不只是参数本身的体积。全量微调的显存大头在优化器状态和激活值上:fp16 参数占 220MB,梯度同理占 220MB,Adam 优化器在混合精度下还需要维护 fp32 的模型参数副本和两个动量状态,大约 1.3GB,再加上自动求导过程中保存的中间激活值,那才是真正的“隐形内存杀手”。所以我常跟朋友说,不要只看参数量,要按“参数量 × 2(梯度)+ 参数量 × 12(优化器状态)+ 激活值”这个粗粒度公式去估算,至少心里有个底。
LoRA 之所以能省显存,本质上是因为它把“梯度”和“优化器状态”这两块大头砍掉了绝大部分。对于大模型微调,尤其是 Qwen、Llama 这类动辄几十亿参数的模型,LoRA 几乎成了默认选项。我在用peft库给 Qwen 做 LoRA 微调的时候,rank 取 8、alpha 取 16、只把 attention 层的 q_proj 和 v_proj 注入 LoRA,训练参数量只有原模型的 0.2% 左右,一张 24GB 的 3090 就能跑得很舒服。
3.3 到底怎么选:项目需求驱动的决策框架
讲完原理和参数,很多人会问:那我做项目到底用哪种?我的建议是按这三个维度来决策。
数据集规模与领域相关度是首要因素。如果下游标注数据很少(几千条),领域和预训练语料比较接近,那么全量微调容易过拟合,LoRA 反而是更稳的选择,因为它限制了模型的更新幅度。如果数据量中等(几万条),且任务对细节敏感(比如专业领域实体识别),全量微调或 Freeze 都有机会拿到更好的效果。如果数据量很大,那就放开手脚做全量微调,甚至可以考虑用更大规模的基座模型。
硬件资源是现实约束。只有 8GB 显存,那就基本告别全量微调 BERT 了,LoRA 或者 Freeze 是唯一选择。训练效率也要考虑,Freeze 因为冻结了大部分层,反向传播只算很少一部分梯度,速度大约是全量微调的 1.5-2 倍;LoRA 虽然反向传播的计算图还是要走,但优化器更新量极小,速度也很可观。
我自己在大部分中小心愿场景下的经验排序是:LoRA 优先,Freeze 兜底,全量微调只在数据量大且硬件充足时用。这不仅仅是显存问题,更是工程效率问题。LoRA 训练出来的低秩矩阵只有几十 MB,部署时可以独立保存,随意切换任务而不影响底座模型,这在多任务并行的业务场景下简直是降维打击——底座模型只需一份,任务旁路各存各的,换来换去也就几秒钟。顺带一提,现在像 LlamaFactory 这类工具已经把 LoRA 微调的流程封装到“改配置就能训”的程度,如果你懒得写训练循环,直接用它的界面把数据集和模型路径填进去,跑起来基本无脑。像一些云平台提供的托管微调服务,底层也是这套思路,只是帮你把环境运维也省了。
4. 实战:用 Transformers 微调一个中文情感分类模型
4.1 准备一份可以直接上手的数据集
实践出真知,我这里选一个最经典的任务:中文情感二分类。为了让大家能立刻上手跑通,我直接基于一份小型示例数据来演示,字段就是text和label两列,label 为 0 表示负面、1 表示正面。
数据量很小没关系,我们的核心目标是跑通整个微调闭环,理解了流程之后你自然知道怎么替换成自己的业务数据。建议把数据整理成 CSV 文件,格式如下:
text,label 这个电影特效很棒,剧情也很紧凑,1 剧情太拖沓了,看了半小时就想睡觉,0 演员演技在线,非常推荐,1 逻辑混乱,观感太差,0然后用datasets库的load_dataset方法读入。注意,load_dataset('csv', data_files='train.csv')在版本 2.x 里返回的是一个 DatasetDict 结构,包含train字段。为了方便演示,我会做一次train_test_split,留出 10% 的数据做验证集。
4.2 加载预训练模型与分词器
这里有一个新手非常容易踩的坑:直接用 BertForSequenceClassification 的默认权重随机初始化,然后拿小数据硬训。这等于抛弃了迁移学习最核心的预训练权重,退回从零训练。正确做法是先指定一个预训练模型名称,比如bert-base-chinese,让 Transformers 自动下载权重。
from transformers import AutoTokenizer, AutoModelForSequenceClassification model_name = "bert-base-chinese" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForSequenceClassification.from_pretrained(model_name, num_labels=2)AutoTokenizer和AutoModelForSequenceClassification是 Transformers 库里的自动派发接口,你只要给出模型名称,它们会自动匹配对应的分词器和模型结构。这也是我说“新版 transformers 省心”的原因,老版本里你可能还要自己去 importBertTokenizer、BertForSequenceClassification,换一个模型就要改一堆代码。
对中文任务,分词器会自动按字切分(BERT 的中文模型用的是字级别词表),所以不需要你额外做 jieba 分词,直接传原始中文文本就行。这一点跟很多人的直觉相反,但确实如此:中文 BERT 是字符级建模,字和字之间天然隔开,模型通过 attention 机制自己学会词语边界。
4.3 数据预处理:Tokenize 与标签映射
这一步是整个流程里最容易出 bug 的部分,但它非常机械。核心逻辑是:把原始文本转成模型能吃的input_ids、attention_mask,再加一个labels字段。
def preprocess_function(examples): # 这里 tokenizer 会自动做 padding 和截断 encoded = tokenizer( examples["text"], truncation=True, max_length=128, padding="max_length", ) encoded["labels"] = examples["label"] return encoded encoded_dataset = dataset.map(preprocess_function, batched=True)这里三个参数的作用要理解清楚:truncation=True会把超过 128 个 token 的文本截断;max_length=128是序列统一长度;padding="max_length"则是把短文本补齐到 128。为什么要统一长度?因为 GPU 上的 tensor 要求同批次内所有样本形状一致,不统一长度就无法并行计算。
但这里我要提醒一个很多教程不会讲的性能细节:用padding="max_length"其实是很浪费的做法。如果大部分文本都很短,只有少数长文本,那么所有样本都会被打到 128 长度,造成大量[PAD]token 的无效计算。更高效的做法是padding=True,让 tokenizer 按当前 batch 内最长文本动态补齐。配合DataCollatorWithPadding,可以做到每个 batch 只有少量填充,训练速度快 20% 都不稀奇。
from transformers import DataCollatorWithPadding data_collator = DataCollatorWithPadding(tokenizer=tokenizer, padding="longest")还有一个隐藏的坑:bert-base-chinese的 tokenizer 没有设置pad_token,默认是[PAD]实际上它是有的,但某些模型没有。保险起见,可以在加载后检查:
if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token # 或手动设为 "[PAD]"如果 pad_token 没设对,训练时 loss 会一直不收敛,而且损失值飘忽不定,排查半天才发现是 mask 的问题。
4.4 配置训练参数并启动微调
接下来就是重头戏:用 Trainer 接口启动训练。Transformers 库的Trainer类把训练循环、梯度累积、学习率调度、日志记录等全都封装好了,你只需要提供一个TrainingArguments配置对象。
from transformers import Trainer, TrainingArguments training_args = TrainingArguments( output_dir="./results", # 输出目录 num_train_epochs=3, # 训练轮数 per_device_train_batch_size=16, # 单卡 batch size per_device_eval_batch_size=32, learning_rate=2e-5, # BERT 微调经典学习率 warmup_ratio=0.1, # 前 10% 训练步数做学习率预热 logging_steps=50, eval_strategy="epoch", # 新版参数名,旧版本是 evaluation_strategy save_strategy="epoch", load_best_model_at_end=True, metric_for_best_model="eval_loss", fp16=True, # 混合精度训练,省显存加速 save_total_limit=1, ) trainer = Trainer( model=model, args=training_args, train_dataset=encoded_dataset["train"], eval_dataset=encoded_dataset["test"], tokenizer=tokenizer, data_collator=data_collator, ) trainer.train()这里的learning_rate=2e-5是 BERT 微调的标准值,为什么不是 1e-3 这种常规值?因为预训练模型已经收敛到一个比较好的局部最优区域,学习率过大会直接把这个区域震碎,破坏学到的特征表示,业内称之为灾难性遗忘。微调的本质是在原有最优区域附近做小范围游走,所以学习率必须低。
fp16=True是混合精度训练,用半精度计算大幅度减少显存占用和计算量。如果你的显卡不支持 fp16(老显卡),这个参数会导致崩溃,需要删掉。如果你的显卡是 30 系及以上,强烈建议开启 fp16,BERT-base 32GB 的训练显存需求能直接降到 16GB 左右。
训练完成后,保存模型和分词器:
model.save_pretrained("./my_sentiment_model") tokenizer.save_pretrained("./my_sentiment_model")4.5 评估与推理:看看迁移学习到底有没有效果
训练完要验货。用 Trainer 的evaluate()方法可以输出验证集指标,但默认只有eval_loss。想看到准确率,需要自己传一个compute_metrics函数:
import numpy as np from evaluate import load accuracy_metric = load("accuracy") def compute_metrics(eval_pred): predictions, labels = eval_pred predictions = np.argmax(predictions, axis=1) return accuracy_metric.compute(predictions=predictions, references=labels) trainer.compute_metrics = compute_metrics eval_result = trainer.evaluate() print(eval_result)推理阶段更简单,加载保存好的模型,然后走一遍“分词 -> 模型前向 -> argmax”的流程:
from transformers import pipeline classifier = pipeline("text-classification", model="./my_sentiment_model", tokenizer="./my_sentiment_model") result = classifier("这部电影太棒了,强烈推荐!") print(result) # [{'label': 'LABEL_1', 'score': 0.987}]pipeline是 Transformers 库提供的高级封装,它会自动完成 tokenize、模型推理、结果后处理。如果你的数据有自定义标签名,可以在保存模型时通过model.config.id2label设置映射,加载后 pipeline 输出的 label 就更友好。我自己在做业务系统集成时,很少直接调pipeline,更习惯写一个轻量的推理回调,把模型输出和业务规则串起来,但排查问题阶段用它非常方便。
4.6 小数据量的过拟合控制技巧
这里要多说一句:如果你手头的数据只有几百条,直接全量微调 BERT 几乎是必过拟合的。一个行之有效的组合策略是:用较小的 max_length(比如 64)+ 较强的 weight decay + early stopping。Trainer 本身不内置 early stopping,但你可以在训练循环外监控 eval_loss,连续两个 epoch 不降就停止。
另一个很实用的技巧是分层学习率。BERT 底层学到的是通用语言特征,顶层更接近任务特征,所以底层可以给一个更低的学习率,比如 5e-6,顶层保持 2e-5。实现方法不是 Trainer 开箱即有的,需要写一点自定义优化器逻辑:
from transformers import AdamW # 给模型不同层设置不同学习率 optimizer_grouped_parameters = [ { "params": [p for n, p in model.bert.parameters()], "lr": 5e-6, }, { "params": [p for n, p in model.classifier.parameters()], "lr": 2e-5, }, ] optimizer = AdamW(optimizer_grouped_parameters, weight_decay=0.01) trainer.optimizer = optimizer这个技巧在领域差异大的任务上效果尤其明显,比如用通用中文 BERT 去做金融舆情分析,底层学习率压低能让预训练知识保留得更完整。有次我在一个法律文本分类项目里对比过,加了分层学习率之后,F1 直接涨了两个点。
4.7 大模型微调场景下的 LoRA 实操思路
刚才讲的 BERT 是小模型的代表,但最近搜索热词里“lora微调实战教程qwen”明显指向大模型场景。如果你要微调的是一个 7B 甚至 14B 的模型,全量微调基本不用想,重点就是 LoRA。这里我把代码思路也放一下,用peft库非常简洁:
from peft import LoraConfig, get_peft_model, TaskType lora_config = LoraConfig( task_type=TaskType.CAUSAL_LM, # 生成式模型的 LoRA 配置 r=8, # 低秩矩阵的秩 lora_alpha=16, # 缩放系数 target_modules=["q_proj", "v_proj"], # 通常是 attention 里的线性层 lora_dropout=0.05, ) model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen-7B", torch_dtype=torch.float16, device_map="auto") model = get_peft_model(model, lora_config) model.print_trainable_parameters() # 看看可训练参数到底有多少peft库会把原模型参数全部冻住,只给指定的q_proj、v_proj模块注入 LoRA 旁路。r是 LoRA 最核心的超参数,它决定了旁路矩阵的宽度,lora_alpha则是缩放系数,实际更新强度近似于lora_alpha / r再乘以注入规模。经验上r=8或r=16起步,如果任务简单可以更小,如果任务复杂或者数据充足,可以加大到 32、64。target_modules的选择也很有讲究,只注入 attention 的 q 和 v 是最省显存的方案,但知识容量有限;如果显存宽裕,可以加上 k_proj、o_proj,甚至 feed forward 里的gate_proj、up_proj、down_proj,效果会有提升,但训练显存和速度也会相应变化。
LoRA 训练完成后的产物是一个几十 MB 的 adapter 文件,部署的时候可以把它和底座模型合并成一个完整模型,也可以动态加载 adapter 推理。我之前在一个工单分类系统里就是只保存 adapter,底座模型只放一份,多个任务 adapter 轮流加载,整个推理服务的内存开销非常可控。
5. 常见问题与排查技巧实录
5.1 环境类与显存类报错速查
训练微调模型,环境问题占了一半的报错比例。我把实际项目里遇到的高频问题整理成了一张速查表,都是能直接照着干的排查思路,遇到问题先去核对这张表能省至少一个小时的排查时间。
| 报错现象 | 根本原因 | 解决路径 |
|---|---|---|
CUDA out of memory | batch size 过大 / 激活值占用过高 | 调小 batch size;开启fp16;使用gradient_checkpointing;切到 LoRA 方案 |
RuntimeError: Expected all tensors to be on the same device | 数据或模型混用了 CPU 和 GPU | 检查to(device)逻辑;确保batch里的 tensor 都在 cuda;使用 Trainer 时很少出现 |
ImportError: tokenizers ... has no attribute | transformers 和 tokenizers 版本不匹配 | 统一升级到新版:pip install -U transformers tokenizers |
CUDA error: no kernel image available | 显卡架构和 PyTorch 编译的 CUDA 版本不匹配 | 换对应显卡架构的 PyTorch 预编译版本,30 系用 cu11.7,40 系用 cu12 或更高 |
| 训练 loss 不降 | 学习率过大 / 数据未正确 mask / 标签严重错乱 | 检查 tokenizer 的 pad_token;调小学习率到 2e-5 附近;检查标签映射 |
| loss 正常但 eval 不涨 | 验证集分布和训练集不一致 / 指标计算代码有 bug | 重新切分验证集;检查compute_metrics的 argmax 是否在正确维度 |
显存问题是最常见的,很多人一上来就买大卡,其实很多问题用工程手段就能解决。gradient_checkpointing这个功能非常值得关注,它以少量计算换显存,把前向传播时的中间激活值不保存,反向传播时重新计算,轻松省下 50% 以上的激活值显存,代价只是约 20% 的训练变慢。对想在单卡 8GB 上微调 BERT 的场景,开这个开关之后基本都能跑起来。
5.2 数据与训练过程中的经典翻车现场
训练过程中还有几类问题,代码层面一点错都没有,但效果就是不对,这类问题最磨人。
第一个是标签失衡导致的“假收敛”。比如你的数据集里 95% 是正样本,模型什么都不学,一直输出正类,宏观准确率随便就有 95%,但实际对负样本毫无识别能力。这种问题要从数据分布层面解决,比如做类别加权采样,或者在 loss 里给少数类更大权重。我在跑情感分类的时候,会先在验证集上打印分类报告,而不是只看 accuracy。
第二个是微调轮数过多导致的过拟合。BERT 在小数据集上微调,通常 2-3 个 epoch 就已经足够,再训下去验证集准确率不升反降。如果是几百条数据,1-2 个 epoch 就该停了。很多教程为了展示“训练过程”,把 epoch 设成 5 甚至 10,这在教学数据上可能没事,但在你自己的小数据上绝对会过拟合。用load_best_model_at_end=True加上验证集监控,让模型自动选择最优 checkpoint 是更稳妥的方案。
第三个是数据泄漏。切分数据的时候没有 shuffle,或者重复样本被同时分到训练集和验证集,导致验证集损失虚低、线下指标和线上效果完全对不上。这个问题做 NLP 的人最容易忽视,尤其是从数据库导出数据时,可能默认按时间排序,同类别数据聚集在一起,不 shuffle 直接切分,后果很严重。我每次做数据切分之前都会先检查label的分布是否在训练集和验证集中大致一致,再跑dataset = dataset.shuffle()。
最后一个非常隐蔽的坑:你在保存 model 的时候没有同时保存 tokenizer,推理时用了错误的 tokenizer。训练时用的bert-base-chinese,推理时手滑用了bert-base-uncased,词表完全不同,效果直接崩盘。我的习惯是永远成对保存和加载 model 与 tokenizer,并且打一个带时间戳的 tag,防止版本混乱。
6. 写在最后的一些实在经验
整个 Transformers 微调的链路走下来,我的切身体会是这样一句话:迁移学习的理论门槛不高,但只要落到实际项目里,环境、数据、训练策略每一个环节都可能让你卡上半天。这也是我为什么在这篇文章里花了大量篇幅在版本匹配、数据预处理和问题排查上,因为这些内容才是社区搜索热词背后真正的痛点。
如果你现在正准备开始做自己的第一个微调实验,我的建议是:别贪多、别贪大。先用bert-base-chinese配一份几百条的数据,把全流程跑通,感受一下预训练权重带来的“快速收敛”究竟是什么样的体验。然后再逐步尝试 Freeze、LoRA 这些进阶方案,去对比不同方案在效果和资源上的取舍。等你把一个小模型、一整套流程玩熟了,再上大模型微调,你会发现所有思路都是相通的——底座模型换成了 Qwen 或 Llama,训练方式换成 LoRA,但数据处理、训练参数、排查问题的那套方法论完全一样。
最后再分享一个小技巧:每次实验开始时,建议把transformers.__version__、torch.__version__、数据集规模、超参数配置全部打印出来存成一个文本文件。微调这个事非常依赖实验记录,你能记住上一次效果好的完整配置,就等于拥有了最快的模型复现速度。