我在刚接触NLP那会儿,总以为训练一个模型就得从零开始,把整套网络结构重新设计一遍。直到有一次接到一个文本分类需求,前辈丢给我一句“用BERT微调一下就行”,我才真正理解什么叫迁移学习。现在无论你看哪篇大模型实战文章,基本都绕不开“预训练+微调”这套打法,而Transformers库正是目前落地这套打法最主流的工具。这篇文章我打算把从环境搭建、数据准备、参数调到常见报错的一整套经验写出来,主题就是“迁移学习怎么落地”,重点放在Transformers库微调实操。内容会比较具体,涉及可以直接复制的Python代码、参数选择和排查思路,适合准备做文本分类、命名实体识别,或者想把Qwen这类开源大模型往自己业务数据集上微调的同学参考。
迁移学习听起来很高深,但落到代码层面,多数场景就是一个预训练模型加载进来,改改输出层,再用自己的数据训练几轮。这篇博文会先帮大家把概念捋清楚,然后一步步实现一次完整的微调,再往LoRA、LLaMA-Factory这类进阶玩法上延展。读完你会发现,真正拦住你的往往不是模型结构,而是版本、显存和数据质量。
1. 先把迁移学习这件事想明白
1.1 为什么NLP任务默认就走“预训练+微调”
深度学习刚火起来那几年,很多NLP任务的做法是先随机初始化一个Embedding和编码器,然后直接从自己的语料里学。问题是,大多数实际任务的标注数据只有几千条、几万条,这点数据量根本喂不饱一个深层模型。于是过拟合、泛化差、调参调到怀疑人生就成了家常便饭。
迁移学习把这个问题换了个思路:先在一个超大语料上用自监督任务把语言本身的规律学出来,比如BERT用“盖住词猜原词”,GPT用“预测下一个词”。这个阶段叫预训练,产出的是通用语言表示,不针对任何具体任务。到了下游任务,我们不再随机初始化,而是把预训练好的权重搬过来作为初始状态,然后用少量标注数据接着训练。这个过程就是微调。
我把迁移学习类比成“学开车”:预训练阶段相当于在封闭场地练基本功,微调阶段相当于拿到真实道路上跑一圈。你不需要每次都在封闭场地从头练,只需要在真实路况下把方向盘、刹车这些动作再调到合适手感就行。对NLP来说,预训练模型已经知道词怎么组合、句子怎么组织,微调只是让这些知识服务于你的具体任务,所以数据量要求低很多,收敛也快很多。
1.2 直推式迁移学习和其他迁移方式别搞混
在文献里迁移学习分好几种类型,面试和实操里容易绕晕的主要是归纳式迁移学习、直推式迁移学习和无监督迁移学习。很多人第一次看到“直推式迁移学习”这个术语会发懵,其实它描述的是一个特定场景:源领域和目标领域是同一个任务,但两个领域的数据分布不一样。
举个例子,你在新闻文本上训练了一个情感分类模型,现在要直接拿去给社交媒体评论做分类。新闻和评论的用词风格差异很大,这就导致训练集和测试集分布不一致,如果直接硬用,效果会明显变差。直推式迁移学习要解决的就是这种“源任务和目标任务相同,但领域数据分布不同”的情况。在文本分类、垃圾文本识别这类任务里很常见,实操上常用领域自适应、数据增强加微调混合的方式来缓解。
而我们平时常说的“先用BERT预训练,再用BERT做分类”,严格来说是归纳式迁移学习:源任务(预训练语言模型)和目标任务(分类)不同,但共用一个模型结构,通过微调把任务差异补上。明白这个区别,看论文时才不会被概念绕进去。
1.3 微调到底在“调”什么
很多人以为微调就是把整个模型重新训练,其实完全不是。预训练模型的底层网络已经学到了非常稳定的通用语法和语义特征,这些特征不大需要变动,真正需要调整的是靠近输出层的部分。所以微调时的默认做法是:加载预训练权重作为初始化,替换掉模型顶部的任务相关层,比如BERT分类模型会把输出层换成带num_labels的线性层,然后用较小的学习率在全量参数上继续训练。
一个值得注意的细节是,如果我们把学习率设得太大,预训练积累的特征会被新数据“冲掉”,最终效果可能比训练不足还差。这也是为什么绝大多数Transformers微调案例会把学习率设置在2e-5到5e-5之间。微调过程中,底层参数更新幅度通常远小于顶层,但Transformers默认是整模型一起反向传播,不做层冻结,这样实现简单,效果也足够好。只有在大模型微调的场景下,为了节省显存和时间,才会引入冻结部分参数或者LoRA这类高效微调方法。
2. 环境准备:版本匹配是第一道坑
2.1 Transformers版本和PyTorch/CUDA到底怎么搭
很多人拿到项目先去装包,结果一跑就报错,最后发现是版本和CUDA不匹配。这里我先泼一盆冷水:如果你用的是比较旧的Transformers版本,比如3.4.0,那就要先确认它到底是哪个时代的东西。transformers==3.4.0是2020年中旬发布的,那个阶段官方推荐的是PyTorch 1.3到1.6,CUDA 10.1或10.2。如果你在一个很新的CUDA 12.x环境里直接装它,大概率会出现底层算子不兼容、编译报错或者GPU不可用的问题。
下面这张表是我根据实际使用经验整理的参考匹配关系,不是唯一答案,但能省掉不少折腾时间:
| Transformers 版本 | 推荐 PyTorch | 推荐 CUDA | 使用说明 |
|---|---|---|---|
| 3.4.0 | 1.3 ~ 1.6 | 10.1 / 10.2 | 老项目维护用,新项目不建议碰 |
| 4.20 ~ 4.30 | 1.12 ~ 2.0 | 11.6 / 11.7 | 比较稳的过渡版本 |
| 4.31 ~ 4.44 | 2.0 ~ 2.2 | 11.8 / 12.1 | 我目前主力使用的版本区间 |
需要注意,这里说的CUDA是指PyTorch自带的那一套CUDA运行时,不一定要和系统驱动完全一致。你只需要保证显卡驱动版本足够新,然后安装对应的PyTorch轮子就行。具体怎么看驱动是否支持,可以在终端执行nvidia-smi查看右上角的CUDA Version,只要这个版本号高于或等于你安装PyTorch需要的CUDA版本,基本上就没问题。
如果你的项目还在用transformers==3.4.0,我会建议你分两步处理:先看项目依赖是否锁死;如果只是历史代码,最好把代码升级到新的Transformers API,再换到PyTorch 2.x加CUDA 11.8组合,性能和兼容性都会好很多。老版本除了API不兼容,很多预训练模型新导出的格式它也不支持,强行用只会给自己添堵。
2.2 一套干净可复现的微调环境怎么建
我比较推荐用conda建独立环境,不要直接在base环境里乱装,不然PyTorch、TensorFlow、Triton这些包很容易互相影响。下面这段命令是我创建一个微调环境的通用流程,当前CUDA版本选择11.8,对应PyTorch 2.1.1:
conda create -n finetune python=3.10 -y conda activate finetune pip install torch==2.1.1 torchvision==0.16.1 torchaudio==2.1.1 --index-url https://download.pytorch.org/whl/cu118 pip install transformers==4.31.0 datasets==2.14.6 evaluate==0.4.1 accelerate==0.24.1 peft==0.7.1装完之后不要急着写代码,先跑几行简单的Python命令验证环境能不能识别GPU和加载模型。我第一次装完后忘了装accelerate,结果Trainer训练时报错说缺少分布式依赖,这个问题只在训练阶段暴露,所以提前验证很有必要。
import torch from transformers import BertTokenizer, BertForSequenceClassification print(torch.cuda.is_available()) print(torch.__version__) tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") model = BertForSequenceClassification.from_pretrained("bert-base-chinese", num_labels=2) print(tokenizer("迁移学习到底怎么落地"))如果能打印出模型输出,说明基础链路已经通了。这一步还能顺便确认预训练模型的下载权限和网络状况,避免真正训练时卡在模型下载上。
2.3 模型下载不动或太慢怎么办
国内访问Hugging Face有时候非常痛苦,模型动不动几百MB甚至几个GB。常规做法是从国内镜像站点下载,比如hf-mirror.com,设置环境变量可以加速。在Linux或者Mac上临时设置:
export HF_ENDPOINT=https://hf-mirror.comWindows PowerShell可以用:
$env:HF_ENDPOINT="https://hf-mirror.com"设置好之后,from_pretrained会自动从镜像地址下载。这个方法我在很多项目里都在用,速度比直接连官方源稳定不少。如果公司有内网缓存或者自己机器上已经下载过模型文件,也可以先把模型下载到本地目录,加载时直接传本地路径,彻底绕开下载问题。
3. 核心实战:用Transformers微调一个文本分类模型
3.1 准备数据:别小看这个环节
大多数教程都会用Hugging Face上的现成数据集,但如果你在公司做项目,数据往往在本地CSV、Excel或者数据库里。这里我就用本地的CSV文件做示范,格式很简单:一列text,一列label。假设我们要做一个中文评论情感二分类,positive和negative各两千条。
用datasets库加载本地数据,然后拆成训练集和验证集:
from datasets import load_dataset dataset = load_dataset("csv", data_files="data.csv", split="train") dataset = dataset.train_test_split(test_size=0.2, seed=42, stratify_by_column="label") train_dataset = dataset["train"] eval_dataset = dataset["test"] print(train_dataset) print(train_dataset.unique("label"))这里有两个细节值得注意。第一,train_test_split里面stratify_by_column参数可以在数据不平衡时保持训练集和验证集的标签比例一致,如果二分类正负样本差距很大,这个参数特别有用。第二,数据量虽然不大,但也要检查是否有重复样本、空文本、标签噪声,否则后面训练出的模型会在验证集上表现很好,一到线上就露馅。我见过太多项目,模型训练曲线很漂亮,最后坏在线上,一查原因,训练集里有一条同一条错误数据被复制了几十次。
3.2 数据预处理:Tokenizer环节不能省
文本不能直接喂给Bert,需要先转成input_ids、attention_mask和token_type_ids。这个过程看起来就是使用tokenizer,但有几个细节容易出错。padding策略在训练集和验证集上不太一样:训练集大多用长batch,靠tokenizer动态padding到batch内最长实现效率最大;验证集则推荐padding到模型最大长度或固定长度,避免batch内样本长度差异过大导致验证结果波动。
下面是对训练集做tokenize的函数:
from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese") def tokenize_function(examples): return tokenizer(examples["text"], truncation=True, max_length=128, padding="max_length") tokenized_train = train_dataset.map(tokenize_function, batched=True, remove_columns=["text"]) tokenized_eval = eval_dataset.map(tokenize_function, batched=True, remove_columns=["text"]) tokenized_train.set_format("torch") tokenized_eval.set_format("torch")truncation控制在128是因为很多短文本分类任务根本不需要512的长度,而长度越长,显存占用和训练时间都会明显增加。你可以在初步实验中先用128跑通,再评估是否需要更长的上下文。如果是做文本匹配、长文档分类,再考虑256或者512。
3.3 加载预训练模型:把BERT改造成分类模型
用AutoModelForSequenceClassification加载,非常省事。它的作用是把预训练模型顶部的池化输出接到一个Linear分类层上。这里num_labels设为2,就是二分类。
from transformers import AutoModelForSequenceClassification model = AutoModelForSequenceClassification.from_pretrained( "bert-base-chinese", num_labels=2 )疑问来了:这个模型的输出层权重是随机初始化的,会不会破坏整体性能?其实不会。预训练模型主体部分依然保留着知识,顶部新加的线性层很快就能从数据中学到合适的映射关系,这也是迁移学习比从零训练高效的关键。整个过程里,只有顶层需要相对较大的梯度来学习新任务,所以经验值是整体学习率设小一点,比如2e-5。
3.4 训练参数:Trainer为什么大家都在用
Transformers库的Trainer封装了训练循环、梯度累积、日志、评估、保存,省去写PyTorch训练脚本的重复劳动。Trainer用起来很舒服,但前提是你要理解TrainingArguments里这些参数都是什么意思,下面这份是我在二分类任务里常用的配置:
from transformers import Trainer, TrainingArguments training_args = TrainingArguments( output_dir="./results", evaluation_strategy="epoch", save_strategy="epoch", learning_rate=2e-5, per_device_train_batch_size=16, per_device_eval_batch_size=32, num_train_epochs=3, weight_decay=0.01, logging_dir="./logs", logging_steps=50, save_total_limit=2, fp16=True, report_to=[], ) trainer = Trainer( model=model, args=training_args, train_dataset=tokenized_train, eval_dataset=tokenized_eval, tokenizer=tokenizer, )learning_rate为什么是2e-5,而不像普通CNN训练那样用0.001?因为预训练模型的权重本身已经处于一个很“好”的位置,如果学习率太大,一步更新就可能把学到的语言知识冲得七零八落。这个学习率是BERT时代无数实验总结出来的经验区间,没有特殊理由不建议标新立异。
fp16=True使用混合精度训练,在V100、A100、RTX 30/40系这些支持FP16的显卡上能明显减少显存占用并加速训练。如果显卡不支持,跑起来会报错,那就把它改成False。
3.5 评估与推理:别只看Loss
Trainer默认只打印loss,我们还需要自定义评估指标。用evaluate库加载准确率或者F1都行:
import evaluate import numpy as np accuracy = evaluate.load("accuracy") def compute_metrics(eval_pred): logits, labels = eval_pred predictions = np.argmax(logits, axis=-1) return accuracy.compute(predictions=predictions, references=labels) trainer = Trainer( model=model, args=training_args, train_dataset=tokenized_train, eval_dataset=tokenized_eval, compute_metrics=compute_metrics, ) trainer.train()训练完之后,用Trainer.evaluate()看一下验证集指标。如果效果好,接下来要做的是保存模型,然后加载回来看几个真实例子:
trainer.save_model("./my_bert_classifier") tokenizer.save_pretrained("./my_bert_classifier") from transformers import pipeline classifier = pipeline("text-classification", model="./my_bert_classifier", tokenizer="./my_bert_classifier") print(classifier("这家店的川味很正宗,下次还会再来"))这里重点检查的是训练数据和验证数据之外的新文本。如果你发现预测结果总偏向某一个类别,大概率不是模型的问题,而是数据本身存在倾向性。这个阶段多花点时间,后续上线会少踩很多坑。
4. 进阶玩法:从全量微调到LoRA微调
4.1 大模型全量微调为什么让人头疼
BERT参数量是1亿级别,全量微调还能勉强靠单卡跑。到了Qwen、Llama这类7B、13B甚至更大规模的模型,全量微调对显存和算力的要求就变得非常苛刻。训练过程中优化器状态、梯度、激活值叠在一起,动辄几十GB显存,个人开发者的消费级显卡基本跑不动,即使跑得动,训练时间也很感人。而且全量微调还有个隐患:当数据量少时,大模型容易在微小数据集上过拟合,学到的不是任务逻辑而是数据记忆,导致通用能力下降,也就是常说的“灾难性遗忘”。
大模型时代,行业更常用的是参数高效微调。这类方法只更新极少一部分参数,却能达到接近全量微调的效果。LoRA是其中最出名的一种。
4.2 LoRA的核心思路:用低秩矩阵模拟大幅更新
LoRA不需要调整原模型参数,而是在模型权重旁边插入两个小矩阵,用它们来模拟权重的增量。公式不展开,打个比方:原来一个全连接层需要更新4096×4096个参数,LoRA把它拆成4096×8和8×4096两个小矩阵,训练时只更新这两个小矩阵,冻结原来的大矩阵。等到推理时再把增量合并回去,甚至不增加额外推理耗时。可训练参数量一下子从千万级降到几十万级,显存占用和训练时间自然大幅下降。
在Transformers生态里,配合微软开源的PEFT库,LoRA的代码实现非常简洁。peft.LoraConfig里几个核心参数:
- r(秩):通常取8或16,增长能提高表达能力,但也增加计算量;
- alpha(缩放因子):一般设成r的2倍或与r相同,控制更新强度;
- target_modules:目标模块名,需要根据模型结构设置,比如常见的qkv层;
- task_type:当前任务类型,比如CAUSAL_LM对应自回归语言模型。
4.3 用LoRA微调Qwen这类大模型
这里我以Qwen系列为例,实际操作也适用于其他GPT风格模型。首先是加载模型和分词器,然后把模型转成bf16或fp16以降低显存:
import torch from transformers import AutoModelForCausalLM, AutoTokenizer from peft import LoraConfig, get_peft_model, TaskType model_name = "Qwen/Qwen2.5-0.5B-Instruct" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForCausalLM.from_pretrained( model_name, torch_dtype=torch.bfloat16, device_map="auto", ) lora_config = LoraConfig( task_type=TaskType.CAUSAL_LM, r=8, lora_alpha=16, lora_dropout=0.05, target_modules=["q_proj", "k_proj", "v_proj", "o_proj"], ) model = get_peft_model(model, lora_config) model.print_trainable_parameters()配置好之后,训练部分和上一节很像,同样可以使用Trainer。不过做指令微调时要特别注意数据集格式。以Qwen为例,通常先把输入和输出拼成对话模板,再tokenize:
def format_example(example): prompt = f"<|im_start|>user\n{example['instruction']}\n<|im_end|>\n<|im_start|>assistant\n" return tokenizer(prompt, padding="max_length", max_length=512, truncation=True)指令微调数据集里的instruction、input、output三种字段,在网上很容易找到模板代码。不过生成时,模型的回答质量很大程度上取决于数据质量,而不仅仅是LoRA参数。如果你想微调qwen-vl-4b这类视觉语言模型,思路也是类似的,只是加载模型时要使用对应的多模态模型类,比如AutoModelForImageTextToText,并确认Transformers版本支持该架构。视觉模块和文本模块可以一起LoRA,也可以冻结视觉塔,一般先用默认配置跑通,再根据效果调整。
4.4 用LLaMA-Factory这类工具省下写代码的时间
如果你不是为了学习细节,而是想快速验证某个大模型在自己数据上的效果,强烈推荐用LLaMA-Factory这类工具。它把数据加载、LoRA、训练、推理都封装成了标准流程,还提供了Web界面。我在给学生演示大模型微调时,就经常用它,因为只需要准备数据、选模型、填几个参数,点启动就能跑,训练曲线和Loss都直接展示出来。这样能避免初学者被一大堆环境配置劝退,也能快速对比不同模型的微调效果。
这类工具还内置了很多公认可用的指令数据格式,支持全量微调、LoRA、QLoRA等,对于要在本地部署和实验的场景非常方便。不过生产环境我通常还是会自己写训练脚本,因为可控性更强,出问题的时候更容易定位。
4.5 指令微调数据集怎么造才不翻车
大模型微调和传统分类微调有一个明显区别:分类任务数据通常是“text + label”,而指令微调数据是“指令 + 输入 + 期望输出”的三元组结构。数据质量决定了模型能否学会按指令回答。我的建议是,初期先用几百条高质量数据验证全流程,不要一上来就投几千条。你会发现,几百条精心设计的样本往往比几千条粗糙样本训练出来的效果更好,这是大模型微调里普遍存在的情况。
具体制作数据时,尽量保证回答格式统一,标点、换行、语气都要一致,千万不要一会儿用简体中文回答,一会儿又用英文回答。也不要指望模型通过微调学会新的知识,微调更擅长改变输出风格和遵循特定指令。如果数据集里出现了超出模型本身知识范围的错误答案,LoRA只会让模型在相关问题上“一本正经地胡说八道”。
5. 实战常见问题与排查技巧
5.1 GPU显存不够怎么办
微调实战里遇到最多的坑就是显存爆炸。训练BERT模型时,如果per_device_train_batch_size设为8都会OOM,不要硬试,先检查是不是开了fp16,其次把batch size调小到4或2。但batch变小会影响梯度估计稳定性,那就需要开梯度累积:
training_args = TrainingArguments( per_device_train_batch_size=2, gradient_accumulation_steps=8, )这样相当于每16个样本更新一次参数,效果接近于batch size=16,但显存占用却小得多。训练大模型时,除了batch size和梯度累积,还可以开启8bit或4bit量化加载,配合QLoRA,让消费级显卡也能跑7B模型的微调。另外,如果是生成类任务,在训练时把model.config.use_cache设为False,不然保存模型或推理时可能出现缓存占用显存的问题。
5.2 Loss一直在降,但测试效果很差
这个现象十有八九是过拟合或者数据泄漏。过拟合的典型表现是训练Loss降到很低,验证Loss却在某个点开始回升。遇到这种情况,优先检查训练数据是不是太少了,可以考虑用预训练模型做数据增强,或者把dropout调大一点。LoRA的lora_dropout也可以从0.05提高到0.1。其次是检查数据是否泄漏,比如验证集里出现了训练集的重复样本,或者文本预处理时把标签信息混进了特征里。我在一个命名实体识别项目里就遇到过,清理数据时没有去掉标签字段,结果模型在验证集上F1高达99%,线上几乎不能用。
5.3 老版本Transformers的相关坑
遇到那种模型加载报错、API不存在、优化器报错,先看Transformers版本。比如transformers==3.4.0里面,启用混合精度的写法还不太稳定,需要对照当时的官方文档,和现在新版代码有很多差异。最直接的排查方法是,用pip show transformers看版本,如果是3.x,尽量迁移到4.x。如果必须使用老版本,就按照第一节表格里的匹配关系,装好对应的PyTorch和CUDA,并保持依赖环境不随意升级。我的经验是,老项目能不动就不动,但新项目一定要用新版本,长痛不如短痛。
5.4 微调后模型出现重复生成或者胡言乱语
在做大模型LoRA微调时,经常有人发现模型训练完反而不会说话了,输出要么重复,要么乱码。原因主要有三类:一是学习率太高,破坏了预训练能力;二是数据中普遍存在重复表达,模型学会的是重复句式;三是max_length设置太小,模型生成到长度上限后开始自我重复。排查时,先用一段指令看生成结果,把num_beams设为1,调大max_new_tokens,再把temperature调低,看是否改善。如果依然重复,就把学习率降到1e-5以下再试。
5.5 我的实操体会:先跑通再优化
在一次又一次踩坑之后,我总结出一个非常朴素的方法:不管用什么模型、什么数据,先拿一个极小的数据集把完整流程跑通,再上全量数据。先用batch_size=1,训练一个step,确认训练脚本能正常反传;然后用100条数据训练3个epoch,看看Loss能不能下降,验证集指标能不能算出来;最后再上全量数据,同时调学习率、batch size和epoch。这个习惯帮我省下了大量排查时间。
迁移学习落到Transformers库上,难度并不在于理解注意力机制,而在于把数据和代码之间的链路一次次跑顺。每个任务都可能有自己的数据脏、格式乱、版本坑,能跑通一次,后面就再也不会觉得它们有多难。先动手,把第一个模型跑出来,你离“迁移学习落地”就已经不远了。