用 Fairseq 微调 RoBERTa 完成自定义文本分类任务:IMDB 情感分类全流程实战
【免费下载链接】unilmLarge-scale Self-supervised Pre-training Across Tasks, Languages, and Modalities项目地址: https://gitcode.com/GitHub_Trending/un/unilm
本文基于 unilm 仓库中 kosmos-2 附带的 fairseq 示例文档(kosmos-2/fairseq/examples/roberta/README.custom_classification.md),完整讲解如何把 RoBERTa 预训练模型微调到一个自定义文本分类任务上,以 IMDB 电影评论情感二分类为具体实例。读完后你可以独立走通“原始文本数据 → 格式化 → BPE 编码 → fairseq 二进制预处理 → 训练命令 → 用 hub 接口加载 checkpoint 推理”的完整链路,并理解每一步背后的 fairseq 源码实现(sentence_prediction任务、分类头注册与 hub 推理接口)。
一、这条自定义分类流水线要解决什么问题
fairseq 中 RoBERTa 的微调示例默认面向 GLUE、WSC 等标准基准,而实际业务中更多是“自己手里有一批文本 + 标签”。这份文档给出的方案是:把任意分类数据组织成 fairseq 的sentence_prediction任务所需的目录结构,然后复用fairseq-train的通用训练框架,用--restore-file加载 RoBERTa 预训练权重,只训练一个随机初始化的分类头(classification head)。
整条链路由 6 个阶段构成:
| 阶段 | 命令 / 工具 | 产物 |
|---|---|---|
| 1. 获取数据 | wget/tar | aclImdb/原始目录 |
| 2. 格式化 | 文档中的 Python 脚本 | train.input0/train.label/dev.input0/dev.label |
| 3. BPE 编码 | multiprocessing_bpe_encoder | *.input0.bpe(token id 文本) |
| 4. 二进制预处理 | fairseq-preprocess(两次调用) | IMDB-bin/input0/与IMDB-bin/label/的.bin/.idx |
| 5. 训练 | fairseq-train | checkpoints/checkpoint_best.pt |
| 6. 推理 | RobertaModel.from_pretrained+predict | 分类预测 |
下面逐步展开。
二、阶段 1:获取并解压 IMDB 数据
IMDB 数据集来自 Stanford 的 aclImdb 发布包,下载后是一个“一条样本一个文件”的目录树(train/pos、train/neg、test/pos、test/neg):
wget http://ai.stanford.edu/~amaas/data/sentiment/aclImdb_v1.tar.gz tar zxvf aclImdb_v1.tar.gz解压后的结构为aclImdb/{train,test}/{pos,neg}/*.txt,每个.txt文件包含一条影评,所在目录名即类别标签。这种“文件即样本”的组织方式正是下一步格式化脚本要处理的输入形态。
三、阶段 2:把“文件即样本”转成两个对齐的文本文件
fairseq 的sentence_prediction任务要求输入是行对齐的文本文件(或后续的二进制索引文件):*.input0存放样本文本,*.label存放对应的标签整数,且两文件行数一一对应。文档给出的转换脚本如下(完整保留):
import argparse import os import random from glob import glob random.seed(0) def main(args): for split in ['train', 'test']: samples = [] for class_label in ['pos', 'neg']: fnames = glob(os.path.join(args.datadir, split, class_label) + '/*.txt') for fname in fnames: with open(fname) as fin: line = fin.readline() samples.append((line, 1 if class_label == 'pos' else 0)) random.shuffle(samples) out_fname = 'train' if split == 'train' else 'dev' f1 = open(os.path.join(args.datadir, out_fname + '.input0'), 'w') f2 = open(os.path.join(args.datadir, out_fname + '.label'), 'w') for sample in samples: f1.write(sample[0] + '\n') f2.write(str(sample[1]) + '\n') f1.close() f2.close() if __name__ == '__main__': parser = argparse.ArgumentParser() parser.add_argument('--datadir', default='aclImdb') args = parser.parse_args() main(args)几个细节值得注意:
- 标签映射:
pos → 1,neg → 0,写入.label文件的是纯整数。后续 fairseq 会把这些行当作“词典 token”处理(见第五节label词典的加载方式)。 - 随机打乱:
random.seed(0)固定随机种子后用random.shuffle(samples)打乱样本,保证每次运行结果可复现。 - split 重命名:原始数据里的
test在这里被重命名为dev(验证集)。这是 fine-tuning 阶段的惯例——dev集用于训练中挑选最佳 checkpoint(--best-checkpoint-metric accuracy),真正的test评测留到最后做。 - 产出文件:
aclImdb/train.input0、aclImdb/train.label、aclImdb/dev.input0、aclImdb/dev.label。注意每条样本只读取文件第一行(fin.readline()),因为 IMDB 的每条影评实际只占一行。
换自己的数据集时,只需把这里的读取与标签映射逻辑替换掉,输出保持“一行一样本、input0 与 label 行对齐”即可。
四、阶段 3:用 GPT-2 BPE 对文本做分词编码
RoBERTa 的分词遵循 GPT-2 BPE,必须使用官方发布的encoder.json与vocab.bpe两份文件。fairseq 提供了多线程编码工具examples/roberta/multiprocessing_bpe_encoder.py,它把--inputs指定的原始文本逐行转成以空格分隔的 token id 序列(对应实现见 multiprocessing_bpe_encoder.py 中的MultiprocessingEncoder.encode_lines)。
# Download encoder.json and vocab.bpe wget -N 'https://dl.fbaipublicfiles.com/fairseq/gpt2_bpe/encoder.json' wget -N 'https://dl.fbaipublicfiles.com/fairseq/gpt2_bpe/vocab.bpe' for SPLIT in train dev; do python -m examples.roberta.multiprocessing_bpe_encoder \ --encoder-json encoder.json \ --vocab-bpe vocab.bpe \ --inputs "aclImdb/$SPLIT.input0" \ --outputs "aclImdb/$SPLIT.input0.bpe" \ --workers 60 \ --keep-empty done命令行参数说明(均可在 multiprocessing_bpe_encoder.py 的argparse定义中确认):
--encoder-json/--vocab-bpe:GPT-2 BPE 词表文件路径;--inputs/--outputs:支持多个输入输出对,要求数量一致,-表示标准输入输出;--workers:多进程 worker 数,默认 20,文档示例用 60;--keep-empty:保留空行。不加该选项时,遇到空行该样本会被标记为EMPTY并过滤掉(见 源码 L108-L119)——分类任务中一旦过滤了某一行,input 与 label 就会行错位,因此这里必须加--keep-empty。
文档也指出,BPE 编码完全可以合并进上一步的格式化脚本中对每条样本直接执行,只是那样更慢,所以实践中把编码独立成一步、用多进程批量跑更快。注意:本步只编码input0,label文件里的整数不需要 BPE 编码,直接进入下一步的预处理。
五、阶段 4:fairseq-preprocess 生成二进制数据
这一步把文本文件转成 fairseq 的二进制索引格式(.bin/.idx),训练时才能被高效加载。需要两次调用,分别处理输入文本和标签,并各自建立词典:
# Download fairseq dictionary. wget -N 'https://dl.fbaipublicfiles.com/fairseq/gpt2_bpe/dict.txt' fairseq-preprocess \ --only-source \ --trainpref "aclImdb/train.input0.bpe" \ --validpref "aclImdb/dev.input0.bpe" \ --destdir "IMDB-bin/input0" \ --workers 60 \ --srcdict dict.txt fairseq-preprocess \ --only-source \ --trainpref "aclImdb/train.label" \ --validpref "aclImdb/dev.label" \ --destdir "IMDB-bin/label" \ --workers 60两次调用的差异:
- 第一次(
IMDB-bin/input0):--srcdict dict.txt指定使用随 RoBERTa 发布的官方 GPT-2 词典(49152 词元)。这样预处理后的 token id 与预训练模型 embedding 的 id 空间完全一致,--restore-file加载权重时才能对得上,无需重建词表。 - 第二次(
IMDB-bin/label):不指定词典,由 fairseq 从train.label/dev.label中自动建一个小词典(对 IMDB 而言就是0和1两个符号)。
从源码可以确认这个目录布局的要求:sentence_prediction.py 的setup_task会分别从data/input0/dict.txt和data/label/dict.txt加载输入词典与标签词典,并要求--num-classes > 0;load_dataset则依次加载input0(必需)、input1(可选,用于句子对)和label三个数据集(L132-L256)。因此IMDB-bin/下必须有input0/与label/两个子目录,且label的标签值必须是可被其词典表示的 token。
六、阶段 5:运行训练
这是整条流水线的核心命令,文档完整给出如下(含全部环境变量):
TOTAL_NUM_UPDATES=7812 # 10 epochs through IMDB for bsz 32 WARMUP_UPDATES=469 # 6 percent of the number of updates LR=1e-05 # Peak LR for polynomial LR scheduler. HEAD_NAME=imdb_head # Custom name for the classification head. NUM_CLASSES=2 # Number of classes for the classification task. MAX_SENTENCES=8 # Batch size. ROBERTA_PATH=/path/to/roberta.large/model.pt CUDA_VISIBLE_DEVICES=0 fairseq-train IMDB-bin/ \ --restore-file $ROBERTA_PATH \ --max-positions 512 \ --batch-size $MAX_SENTENCES \ --max-tokens 4400 \ --task sentence_prediction \ --reset-optimizer --reset-dataloader --reset-meters \ --required-batch-size-multiple 1 \ --init-token 0 --separator-token 2 \ --arch roberta_large \ --criterion sentence_prediction \ --classification-head-name $HEAD_NAME \ --num-classes $NUM_CLASSES \ --dropout 0.1 --attention-dropout 0.1 \ --weight-decay 0.1 --optimizer adam --adam-betas "(0.9, 0.98)" --adam-eps 1e-06 \ --clip-norm 0.0 \ --lr-scheduler polynomial_decay --lr $LR --total-num-update $TOTAL_NUM_UPDATES --warmup-updates $WARMUP_UPDATES \ --fp16 --fp16-init-scale 4 --threshold-loss-scale 1 --fp16-scale-window 128 \ --max-epoch 10 \ --best-checkpoint-metric accuracy --maximize-best-checkpoint-metric \ --shorten-method "truncate" \ --find-unused-parameters \ --update-freq 4关键参数逐项解读
调度与批量
| 参数 | 取值 | 作用 |
|---|---|---|
TOTAL_NUM_UPDATES | 7812 | 多项式衰减的总优化步数,等于“10 个 epoch × IMDB 训练集 25000 条 ÷ 有效 batch 32”取整 |
WARMUP_UPDATES | 469 | warmup 步数,约为总步数的 6% |
--lr | 1e-05 | 多项式调度的峰值学习率 |
--batch-size/--update-freq | 8 / 4 | 有效 batch 为 8×4 = 32 句 |
--max-tokens | 4400 | 单步最大 token 数,防止长句把显存打爆 |
--max-epoch | 10 | 最多训练 10 个 epoch |
任务与模型
| 参数 | 取值 | 作用 |
|---|---|---|
--task | sentence_prediction | 使用上文分析的分类任务,数据目录为第一个位置参数IMDB-bin/ |
--restore-file | RoBERTa 预训练权重 | 加载 encoder 权重,重置优化器/数据加载器/计步器(--reset-optimizer --reset-dataloader --reset-meters)以支持从头开始微调 |
--arch | roberta_large | 355M 参数的大模型架构 |
--num-classes | 2 | 分类类别数(IMDB 二分类);SentencePredictionTask.setup_task强制要求num_classes > 0 |
--classification-head-name | imdb_head | 分类头注册名,推理时roberta.predict('imdb_head', ...)用它寻址 |
--init-token/--separator-token | 0 / 2 | 句首加<s>(id 0),句子对之间以</s>(id 2)分隔;IMDB 是单句任务,separator 实际不参与 |
--max-positions | 512 | 序列上限;配合--shorten-method truncate对超长样本做截断 |
优化与数值
--criterion sentence_prediction:与 task 配套的分类损失;--dropout 0.1 --attention-dropout 0.1、--weight-decay 0.1;Adam 参数为(0.9, 0.98)、eps1e-06(沿用 RoBERTa 官方配置风格);--clip-norm 0.0:不做梯度裁剪;--fp16系列:混合精度训练及损失缩放窗口参数(--fp16-init-scale 4 --threshold-loss-scale 1 --fp16-scale-window 128);--find-unused-parameters:分类头之外的部分参数在某些 step 可能不参与梯度,DistributedDataParallel 需要该标志避免报错;--best-checkpoint-metric accuracy --maximize-best-checkpoint-metric:以验证集 accuracy 为指标保留最优 checkpoint。
显存不足时的调整方法
文档给出的建议是:调小--batch-size、同时调大--update-freq以保持有效 batch size 不变(例如--batch-size 4 --update-freq 8)。
预期结果
文档报告:RoBERTa-large 在有效 batch 32 下训练 10 个 epoch 后,best-validation-accuracy约96.5%(IMDB dev 集)。这是文档原文明确给出的参考值,可作为你环境是否配置正确的对照基线。
训练时分类头是如何被创建并训练的
从源码链路看:fairseq-train启动时,sentence_prediction.py 的 build_model 在构建完 RoBERTa encoder 后,调用model.register_classification_head(head_name, num_classes=...)注册一个随机初始化的RobertaClassificationHead;该头以nn.ModuleDict形式挂载在模型上(model.py L51),注册逻辑见 register_classification_head。训练时每个 batch 的目标是 OffsetTokensDataset 提供的标签 id(label - nspecial,即0/1映射回0/1),损失由sentence_predictioncriterion 在分类头输出上计算——encoder 主体与分类头一起被端到端微调。
七、阶段 6:用 hub 接口加载 checkpoint 并推理
训练结束后,checkpoint 位于checkpoints/目录。此时可以用 RoBERTa 的 hub 接口把“task + 模型 + 分词器”打包加载,接口实现见 hub_interface.py:
from fairseq.models.roberta import RobertaModel roberta = RobertaModel.from_pretrained( 'checkpoints', checkpoint_file='checkpoint_best.pt', data_name_or_path='IMDB-bin' ) roberta.eval() # disable dropoutcheckpoint_file='checkpoint_best.pt':即第五步按 accuracy 指标挑选出的最优 checkpoint;data_name_or_path='IMDB-bin':指向预处理好的数据目录,接口用它重建sentence_predictiontask(从而拿到source_dictionary、label_dictionary和 BPE 编码器)。
然后用--classification-head-name设置的名字imdb_head做预测,并把预测 id 通过 label 词典翻译回字符串(hub_interface.py 的 predict 会对特征提取结果过分类头并返回log_softmax值,argmax即预测类别):
label_fn = lambda label: roberta.task.label_dictionary.string( [label + roberta.task.label_dictionary.nspecial] ) tokens = roberta.encode('Best movie this year') pred = label_fn(roberta.predict('imdb_head', tokens).argmax().item()) assert pred == '1' # positive tokens = roberta.encode('Worst movie ever') pred = label_fn(roberta.predict('imdb_head', tokens).argmax().item()) assert pred == '0' # negative两处容易踩坑的细节:
roberta.encode(...)会自动补<s> ... </s>并在 BPE 前加空格处理(encode 实现),与预处理阶段用dict.txt编码的 token 空间一致,无需手动分词;label_fn中的+ nspecial是逆操作:训练时目标做了id - nspecial(见 OffsetTokensDataset),推理翻译回字符串时就要把特殊符号的偏移加回来,label_dictionary.string([label + nspecial])才能得到'0'/'1'这样的字符串。
八、复用到你自己的分类数据集:改造清单
把这条流水线迁移到其他分类任务(多分类、短文本/长文本均可)时,需要改动的点非常集中:
- 阶段 2 脚本:按你的数据源改读取逻辑,保证输出
train.input0/train.label/dev.input0/dev.label且行对齐;标签可以是任意整数集合(0..K-1)。 - 阶段 4 第二次预处理:
label词典仍自动生成,无需干预;但如果你的任务需要句子对(如 NLI),则需准备input0+input1两份文本,任务会按--separator-token拼接(ConcatSentencesDataset 逻辑)。 - 阶段 5 命令:只改
NUM_CLASSES、TOTAL_NUM_UPDATES(按你的训练集大小与有效 batch 重算)、HEAD_NAME和ROBERTA_PATH;其余超参可作为起点。 - 阶段 6 推理:
predict的第一个参数换成你的HEAD_NAME。
九、涉及的关键文件索引
| 内容 | 路径 |
|---|---|
| 本文对应的原始示例文档 | kosmos-2/fairseq/examples/roberta/README.custom_classification.md |
| 多进程 BPE 编码工具 | kosmos-2/fairseq/examples/roberta/multiprocessing_bpe_encoder.py |
| sentence_prediction 任务实现 | kosmos-2/fairseq/fairseq/tasks/sentence_prediction.py |
| RoBERTa 模型与分类头注册 | kosmos-2/fairseq/fairseq/models/roberta/model.py |
| hub 加载/编码/预测接口 | kosmos-2/fairseq/fairseq/models/roberta/hub_interface.py |
| RoBERTa 主 README(模型下载与用法总览) | kosmos-2/fairseq/examples/roberta/README.md |
适用前提与限制:以上流程基于仓库中 kosmos-2 目录下捆绑的这一版 fairseq(hydra 化之前的fairseq-train命令行风格),要求能拿到roberta.large的model.pt与 GPT-2 BPE 词表文件;--restore-file指向的路径需替换为你本地的实际 checkpoint 位置。IMDB 的 96.5% 验证准确率是文档给出的参考基线,复现时应以同配置对照。
【免费下载链接】unilmLarge-scale Self-supervised Pre-training Across Tasks, Languages, and Modalities项目地址: https://gitcode.com/GitHub_Trending/un/unilm
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考