☰
在 ROCm 上单卡 LoRA 微调 Gemma 4 情绪分类模型
2026/10/23 2:26:37 网站建设 项目流程

原文:Datawhale 社区 → Hello ROCm 系列 Notebook
链接:

Radeon Cloud 预览

|

GitHub 源码


标签:#ROCm #Gemma4 #LoRA #情绪分类 #ModelScope #AMD GPU

一、为什么要读这个

AI 微调生态一直围着 NVIDIA CUDA 转。AMD ROCm 虽然能跑,但教程少、坑多。这篇 Notebook 是 Datawhale 社区 X AMD 合作的实操教程,手把手教你在 AMD GPU 上用魔搭 ModelScope 对 Gemma 4(谷歌最新 4B 模型)做 LoRA 微调。完全不依赖 Hugging Face,从模型下载到训练到评估,全部走魔搭国内镜像→本地路径。对国内开发者/学生党非常实用。

二、核心内容速览

📌 任务:情绪分类(6 类)

标签

含义

示例

0

sadness(悲伤)

"I'm feeling so alone..."

1

joy(快乐)

"I am so happy today!"

2

love(爱)

"I love you so much!"

3

anger(愤怒)

"This is so frustrating!"

4

fear(恐惧)

"I'm scared of the dark."

5

surprise(惊讶)

"I can't believe it!"

数据集 AI-ModelScope/emotion(镜像自 dair-ai/emotion)16000 训练 / 2000 验证 / 2000 测试。

📌 模型:Gemma 4 E4B-it

谷歌 2025 年发布的 Gemma 4 系列,4B 参数、E4B-it 是指令微调版,自带官方 chat_template,无需额外构造模板。

📌 方法:LoRA

  • r=16, alpha=32, target_modules="all-linear"

  • 只微调 0.5% 左右的参数

  • 单卡 BF16 训练,等效 batch size = 16

三、完整流程(共 20 步)

环境准备 → 下载模型&数据 → 格式转换 → 加载模型 → 评估基线 → LoRA训练 → 评估对比 → 保存结果

Step 1-3:环境搭建

  • 配置源pip config set global.index-url https://mirrors.cloud.tencent.com/pypi/simple/
  • 安装魔搭ModelScopepip install modelscope
  • 下载模型

  • modelscope download --model Qwen/Qwen3-32B --cache_dir "./models"

  • modelscope download --model google/gemma-4-E4B-it --cache_dir "./models"

  • 启动VLMM uv pip uninstall torchvision torchaudio # 经测试,在该云环境中,需卸载重新安装这个库才能正常使用

    uv pip install 'vllm==0.23.0+rocm723' torchvision torchaudio 'fastapi[standard]==0.136.0' \ --no-cache \ --index-url https://mirrors.aliyun.com/pypi/simple/ \ --extra-index-url https://wheels.vllm.ai/rocm/ \ -U
  • 启动vllm

    vllm serve ./models/Qwen/Qwen3-32B/ --served-model-name Qwen3-32B vllm serve ./models/google/gemma-4-E4B-it/ --served-model-name gemma-4-E4B-it

启动客户端:

vllm chat --url http://localhost:8000/v1 --model gemma-4-E4B-it

测试成功!!!

Step 4:

模型下载(魔搭)

modelscope.snapshot_download("google/gemma-4-E4B-it")

系统提供了准备好了一份“一键顺滑”的微调实验报告(Notebook 文件-gemma4_emotion_lora_modelscope_single_gpu),

它能自动完成复杂的微调流程。

在左侧目录中双击刚才上传的.ipynb后缀文件。

在打开的界面顶部,找到并点击“⏩” (Run All)按钮,程序就会按照预设顺序自动执行所有代码


import os import re import json import random import warnings import numpy as np import pandas as pd import torch from tqdm.auto import tqdm from datasets import Dataset, DatasetDict, ClassLabel, load_dataset from sklearn.metrics import accuracy_score, classification_report, confusion_matrix, f1_score from modelscope import snapshot_download from modelscope.hub.snapshot_download import dataset_snapshot_download from transformers import AutoModelForCausalLM, AutoTokenizer, set_seed from peft import LoraConfig, PeftModel from trl import SFTConfig, SFTTrainer warnings.filterwarnings("ignore") # ----------------------------- # 基础配置 # ----------------------------- # 魔搭上的模型 ID(Gemma 4 E4B-it 在 ModelScope 上的官方仓库,instruction-tuned 版本, # 仓库内自带官方 chat_template.jinja,无需手动处理 chat template)。 # 仓库地址: https://www.modelscope.cn/models/google/gemma-4-E4B-it MODELSCOPE_MODEL_ID = "google/gemma-4-E4B-it" # 魔搭上的数据集 ID(dair-ai/emotion 在 ModelScope 上的官方镜像)。 MODELSCOPE_DATASET_ID = "AI-ModelScope/emotion" # 微调输出目录 OUTPUT_DIR = "./gemma4-it-emotion-lora-ms-single-gpu" # 这里代码默认只截取一部分数据。跑通后你可以把 TRAIN_LIMIT 改大,甚至设为 None 使用全量数据。 TRAIN_LIMIT = 4000 VALIDATION_LIMIT = 400 TEST_LIMIT = 400 EVAL_LIMIT = 400 SEED = 42 MODEL_DTYPE = torch.bfloat16 BF16 = True # 默认使用BF16 FP16 = False SYSTEM_PROMPT = """You are an emotion classification assistant. Read the user's text and answer with exactly one label. Only choose from: sadness, joy, love, anger, fear, surprise. Return only the label and nothing else.""" LABEL_PATTERN = re.compile(r"(sadness|joy|love|anger|fear|surprise)", re.IGNORECASE) os.makedirs(OUTPUT_DIR, exist_ok=True) os.makedirs("./models", exist_ok=True) os.makedirs("./datasets", exist_ok=True) print("torch version:", torch.__version__) print("torch.cuda.is_available():", torch.cuda.is_available()) print("torch.cuda.device_count():", torch.cuda.device_count()) if torch.cuda.is_available(): print("current device:", torch.cuda.current_device()) print("device name:", torch.cuda.get_device_name(0))

固定随机种子

固定随机种子可以让数据 shuffle、LoRA 初始化和训练过程尽量可复现。

def setup_seed(seed: int = 42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) set_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed) setup_seed(SEED)

Step 5. 下载并加载数据集
数据集使用 AI-ModelScope/emotion。
Notebook 没有直接用 MsDataset.load() 走桥接路径,而是:
用 dataset_snapshot_download 下载整个数据集仓库
找到本地 parquet 文件
用 datasets.load_dataset("parquet", ...) 加载
这样做的好处是绕开 modelscope 和 datasets 之间可能出现的版本兼容问题。

MODELSCOPE_MODEL_ID = "google/gemma-4-E4B-it" print("Downloading model from ModelScope...") print("ModelScope model id:", MODELSCOPE_MODEL_ID) model_dir = snapshot_download( MODELSCOPE_MODEL_ID, cache_dir="./models", # 将模型下载都当前models文件夹下 ) print("Downloaded model dir:", model_dir) # 后续统一使用本地路径加载 LOCAL_MODEL_DIR = model_dir

Step 6. 构造 prompt-completion 数据

import glob EMOTION_LABEL_NAMES = ["sadness", "joy", "love", "anger", "fear", "surprise"] # 直接把魔搭上的数据集仓库(parquet 文件)整体下载到本地,然后用 datasets 库从本地 parquet 加载。 # 不走 MsDataset.load -> datasets.load_dataset 的桥接路径,可以规避 modelscope 与 datasets 之间 # `as_dataset() got an unexpected keyword argument 'verification_mode'` 这类版本错配错误。 print("Downloading dataset from ModelScope...") print("ModelScope dataset id:", MODELSCOPE_DATASET_ID) dataset_dir = dataset_snapshot_download( MODELSCOPE_DATASET_ID, cache_dir="./datasets", ) print("Downloaded dataset dir:", dataset_dir) def _parquet_files_for(split_name: str): pattern = os.path.join(dataset_dir, "data", f"{split_name}-*.parquet") files = sorted(glob.glob(pattern)) if not files: raise FileNotFoundError( f"No parquet files matched pattern: {pattern}. " f"Please check the dataset repo layout under {dataset_dir}." ) return files raw_dataset = load_dataset( "parquet", data_files={ "train": _parquet_files_for("train"), "validation": _parquet_files_for("validation"), "test": _parquet_files_for("test"), }, ) # 从 parquet 加载时,label 字段类型会退化成普通整数,这里显式 cast 成 ClassLabel, # 这样后续 `dataset["train"].features["label"].names` 和原始 HF 版接口完全一致。 for split_name in list(raw_dataset.keys()): if not isinstance(raw_dataset[split_name].features.get("label"), ClassLabel): raw_dataset[split_name] = raw_dataset[split_name].cast_column( "label", ClassLabel(names=EMOTION_LABEL_NAMES) ) print("Raw dataset:", raw_dataset) def maybe_limit(split, limit): split = split.shuffle(seed=SEED) if limit is None: return split return split.select(range(min(limit, len(split)))) dataset = DatasetDict({ "train": maybe_limit(raw_dataset["train"], TRAIN_LIMIT), "validation": maybe_limit(raw_dataset["validation"], VALIDATION_LIMIT), "test": maybe_limit(raw_dataset["test"], TEST_LIMIT), }) label_names = dataset["train"].features["label"].names VALID_LABELS = set(label_names) ALL_EVAL_LABELS = label_names + ["INVALID"] print(dataset) print("label_names:", label_names) print("example:", dataset["train"][0])
Downloading dataset from ModelScope... ModelScope dataset id: AI-ModelScope/emotion Downloading Dataset to directory: ./datasets/AI-ModelScope/emotion Fetching file list (root: /)... 100% 6/6 [00:00<00:00, 5.86 files/s] Fetched 6 matching files (1 pages)... Download complete: 6 files found, 6 cached, 0 downloaded. Downloaded dataset dir: ./datasets/AI-ModelScope/emotion Raw dataset: DatasetDict({ train: Dataset({ features: ['text', 'label'], num_rows: 16000 }) validation: Dataset({ features: ['text', 'label'], num_rows: 2000 }) test: Dataset({ features: ['text', 'label'], num_rows: 2000 }) }) DatasetDict({ train: Dataset({ features: ['text', 'label'], num_rows: 4000 }) validation: Dataset({ features: ['text', 'label'], num_rows: 400 }) test: Dataset({ features: ['text', 'label'], num_rows: 400 }) }) label_names: ['sadness', 'joy', 'love', 'anger', 'fear', 'surprise'] example: {'text': 'while cycling in the country', 'label': 4}


原始数据是普通分类格式:
text

text + label
Notebook 把它改成聊天模型适合的格式:

这里把普通分类数据转换成聊天模型适合的指令微调格式: prompt:system + user completion:assistant 只输出一个情绪标签 这样训练后的模型会更倾向于严格输出: sadness / joy / love / anger / fear / surprise
DatasetDict({ train: Dataset({ features: ['prompt', 'completion'], num_rows: 4000 }) validation: Dataset({ features: ['prompt', 'completion'], num_rows: 400 }) test: Dataset({ features: ['prompt', 'completion'], num_rows: 400 }) }) {'prompt': [{'role': 'system', 'content': "You are an emotion classification assistant.\nRead the user's text and answer with exactly one label.\nOnly choose from: sadness, joy, love, anger, fear, surprise.\nReturn only the label and nothing else."}, {'role': 'user', 'content': 'Classify the emotion of this text:\n\nwhile cycling in the country'}], 'completion': [{'role': 'assistant', 'content': 'fear'}]}


textsystem: 你是情绪分类助手
user: Classify the emotion of this text: ...
assistant: joy / sadness / love / anger / fear / surprise
这样训练后的模型不是输出长篇解释,而是尽量只输出一个合法情绪标签。
这是把 “分类任务” 转成 “指令微调任务” 的关键步骤。
Step 7. 加载 tokenizer 和基础模型
Notebook 从 ModelScope 下载后的本地路径加载 tokenizer 和模型。

print("Loading tokenizer from:", LOCAL_MODEL_DIR) tokenizer = AutoTokenizer.from_pretrained( LOCAL_MODEL_DIR, use_fast=True, trust_remote_code=True, extra_special_tokens={}, # 传入空字典而非列表 ) if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token print("pad_token:", tokenizer.pad_token) print("eos_token:", tokenizer.eos_token) # `google/gemma-4-E4B-it` 的 tokenizer 通常会自带 chat_template。 # 若缺失(缓存不完整等),从同一魔搭仓库拉取官方 chat_template.jinja 注入(权重已在上面整仓下载时可跳过额外拉取)。 TEMPLATE_SOURCE_MODEL_ID = "google/gemma-4-E4B-it" def _load_official_gemma_chat_template() -> str: """从 gemma-4-E4B-it 仓库下载官方 chat_template.jinja 并返回字符串。 主路径:modelscope.snapshot_download(allow_file_pattern=["chat_template.jinja"]) 兜底:ModelScope raw file API 直接 HTTP GET """ try: template_dir = snapshot_download( TEMPLATE_SOURCE_MODEL_ID, cache_dir="./models", allow_file_pattern=["chat_template.jinja"], ) path = os.path.join(template_dir, "chat_template.jinja") if os.path.exists(path): with open(path, "r", encoding="utf-8") as f: return f.read() except Exception as e: print("snapshot_download(allow_file_pattern) failed, fallback to HTTP. err =", e) import urllib.request url = ( "https://www.modelscope.cn/api/v1/models/" f"{TEMPLATE_SOURCE_MODEL_ID}/repo?Revision=master&FilePath=chat_template.jinja" ) with urllib.request.urlopen(url, timeout=60) as resp: return resp.read().decode("utf-8") if not getattr(tokenizer, "chat_template", None): print(f"Loading official chat_template.jinja from {TEMPLATE_SOURCE_MODEL_ID} ...") tokenizer.chat_template = _load_official_gemma_chat_template() print("Loaded official chat_template, length =", len(tokenizer.chat_template)) else: print("tokenizer.chat_template already set, leaving as-is.") # 自检:跑一次 apply_chat_template,确保模板可用。 _probe = tokenizer.apply_chat_template( [ {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "Hello"}, ], tokenize=False, add_generation_prompt=True, ) print("chat_template probe output:\n" + _probe) print("Loading base model from:", LOCAL_MODEL_DIR) device = "cuda" if torch.cuda.is_available() else "cpu" print("Using device:", device) print("HIP version:", getattr(torch.version, "hip", None)) # base_model = AutoModelForCausalLM.from_pretrained( # LOCAL_MODEL_DIR, # torch_dtype=MODEL_DTYPE, # low_cpu_mem_usage=True, # trust_remote_code=True, # ) base_model = AutoModelForCausalLM.from_pretrained( LOCAL_MODEL_DIR, torch_dtype=MODEL_DTYPE, device_map="auto", trust_remote_code=True, ) base_model.to(device) base_model.config.use_cache = False base_model.config.pad_token_id = tokenizer.pad_token_id base_model.config.bos_token_id = tokenizer.bos_token_id base_model.config.eos_token_id = tokenizer.eos_token_id base_model.generation_config.pad_token_id = tokenizer.pad_token_id base_model.generation_config.bos_token_id = tokenizer.bos_token_id base_model.generation_config.eos_token_id = tokenizer.eos_token_id print("Base model loaded.") print("Base model device:", next(base_model.parameters()).device)

关键点:
使用 instruction-tuned 版本
tokenizer 通常自带官方 chat_template
如果没有 pad_token,就设置为 eos_token
单卡版本不使用 device_map="auto" 做多卡切分
不使用 4bit bitsandbytes
开启 gradient_checkpointing 节省显存
这套配置更适合 AMD ROCm 单卡环境。

推理函数 微调前后都用同一套推理函数做评估,方便对比模型效果。 extract_label() 用于从模型生成文本里提取合法标签。如果模型输出了奇怪内容,会被记为 INVALID。
def extract_label(raw_text: str) -> str: raw_text = raw_text.strip().lower() match = LABEL_PATTERN.search(raw_text) if match: return match.group(1) tokens = raw_text.split() if not tokens: return "INVALID" return tokens[0].strip(".,!?:;\"'()[]{}") def generate_label(model, tokenizer, user_text: str, system_prompt: str = SYSTEM_PROMPT, max_new_tokens: int = 4) -> str: user_content = f"Classify the emotion of this text:\n\n{user_text}" messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_content}, ] device = next(model.parameters()).device inputs = tokenizer.apply_chat_template( messages, tokenize=True, add_generation_prompt=True, return_dict=True, return_tensors="pt", ) inputs = {k: v.to(device) for k, v in inputs.items()} input_len = inputs["input_ids"].shape[-1] model.eval() with torch.no_grad(): outputs = model.generate( **inputs, max_new_tokens=max_new_tokens, do_sample=False, pad_token_id=tokenizer.pad_token_id, eos_token_id=tokenizer.eos_token_id, ) raw_pred = tokenizer.decode(outputs[0][input_len:], skip_special_tokens=True).strip() return extract_label(raw_pred) def predict_emotion(text: str, model=None): model = model or base_model return generate_label(model, tokenizer, text) predict_emotion("I feel so happy and excited today!")
由于生成式评估比较慢,默认只评估 EVAL_LIMIT=400 条测试样本。
def evaluate_model(model, tokenizer, split="test", limit=EVAL_LIMIT): y_true, y_pred, rows = [], [], [] raw_source = dataset[split] if limit is not None: raw_source = raw_source.select(range(min(limit, len(raw_source)))) model.eval() for ex in tqdm(raw_source, desc=f"Evaluating {split}", leave=False): true_label = label_names[ex["label"]] raw_pred_label = generate_label(model, tokenizer, ex["text"], SYSTEM_PROMPT) pred_label = raw_pred_label if raw_pred_label in VALID_LABELS else "INVALID" y_true.append(true_label) y_pred.append(pred_label) rows.append({ "text": ex["text"], "true_label": true_label, "pred_label": pred_label, "raw_pred_label": raw_pred_label, "correct": true_label == pred_label, }) metrics = { "accuracy": accuracy_score(y_true, y_pred), "macro_f1": f1_score(y_true, y_pred, labels=label_names, average="macro", zero_division=0), "invalid_predictions": sum(1 for p in y_pred if p == "INVALID"), "evaluated_examples": len(y_true), } report = classification_report( y_true, y_pred, labels=label_names, output_dict=True, zero_division=0, ) return metrics, report, pd.DataFrame(rows) def confusion_matrix_df(pred_df): return pd.DataFrame( confusion_matrix(pred_df["true_label"], pred_df["pred_label"], labels=ALL_EVAL_LABELS), index=ALL_EVAL_LABELS, columns=ALL_EVAL_LABELS, )


Step 8. 微调前评估
训练之前先跑一轮基础模型评估,作为 baseline。

pre_metrics, pre_report, pre_preds = evaluate_model(base_model, tokenizer, split="test", limit=EVAL_LIMIT) pre_metrics
{'accuracy': 0.0, 'macro_f1': 0.0, 'invalid_predictions': 400, 'evaluated_examples': 400}     [13]: 


评估指标包括:
accuracy
macro_f1
invalid_predictions
classification_report
confusion_matrix
因为生成式评估比较慢,所以默认只评估 EVAL_LIMIT=400 条测试样本。
这里的设计很实用:先知道基础模型能力,再判断 LoRA 是否真的带来提升。
Step 9. 配置 LoRA
LoRA 配置大致是:

python r = 16 lora_alpha = 32 lora_dropout = 0.05 target_modules = "all-linear" task_type = "CAUSAL_LM"


target_modules="all-linear" 的意思是尽量给所有线性层加 LoRA。
优点是容易跑通,适合实验初版;缺点是可能增加显存和训练开销。后续如果要优化,可以改成指定模块名。

lora_config = LoraConfig( r=16, lora_alpha=32, lora_dropout=0.05, bias="none", task_type="CAUSAL_LM", target_modules="all-linear", ) training_args = SFTConfig( output_dir=OUTPUT_DIR, per_device_train_batch_size=4, # 显存不够的话,可以把 per_device_train_batch_size 改成 1 或 2 per_device_eval_batch_size=1, gradient_accumulation_steps=4, learning_rate=1e-4, weight_decay=0.01, lr_scheduler_type="linear", warmup_steps=50, num_train_epochs=1, logging_steps=5, eval_strategy="steps", eval_steps=25, save_strategy="steps", save_steps=25, save_total_limit=2, metric_for_best_model="eval_loss", greater_is_better=False, gradient_checkpointing=True, bf16=BF16, fp16=FP16, tf32=False, max_length=256, packing=False, completion_only_loss=True, remove_unused_columns=False, dataloader_num_workers=2, optim="adamw_torch", report_to="none", seed=SEED, data_seed=SEED, )


Step 10. 训练参数
单卡训练默认配置:

per_device_train_batch_size=4 gradient_accumulation_steps=4 等效 batch size = 16 learning_rate=1e-4 num_train_epochs=1 优化器使用 adamw_torch


这里没有用 bitsandbytes 优化器,主要是为了避免 ROCm 兼容问题。
如果显存不足,可以把 batch size 改成 1 或 2。
Step 11. 开始 LoRA 微调

if isinstance(base_model, PeftModel): base_model = base_model.unload() base_model.config.use_cache = False trainer = SFTTrainer( model=base_model, train_dataset=sft_dataset["train"], eval_dataset=sft_dataset["validation"], peft_config=lora_config, args=training_args, processing_class=tokenizer, ) trainable_params = 0 total_params = 0 trainable_param_names = [] for name, param in trainer.model.named_parameters(): total_params += param.numel() if param.requires_grad: trainable_params += param.numel() trainable_param_names.append(name) if trainable_params == 0: raise RuntimeError("No trainable LoRA parameters were attached. Check target_modules before training.") print(f"Trainable LoRA parameters: {trainable_params:,}") print(f"Total parameters: {total_params:,}") print(f"Trainable ratio: {100 * trainable_params / total_params:.4f}%") print("Example trainable parameters:") print(trainable_param_names[:20]) train_result = trainer.train() trainer.model.eval() trainer.model.config.use_cache = True print(train_result)


训练通过 SFTTrainer 完成。
训练前会检查 LoRA 参数是否成功挂载。如果可训练参数为 0,说明 target_modules 没有匹配成功,需要调整 LoRA 配置。

ft_model = trainer.model ft_model.eval() ft_model.config.use_cache = True post_metrics, post_report, post_preds = evaluate_model(ft_model, tokenizer, split="test", limit=EVAL_LIMIT) print(post_metrics) print(pd.DataFrame(post_report).transpose()) print(confusion_matrix_df(post_preds))


这一点很重要,因为 LoRA 配错时,训练可能看起来在跑,但实际上没有训练任何有效参数。
Step 12. 保存模型与结果
训练后保存的是 LoRA adapter,不是完整大模型。
通常包括:

comparison_df.to_csv(os.path.join(OUTPUT_DIR, "gemma4_emotion_before_after_metrics.csv"), index=False) merged_examples.to_csv(os.path.join(OUTPUT_DIR, "gemma4_emotion_prediction_examples.csv"), index=False) changed_predictions.to_csv(os.path.join(OUTPUT_DIR, "gemma4_emotion_changed_predictions.csv"), index=False) pre_preds.to_csv(os.path.join(OUTPUT_DIR, "pre_finetuning_predictions.csv"), index=False) post_preds.to_csv(os.path.join(OUTPUT_DIR, "post_finetuning_predictions.csv"), index=False) pd.DataFrame(pre_report).transpose().to_csv(os.path.join(OUTPUT_DIR, "pre_finetuning_classification_report.csv")) pd.DataFrame(post_report).transpose().to_csv(os.path.join(OUTPUT_DIR, "post_finetuning_classification_report.csv")) confusion_matrix_df(pre_preds).to_csv(os.path.join(OUTPUT_DIR, "pre_finetuning_confusion_matrix.csv")) confusion_matrix_df(post_preds).to_csv(os.path.join(OUTPUT_DIR, "post_finetuning_confusion_matrix.csv")) print("Saved all outputs to:", OUTPUT_DIR)


adapter_model.safetensors
adapter_config.json
tokenizer 文件
training metrics
checkpoint 文件
这样保存的好处是体积小,后续加载时只需要基础模型 + adapter。

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

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

立即咨询