LoRA微调Qwen2.5-1.5B-instruct实践
1 数据集加载
import pandas as pd
from datasets import Dataset
def convert(row):
text = row["sentence"]
label = row["label"]
label_text = "正向" if label == 1 else "负向"
return {
"messages": [
{"role": "user", "content": f"请判断以下评论的情感倾向,仅回答正向或负向:\n{text}"},
{"role": "assistant", "content": label_text}
]
}
df = pd.read_csv("./dataset/train.csv")
train_dataset = Dataset.from_pandas(df)
train_dataset = train_dataset.map(convert)
df_dev = pd.read_csv("./dataset/dev.csv")
dev_dataset = Dataset.from_pandas(df_dev)
dev_dataset = dev_dataset.map(convert)
train_dataset.save_to_disk("processed/train")
dev_dataset.save_to_disk("processed/dev")
print("数据预处理完成")
1.1 map(convert) 的逐字解析
1. train_dataset:待处理的原始数据集
是从 CSV 文件转换来的 Dataset 对象,每条数据包含 sentence 和 label 两个字段。
2. .map(convert):核心操作
-
.map():Dataset类的内置方法,类似 Python 的map函数,但专为数据集优化 —— 遍历数据集的每一条数据,把convert函数应用到这条数据上,返回转换后的新数据; -
convert:你自定义的转换函数,输入是单条原始数据(包含sentence/label),输出是包含messages的新格式数据。
3. 赋值回 train_dataset:覆盖原数据集
把转换后的新数据集重新赋值给 train_dataset,此时 train_dataset 就从「原始格式」变成了「模型训练格式」。
1.2 举例
假设原始 train_dataset 中有一条数据:
# 原始单条数据(转换前)
{
"sentence": "这家店超好吃!",
"label": 1
}
返回新格式:
{
"messages": [
{"role": "user", "content": "请判断以下评论的情感倾向,仅回答正向或负向:\n这家店超好吃!"},
{"role": "assistant", "content": "正向"}
]
}
2 模型推理
3 LoRA微调
3.1 整体结构总览
1. 基础配置区 → 定义所有可调参数(路径、超参、LoRA参数)
2. 数据处理区 → 格式化文本、Tokenize、加载数据集
3. 模型构建区 → 加载基座模型、配置LoRA、冻结主权重
4. 数据集加载区 → 加载预处理数据集、转换为模型可识别格式
5. 训练配置区 → 定义训练策略(批次、学习率、显存优化等)
6. 训练执行区 → 启动训练、保存模型
3.2 基础配置模块
作用:把所有需要调整的参数集中定义,方便后续修改(不用在代码里到处找)
# 路径类(告诉代码去哪找模型/数据,结果存哪)
MODEL_PATH = "./Qwen2.5-1.5B-Instruct" # 基座模型路径
TRAIN_DS = "processed/train" # 训练集路径
DEV_DS = "processed/dev_split" # 验证集路径
OUTPUT_DIR = "./qwen2.5-lora-checkpoint"# 训练结果保存路径
# 训练超参(控制训练过程)
NUM_EPOCHS = 1 # 训练几轮(1轮=把训练集全看一遍)
BATCH_SIZE = 8 # 每次喂给模型8条数据
GRAD_ACC = 8 # 梯度累积:攒8个批次再更新一次参数(变相增大批次,省显存)
LR = 2e-5 # 学习率:参数更新的步长(太小学的慢,太大学不稳)
MAX_LEN = 512 # 文本最长512个token(超过截断,不足补pad)
FP16 = False/BF16 = True # 混合精度训练:用bf16精度省显存(需显卡支持,比如A100/3090)
USE_8BIT = False # 是否8bit加载模型(更省显存,新手先关)
# LoRA专属参数(核心是只训练少量参数)
LORA_R = 16 # LoRA的秩(越小参数越少,一般16/32)
LORA_ALPHA = 32 # 缩放系数(一般是R的2倍)
LORA_DROPOUT = 0.05 # 防止过拟合的dropout
LORA_TARGET = ["q_proj","k_proj","v_proj","o_proj"] # 只训练注意力层的这4个模块
3.3 数据格式化核心函数
先明确背景
数据集里的单条原始数据长这样(举例):
examples = {
"sentence": ["这家店太难吃了,服务也差!", "性价比超高,下次还来~"],
"label": [0, 1] # 0=负向,1=正向
}
我们的目标是把这些原始数据,转换成 Qwen2.5 模型能训练的格式。
3.3.1 build_chat_template 函数
这个函数的核心是:把「系统指令 + 用户点评 + 情感标签」组装成 Qwen2.5 要求的对话格式文本(不是数字,是带特殊标记的文本)。
def build_chat_template(tokenizer, query, label):
"""构建完整的Chat Template(包含用户输入+助手回复)"""
# 第一步:处理标签格式(确保是文本,不是数字)
if isinstance(label, int): # 如果标签是数字(0/1)
label = "正向" if label == 1 else "负向" # 转成文本:1→正向,0→负向
如果输入 label=0 → 变成 "负向";如果 label=1 → 变成 "正向";如果 label 已经是文本(比如 "负向"),这行跳过。
# 第二步:构建Qwen要求的对话结构(messages列表)
messages = [
# 系统角色:告诉模型它的任务(固定不变)
{"role": "system", "content": "你是一个情感分析助手,请区分用户在大众点评上对店铺评价的情感倾向为正向或负向"},
# 用户角色:输入的点评文本
{"role": "user", "content": query},
# 助手角色:模型要输出的目标(情感标签)
{"role": "assistant", "content": label} # 关键:添加标签作为模型的输出目标
]
举例(第一条):
query="这家店太难吃了,服务也差!",label=0 → 转换后:
messages = [
{"role": "system", "content": "你是一个情感分析助手,请区分用户在大众点评上对店铺评价的情感倾向为正向或负向"},
{"role": "user", "content": "这家店太难吃了,服务也差!"},
{"role": "assistant", "content": "负向"}
]
# 第三步:用tokenizer把messages转成Qwen的标准Chat文本(不提前转数字)
return tokenizer.apply_chat_template(
messages,
tokenize=False, # 关键:只生成文本,不转成input_ids(数字)
add_generation_prompt=False # 关闭生成prompt(训练时不需要<|im_start|>assistant这类生成提示)
)
作用:apply_chat_template是 Qwen tokenizer 的专属方法,会把messages转成带特殊标记的文本(模型训练的标准格式)。
举例(续上):
返回的文本长这样(Qwen2.5的 Chat 模板格式):
<|im_start|>system
你是一个情感分析助手,请区分用户在大众点评上对店铺评价的情感倾向为正向或负向<|im_end|>
<|im_start|>user
这家店太难吃了,服务也差!<|im_end|>
<|im_start|>assistant
负向<|im_end|>
3.3.2 tokenize_fn 函数
这个函数的核心是:把上面生成的「Chat 文本」转成模型能计算的数字(input_ids),并构建对应的 labels(损失计算的标准答案)。
def tokenize_fn(examples, tokenizer):
"""正确的tokenize逻辑:输入+标签一体化处理,生成对齐的input_ids/labels"""
# 1. 构建完整的chat文本(输入+输出标签)
texts = [
build_chat_template(tokenizer, query, label)
for query, label in zip(examples["sentence"], examples["label"])
]
核心逻辑:批量处理数据集(examples是一批数据,不是单条),用zip把sentence和label一一对应,调用上面的函数生成 Chat 文本。
# 2. 统一tokenize(输入+标签):把文本转成数字
tokenized = tokenizer(
texts, # 传入上面生成的Chat文本列表
truncation=True, # 超过MAX_LEN(512)就截断
max_length=MAX_LEN, # 文本最长512个token
padding="max_length", # 不足512的补pad_token(固定长度,方便批处理)
return_tensors="pt" if torch.cuda.is_available() else "np" # 返回PyTorch张量(GPU可用)或numpy数组
)
核心作用:tokenizer 的核心功能是「文本→数字」,每个字符/词语对应一个数字(token id)。
关键参数解释:
truncation=True:比如某条文本转成 token 后有 600 个,会截断到 512 个padding="max_length":比如某条文本转成 token 后只有 100 个,会补 412 个 pad_token_id(比如 Qwen 的 pad_token_id=151643);return_tensors="pt":返回torch.Tensor类型(模型训练必须用张量)。
此时tokenized是一个字典,包含:
input_ids:文本对应的数字序列(shape=[2, 512],2 条数据,每条 512 个 token);attention_mask:掩码(1 表示有效 token,0 表示 pad_token,告诉模型哪些是有效内容);
重点:
# 3. 构建labels:和input_ids一致,但pad_token部分设为-100(不参与损失计算)
labels = tokenized["input_ids"].clone() # 复制input_ids作为labels(因为自回归模型要预测每个token)
labels[labels == tokenizer.pad_token_id] = -100 # 把pad_token的位置换成-100
tokenized["labels"] = labels # 把labels加入tokenized字典
为什么要这么做?
- 自回归模型(比如 Qwen)的训练逻辑是:输入前 i 个 token,预测第 i+1 个 token,损失计算的是「预测值 vs labels 的真实值」;
- 我们希望模型学习的是:根据「system+user」的文本,输出正确的「assistant」标签(正向 / 负向);
pad_token是补位的无效内容,不需要计算损失,所以 PyTorch 规定用 - 100 标记(损失函数会忽略 - 100)。
举例(简化版):
假设input_ids是:[10, 20, 30, 151643, 151643](pad_token_id=151643)
→ labels先克隆:[10, 20, 30, 151643, 151643]
→ 把 pad_token_id 换成 - 100:[10, 20, 30, -100, -100]
核心逻辑:
模型训练时,会用input_ids作为输入,预测每个位置的 token,然后和labels对比计算损失:
- 有效 token 位置(10/20/30):计算损失(模型要学);
- pad 位置(-100):跳过损失计算(不用学)。
3.3.3 常见疑问
-
为什么要把 input_ids 克隆成 labels?自回归模型的训练目标是「预测下一个 token」,所以 labels 必须和 input_ids 完全对齐(input_ids 是输入,labels 是每个位置的标准答案)。比如 input_ids 的第 5 个 token 是「负」,labels 的第 5 个位置也必须是「负」的 id,模型才能计算预测误差。
-
labels 为什么包含 system/user 部分?自回归模型不是「只预测最后几个字」,而是「逐字预测整个序列」。模型要输出正确的「负向」,必须先理解前面的「system 指令」和「user 点评」—— 如果只把 labels 的「负向」部分保留,删掉前面的 system/user 部分,模型会失去上下文,根本不知道要做什么(比如模型看到「负」,但不知道为什么要输出「负」)。
-
为什么 add_generation_prompt=False?
add_generation_prompt=True会在文本末尾加<|im_start|>assistant(生成提示),这是推理时用的(告诉模型该输出了);训练时我们已经把 assistant 的回答(标签)写在文本里了,不需要这个提示,所以设为 False。 -
对比add_generation_prompt两种参数的文本结果?
add_generation_prompt=False(训练用):就是你贴的格式(完整包含 assistant 的回答):
<|im_start|>system
你是一个情感分析助手,请区分用户在大众点评上对店铺评价的情感倾向为正向或负向<|im_end|>
<|im_start|>user
这家店太难吃了,服务也差!<|im_end|>
<|im_start|>assistant
负向<|im_end|>
add_generation_prompt=True(推理用):会在最后多一行 <|im_start|>assistant(没有「负向」,也没有<|im_end|>),格式变成:
<|im_start|>system
你是一个情感分析助手,请区分用户在大众点评上对店铺评价的情感倾向为正向或负向<|im_end|>
<|im_start|>user
这家店太难吃了,服务也差!<|im_end|>
<|im_start|>assistant # 多出来的生成提示(只有这一行,无回答、无结束符)
3.4 加载 tokenizer & model
3.4.1 模型加载前的精度
设置模型加载的数值精度(平衡显存占用和训练效果):
-
torch.bfloat16(BF16):比 FP16 精度更高,显存占用和 FP16 一样(每个参数 2 字节),支持的显卡:A100、V100、RTX3090/4090、AMD MI 系列; -
torch.float16(FP16):半精度,显存占用是 FP32 的 1/2,支持大部分 NVIDIA 显卡(如 T4、1080Ti); -
torch.float32(FP32):单精度,显存占用最大(每个参数 4 字节),新手如果显卡不支持 BF16/FP16 可用这个(但 1.5B 模型 FP32 需要约 6GB 显存,加上训练开销可能不够)。
print("加载模型...")
dtype = torch.bfloat16 if BF16 else (torch.float16 if FP16 else torch.float32)
逻辑:
先判断是否开启 BF16(BF16=True),是则用 bfloat16;否则判断是否开启 FP16,是则用 float16;否则用 float32。
3.4.2 加载基座模型(8bit/普通模式)
if USE_8BIT:
model = AutoModelForCausalLM.from_pretrained(
MODEL_PATH,
load_in_8bit=True,
**model_kwargs
)
model = prepare_model_for_kbit_training(model)
else:
model = AutoModelForCausalLM.from_pretrained(MODEL_PATH, **model_kwargs)
1. 8bit 加载(USE_8BIT=True):
-
load_in_8bit=True:用 bitsandbytes 库把模型加载为 8bit 精度(每个参数 1 字节),显存占用仅为 FP16 的 1/2(1.5B 模型 8bit 仅需约 1.5GB 显存); -
prepare_model_for_kbit_training(model):专为 8bit 训练准备的函数(来自 peft 库),做两件关键事:-
把模型的某些层(如 layernorm)转回 FP16,避免 8bit 精度导致训练不稳定;
-
配置模型的梯度计算方式,适配 8bit 权重。
-
2. 普通加载(USE_8BIT=False,默认):
-
AutoModelForCausalLM.from_pretrained:加载因果语言模型(Qwen2.5 是自回归模型,属于 CausalLM); -
**model_kwargs:解包上面定义的参数(trust_remote_code、dtype、device_map 等)。
新手注意:8bit 加载需要安装bitsandbytes库(pip install bitsandbytes),且 Windows 系统可能需要额外配置,新手先设USE_8BIT=False。
3.5 加载数据集
3.5.1 对数据集做 Tokenize 处理
# 处理数据集(固定缓存文件名,避免重复生成)
train_tok = train_ds.map(
lambda e: tokenize_fn(e, tokenizer),
batched=True,
remove_columns=train_ds.column_names,
# cache_file_name="./processed/train/cache_train.arrow",
desc="Tokenizing train dataset"
)
逐参数拆解:
1. train_ds.map(...):Dataset 的核心方法
map 类似 Python 的map函数,把自定义函数应用到数据集的每一条 / 每一批数据上,返回新的 Dataset。
2. lambda e: tokenize_fn(e, tokenizer):要应用的处理函数
-
lambda e:匿名函数,接收「一批数据」(因为batched=True),e是字典格式,包含sentence和label列; -
tokenize_fn(e, tokenizer):调用我们之前定义的 tokenize 函数,把这批数据转换成input_ids/attention_mask/labels。
举例:
当batched=True时,e是一批数据的字典,比如批次大小为 2 时:
e = {
"sentence": ["这家店太难吃了", "性价比超高"],
"label": [0, 1]
}
# 调用tokenize_fn(e, tokenizer)后,返回:
{
"input_ids": tensor([[151644, 100, ..., 400, 151643], [151644, 200, ..., 500, 151643]]), # 2条数据,每条512个token
"attention_mask": tensor([[1, 1, ..., 0, 0], [1, 1, ..., 0, 0]]), # 1=有效token,0=pad
"labels": tensor([[151644, 100, ..., 400, -100], [151644, 200, ..., 500, -100]]) # pad部分设为-100
}
3. batched=True:批量处理数据
-
作用:不是逐条处理(速度慢),而是按批次处理(默认批次大小 1000),大幅提升 tokenize 速度;
-
例子:1 万条数据,
batched=True会分成 10 批(每批 1000 条)处理,比逐条快 10 倍以上。
4. remove_columns=train_ds.column_names:删除原始列
-
train_ds.column_names:原始数据集的列名,即["sentence", "label"]; -
作用:处理后的数据只需要
input_ids/attention_mask/labels,删除原始列避免冗余,也防止训练时 Trainer 读取无关列报错; -
对比:
-
处理前:列是
["sentence", "label"]; -
处理后:列是
["input_ids", "attention_mask", "labels"]。
-
5. # cache_file_name=...:缓存处理结果(注释掉了)
-
作用:第一次处理后,把结果保存到指定路径(Arrow 文件),下次运行时直接加载缓存,不用重复 tokenize(节省时间);
-
例子:
cache_file_name="./processed/train/cache_train.arrow"会把 tokenize 后的结果保存到这个文件,下次运行map时直接读缓存。
3.5.2 转换为 PyTorch 格式
# 转换为PyTorch格式(避免训练时类型错误)
train_tok.set_format("torch", columns=["input_ids", "attention_mask", "labels"])
dev_tok.set_format("torch", columns=["input_ids", "attention_mask", "labels"])
set_format("torch", columns=[...]):
-
核心作用:把 Dataset 中的数据从「Arrow 格式」转换成「PyTorch 张量(Tensor)」格式,指定只转换
input_ids/attention_mask/labels这三列; -
为什么必须做?Trainer 训练时只能处理 PyTorch 张量,如果不转换,会报「类型不匹配」错误(比如传入 list/numpy 数组)
3.6 训练配置
3.6.1 明确核心概念
TrainingArguments:定义训练的所有规则(批次、学习率、保存策略等);DataCollator:批量整理数据(补全、掩码等),适配模型输入格式;Trainer:HuggingFace 封装的训练器,把模型、数据、配置整合,一键启动训练;- 整个流程:先定规则→整理数据→组装训练器→启动训练→保存结果。
3.6.2 训练配置(TrainingArguments)
# ------------------ 训练配置 ------------------
args = TrainingArguments(
output_dir=OUTPUT_DIR,
num_train_epochs=NUM_EPOCHS,
per_device_train_batch_size=BATCH_SIZE,
per_device_eval_batch_size=BATCH_SIZE,
gradient_accumulation_steps=GRAD_ACC,
基础训练参数(核心控制训练节奏):
| 参数 | 含义+举例 |
output_dir=OUTPUT_DIR |
训练结果保存路径(模型 checkpoint、日志、评估结果),例子:./qwen2.5-lora-checkpoint |
num_train_epochs=NUM_EPOCHS |
训练轮数(1 轮 = 把训练集全看一遍),例子:NUM_EPOCHS=1 → 只训练 1 轮 |
|
per_device_train_batch_size=BATCH_SIZE |
训练时,每一步单卡喂 8 条数据 |
| per_device_eval_batch_size=BATCH_SIZE | 验证时,每一批单卡喂 8 条数据 |
gradient_accumulation_steps=GRAD_ACC |
梯度累积步数,例子:GRAD_ACC=8 → 攒 8 个批次的梯度再更新一次参数(等效批次 = 8*8=64,省显存) |
# 评估和保存策略
eval_steps=100,
save_steps=200,
logging_steps=10,
eval_strategy="steps", # 按step评估(必须显式指定)
save_strategy="steps",
| 参数 | 含义 + 例子 |
eval_steps=100 |
每训练 100 步,用验证集评估一次模型效果(计算验证集 loss) |
save_steps=200 |
每训练 200 步,保存一次模型 checkpoint(权重文件) |
logging_steps=10 |
每训练 10 步,打印一次训练日志(loss、学习率、步数等) |
重点:
一、验证集 loss 的计算流程
结合 per_device_eval_batch_size=8 的例子:
-
训练到
eval_steps=100步时,Trainer 会暂停训练; -
把验证集(比如 1000 条数据)按「单卡 8 条 / 批」分成 125 批(1000/8=125);
-
逐批把验证集数据喂给模型(仅前向传播,不反向传播 / 不更新参数);
-
对每一批计算「预测值 vs labels」的损失(和训练时的 loss 计算逻辑完全一致);
-
把 125 批的 loss 取平均,得到「验证集平均 loss」(就是终端里看到的
Validation Loss); -
验证完成后,继续训练,直到下一个
eval_steps。 -
每到
eval_steps=100步时,都会完整遍历一遍验证集的所有批次(125 批),计算每一批的 loss 后取平均,得到该步的「验证集平均 loss」 -
保存最优模型参考「验证集 loss」
3.6.3 显存优化配置
# 显存优化
gradient_checkpointing=True, # 开启梯度检查点,节省显存
gradient_checkpointing_kwargs={"use_reentrant": False}, # 兼容新版PyTorch
gradient_checkpointing=True:不保存所有中间层的激活值,反向时重新算激活值,再算梯度 → 显存占用减少 30%-50%(代价:训练速度慢 10%-20%)
重点:
一、模型训练时,显存到底被什么占了?
以 Qwen2.5-1.5B 为例,训练时显存主要消耗在两部分:
-
模型权重:1.5B 模型 BF16 精度约 3GB(固定);
-
中间激活值(Activation):这是显存占用的「大头」!
-
模型前向传播时,每一层(比如注意力层、全连接层)都会产生「中间激活值」(可以理解为每层的输出结果);
-
反向传播时,需要用这些激活值计算梯度(链式法则),所以默认情况下,前向传播会把所有层的激活值都保存在显存里;
-
1.5B 模型训练时,激活值可能占 8~10GB 显存(远超模型权重)。
-
二、 有梯度检查点(gradient_checkpointing=True)
-
只存「检查点」的激活值
-
梯度检查点会把模型分成若干「段」,只保存每段起始位置的激活值(检查点),其余激活值在前向传播后直接丢弃;反向传播时,重新计算丢弃的激活值:
3.7 训练器
# ------------------ 训练器 ------------------
trainer = Trainer(
model=model, # 带LoRA的Qwen模型
args=args, # 上面定义的训练规则
train_dataset=train_tok, # 处理好的训练集(PyTorch格式)
eval_dataset=dev_tok, # 处理好的验证集(PyTorch格式)
data_collator=data_collator, # 数据整理器
)
3.8 保存模型
# ------------------ 保存模型 ------------------
# 保存PEFT(LoRA)权重
model.save_pretrained(os.path.join(OUTPUT_DIR, "lora_weights"))
tokenizer.save_pretrained(os.path.join(OUTPUT_DIR, "tokenizer"))
逐行解释:
-
model.save_pretrained(...):-
保存 LoRA 的权重(不是完整模型)→ 体积很小(几 MB),包含:
-
adapter_config.json:LoRA 的配置(r、alpha、target_modules 等); -
adapter_model.bin:LoRA 的可训练参数(A/B 矩阵);
-
-
路径:
./qwen2.5-lora-checkpoint/lora_weights; -
为什么只保存 LoRA?基座模型没动,推理时加载基座模型 + LoRA 权重即可(省空间)。
-
-
tokenizer.save_pretrained(...):-
保存 tokenizer 的配置(
tokenizer.json、vocab.json、special_tokens_map.json等); -
路径:
./qwen2.5-lora-checkpoint/tokenizer; -
推理时必须用相同的 tokenizer,否则文本转 token 会出错。
-
4 模型评估
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
from datasets import load_from_disk
from sklearn.metrics import accuracy_score, f1_score, confusion_matrix
from peft import PeftModel # 仅新增导入PeftModel,其余保留你的逻辑
# 配置(修正LoRA路径为lora_weights)
MODEL_PATH = "./Qwen2.5-1.5B-Instruct" # 预训练模型路径
LORA_PATH = "./qwen2.5-lora-checkpoint/lora_weights" # ✅ 改为训练保存的lora_weights路径
TEST_DIR = "processed/test_split" # 测试集路径
# ============ 加载模型与 Tokenizer ============
print("加载模型与 tokenizer...")
tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH, trust_remote_code=True)
# 加载原始的预训练模型(保留你的原始参数)
model = AutoModelForCausalLM.from_pretrained(
MODEL_PATH,
device_map="auto",
dtype=torch.bfloat16,
trust_remote_code=True,
)
# ✅ 修正LoRA权重加载方式
model = PeftModel.from_pretrained(
model, # 基座模型
LORA_PATH # LoRA权重路径
)
# 构建模型输入的提示文本(完全保留你的原始代码)
def build_prompt(text):
messages = [
{"role": "system", "content": "你是一个情感分析助手,只回答正向或负向"},
{"role": "user", "content": f"请判断以下评论的情感倾向:\n{text}"}
]
return tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
# ============ 推理函数 (predict) ============
def predict(text):
prompt = build_prompt(text)
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
with torch.no_grad():
out = model.generate(
**inputs, max_new_tokens=16, temperature=0.0, do_sample=False
)
output = tokenizer.decode(out[0], skip_special_tokens=False).split("<|im_start|>assistant")[1]
# print(output)
if "正向" in output:
return 1
else:
return 0
# ============ 加载测试集 ============
test_dataset = load_from_disk(TEST_DIR)
labels = []
preds = []
print("开始评估...")
# 遍历测试集进行推理(完全保留你的原始逻辑)
for item in test_dataset:
text = item["sentence"] # 提取评论文本
gt = item["label"] # 提取标签
pred = predict(text) # 调用predict函数
labels.append(gt)
preds.append(pred)
# 计算评估指标(完全保留你的原始逻辑)
acc = accuracy_score(labels, preds)
f1 = f1_score(labels, preds)
# 打印评估结果(完全保留你的原始逻辑)
print("=== 测试集评估 ===")
print("Accuracy:", acc)
print("F1 Score:", f1)
print("Confusion Matrix:")
print(confusion_matrix(labels, preds))
4.1 PeftModel
PeftModel 是 PEFT 库中封装的模型类,专门用于管理「基座模型 + PEFT 适配器」的融合逻辑。
PeftModel.from_pretrained 是 PEFT(Parameter-Efficient Fine-Tuning)库 中核心的 API,作用是将预训练的 LoRA(或其他 PEFT 方法)权重加载到基座模型中,得到一个融合了 PEFT 适配器的可推理 / 微调模型。
简单来说:它是连接「基座大模型」和「轻量化 PEFT 权重(如 LoRA)」的桥梁,让你无需加载完整的微调模型,只需加载小体积的 PEFT 适配器权重,就能实现基座模型的微调效果。
4.2 推理函数 (predict)
这个函数是模型从「输入评论文本」到「输出情感标签(0/1)」的核心逻辑
# ============ 推理函数 (predict) ============
def predict(text):
prompt = build_prompt(text)
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
with torch.no_grad():
out = model.generate(
**inputs, max_new_tokens=16, temperature=0.0, do_sample=False
)
output = tokenizer.decode(out[0], skip_special_tokens=False).split("<|im_start|>assistant")[1]
# print(output)
if "正向" in output:
return 1
else:
return 0
第一步:prompt = build_prompt(text):
调用build_prompt函数,把原始评论文本转换成模型能识别的「Chat 模板格式提示词」;
(注:add_generation_prompt=True 会在最后加<|im_start|>assistant,告诉模型该输出了)。
""" build_prompt返回的prompt是:"""
<|im_start|>system
你是一个情感分析助手,只回答正向或负向<|im_end|>
<|im_start|>user
请判断以下评论的情感倾向:
这家店太难吃了,服务也差!<|im_end|>
<|im_start|>assistant
第二步:inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
-
tokenizer(prompt):把prompt文本转成input_ids(数字序列)和attention_mask(掩码); -
return_tensors="pt":返回 PyTorch 张量(而非默认的列表 /numpy 数组); -
.to(model.device):把张量从 CPU 移到模型所在设备(比如 GPU),避免「设备不匹配」报错;
{
"input_ids": tensor([[151644, 100, 200, ..., 151645]], device='cuda:0'), # prompt对应的数字序列,shape=[1, 长度]
"attention_mask": tensor([[1, 1, 1, ..., 1]], device='cuda:0') # 掩码,1表示有效token
}
第三步:with torch.no_grad():
作用:禁用 PyTorch 的梯度计算(推理阶段不需要更新参数);
第四步:out = model.generate( **inputs, max_new_tokens=16, temperature=0.1, do_sample=False )
核心作用:调用模型的generate方法,基于prompt生成回复(情感标签);
-
**inputs:解包inputs字典(把input_ids和attention_mask传给模型); -
max_new_tokens=16:模型最多生成 16 个新 token(足够容纳 "正向"/"负向",甚至多余字符); -
temperature=0.1:生成温度(越小越确定,越大越随机),0.1 接近确定性生成,避免模型输出乱码; -
do_sample=False:关闭采样生成(用贪心搜索,选概率最高的 token),保证结果稳定;
"""out是模型生成的input_ids张量,包含原始prompt+ 生成的回复:"""
tensor([[151644, 100, ..., 400, 401]], device='cuda:0') # 400=「负」的token_id,401=「向」的token_id
第五步(重点):
output = tokenizer.decode(out[0], skip_special_tokens=False).split("<|im_start|>assistant")[1]
核心作用:把模型生成的数字序列转回文本,并提取「助手回复部分」;
逐部分拆解:
-
tokenizer.decode(out[0]):把out的张量(数字)解码成文本;-
skip_special_tokens=False:保留<|im_start|>/<|im_end|>等特殊标记(方便拆分);
-
"""例子解码结果:"""
<|im_start|>system
你是一个情感分析助手,只回答正向或负向<|im_end|>
<|im_start|>user
请判断以下评论的情感倾向:
这家店太难吃了,服务也差!<|im_end|>
<|im_start|>assistant
负向<|im_end|>
-
.split("<|im_start|>assistant")[1]:按<|im_start|>assistant拆分文本,取拆分后的第 2 部分(索引 1);
"""例子拆分结果:"""
"\n负向<|im_end|>"
第六步:判断助手回复的情感倾向,返回对应的数字标签;
# print(output)
if "正向" in output:
return 1
else:
return 0
4.3 评估分数
| 字母 | 英文全称 | 中文含义 | 适用场景 |
|---|---|---|---|
| T | True | 正确 / 真实的 | 模型预测结果「符合」真实标签 |
| F | False | 错误 / 虚假的 | 模型预测结果「不符合」真实标签 |
| P | Positive | 阳性(你定义的 “正样本”) | 模型预测为「正样本」(比如你的场景 = 正向 = 1) |
| N | Negative | 阴性(你定义的 “负样本”) | 模型预测为「负样本」(比如你的场景 = 负向 = 0) |
| 缩写 | 拆解逻辑 | 英文全称 | 中文含义(你的场景) | 记忆口诀 |
|---|---|---|---|---|
| TP | T(对) + P(预测正) | True Positive | 真实正向,模型预测正向 | 对的,预测正 |
| TN | T(对) + N(预测负) | True Negative | 真实负向,模型预测负向 | 对的,预测负 |
| FP | F(错) + P(预测正) | False Positive | 真实负向,模型预测正向 | 错的,预测正 |
| FN | F(错) + N(预测负) | False Negative | 真实正向,模型预测负向 | 错的,预测负 |
4.3.1 准确率(Accuracy)
最简单的「整体正确率」
准确率 = (TP + TN) / (TP + TN + FP + FN)
举例:
假设测试集有 1000 条样本:
-
正向样本:100 条(TP=80,FN=20);
-
负向样本:900 条(TN=890,FP=10);
准确率 = (80+890)/(80+890+10+20) = 970/1000 = 97%。
4.3.2 F1 分数(F1 Score)
F1 是「精确率(Precision)」和「召回率(Recall)」的调和平均,先理解这两个子指标:
1. 子指标(针对正向样本 1)
-
精确率(Precision):模型预测为正向的样本中,真实是正向的比例 →
-
Precision = TP / (TP + FP)
-
-
召回率(Recall):真实是正向的样本中,模型预测为正向的比例 →
-
Recall = TP / (TP + FN)
-
2. F1 分数计算公式
F1 = 2 * (Precision * Recall) / (Precision + Recall)
4.3.3 区别
1. 什么时候看准确率?
如果你的测试集中「正向样本和负向样本数量差不多」(比如各 500 条),准确率能真实反映模型效果 —— 比如准确率 95%,说明模型不管正向 / 负向都预测得好。
2. 什么时候必须看 F1?
如果你的测试集中「正负样本不均衡」(比如负向 900 条,正向 100 条):
-
准确率高(比如 90%)可能是模型 “偷懒” 只预测负向;
-
F1 分数能戳穿这个问题(比如 F1=0),真正反映模型对正向样本的判断能力。
# 查看完整的显卡/显存信息
nvidia-smi
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)