做迁移学习和 Transformers 微调,我踩过不少坑,也总结出一套能直接上手的路径。这篇文章不讲虚的,全部是实操层面的东西:版本怎么选、数据怎么喂、三种微调方式怎么取舍、训练时监控什么、出了错怎么排查,最后再带一个完整的中文情感分析微调案例。不管你是给学生做演示,还是自己项目里要改造一个开源模型,按这条路走,能少走很多弯路。
1. 先理清思路:迁移学习到底解决什么问题
1.1 为什么讲迁移学习,而不是从头训练
很多初学者容易陷入一个误区:以为做 NLP 任务就是要自己设计网络结构、从头训练一个模型。但实际上,在绝大多数真实场景里,从头训练是没有性价比的。预训练模型(比如 BERT、RoBERTa、Qwen 这些)已经在海量通用语料上学会了语言的基本规律——词法、句法、语义常识、上下文理解——这些能力相当于一个“通用底座”。
迁移学习的核心思想就是:把别人已经训练好的底座拿过来,用你自己的数据做一次“定向改造”。打个生活化的比方,一个已经会开车的司机,换一辆新车只需要熟悉一下方向盘和刹车的感觉,不用重新去驾校学一遍交规。模型也是这样,它在预训练阶段已经耗了大量算力和数据,你只需要用少量标注数据做“微调”(Fine-tuning),把它从“通用模型”变成“你的业务模型”。
这里要特别说清楚一个概念:迁移学习是一个大类,微调是它最常用的实现手段。除了微调,迁移学习还包括特征提取(把预训练模型当固定特征提取器,只改最后的分类头)和直接迁移(严格意义上参数不动、只做输出映射)等。但在 Transformers 生态里,大家讨论最多、落地最容易的就是微调。微调又分成全量微调、Freeze(冻结部分层)微调和 LoRA(低秩适配)等,后面我会逐一展开。
1.2 Transformers 库到底在做什么
Hugging Face 的 Transformers 库,本质上做了一件非常“省事”的事情:把上千个预训练模型的权重、分词器、配置类统一封装成了同一套 API。你不需要关心某个模型是用 TensorFlow 还是 PyTorch 训练的,也不用关心它的词表长什么样,只要用AutoModel系列接口,几行代码就能把模型加载出来。
它的核心抽象有三个:
- Model:模型主体,包含预训练权重。
- Tokenizer:分词器,把原始文本变成模型能读懂的 input_ids、attention_mask 等输入格式。
- Config:模型配置,负责指定层数、隐藏层大小、类别数等参数。
微调的本质是训练过程,训练过程就要涉及优化器、损失函数、学习率调度、数据分批。Transformers 库本身不处理训练循环,它配合Trainer、datasets、evaluate、peft这些生态库,才形成了一条完整的微调流水线。
我见过很多同学一开始就在原始代码里自己写训练循环,结果模型加载对了、数据处理错了,排了半天发现是 padding 方向写反了。我的建议是:先用 Transformers 官方推荐的标准流水线把流程跑通,再根据自己的需求去魔改。
2. 环境搭建与版本匹配:第一步错,后面全废
2.1 版本匹配方法论:PyTorch、CUDA、Transformers 怎样才不会打架
在微调项目里,环境问题占掉的时间比训练本身还多。尤其是老项目,比如热词里面提到的transformers==3.4.0,很多人一装上就报错,报错信息五花八门:tokenizer加载不出来、Trainer参数不兼容、CUDA 版本不匹配等等。
先说结论:新项目不要用 3.x 版本,直接上 4.x 甚至更新的版本。3.4.0 是 2020 年底的版本,那时候的 API 设计和现在差别很大,很多教程里的写法在那个版本下根本跑不通。如果你是因为某些老项目的requirements.txt锁定了这个版本,那么你需要知道,它对应的 PyTorch、CUDA 和 Python 版本组合是比较苛刻的。
根据当时 Transformers 3.4.0 的发布背景,常见的稳定组合是:
| transformers 版本 | 推荐 pytorch | 推荐 CUDA | 推荐的 Python |
|---|---|---|---|
| 3.4.0 | 1.5.0 / 1.6.0 | 10.2 / 11.0 | 3.6 / 3.7 / 3.8 |
| 4.30+ | 2.0.0 / 2.1.0 | 11.8 / 12.1 | 3.8 / 3.9 / 3.10 |
| 4.40+ | 2.2+ | 12.1+ | 3.9 / 3.10 / 3.11 |
注意,这个表格不是随意给的,它遵循一个基本原则:Transformers 的版本越新,对 PyTorch 的要求越高,对应的 CUDA 版本也越高。如果你非要用 3.4.0,我实测可行的组合是torch==1.5.0 + transformers==3.4.0 + cudatoolkit==10.2,这个组合下用BertForSequenceClassification跑基线模型是没问题的。但如果你是第一次接触微调,别在这个旧版本上浪费时间,直接装 4.31 以上的版本,生态最成熟、教程最多、报错也最好搜。
2.2 我的实际安装方案与验证流程
我的新项目环境配置一般是这样的:
conda create -n finetune python=3.10 -y conda activate finetune conda install pytorch torchvision torchaudio pytorch-cuda=11.8 -c pytorch -c nvidia -y pip install transformers datasets evaluate accelerate peft安装完之后,一定不要直接上手训练,先跑一个最小验证脚本,确认环境没问题:
from transformers import AutoTokenizer, AutoModelForSequenceClassification, AutoConfig model_name = "bert-base-chinese" tokenizer = AutoTokenizer.from_pretrained(model_name) config = AutoConfig.from_pretrained(model_name, num_labels=2) model = AutoModelForSequenceClassification.from_pretrained(model_name, config=config) inputs = tokenizer(["我觉得这个电影很好看", "这个产品太难用了"], padding=True, truncation=True, return_tensors="pt") outputs = model(**inputs) print(outputs.logits.shape)如果这一步能输出torch.Size([2, 2]),说明你的 Transformers、PyTorch、CUDA 链路是通的。很多人在这一步就挂了,最常见的原因是tokenizer下载时网络中断,导致缓存文件不完整。解决办法是提前用huggingface-cli download或者设置镜像源把模型下载到本地。
另外强调一点:GPU 不是必须的。如果你只是验证流程、做小批量试验,CPU 也能跑,只是慢。真正训练的时候再租卡或者用实验室服务器也不迟。但要注意,如果你装了 CPU 版 PyTorch 之后再装 GPU 版,经常出现不兼容。最干净的方式是创建独立环境,把 GPU 版本一次装对。
3. 数据与预处理:模型吃不下原始文本
3.1 从原始语料到 Dataset
微调的第一步不是建模型,而是把数据准备好。Transformers 生态推荐用datasets库,它在底层帮我们做好了内存映射、批量处理、缓存,比手动写 DataLoader 方便很多。
我常用的做法是先把数据整理成一个标准的 dialect 格式,再通过datasets加载。比如做中文情感分析,数据可能是 CSV 文件,里面有text列和label列:
from datasets import Dataset, DatasetDict import pandas as pd train_df = pd.read_csv("train.csv") eval_df = pd.read_csv("eval.csv") train_dataset = Dataset.from_pandas(train_df) eval_dataset = Dataset.from_pandas(eval_df) dataset_dict = DatasetDict({"train": train_dataset, "eval": eval_dataset}) print(dataset_dict)这里有个小细节:如果你的原始数据是 JSON 格式,或者每条样本是{"text": "...", "label": 0}的字典列表,直接用Dataset.from_list()会更高效。datasets库会自动识别字段名,后面预处理的时候就不用再手动对列名了。
如果你做的是大模型微调,比如用 Qwen 这类指令模型,数据格式通常是对话结构。常见的做法是每一行包含instruction、input、output三个字段,然后通过一个模板拼成模型的输入。这一点和 BERT 这类模型不一样,BERT 是直接输入一段文本和一个标签,而生成式模型需要构造完整的指令模板。所以你要先搞清楚自己用的模型是“判别式”还是“生成式”,这两种模型的预处理逻辑完全不同。
3.2 Tokenizer 的使用要点:padding、truncation 和 attention_mask
Tokenizer 是微调环节里最容易出错的部分。很多新手直接对整句话做tokenizer.encode(),然后发现不同句子的长度不一样,放进 batch 之后维度对不上。
正确的姿势是使用批量处理函数,并且设置padding=True和truncation=True:
from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese") def preprocess_function(examples): return tokenizer( examples["text"], padding="max_length", truncation=True, max_length=128, return_tensors="pt" ) tokenized_datasets = dataset_dict.map(preprocess_function, batched=True)注意这里padding有几种策略:
padding="max_length":把每一条都 padding 到固定长度(比如 128),缺点是会浪费算力,因为短句子后面全是[PAD]。padding=True:只在 batch 内部 padding 到最长的那条,节省算力,但需要dynamic padding。Trainer内置的DataCollatorWithPadding可以实现这一点。
我的推荐是:在Trainer里用DataCollatorWithPadding,而不是在map阶段就做max_lengthpadding。这样训练效率更高,推理的时候也灵活,因为长度不固定,处理新数据时不需要每次重算。
另外一个关键点是attention_mask。它告诉模型哪些 token 是真实内容,哪些是 padding 出来的。如果你忘了返回attention_mask,模型会把 padding 位置也当作有效内容去算注意力,相当于把一堆“空字符”也纳入语义理解,效果自然会差。Transformers 的tokenizer默认会生成attention_mask,但如果你手动处理数据、直接用input_ids构造 batch,那就要自己补上。
4. 三种微调方法的落地对比:全量、Freeze、LoRA
4.1 全量微调:效果最直接,但显存压力最大
全量微调就是把预训练模型的全部参数都放进训练过程,更新每一层权重。这种方式的优点是模型可以充分适应新任务,在数据量足够的前提下,效果通常是最稳的。缺点是显存开销大,因为优化器需要保存每个参数的梯度、动量等状态。以 BERT-base 为例,参数量 1.1 亿,如果用 AdamW 优化器,每个参数需要 8 字节以上的额外内存(参数本身 4 字节、梯度 4 字节、动量 4 字节、方差 4 字节),这么一算,仅优化器状态就超过 3GB。再算上激活值,一张 11GB 显存的卡做 batch size 16 的 BERT-base 微调都很紧张。
所以全量微调一般适合两种场景:一是数据量足够大,确实需要让模型所有层都重新适应;二是显存充足(比如 A100、多卡),且任务和预训练领域差异较大。
使用Trainer时,全量微调不需要额外设置,默认就是全量更新。只要你加载模型的时候没有加requires_grad=False,所有参数都是可训练的。训练代码里要注意设置per_device_train_batch_size和gradient_accumulation_steps,显存不足时优先减小 batch size,再用梯度累积弥补。
4.2 Freeze 微调:冻结一部分层,省显存,也防灾难性遗忘
Freeze 微调指的是把模型的一部分层冻结,不参与更新,只训练剩下的层。常见做法是冻结 BERT 的所有 transformer encoder 层,只训练最后的分类头;或者是冻结前几层 encoder,微调靠近输出端的高层。
它的逻辑是:靠近输入的层学到的是通用的词法、句法特征,这些特征在你的下游任务中并不需要改变;靠近输出的层学到的是和预训练任务有关的语义特征,需要针对新任务做调整。所以冻结底层可以减少显存、加速训练,同时也能缓解数据量小时微调导致的“灾难性遗忘”(也就是模型把预训练知识忘光,只记住了少量新样本)。
代码实现方式很简单,加载模型后手动设置requires_grad:
for param in model.bert.parameters(): param.requires_grad = False # 如果只微调分类头,BERT 参数全部冻结 for param in model.classifier.parameters(): param.requires_grad = True然后训练时,优化器只接收requires_grad=True的参数:
optimizer = AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr=5e-5)这样显存占用明显下降,实测在 6GB 显存下跑 BERT-base 情感分类没有问题。但要注意,如果任务和你预训练模型的领域差异特别大(比如用纯英文 BERT 处理中文业务数据),冻结底层可能会限制效果,因为模型压根没学会中文的表示,你连底层特征都没训练,等于用“瞎眼”的模型在硬撑。Freeze 微调更适合预训练模型本身已经覆盖了任务语言但任务形态不同的情况。
4.3 LoRA 微调:现在的首选方案,参数少、效果好
LoRA(Low-Rank Adaptation)是目前大模型微调事实上的标准做法。它的思想很巧妙:不更新原始权重矩阵 W,而是把权重更新量 ΔW 分解成两个低秩矩阵 A 和 B 的乘积:
ΔW = A × B
其中 A 是一个 (in_dim, r) 的矩阵,B 是一个 (r, out_dim) 的矩阵,r 远小于 in_dim 和 out_dim。训练时只更新 A 和 B,原始权重 W 冻结不动。这样一个 7B 参数的大模型,LoRA 真正训练的参数量可能只有几百万到几千万,显存需求断崖式下降。
有人可能会疑惑:既然只训练了小矩阵,效果能比全量微调好吗?实际上,大量实验表明,在大多数下游任务上,LoRA 的效果和全量微调非常接近,甚至在某些数据量小的场景下更好,因为它天然带了正则化作用,不容易过拟合。
在 Transformers 生态里,LoRA 的落地用peft库:
from peft import LoraConfig, get_peft_model, TaskType lora_config = LoraConfig( task_type=TaskType.SEQ_CLS, r=8, lora_alpha=16, lora_dropout=0.1, target_modules=["query", "value"] ) model = get_peft_model(model, lora_config) model.print_trainable_parameters()r是秩,lora_alpha是缩放系数,lora_dropout是丢弃率。这里我可以给一个经验值:BERT 这类模型r=8~16就够,再大收益不明显,反而增加显存;大模型(7B 以上)常用r=16~64。lora_alpha通常设置成r的 2 倍,这是一个比较稳的起调。
三种方式对比下来:
| 方式 | 更新参数量 | 显存占用 | 适用场景 | 训练速度 | 风险点 |
|---|---|---|---|---|---|
| 全量微调 | 所有参数 | 最高 | 数据充足、算力充足 | 慢 | 过拟合、灾难性遗忘 |
| Freeze 微调 | 部分参数 | 中 | 数据量中等、显存有限 | 中 | 需要人工设计冻结层 |
| LoRA | 低秩矩阵 | 最低 | 大模型微调、显存有限 | 快 | 秩太小可能欠拟合 |
我现在实际工作里,除了比赛冲榜会考虑全量微调,其余默认 LoRA。原因很现实:显存便宜不常有,时间省下来才是真的。
5. 实战案例:用 Transformers 微调一个中文情感分析模型
5.1 完整代码与训练脚本
这里给一个可以直接跑通的完整案例,数据集用简单的 CSV 格式,字段为text和label,标签 0 表示负面、1 表示正面。我用bert-base-chinese作为底座,采用 LoRA 方式微调。
import pandas as pd import torch from datasets import Dataset, DatasetDict from transformers import ( AutoTokenizer, AutoModelForSequenceClassification, DataCollatorWithPadding, Trainer, TrainingArguments ) from peft import LoraConfig, get_peft_model, TaskType # 1. 加载数据 train_df = pd.read_csv("train.csv") eval_df = pd.read_csv("eval.csv") train_dataset = Dataset.from_pandas(train_df) eval_dataset = Dataset.from_pandas(eval_df) # 2. 加载 tokenizer tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese") def preprocess(examples): return tokenizer( examples["text"], truncation=True, max_length=128, padding=False ) train_dataset = train_dataset.map(preprocess, batched=True) eval_dataset = eval_dataset.map(preprocess, batched=True) # 3. 加载模型并应用 LoRA model = AutoModelForSequenceClassification.from_pretrained( "bert-base-chinese", num_labels=2 ) lora_config = LoraConfig( task_type=TaskType.SEQ_CLS, r=8, lora_alpha=16, lora_dropout=0.1, target_modules=["query", "value"] ) model = get_peft_model(model, lora_config) # 4. 配置训练参数 training_args = TrainingArguments( output_dir="./finetuned_model", per_device_train_batch_size=16, per_device_eval_batch_size=32, gradient_accumulation_steps=1, num_train_epochs=3, learning_rate=2e-5, fp16=True, evaluation_strategy="epoch", logging_steps=50, save_strategy="epoch", load_best_model_at_end=True, metric_for_best_model="eval_loss", ) # 5. 使用 Trainer 训练 data_collator = DataCollatorWithPadding(tokenizer=tokenizer) trainer = Trainer( model=model, args=training_args, train_dataset=train_dataset, eval_dataset=eval_dataset, tokenizer=tokenizer, data_collator=data_collator, ) trainer.train() # 6. 保存模型 model.save_pretrained("./finetuned_model_lora") tokenizer.save_pretrained("./finetuned_model_lora")这段代码有几个地方值得注意:
padding=False在map阶段先不 padding,交给DataCollatorWithPadding动态处理,这样每条训练样本都按自己的真实长度传入,batch 内部再 padding,省算力。fp16=True在你显卡支持混合精度时开启,能明显降低显存占用,训练速度也会提升。load_best_model_at_end=True配合metric_for_best_model="eval_loss",训练结束后会自动把验证集上 loss 最低的 checkpoint 加载回来。
5.2 训练过程监控与参数调整经验
训练过程中,最需要盯的三个指标是loss、eval_loss和learning_rate。
loss下降速度太快不一定是好事。比如第一个 epoch 训练 loss 就掉到 0.1 以下,说明学习率可能过大,或者模型在死记样本,通常这时候 eval loss 会先降后升,出现典型的过拟合曲线。正常的训练曲线应该是训练 loss 平稳下降,eval loss 在第二个 epoch 附近趋于平稳,后面开始微升。
如果 eval loss 迟迟不降,排查顺序是:
- 学习率是否太小?BERT 通常用 2e-5 到 5e-5,低于 1e-5 训练速度会非常慢。
- 数据预处理有没有问题?可以打印一条 tokenizer 的输出,人工看看内容是否符合预期。
- 标签是否平衡?如果标签比例严重失衡,需要设置
class_weight或用 F1 作为评估指标。
如果显存不足,优先改per_device_train_batch_size,比如 16 降到 8,再用gradient_accumulation_steps=2保持等效 batch size 不变。等效 batch size 的计算方式是:per_device_train_batch_size × 梯度累积步数 × GPU 数量。这个数值不要随意改动,因为调参的时候,你调整的是“全局 batch size”带来的训练稳定性,而不是单纯看单卡显存。
我一般建议先跑一个很小的子集(几百条样本),让训练流程完整跑通,确认没有报错、能正常保存 checkpoint,再用全量数据训练。这样可以避免数据格式问题在训练一天之后才暴露。
6. 大模型微调的实际落地路径:从 BERT 到 Qwen、LLaMA
6.1 生成式模型微调与判别式模型微调的区别
前面案例用的是 BERT 这类 encoder-only 模型,它适合分类、实体识别这类任务。但从 2023 年之后,大家聊的“大模型微调”更多指的是 Qwen、LLaMA、DeepSeek 这类 decoder-only 的生成式模型。这两类模型的微调方式在数据构造和训练目标上差别很大。
判别式模型微调的目标是“输出一个标签”,所以数据是(文本, 标签)配对;生成式模型微调的目标是“输出一段文本”,所以数据通常是(指令, 输入, 期望输出)的三元组,在训练时拼成完整的指令模板。比如:
用户:请把下面这段话翻译成英文:我今天很开心。 助手:I am very happy today.训练时,模型读入的是左边这段完整文本,但计算 loss 时只计算“助手”部分对应 token 的损失。这个“只计算输出部分损失”的操作非常关键,如果连指令部分都算 loss,模型会学着去“预测用户的提问”,训练出来的模型回答问题会非常啰嗦。
在 Transformers 生态里,做生成式模型微调时要注意tokenizer是否设置了pad_token。很多生成式模型的 tokenizer 没有默认 pad_token,需要手动设置:
if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token否则在数据批处理阶段会出现各种奇怪的报错。
6.2 大模型微调工具链:为什么大家都用 llama-factory
对于 7B 以上的模型,自己手写训练循环还是有点吃力。这时候我更推荐用现成的训练框架,比如 llama-factory 这类工具。它是一个把数据预处理、LoRA/QLoRA 配置、训练、评估、推理打包好的工具,适合快速实验。
我自己的使用体验是:llama-factory 的价值在于它把“数据格式”统一了,你只需要按照它的对话模板整理 JSON,然后写一个 YAML 配置文件,就能启动训练。它支持的模型种类很多,包括 Qwen、LLaMA、DeepSeek 这些主流模型,也内置了 LoRA、Freeze、全量微调等选项。
但也别把它当成万能钥匙。工具化之后,很多细节被隐藏了,出了问题反而不容易排查。我遇到最多的是“数据格式对不上”导致训练时 loss 一直不变,后来打开它生成的训练样本才发现,指令和回答拼接符号错位了。所以如果你用这类工具,一定要在训练前开启“预览样本”功能,肉眼检查至少 10 条训练数据,确认格式没问题再真正训练。
大模型微调还有一个绕不开的话题是量化。如果显存不足以加载 7B 模型的 fp16 权重,可以考虑 QLoRA,也就是把基座模型量化成 4-bit,再在量化后的底座上做 LoRA。这种方式让很多人在消费级显卡上也能微调 7B 模型。但要注意,量化版本的效果和 fp16 版本会有细微差异,如果你对效果要求很高,还是优先保证显存足够,再上 LoRA 而不是 QLoRA。
7. 常见问题与排查技巧实录
7.1 显存不足:batch size、梯度累积与混合精度的组合拳
显存不够是最常见的报错,英文通常是一句CUDA out of memory。我见过有人为了凑一个大的 batch size 反复重启训练,其实完全没有必要。
按优先级处理:
- 先开
fp16=True,这是零成本降低显存的方案。 - 减小
per_device_train_batch_size,从 16 减到 8、4、2,直到能跑起来。 - 用
gradient_accumulation_steps补回等效 batch size,比如 batch size 减半到 4,梯度累积步数设为 4,等效 batch size 还是 16。 - 检查是否加载了不必要的中间变量,比如把数据一股脑
tensor.to("cuda")放到显存里,应该用 DataLoader 分批加载。
一个容易忽略的问题:验证阶段也会占显存。per_device_eval_batch_size设得过大,同样会 OOM。验证阶段不需要梯度,显存需求小一些,但也要合理设置。
7.2 模型加载与 Tokenizer 的版本兼容问题
很多人卡在transformers版本导致的加载错误。比如有些老模型权重文件是基于旧版pytorch_model.bin格式,新版 Transformers 加载时会有警告,但一般能自动兼容。真正麻烦的是tokenizer,BERT 系加载时如果缺少do_lower_case之类的配置,会导致英文大小写处理方式不一致。
排查思路很简单:遇到加载报错先看版本号,print(transformers.__version__),然后去对应版本的官方文档查 API 是否变更。如果项目日志里明确写了某个参数不识别,大概率是新版本废弃了老参数,删掉就好。
7.3 微调效果不好:先别急着换模型,检查这四件事
训练跑通了,但效果不达标,这种情况也很常见。我的排查清单如下:
- 数据质量:有没有 label 标注错误?有没有空文本?有没有明显重复的样本?这些都会直接影响效果。
- 数据量:太少的话,换 LoRA 或 Freeze 而不是全量微调;太少的话,加数据增强(同义词替换、回译、随机裁剪)也是一个方案。
- 学习率与训练轮数:BERT 系模型一般 3 epoch 左右,太久了容易过拟合。可以设置
early stopping回调,监控 eval loss 连续 2 个 epoch 不降就提前结束。 - 指标定义:分类任务用准确率不够全面时,改用 F1;生成任务用 BLEU 不一定合理,可以人工抽样看生成结果。
我见过太多人效果不好就马上换更大的模型,结果更大的模型对数据量的要求更高,反而更差。先把上面四件事排查掉,往往能解决 80% 的问题。
最后分享一个我自己实践下来很受益的习惯:每次微调训练,都把TrainingArguments的关键参数和验证指标记录成一份简单的实验日志,标明“改了哪个参数、结果提升了多少”。这样你不会在反复调参的时候忘记自己已经试过哪些组合,而且三个月后再回来看这个项目,也能快速回忆起当时的决策过程。微调这件事,与其说是技术问题,不如说是一个“用系统化的方法逼近最优解”的工程过程。希望你也能在调试模型的路上,慢慢建立起自己的实验感觉。