☰
ROCm云端Gemma4 LoRA微调实战:情绪分类准确率提升至0.734
2026/10/1 17:13:16 网站建设 项目流程

上个月我在某云平台开了一台带 AMD MI210 的 ROCm 实例,主要想验证一个想法:在完全不用 NVIDIA 卡的条件下,能不能把 Gemma4 这种轻量级生成模型用 LoRA 拉起来做情绪分类。跑通之后,准确率从 0.594 涨到 0.734,提升不算惊艳,但整个过程踩出来的坑足够写一篇完整总结。这篇文章没有多余的废话,从环境安装、数据准备、微调参数到我遇上的 4 个天坑,全部按我实际操作的顺序写给你。适合那些手里只有 ROCm 云资源、又想把大模型落地到具体任务的开发者,也适合准备入坑 LoRA 微调但还没系统性跑过的朋友。

1. 项目设想与方案选型

1.1 为什么选 AMD ROCm 云而不是租 NVIDIA 卡

先说背景。我当时手上没有本地 A100,临时任务也不值当按小时包一块 80G 的 NVIDIA 卡。正好那阵子云厂商推 AMD 实例,价格比同规格 NVIDIA 便宜不少,而且 ROCm 从 5.6 开始对 PyTorch 的兼容已经比前两年好了很多,所以我决定赌一把。

ROCm 的全称是 Radeon Open Compute,AMD 自己的一套 GPU 计算栈。很多人一听到“不用 CUDA”就头皮发麻,实际上今天 PyTorch 官方直接提供torch+rocm的预编译包,安装方式和 CUDA 版几乎一样。我的目标很简单:让 Gemma4 在 ROCm 上完成 forward、backward、LoRA 参数更新,评测指标达到可接受范围。至于要不要榨干每一帧显存,那是下一步的事。

如果你是第一次接触 ROCm,先记住一个概念:ROCm 里的显卡驱动、运行时库和 PyTorch 的轮子必须对齐版本。没有对齐,后面大概率会出现“模型加载正常,一 forward 就 kernel crash”的玄学问题。这也是我后面 4 个坑里最折腾的一个。

1.2 为什么选 Gemma4 做情绪分类

Gemma4 是当前开源社区里“小而能打”的一类模型代表,参数量比动辄几十 B 的模型友好太多,但基础语义理解、指令跟随能力都还在线。我需要的是一个能完成“给定一句话,输出正/负情绪标签”的底座,而不是一个需要从头训练的词向量模型。

选它的另一个原因是 HuggingFace 的 transformers 已经从代码层面做了兼容,AutoModelForSequenceClassification可以直接把这类 decoder-only 模型包成一个分类器。这样一来,我只需要操心“怎么在分类头上做好 LoRA”,而不需要手写 transformer block。

当然,情绪分类本身是个入门级任务,杀鸡用牛刀也不丢人。真正的目的不是刷 SOTA,而是把整套 ROCm + Gemma4 + LoRA 的链路打通。后面如果有更难的任务,比如舆情细粒度分类、客服对话意图识别,直接复用这套流程就很快。

1.3 LoRA 微调的基础逻辑

LoRA 的玩法其实不复杂:冻结预训练模型的全部权重,在部分线性层旁边插入两个低秩矩阵 A 和 B。训练时只更新这两个矩阵,推理时再把 AB 合并回原权重。这样可以极大减少可训练参数量,一个小模型微调任务通常只需要训练几百万到几千万参数。

用生活类比来说,基座模型就像一个知识量很大但不会“说人话”的转述机器,LoRA 则是给它加一个轻量校正扳手,只调“这个任务下该出的调子”,不让它把已经学会的知识洗掉。

情绪分类场景下,我不需要 LoRA 覆盖所有层,一般选择注意力层的 q_proj、v_proj 或者 gate_proj 就行。具体目标模块取决于任务难度,太深的模型层或者 MLP 层也可以微调,但参数量会成倍上涨,收益未必成正比。这个平衡我后面会详细讲。

2. 环境准备与数据准备

2.1 ROCm 环境安装与验证

我先列一下我的云实例规格:AMD EPYC 7003 系列 CPU,一张 64GB 显存的 MI210,系统是 Ubuntu 22.04。首先确保内核版本、驱动、ROCm 库三者匹配。云厂商给的镜像一般已经装好了 amdgpu 驱动,所以我不需要重新编译内核模块,只需要安装 user-space 工具和 PyTorch。

实际操作是预先安装好 ROCm 运行库和 rocm-smi 工具,检查 GPU 是否被系统识别:

rocm-smi --showallinfo

这里会列出 GPU 类型、显存总量、驱动版本、风扇转速等。如果确实看到 MI210,说明驱动层没问题。接着安装 PyTorch 的 ROCm 版本。我的做法是创建干净的 conda 环境:

conda create -n gemma-lora python=3.10 -y conda activate gemma-lora pip install torch==2.3.0+rocm5.7 --index-url https://download.pytorch.org/whl/rocm5.7 pip install transformers==4.40.0 datasets peft accelerate

版本对应关系很关键。当时 PyTorch 那边维护了多个 ROCm 分支,2.3.0+rocm5.7是官方测试比较充分的一组。如果你拿到的是 MI250 这种卡,可能还要额外设置一个环境变量,这个我放在后面的坑里讲。

验证 torch 是否真正可用 GPU,不要只看torch.cuda.is_available(),ROCm 下这个函数会返回 True,但实际调用的是 HIP。最可靠的办法是直接跑一次矩阵乘法,然后把张量打印出来。我当时写了一段:

import torch print(torch.version.hip) x = torch.randn(10, 10, device='cuda') y = torch.matmul(x, x) print(y.sum().item())

如果看到一串数字输出,说明计算链路已经通了。注意不要被“cuda”字样吓到,PyTorch 为了兼容老代码把 ROCm 也暴露为cuda接口,实际底层是 HIP。

2.2 模型加载与 tokenizer 配置

Gemma4 的权重我在 HuggingFace Hub 上找了一个带分类头的 checkpoint,也或者说只有原始基座权重,我用 transformers 加上分类头。前者更省事,后者更灵活。

加载模型时需要注意一个细节:如果 repo 里有自定义代码,需要trust_remote_code=True。不过更规范的路径是直接加载普通模型,再换分类头。我最终用的方案是:

from transformers import AutoTokenizer, AutoModelForSequenceClassification model_path = "your-registry/gemma4-base" tokenizer = AutoTokenizer.from_pretrained(model_path, use_fast=False) model = AutoModelForSequenceClassification.from_pretrained( model_path, num_labels=2, trust_remote_code=True )

为什么use_fast=False?因为 Gemma4 的分词器在某些版本下tokenizer.json格式不完整,用 fast tokenizer 会直接抛异常,换成 slow 反而稳。这个坑我详细写在第 4 节。

再强调一点:加载完模型后立刻打印model.config.hidden_size和参数量,确认模型没有读成一个奇怪的随机初始化版本。如果你发现参数量少了一个量级,大概率是 repo 选错了。

2.3 情绪数据集构建与标签设计

数据我用的是影评情绪二分类,训练集 5000 条,验证集 800 条,测试集 1200 条。长文本统一截断到 256 个 token。标签设计很简单:1 表示正向,0 表示负向。但有一条经验:不要隐含地把“中性”也塞进去,三分类会明显增加任务难度,而 LoRA 在小样本情况下最容易把这几个类混淆。

数据加载直接用 HuggingFace 的 datasets:

from datasets import load_dataset dataset = load_dataset("your_usecase/imdb-style-sentiment", split="train") dataset = dataset.train_test_split(test_size=0.15, seed=42) def tokenize(batch): return tokenizer(batch["text"], truncation=True, max_length=256, padding="max_length") dataset = dataset.map(tokenize, batched=True, remove_columns=["text"])

这里有个细节:padding="max_length"会让每个样本定长,batch 内部没有 padding 差异,可以省去注意力掩码的动态计算,但对显存不友好。如果你用 64G 显存,问题不大;如果只有 24G,建议改成padding="longest"。

我这次的训练数据分布还算平衡,正负各 2500 左右。说实话,0.594 这个基线水平主要不是数据问题,而是直接拿“零样本提示词”去让 Gemma4 输出标签时,模型总是倾向于输出格式里的第二个 token,导致准确率不算高。等用 LoRA 真正微调后,分类头才学到稳定边界。

3. LoRA 微调实操全流程

3.1 冻结基座模型并插入 LoRA 层

拿到分类模型后,我直接交给 PEFT 处理。首先确认哪些模块要被注入 LoRA。对于 Gemma4 这种架构,注意力层的投影矩阵通常叫q_proj、k_proj、v_proj、o_proj。我只选择了q_proj和v_proj,这是任务复杂度比较低时的保守选择,参数量少,过拟合风险低。

from peft import LoraConfig, get_peft_model lora_config = LoraConfig( r=8, lora_alpha=16, lora_dropout=0.05, target_modules=["q_proj", "v_proj"], bias="none", task_type="SEQ_CLS" ) model = get_peft_model(model, lora_config) model.print_trainable_parameters()

输出大概是:

Trainable params: 4,718,592 || all params: 2,164,682,752 || trainers%: 0.2178

不到千万的 trainable 参数对 ROCm 来说没有任何压力。之所以不把k_proj、o_proj也加进去,是因为情绪分类任务是一种粗粒度语义判断,相比于细粒度 NER 或推理任务,并不需要那么强的表示自由度。加了反而可能损失稳定性。

task_type必须设为"SEQ_CLS",这是一个很容易被忽视的点。如果把task_type留空,PEFT 不会自动为分类头创建可训练参数,而你恰恰需要分类头有一部分可训练权重去拟合标签映射。

3.2 训练参数设置与保存策略

训练器的选择上,我没有用 Trainer,而是手动写了一个torch训练循环。Trainer 很方便,但对 ROCm 上出现的一些底层异常不够透明,不利于排查 kernel crash。手动循环能看到每一步的显存占用和报错现场。

超参配置如下:

learning_rate: 2e-4 batch_size: 16 gradient_accumulation_steps: 2 num_epochs: 3 optimizer: AdamW lr_scheduler: cosine weight_decay: 0.01 max_grad_norm: 1.0

这里学习率 2e-4 是 LoRA 微调的常见起点。普通全参数微调一般用 1e-5 到 3e-5,但 LoRA 可训练参数少,可以用更大的学习率去快速适应新任务。如果学习率设成 5e-5,loss 虽然有下降,但收敛速度会慢不少。

优化器我用了标准的 AdamW,没有用 8bit 版。ROCm 上 bitsandbytes 的支持还不够完善,我在这个卡的 ROCm 环境里试过一次,直接报“未检测到 CUDA”。虽然可以强行设环境变量去骗过检查,但风险太大,不如直接用全精度优化器状态。因为一共才 470 万可训练参数,优化器态占用的显存可以忽略不计。

训练循环核心部分:

from transformers import get_cosine_schedule_with_warmup from torch.optim import AdamW optimizer = AdamW(model.parameters(), lr=2e-4, weight_decay=0.01) scheduler = get_cosine_schedule_with_warmup(optimizer, num_warmup_steps=100, num_training_steps=total_steps) for epoch in range(3): model.train() for step, batch in enumerate(dataloader): batch = {k: v.to("cuda") for k, v in batch.items()} outputs = model(**batch) loss = outputs.loss / gradient_accumulation_steps loss.backward() if (step + 1) % gradient_accumulation_steps == 0: torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() optimizer.zero_grad()

每 10 步打印一次当前 loss 和 GPU 利用率:

step 10 | loss 0.6832 | lr 1.98e-04 | mem 38.2G step 20 | loss 0.5817 | lr 1.92e-04 | mem 38.4G ...

打印torch.cuda.memory_summary()可以看到 ROCm 下的显存分配情况。实际上 ROCm 显存管理和 CUDA 差不多,都会随着第一次 forward 逐步分配,后面趋于平稳。

3.3 评估与准确率跃迁记录

训练完三个 epoch 后,我用验证集挑选最佳 checkpoint,再加载到测试集评估。评估时绝不能继续用 training mode,要把 LoRA 权重合并进基座模型,或者至少用带 adapter 的推理模式。

合并权重的方式:

merged_model = model.merge_and_unload()

合并之后推理性价比更高,也方便导出成一个独立的单文件模型。如果不合并而是直接使用 PeftModel 推理,首次加载 adapter 权重会多耗一点时间。

评估代码如下:

from sklearn.metrics import accuracy_score, classification_report model.eval() preds, labels = [], [] with torch.no_grad(): for batch in test_dataloader: batch = {k: v.to("cuda") for k, v in batch.items()} logits = model(**batch).logits preds.extend(torch.argmax(logits, dim=-1).cpu().tolist()) labels.extend(batch["labels"].cpu().tolist()) print("accuracy:", accuracy_score(labels, preds)) print(classification_report(labels, preds))

最终结果:

accuracy: 0.7342 precision recall f1-score support 0 0.73 0.72 0.72 600 1 0.74 0.75 0.73 600

对比零样本基线 0.594,提升约 14 个百分点。这个提升幅度对我很有意义,因为它说明即使是轻量级 LoRA,在 ROCm 上也能完成一次有效果的适配。真要说哪里贡献最大,我的体会是分类头那层可训练参数和 v_proj 的 low-rank 更新共同起了作用,只靠 q_proj 时准确率卡在 0.68 左右。

4. 我踩过的 4 个坑及处理实录

4.1 坑 1:PyTorch ROCm 版本与驱动不匹配,kernel 一执行就崩

现象:环境检查全部正常,rocm-smi能看到 GPU,torch.cuda.is_available()返回 True。但第一次调用模型model(input_ids)时,程序直接崩溃,报错末尾往往跟着Memory access fault或者Kernel launch failed,也可能直接让整个 python 进程退出,连 traceback 都不给。

原因:PyTorch 编译时绑定了某个 MI 系列架构的指令集,而我这个实例的实际 GPU 架构不在默认支持列表里。云厂商提供的是 MI210,GCN 架构代号是gfx90a,但 PyTorch 的 hipify 层可能默认匹配成gfx908,导致 kernel 编译出来跑不动。

解决:在启动 python 前设置环境变量,告诉 ROCm 后端强制使用兼容架构:

export HSA_OVERRIDE_GFX_VERSION=9.0.8 python train.py

这里9.0.8对应 gfx908,是一个相对通用的兼容选项。如果你不确定自己的卡是哪一代,可以先运行rocminfo | grep gfx看 Compute Unit 信息,再决定填9.0.8还是9.0.a。

经验:遇到 kernel 崩溃不要先怀疑代码逻辑,先检查环境变量。ROCm 这种问题十有八九是架构不对齐。另一个办法是直接安装云厂商提供的 docker 镜像,那里面的 PyTorch 已经调好了,能省掉很多痛苦。

4.2 坑 2:Gemma4 的分词器加载出现“special tokens lost”

现象:用AutoTokenizer.from_pretrained(model_path)加载时,提示找不到tokenizer_config.json或者把unk_token设置成了None。起初没在意,等训练时发现 loss 忽高忽低,而且生成出来的标签经常是同一个 token id。

原因:Gemma4 的分词器有些文件是从原始训练框架里导出的,并不是标准的tokenizer.json加tokenizer_config.json组合。直接用 fast tokenizer 去推断特殊 token 时会失败。transformers 4.40 对此处理得还不够智能。

解决:把use_fast=True改为use_fast=False,并且显式把特殊 token 重新塞进去:

tokenizer = AutoTokenizer.from_pretrained(model_path, use_fast=False) if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token if tokenizer.unk_token is None: tokenizer.unk_token = tokenizer.eos_token

另外推荐一个习惯:每次把 tokenizer 保存到训练输出目录时,主动保存一份tokenizer_config.json,这样后续指定from_pretrained(model_dir)时不会出兼容问题。

经验:不要全信from_pretrained的“自动模式”。加载任何非主流结构模型前,先打开 config 文件看一眼tokenizer_class是不是GemmaTokenizer,如果是但没有匹配的 fast 类型,那就是 slow 的命。

4.3 坑 3:LoRA 只挂到了 embedding 上,训练半天指标纹丝不动

现象:loss 从 0.7 降到 0.62,看起来在收敛,但测试集准确率还是 0.59 附近。我第一反应是数据有问题,后来打印了 trainable 参数位置才发现,model.print_trainable_parameters()显示可训练参数只有 20 万,明显偏少。

原因:模型仓库里的层名和我预想的不同。Gemma4 的分类头不在常规的score层,而 LoRA 配置的target_modules用通配符匹配时只匹配到了embed_tokens。embedding 的更新对分类边界影响非常有限,自然指标不动。

解决:先用model.named_modules()把所有模块名字列出来,筛选出真正包含q_proj、v_proj、dense的路径。我最终的 target 是这样写的:

target_modules=[".*q_proj", ".*v_proj", "score"]

然后再次print_trainable_parameters()确认可训练参数回到了 470 万。

经验:任何微调任务开始之前,都值得打印一次可训练参数数量。如果莫名其妙特别少,不用急着调学习率,先查目标模块匹配对不对。

4.4 坑 4:显存不足想用 CPU offload,结果每个 step 慢到怀疑人生

现象:训练过程中 batch size 从 16 加到 32,直接 OOM。我快速加了accelerate的 CPU offload 配置,把优化器状态和部分参数放到 CPU。结果每 step 时间从 0.8 秒涨到了 5 秒,三天都跑不完。

原因:ROCm 上 PCIe 传输带宽和 CUDA 环境下的 NVLink 完全不是一回事,频繁把梯度状态在 GPU/CPU 之间搬移,传输开销远超省下来的一点显存收益。

解决:这条路放弃。我改回 batch size 16,开启梯度累积到 2,相当于“伪 batch size 32”。梯度累积不会增加瞬时显存,只会让更新频率变慢一点,但对 LoRA 这种参数小的任务完全够用。

经验:优先缩小 batch size + 梯度累积,而不是无脑 offload。只有当输入序列特别长、且 batch size 已经小到 1 时才值得考虑 offload。

5. 效果复盘与后续扩展

5.1 从 0.594 到 0.734:准确率提升拆解

0.594 的基线并不是随机猜测。二分类随机猜是 0.5,0.594 比随机高了不少,这是因为直接使用模型在预训练阶段形成的语言先验:某些积极词出现时,模型会偏向输出正类。但整体偏保守,对否定句、反讽句式基本失效。

LoRA 微调后,分类头学到了一个更贴合数据分布的决策边界。同样一句话,模型不再依赖“词表面情感”,而是从注意力层抽取更抽象的上下文表示。提升最大的样本集中在否定句上,比如“我本来觉得无聊,结果越看越上头”,原先会判成负,现在判成正。

另外一个数据细节:验证集上最优 epoch 出现在第 2 轮而不是第 3 轮。后续继续训练会有轻微的过拟合,训练继续收敛,验证准确率却开始下降。如果你复现时发现第 2 轮最高,建议把early_stopping或直接按验证集保存最佳权重。

5.2 LoRA 超参数与数据量对结果的影响

我在固定其他参数不变的情况下,稍微测了两组对照。第一组把lora_alpha调到 32,验证集精度没有继续上升,反而在最后 epoch 掉了 0.02,说明alpha=16已经足够表达这个任务的低秩空间。第二组把训练数据削减到 1200 条,准确率仍然能到 0.70,说明小样本场景下 LoRA 的泛化能力还可以。

如果后续要进一步提高,最值得尝试的改动是增加目标模块,把o_proj也纳入训练,同时把r从 8 提到 16。代价是训练参数翻倍还多,但对更复杂的情绪类别可能有利。

我没有做特别夸张的 grid search,原因是在 ROCm 云上按小时计费,每次全量训练大概 40 分钟,我想把预算留给更有价值的任务。如果你有耐心,建议至少跑三组 r(8、16、32)和两组 target_modules,来找到你所在数据域的最佳平衡。

5.3 后续还能怎么扩展

这套链路里真正值得复用的是“ROCm 云 + Gemma4 + LoRA 分类头”的最小工程闭环。我后续会把它扩展成两个方向:一是多标签情绪分类,比如惊讶、愤怒、悲伤、喜悦四分类,这时需要把num_labels改成 4,同时增加训练样本;二是把 LoRA 与指令微调结合,让模型不仅输出标签,还要生成一句简短的评论,这会变成一个序列到序列的任务。

另外值得提一句:用 ROCm 做 LoRA 微调的生态已经很成熟,但要注意 bitsandbytes 的 8bit/4bit 量化在 ROCm 上仍不原生可用。如果你为了省显存想上 QLoRA,最好先去查一下当前版本的兼容情况,否则可能浪费很多时间在“骗过 CUDA 检测”上。

最终个人经验补充分享

最后分享一个我从这次实践中沉淀下来的小技巧:每次训练前把环境版本信息固定下来,至少记录torch.__version__、torch.version.hip、transformers.__version__这三个。虽然听起来很啰嗦,但 ROCm 环境下版本错配是最大的隐形杀手,把这三个版本写进训练日志里,再遇到问题翻日志会省掉一半的排查时间。另外,训练过程中每 50 步强制做一次验证集推理,把预测结果写进 JSONL,方便后续看错误样本,这比只看准确率数字有用得多。这次项目能稳定跑通到 0.734,很大程度上也归功于这种“每个中间结果都留痕”的习惯。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询