Gemma 4 情绪分类 LoRA 微调实战记录(AMD ROCm 单卡)

本文是微调链路的逐步执行记录,与概念梳理文 AMD ROCm 上基于 LoRA 微调 Gemma 4 情绪分类实操手记 互补:这里只跟 Notebook 走,不写与部署/推理的逐项对照。

前置条件:同一台 48 GB ROCm 机器,PyTorch 已能识别 GPU;基座权重 google/gemma-4-E4B-it 已通过魔搭下载至 ./models/(若目录已存在,Step 4 可跳过)。实操 Notebook:gemma4_emotion_lora_modelscope_single_gpu.ipynb

任务:用 LoRA 在 AI-ModelScope/emotion 上微调 Gemma 4,使模型在 system 约束下稳定输出六类情绪标签,并完成微调前后量化对比。

微调流程概览

每步结构:目标 → 方法 → 数据结构(若有)→ 结果里盯什么


Step 1:安装依赖

目标

补齐微调训练栈:trl(训练循环)、peft(LoRA)、datasets(数据管道)、scikit-learn(评估指标)。

方法

1
2
3
4
# -U:升级到最新兼容版本
uv pip install -U trl peft datasets scikit-learn pandas tqdm modelscope transformers accelerate \
-i https://mirrors.cloud.tencent.com/pypi/simple/ # 国内 PyPI 镜像,加速下载
# trl:SFTTrainer 训练循环 | peft:LoRA | datasets:数据管道 | scikit-learn:评估指标

ROCm 环境若已装定制版 PyTorch,只补上述包即可,不必改动底层 torch 构建。

结果里盯什么

  • import trl, peft, datasets 无报错。
  • 若后续 MsDataset 桥接报错,本流程不依赖它(Step 5 用 parquet 直读)。

Step 2:导入依赖与全局配置

目标

集中定义模型 ID、数据集 ID、数据量上限、训练精度、任务 prompt 与输出目录,供后续各步引用。

方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
MODELSCOPE_MODEL_ID = "google/gemma-4-E4B-it"          # 魔搭基座模型 ID
MODELSCOPE_DATASET_ID = "AI-ModelScope/emotion" # 魔搭情绪分类数据集 ID
OUTPUT_DIR = "./gemma4-it-emotion-lora-ms-single-gpu" # adapter、checkpoint、CSV 输出目录

TRAIN_LIMIT = 4000 # 训练子集上限;None = 全量 16000 条
VALIDATION_LIMIT = 400 # 验证子集上限;供训练中 eval_loss 监控
TEST_LIMIT = 400 # 测试子集上限;供微调前/后评估
EVAL_LIMIT = 400 # 生成式评估条数上限(decode 慢,单独控制)

SEED = 42 # 全局随机种子,与 Step 3 一致
MODEL_DTYPE = torch.bfloat16 # 模型加载精度
BF16, FP16 = True, False # 训练精度开关;ROCm 下优先 BF16,不用 4bit QLoRA

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"\b(sadness|joy|love|anger|fear|surprise)\b", re.IGNORECASE
)

结果里盯什么

启动时打印 torch.cuda.is_available(): True 与设备名;ROCm 下仍显示 cuda 属 PyTorch 统一接口,属正常现象。


Step 3:固定随机种子

目标

让数据 shuffle、LoRA 初始化、dropout 尽量可复现,便于对比不同超参实验。

方法

1
2
3
4
5
6
7
8
9
def setup_seed(seed=42):
random.seed(seed) # Python 内置随机(如数据 shuffle)
np.random.seed(seed) # NumPy 随机
torch.manual_seed(seed) # PyTorch CPU 随机
set_seed(seed) # transformers 内部随机(含 dataloader)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed) # 所有 GPU 随机(含 LoRA 初始化、dropout)

setup_seed(SEED)

结果里盯什么

同一配置重复跑,loss 曲线与评估指标应高度接近(算子非确定性可能带来微小漂移)。


Step 4:准备基座模型

目标

拿到本地 Gemma 4 E4B-it 权重路径,供 transformers 加载 tokenizer 与 AutoModelForCausalLM

方法

权重若已在 ./models/google/gemma-4-E4B-it,直接设 LOCAL_MODEL_DIR 即可;否则:

1
2
3
4
LOCAL_MODEL_DIR = snapshot_download(
MODELSCOPE_MODEL_ID,
cache_dir="./models", # 下载到本地 models 目录,后续 offline 加载
)

仓库需含 model.safetensorstokenizer.jsonchat_template.jinja 等;Gemma 系列首次需在魔搭网页接受许可协议。

结果里盯什么

LOCAL_MODEL_DIR 指向含 config.jsonmodel.safetensors(约 14.9 GB)的目录。


Step 5:加载情绪分类数据集

目标

得到带 train / validation / test 三个 split 的标注数据,且 label 能映射到六类情绪名称。

方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
dataset_dir = dataset_snapshot_download(
MODELSCOPE_DATASET_ID,
cache_dir="./datasets", # 整仓拉取含 data/*.parquet 的数据集仓库
)

raw_dataset = load_dataset(
"parquet", # 直接从本地 parquet 读,绕过 MsDataset 桥接
data_files={
"train": sorted(glob.glob(f"{dataset_dir}/data/train-*.parquet")),
"validation": sorted(glob.glob(f"{dataset_dir}/data/validation-*.parquet")),
"test": sorted(glob.glob(f"{dataset_dir}/data/test-*.parquet")),
},
)

EMOTION_LABEL_NAMES = ["sadness", "joy", "love", "anger", "fear", "surprise"]
for split in raw_dataset:
raw_dataset[split] = raw_dataset[split].cast_column(
"label", ClassLabel(names=EMOTION_LABEL_NAMES) # int → ClassLabel,便于 names[i] 取类名
)

def maybe_limit(split, limit):
split = split.shuffle(seed=SEED) # 固定种子 shuffle,子集可复现
return split if limit is None else 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),
})

数据结构:原始分类格式

每条样本是 Hugging Face Dataset 的一行 dict:

1
2
3
4
{
"text": "while cycling in the country", # str,待分类英文文本
"label": 4, # int,0–5 对应六类情绪
}
label id 类别名
0 sadness
1 joy
2 love
3 anger
4 fear
5 surprise

全量规模:train 16000 / validation 2000 / test 2000。本子集:4000 / 400 / 400。

为何 ClassLabel cast? 从 parquet 直读时 label 仅为 int64;cast 后可通过 features["label"].names[i] 取类别字符串,供 Step 6 拼 completion。

为何不用 MsDataset.load() 规避 modelscopedatasets 版本错配时的 verification_mode 报错。

结果里盯什么

print(dataset) 三个 split 行数符合 limit;dataset["train"][0] 能正确读出 textlabel


Step 6:构造 SFT 格式数据

目标

(text, label) 分类样本转为 SFTTrainer 所需的 prompt-completion 对话结构,使训练目标与 chat 模型接口一致。

方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def to_prompt_completion(example):
label = label_names[example["label"]] # 整数 id → 字符串标签名
return {
"prompt": [
{"role": "system", "content": SYSTEM_PROMPT}, # 任务规则
{"role": "user", "content": f"Classify the emotion of this text:\n\n{example['text']}"},
],
"completion": [
{"role": "assistant", "content": label}, # 监督目标:模型应生成的标签
],
}

sft_dataset = dataset.map(
to_prompt_completion,
remove_columns=dataset["train"].column_names, # 去掉原始 text/label,只留 prompt+completion
)

数据结构:SFT prompt-completion 格式

每条训练样本变为:

1
2
3
4
5
6
7
8
9
{
"prompt": [
{"role": "system", "content": "<SYSTEM_PROMPT 全文>"}, # 任务规则
{"role": "user", "content": "Classify the emotion of this text:\n\n<原文>"}, # 待分类文本
],
"completion": [
{"role": "assistant", "content": "fear"}, # 监督目标:标签名字符串,非整数 id
],
}
字段 类型 含义
prompt list[dict] 模型条件输入:role + content 消息列表
completion list[dict] 监督目标:assistant 应生成的标准标签文本

SFTTrainer 内部调用 tokenizer.apply_chat_template 将上述结构 tokenize;配合 completion_only_loss=True(Step 12),loss 只算 completion 段,不对 system/user 前缀回传梯度。

段末注释:SFT(Supervised Fine-Tuning,监督微调)在此指用「输入对话 + 标准回答」对继续训练 Causal LM;Causal LM 按序预测下一个 token。

结果里盯什么

sft_dataset["train"].column_names 应为 ['prompt', 'completion'];抽查一条,completion[0]["content"]label_names[原 label id] 一致。


Step 7:加载 tokenizer 与基座模型

目标

以训练态加载 Gemma 4:tokenizer 可用官方 chat template;基座模型置于 GPU,并关闭 KV cache。

方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
tokenizer = AutoTokenizer.from_pretrained(
LOCAL_MODEL_DIR,
use_fast=True, # Rust 实现的分词器,速度更快
trust_remote_code=True, # 允许加载仓库内自定义代码(若存在)
)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token # Gemma 无独立 pad token,复用 eos

# chat_template 缺失时从魔搭补拉 chat_template.jinja(it 版通常已自带)
# 探针:apply_chat_template([...], tokenize=False, add_generation_prompt=True)

base_model = AutoModelForCausalLM.from_pretrained(
LOCAL_MODEL_DIR,
torch_dtype=MODEL_DTYPE, # BF16 加载,与训练精度一致
low_cpu_mem_usage=True, # 分片加载权重,降低 CPU 峰值内存
trust_remote_code=True,
)
base_model.to("cuda") # 单卡显式上 GPU;ROCm 下设备名仍为 cuda
base_model.config.use_cache = False # 训练态关闭 KV cache,否则与反向传播 / checkpointing 冲突

结果里盯什么

  • chat_template probe 输出含 <|turn>system 等 Gemma 4 标记。
  • next(base_model.parameters()).devicecuda:0
  • 加载后显存占用明显上升,属正常。

Step 8:推理辅助函数

目标

封装「文本 → 情绪标签」的生成式预测,供微调前/后评估共用,保证对比公平。

方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
def extract_label(raw_text: str) -> str:
"""从模型生成的自由文本中抽取合法标签。"""
match = LABEL_PATTERN.search(raw_text.strip().lower()) # 优先正则匹配六类词
if match:
return match.group(1)
tokens = raw_text.strip().lower().split()
# 正则未命中时取首 token 并去标点;仍不在词表则后续记 INVALID
return tokens[0].strip(".,!?:;\"'()[]{}") if tokens else "INVALID"

def generate_label(model, tokenizer, user_text, system_prompt=SYSTEM_PROMPT, max_new_tokens=4):
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"Classify the emotion of this text:\n\n{user_text}"},
]
inputs = tokenizer.apply_chat_template(
messages,
tokenize=True, # 直接返回 token id
add_generation_prompt=True, # 追加 assistant 起始标记,模型从该处续写
return_dict=True,
return_tensors="pt",
)
inputs = {k: v.to(model.device) for k, v in inputs.items()}
input_len = inputs["input_ids"].shape[-1] # 记录 prompt 长度,decode 时只取新生成段

with torch.no_grad(): # 评估/推理不需要梯度
outputs = model.generate(
**inputs,
max_new_tokens=max_new_tokens, # 标签极短,4 个 token 足够
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)
return extract_label(raw_pred)

为何需要 generate_label

Gemma-it 是 Causal LM,接口返回的是续写 token 解码后的字符串(可能是 joyJoy.The emotion is joy 等),不是固定的 6 维 logits 分类结果。generate_label 负责:chat 模板编码 → 短续写 → 正则抽取合法标签 → 非法则记 INVALID

结果里盯什么

"I feel so happy and excited today!" 试跑,基座模型通常返回 joy 或接近标签。


Step 9:评估函数

目标

在指定 split 上批量调用 generate_label,汇总分类指标与逐条预测明细。

方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
def evaluate_model(model, tokenizer, split="test", limit=EVAL_LIMIT):
y_true, y_pred, rows = [], [], []
raw_source = dataset[split] # 用原始 text+label 集,非 sft_dataset

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( # 六类 F1 宏平均,兼顾少数类
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( # 每类 precision/recall/F1
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 # 含 INVALID 列
),
index=ALL_EVAL_LABELS,
columns=ALL_EVAL_LABELS,
)

数据结构:评估输出

metrics dict(一次评估的汇总):

1
2
3
4
5
6
{
"accuracy": 0.625, # 预测标签与真实标签完全一致的比例
"macro_f1": 0.482, # 六类 F1 宏平均
"invalid_predictions": 2, # 生成结果不在六类词表内的条数
"evaluated_examples": 400, # 实际评估样本数
}

pred_df(逐条明细,每行):

1
2
3
4
5
6
7
{
"text": "...", # 原始待分类文本
"true_label": "fear", # gold 标签
"pred_label": "fear", # 规范化后预测(合法标签或 "INVALID")
"raw_pred_label": "fear", # extract_label 原始输出,便于排查解析问题
"correct": True, # pred_label == true_label
}
指标 含义
accuracy 预测与真实完全一致的比例
macro_f1 六类 F1 宏平均,对类别不平衡更敏感
invalid_predictions 预测不在六类词表内的条数

结果里盯什么

函数能跑完 EVAL_LIMIT 条而不 OOM;pred_df 行数等于评估条数。


Step 10:微调前评估(基线)

目标

未训练 LoRA 的基座模型上建立对照分数,后续才能量化微调收益。

方法

1
2
3
4
pre_metrics, pre_report, pre_preds = evaluate_model(
base_model, tokenizer, split="test", limit=EVAL_LIMIT # 未微调基座,建立对照基线
)
confusion_matrix_df(pre_preds) # 查看类间混淆(如 sadness ↔ joy)

实测结果(400 条 test)

指标 数值
accuracy 0.625
macro_f1 0.482
invalid_predictions 2

image
基座 + SYSTEM_PROMPT 已有零样本能力,但 macro_f1 明显低于 accuracy,少数类 recall 偏弱;混淆矩阵上 sadnessjoy 互混较多。

结果里盯什么

基线数字落盘或记录在案;此后勿改 SYSTEM_PROMPT,否则前后不可比。


Step 11:配置 LoRA

目标

指定低秩适配器挂载方式:冻结基座,只训练少量 LoRA 参数。

方法

1
2
3
4
5
6
7
8
lora_config = LoraConfig(
r=16, # 低秩矩阵的秩;越大可训练参数越多、表达能力越强,但显存与过拟合风险也上升
lora_alpha=32, # LoRA 更新的缩放系数;实际生效倍率为 alpha/r(此处 32/16=2),影响 adapter 对原权重的修正幅度
lora_dropout=0.05, # 训练时在 LoRA 分支上施加的 dropout;轻微正则,减轻过拟合
bias="none", # 不训练偏置项;保持基座 bias 冻结,进一步减少可训练参数
task_type="CAUSAL_LM", # 声明任务为因果语言建模;与 Gemma 自回归生成目标一致,peft 据此选择适配方式
target_modules="all-linear", # 在哪些层挂 LoRA;all-linear 尽量覆盖全部线性层,参数量大但易跑通;可改为 q_proj/v_proj 等以省显存
)

对线性层权重 (W),LoRA 增加 (\Delta W = BA)((B \in \mathbb{R}^{d \times r}),(A \in \mathbb{R}^{r \times k}),(r=16))。前向输出为 (Wx + \frac{\alpha}{r}BAx);lora_alphar 共同决定 adapter 修正强度,而非单独由 r 决定。

结果里盯什么

Step 13 创建 Trainer 后检查:

1
2
3
Trainable LoRA parameters: 50,499,584
Total parameters: 7,991,600,416
Trainable ratio: 0.6319%

若为 0,说明 target_modules 未命中,需调整后再训练。


Step 12:定义训练参数

目标

配置 SFTConfig:batch、学习率、精度、截断长度及 ROCm 兼容项。

方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
training_args = SFTConfig(
output_dir=OUTPUT_DIR, # 训练日志、checkpoint、最终 adapter 的保存目录
per_device_train_batch_size=4, # 每张 GPU 每步喂入的样本数;OOM 时优先调小
per_device_eval_batch_size=1, # 验证时每步样本数;生成式 eval 占显存,通常设 1
gradient_accumulation_steps=4, # 梯度累积步数;等效 batch = 4×4=16,小显存下模拟大 batch
learning_rate=1e-4, # LoRA 常用学习率;过大易发散,过小收敛慢
weight_decay=0.01, # L2 正则系数;抑制 LoRA 权重过大,减轻过拟合
lr_scheduler_type="linear", # 学习率调度:warmup 后线性降至 0
warmup_steps=50, # 前 50 step 学习率从 0 线性升到峰值,避免初始梯度冲击
num_train_epochs=1, # 遍历训练集轮数;子集 4000 条 × 1 epoch ≈ 250 step
logging_steps=5, # 每 5 step 打印一次 train loss 等指标
eval_strategy="steps", # 按 step 触发验证(非按 epoch 末)
eval_steps=25, # 每 25 step 在 validation 集上算 eval_loss
save_strategy="steps", # 按 step 保存 checkpoint
save_steps=25, # 与 eval_steps 对齐,便于对照同一训练进度
save_total_limit=2, # 磁盘最多保留 2 个 checkpoint,自动删更早的
metric_for_best_model="eval_loss", # 以验证集 loss 选最优 checkpoint
greater_is_better=False, # eval_loss 越小越好
gradient_checkpointing=True, # 反向时重算激活而非全部缓存;显著省显存,略增计算
bf16=BF16, # 训练用 bfloat16;与 Step 2 MODEL_DTYPE 一致
fp16=FP16, # 本流程 BF16=True、FP16=False;硬件不支持 BF16 时改 FP16
max_length=256, # 单条样本 token 上限,超出截断;情绪句短,主要防异常长文本
packing=False, # 不将多条短样本拼进同一序列;分类任务保持样本边界清晰
completion_only_loss=True, # loss 只算 completion(assistant 标签)段,不对 prompt 算梯度
optim="adamw_torch", # PyTorch 原生 AdamW;ROCm 下比 bitsandbytes 优化器更稳
seed=SEED, # 训练随机种子;影响 dropout、数据 shuffle 等
data_seed=SEED, # 数据加载与采样随机种子;与 seed 一致便于复现
report_to="none", # 不上报 wandb/tensorboard;本地 Notebook 够用
)

结果里盯什么

output_dir 目录已创建;配置与 Step 2 的 BF16/SEED 一致。


Step 13:开始 LoRA 微调

目标

创建 SFTTrainer,完成 tokenize → 训练 → 验证 → checkpoint。

方法

1
2
3
4
5
6
7
8
9
10
11
12
trainer = SFTTrainer(
model=base_model, # 基座 Causal LM;内部自动挂载 LoRA
train_dataset=sft_dataset["train"], # Step 6 的 prompt-completion 数据
eval_dataset=sft_dataset["validation"], # 训练过程中周期性验证
peft_config=lora_config, # Step 11 的 LoRA 配置
args=training_args, # Step 12 的训练超参
processing_class=tokenizer, # 负责 apply_chat_template 与 tokenize
)

train_result = trainer.train() # 开始训练;返回含 loss 等指标的 TrainOutput
trainer.model.eval() # 切评估模式(关闭 dropout 等)
trainer.model.config.use_cache = True # 训练结束,恢复 cache 供后续 generate 加速

训练日志(节选)

Step Training Loss Validation Loss Mean Token Accuracy
25 0.577 0.405 0.846
50 0.339 0.302 0.899
100 0.154 0.182 0.940
150 0.123 0.152 0.944

总 step 约 250(4000 条 ÷ 等效 batch 16 × 1 epoch)。train / val loss 同步下降,mean token accuracy 升至 ~0.94。

结果里盯什么

  • 无 OOM;loss 曲线单调下降为主。
  • Trainable LoRA parameters > 0。
  • checkpoint 写入 OUTPUT_DIR/checkpoint-*

Step 14:保存 LoRA adapter

目标

持久化 adapter 权重与 tokenizer,供后续评估、推理或合并部署。

方法

1
2
3
4
5
trainer.model.save_pretrained(OUTPUT_DIR)   # 保存 LoRA adapter(adapter_model.safetensors 等)
tokenizer.save_pretrained(OUTPUT_DIR) # 保存 tokenizer,便于独立分发与重载

with open(f"{OUTPUT_DIR}/train_metrics.json", "w") as f:
json.dump(train_result.metrics, f, ensure_ascii=False, indent=2) # 留存最终 train loss 等

数据结构:输出目录

1
2
3
4
5
6
gemma4-it-emotion-lora-ms-single-gpu/
├── adapter_model.safetensors # LoRA 权重(~百 MB 级)
├── adapter_config.json # r、alpha、target_modules 等
├── tokenizer.json / tokenizer_config.json
├── checkpoint-25/ checkpoint-50/ ...
└── train_metrics.json

产物是 adapter,不是 15 GB 全量基座;推理时需基座 + adapter 组合加载。

结果里盯什么

adapter_model.safetensorsadapter_config.json 存在且非空。


Step 15:微调后评估

目标

用与 Step 10 完全相同evaluate_model(),在 test 集上测量微调后指标。

方法

1
2
3
4
5
6
7
8
9
ft_model = trainer.model   # 直接使用内存中已训练模型,避免重载 OOM
ft_model.eval()

post_metrics, post_report, post_preds = evaluate_model(
ft_model, tokenizer, split="test", limit=EVAL_LIMIT # 与 Step 10 同脚本、同 limit
)

confusion_matrix_df(post_preds) # 查看类间混淆(如 sadness ↔ joy)

优先在内存中直接评估,避免训练后立即重载模型导致显存碎片 OOM。
image

结果里盯什么

对比 Step 10:accuracymacro_f1 应上升,invalid_predictions 应下降或归零;少数类 recall 改善可从 post_report 读出。


Step 16:对比微调前后效果

目标

将前后指标与逐条预测合并,便于写报告或排查退化样本。

方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
comparison_df = pd.DataFrame([
{"stage": "pre_finetuning", **pre_metrics}, # Step 10 基线指标
{"stage": "post_finetuning", **post_metrics}, # Step 15 微调后指标
])

merged_examples = pre_preds.copy()
merged_examples = merged_examples.rename(columns={
"pred_label": "pre_pred",
"correct": "pre_correct",
"raw_pred_label": "pre_raw_pred_label",
})
merged_examples["post_pred"] = post_preds["pred_label"]
merged_examples["post_raw_pred_label"] = post_preds["raw_pred_label"]
merged_examples["post_correct"] = post_preds["correct"]

# 仅保留微调前后预测标签发生变化的样本,便于分析纠错/退化
changed_predictions = merged_examples[merged_examples["pre_pred"] != merged_examples["post_pred"]]

数据结构:对比表

comparison_df

image
changed_predictions:仅含微调前后预测标签发生变化的样本,用于分析「纠错」与「退化」案例。

结果里盯什么

macro_f1 delta 比 accuracy delta 更有参考价值;关注是否出现「训练 loss 很低但 macro_f1 几乎不变」的假提升。


Step 17:手动测试

目标

用自定义句子快速冒烟,确认模型能稳定输出合法标签。

方法

1
2
3
4
5
6
7
8
9
10
test_texts = [
"I feel completely heartbroken and alone.", # 期望 sadness
"This is the best day of my life!", # 期望 joy
"I am really scared about what might happen.", # 期望 fear
"I can't believe they remembered my birthday!", # 期望 surprise
"I am so angry that nobody listened to me.", # 期望 anger
"I really love spending time with my family.", # 期望 love
]
for text in test_texts:
print(text, "=>", generate_label(ft_model, tokenizer, text)) # 快速人工冒烟

结果里盯什么

六条均应返回单一合法标签,无长句解释或越界输出。


Step 18:保存评估结果

目标

将全部评估明细导出 CSV,便于画图或写实验报告。

方法

1
2
3
4
5
6
7
8
9
comparison_df.to_csv(f"{OUTPUT_DIR}/gemma4_emotion_before_after_metrics.csv", index=False)       # 前后指标汇总
merged_examples.to_csv(f"{OUTPUT_DIR}/gemma4_emotion_prediction_examples.csv", index=False) # 逐条前后预测
changed_predictions.to_csv(f"{OUTPUT_DIR}/gemma4_emotion_changed_predictions.csv", index=False) # 预测变化子集
pre_preds.to_csv(f"{OUTPUT_DIR}/pre_finetuning_predictions.csv", index=False)
post_preds.to_csv(f"{OUTPUT_DIR}/post_finetuning_predictions.csv", index=False)
pd.DataFrame(pre_report).transpose().to_csv(f"{OUTPUT_DIR}/pre_finetuning_classification_report.csv")
pd.DataFrame(post_report).transpose().to_csv(f"{OUTPUT_DIR}/post_finetuning_classification_report.csv")
confusion_matrix_df(pre_preds).to_csv(f"{OUTPUT_DIR}/pre_finetuning_confusion_matrix.csv")
confusion_matrix_df(post_preds).to_csv(f"{OUTPUT_DIR}/post_finetuning_confusion_matrix.csv")

结果里盯什么

OUTPUT_DIR 下 CSV 齐全,行数与 EVAL_LIMIT 一致。


Step 19(可选):重新加载 adapter 推理

目标

验证「重启 kernel 后仅凭磁盘文件」能否恢复微调模型推理能力。

方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
RUN_RELOAD_TEST = False  # 开关:默认 False,避免与 Step 13~15 训练/评估同 kernel 抢显存

if RUN_RELOAD_TEST:
# ① 从 OUTPUT_DIR 加载 tokenizer(Step 14 已随 adapter 一并保存)
reload_tokenizer = AutoTokenizer.from_pretrained(
OUTPUT_DIR,
use_fast=True, # 使用 fast tokenizer 实现
trust_remote_code=True, # 允许加载仓库内自定义分词逻辑(若存在)
)
if reload_tokenizer.pad_token is None:
reload_tokenizer.pad_token = reload_tokenizer.eos_token # 与 Step 7 一致,补 pad token

# ② 从 LOCAL_MODEL_DIR 加载原始基座(15 GB 全量权重,不含 LoRA)
reload_base = AutoModelForCausalLM.from_pretrained(
LOCAL_MODEL_DIR,
torch_dtype=MODEL_DTYPE, # 与训练时相同精度(BF16)
low_cpu_mem_usage=True, # 分片加载,降低 CPU 峰值内存
trust_remote_code=True,
)

# ③ 将 adapter 挂载到基座:基座冻结 + 叠加 Step 14 的 LoRA 权重
reload_model = PeftModel.from_pretrained(
reload_base,
OUTPUT_DIR, # 读取 adapter_model.safetensors + adapter_config.json
)
reload_model.eval() # 推理模式:关闭 dropout 等训练专用行为

# ④ 复用 Step 8 的 generate_label,验证磁盘产物能否独立推理
print(generate_label(
reload_model,
reload_tokenizer,
"I feel completely heartbroken and alone.", # 期望输出 sadness
))

结果里盯什么

输出与 Step 17 一致;若 OOM,先释放训练占用显存再跑。


Step 20:常见问题

现象 对策
parquet 路径匹配失败 检查 dataset_dir/data/{split}-*.parquet 实际 layout
MsDataset / verification_mode 报错 沿用 Step 5 parquet 直读路径
训练 OOM batch_sizemax_lengthTRAIN_LIMIT
Trainable params = 0 调整 target_modules
微调后指标没涨 检查 SYSTEM_PROMPT 训练/评估是否一致
生成式评估太慢 降低 EVAL_LIMIT
多卡需求 .py + accelerate launch,勿在 Notebook 硬上

显存调参顺序:per_device_train_batch_sizemax_lengthTRAIN_LIMIT → 收窄 target_modules


执行路径一览

1
2
3
4
5
6
7
8
9
10
11
12
安装依赖 → 全局配置 → 固定种子
→ [基座已在 ./models 则跳过下载]
→ 下载/加载 emotion 数据集 (text, label)
→ 转 SFT prompt-completion
→ 加载 tokenizer + 基座 (训练态)
→ 实现 generate_label / evaluate_model
→ 微调前基线评估
→ 配置 LoRA + SFTConfig
→ SFTTrainer.train()
→ 保存 adapter
→ 微调后评估 → 前后对比 → 手动冒烟 → CSV 落盘
→ [可选] PeftModel 重载推理

建议首次实验保持 TRAIN_LIMIT=4000、1 epoch 跑通全链路,确认 macro_f1 相对基线有提升后,再放开数据量与 epoch 做正式训练。

-------------本文结束感谢您的阅读-------------