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是一批数据,不是单条),用zipsentencelabel一一对应,调用上面的函数生成 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 常见疑问

  1. 为什么要把 input_ids 克隆成 labels?自回归模型的训练目标是「预测下一个 token」,所以 labels 必须和 input_ids 完全对齐(input_ids 是输入,labels 是每个位置的标准答案)。比如 input_ids 的第 5 个 token 是「负」,labels 的第 5 个位置也必须是「负」的 id,模型才能计算预测误差。

  2. labels 为什么包含 system/user 部分?自回归模型不是「只预测最后几个字」,而是「逐字预测整个序列」。模型要输出正确的「负向」,必须先理解前面的「system 指令」和「user 点评」—— 如果只把 labels 的「负向」部分保留,删掉前面的 system/user 部分,模型会失去上下文,根本不知道要做什么(比如模型看到「负」,但不知道为什么要输出「负」)。

  3. 为什么 add_generation_prompt=False?add_generation_prompt=True会在文本末尾加<|im_start|>assistant(生成提示),这是推理时用的(告诉模型该输出了);训练时我们已经把 assistant 的回答(标签)写在文本里了,不需要这个提示,所以设为 False。

  4. 对比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是字典格式,包含sentencelabel列;

  • 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 的例子:

  1. 训练到 eval_steps=100 步时,Trainer 会暂停训练;

  2. 把验证集(比如 1000 条数据)按「单卡 8 条 / 批」分成 125 批(1000/8=125);

  3. 逐批把验证集数据喂给模型(仅前向传播,不反向传播 / 不更新参数);

  4. 对每一批计算「预测值 vs labels」的损失(和训练时的 loss 计算逻辑完全一致);

  5. 把 125 批的 loss 取平均,得到「验证集平均 loss」(就是终端里看到的 Validation Loss);

  6. 验证完成后,继续训练,直到下一个eval_steps

  7. 每到 eval_steps=100 步时,都会完整遍历一遍验证集的所有批次(125 批),计算每一批的 loss 后取平均,得到该步的「验证集平均 loss」

  8. 保存最优模型参考「验证集 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. 模型权重:1.5B 模型 BF16 精度约 3GB(固定);

  2. 中间激活值(Activation):这是显存占用的「大头」!

    • 模型前向传播时,每一层(比如注意力层、全连接层)都会产生「中间激活值」(可以理解为每层的输出结果);

    • 反向传播时,需要用这些激活值计算梯度(链式法则),所以默认情况下,前向传播会把所有层的激活值都保存在显存里;

    • 1.5B 模型训练时,激活值可能占 8~10GB 显存(远超模型权重)。

二、 有梯度检查点(gradient_checkpointing=True)

  1. 只存「检查点」的激活值

  2. 梯度检查点会把模型分成若干「段」,只保存每段起始位置的激活值(检查点),其余激活值在前向传播后直接丢弃;反向传播时,重新计算丢弃的激活值:

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"))

逐行解释:

  1. 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 权重即可(省空间)。

  2. tokenizer.save_pretrained(...)

    • 保存 tokenizer 的配置(tokenizer.jsonvocab.jsonspecial_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)

  1. tokenizer(prompt):把prompt文本转成input_ids(数字序列)和attention_mask(掩码);

  2. return_tensors="pt":返回 PyTorch 张量(而非默认的列表 /numpy 数组);

  3. .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生成回复(情感标签);

  1. **inputs:解包inputs字典(把input_idsattention_mask传给模型);

  2. max_new_tokens=16:模型最多生成 16 个新 token(足够容纳 "正向"/"负向",甚至多余字符);

  3. temperature=0.1:生成温度(越小越确定,越大越随机),0.1 接近确定性生成,避免模型输出乱码;

  4. 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
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐