使用bert-base-chinese中文预训练模型,使用 lansinuote/ChnSentiCorp 中文网购评价数据集进行情感分类微调和训练。
from datasets import load_dataset
from transformers import (BitsAndBytesConfig, # 用于配置量化参数AutoModel, AutoTokenizer,TrainingArguments,DataCollatorForLanguageModeling,Trainer
)
import torch
from peft import prepare_model_for_kbit_training, LoraConfig, get_peft_model# 1.配置4bit量化参数,减少内存占用同时保持模型性能
bnb_config = BitsAndBytesConfig(# 启用4bit精度加载模型,大幅度减少内存使用load_in_4bit=True,# 使用NF4量化类型,一种优化的4bit量化方法bnb_4bit_quant_type="nf4",# 计算时使用bfloat16精度,平衡精度和性能bnb_4bit_compute_dtype=torch.bfloat16,#启用双量化,对量化参数再次量化,进一步压缩模型bnb_4bit_use_double_quant=True,
)# 2.加载模型和分词器 并应用量化配置
model_name = "openchat/openchat-3.5-1210"
model = AutoModel.from_pretranined(model_name,quantization_config=bnb_config, # 应用上面配置的量化参数device_map="auto",trust_remote_code=True, # 信任远程代码,加载自定义模型cache_dir='model/',
)# 加载对应的分词器
tokenizer = AutoTokenizer.from_pretrained(model_name)
tokenizer.pad_token = tokenizer.eos_token # 设置填充token为结束token,用于批次处理# 3.为Qlora训练准备模型,应用必要的修改以支持量化训练
model = prepare_model_for_kbit_training(model)# 4.配置lora参数
peft_config = LoraConfig(r=16, # LoRA的秩(rank),决定适配器的大小lora_alpha=32, # 缩放参数,控制适配器输出的缩放程度target_modules=["q_proj", "v_proj"], # 要应用LoRA的目标模块(注意力机制中的查询和值投影)lora_dropout=0.05, # LoRA层的dropout率,防止过拟合bias="none", # 不训练偏置参数task_type="CAUSAL_LM" # 任务类型为因果语言建模
)# 将lora适配器应用到模型上
model = get_peft_model(model, peft_config)
# 打印可训练参数
model.print_trainable_parameters()# 5.配置训练参数
training_arguments = TrainingArguments(output_dir="./results/openchat", # 训练结果保存目录num_train_epochs=1, # 训练轮数per_device_train_batch_size=4, # 每个设备的训练批次大小gradient_accumulation_steps=2, # 梯度累积步数,模拟更大的批次大小optim="paged_adamw_32bit", # 使用分页AdamW优化器,防止内存碎片save_steps=50, # 每50步保存一次检查点learning_rate=2e-4, # 学习率fp16=False, # 禁用float16精度bf16=True, # 启用bfloat16精度,更好的数值稳定性max_grad_norm=0.3, # 梯度裁剪阈值,防止梯度爆炸warmup_ratio=0.03, # 预热比例,学习率从0线性增加到初始值group_by_length=True, # 按长度分组样本,提高训练效率lr_scheduler_type="constant", # 学习率调度器类型(恒定学习率)
)# 6. 加载和预处理数据集
dataset = load_dataset("timdettmers/openassistant-guanaco", split="train", cache_dir='./dataset')def tokenizer_function(examples):return tokenizer(examples["text"],truncation=True, # 超过最大长度时截断padding=False, # 不填充,使用数据整理器进行动态填充max_length=512 # 最大序列长度)# 应用分词函数到整个数据集
tokenized_dataset = dataset.map(tokenizer_function, batched=True)
print(tokenized_dataset)# 创建数据整理器,用于动态填充和准备训练批次
data_collector = DataCollatorForLanguageModeling(tokenizer,mlm=False, # 禁用掩码语言建模(使用因果语言建模)pad_to_multiple_of=8, # 填充到8的倍数,优化硬件性能return_tensors="pt" # 返回PyTorch张量
)# 7. 初始化Trainer类,封装整个训练流程
trainer = Trainer(model=model, # 要训练的模型train_dataset=tokenized_dataset, # 训练数据集processing_class=tokenizer, # 分词器类args=training_arguments, # 训练参数data_collator=data_collector # 数据整理器
)# 8. 开始训练!
trainer.train()# 9. 保存适配器权重(只需保存LoRA权重,文件非常小)
trainer.model.save_pretrained("./results/openchat-qlora-adapter")
1. 使用4bit量化加载模型了,为什么还要 prepare_model_for_kbit_training
?
简短回答:
“4bit加载”只是推理/初始化阶段的内存压缩;而prepare_model_for_kbit_training
是为在量化权重上进行反向传播训练所做的必要准备。
✅ 详细解释:
-
当你用
BitsAndBytesConfig(load_in_4bit=True)
加载模型时:- 模型权重以 4bit 量化形式存储在显存中,大幅减少显存占用。
- 但 前向传播时会动态反量化成 bfloat16(或 float16)进行计算。
- 这是 推理友好型量化(inference-time quantization)。
-
然而,在 训练阶段,我们需要对模型做梯度更新。如果直接在量化权重上训练,梯度计算不稳定,甚至无法收敛。
-
所以
prepare_model_for_kbit_training(model)
做了以下几件事:- 启用梯度检查点(Gradient Checkpointing):节省显存。
- 设置模块的
requires_grad = True
:确保 LoRA 层可以训练。 - 添加可学习的残差连接(如适配器层)到量化层。
- 冻结原始量化权重(不更新主权重),只训练 LoRA 适配器。
- 确保输入嵌入层(input embedding)可训练(有些模型默认冻结)。
📌 总结:
load_in_4bit=True
是为了 节省显存加载模型;
prepare_model_for_kbit_training
是为了让模型 支持在量化权重基础上进行 LoRA 微调,属于训练流程的一部分。
🔹 2. 计算时使用 bfloat16 是意味着每次都要反量化吗?
✅ 是的,每次前向/反向传播都会动态反量化。
📌 原理说明:
- BitsAndBytes 的 4bit 量化(如 NF4)是一种 离线量化存储 + 在线反量化计算 的策略。
- 权重以 4bit 存储,但在前向传播(forward pass)时:
- 系统会将 4bit 权重 实时反量化为 bfloat16(或 float16)。
- 然后与输入张量进行矩阵乘法运算(使用 GPU 的 Tensor Core 加速)。
- 反向传播时,只更新 LoRA 参数,原始 4bit 权重 保持不变(冻结)。
🎯 优点:
- 显存占用低(仅 4bit 存储)。
- 计算精度高(bfloat16 支持训练稳定性)。
- 训练效率高(只训练小部分 LoRA 参数)。
📌 所以:
bnb_4bit_compute_dtype=torch.bfloat16
就是告诉系统:“虽然我用 4bit 存权重,但算的时候请用 bfloat16 精度”。
🔹 3. tokenizer.pad_token = tokenizer.eos_token
有什么用?
✅ 用于 批次处理(batching)时的填充(padding)。
🧩 背景知识:
- 在训练时,一个 batch 中的样本长度不同,必须统一长度才能堆叠成 tensor。
- 方法是:短的句子用
pad_token
填充到最大长度。 - 但很多语言模型(如 GPT 系列)没有定义
pad_token
,只有eos_token
(结束符)。
❗问题:
- 如果强行 padding,模型会把 pad token 当作有效 token 处理,影响训练。
✅ 解决方案:
Python
编辑
tokenizer.pad_token = tokenizer.eos_token
- 把
pad_token
设为eos_token
,意味着:- 填充部分等价于 “句子结束”。
- 模型知道这些位置不需要预测或关注(注意力 mask 也会屏蔽)。
📌 为什么重要?
- 避免模型误认为 padding 是语义内容。
- 兼容因果语言建模(Causal LM)任务,防止在 padding 上计算 loss。
🔹 4. prepare_model_for_kbit_training
到底干了什么?
✅ 它是一个预处理函数,为 在量化模型上进行微调 做准备。
🛠️ 主要功能包括:
功能 | 说明 |
---|---|
1. 梯度检查点(Gradient Checkpointing) | 减少显存占用,牺牲少量计算时间 |
2. 启用 input embeddings 的梯度 | 有些模型默认冻结词嵌入层,需手动开启 |
3. 设置 requires_grad_(True) | 确保后续插入的 LoRA 层可以训练 |
4. 保持量化权重冻结 | 主权重不参与更新,只训练 LoRA |
5. 兼容性修复 | 处理某些层在量化下的异常行为 |
📌 举例:
model = prepare_model_for_kbit_training(model)
等价于:
model.gradient_checkpointing_enable() # 开启梯度检查点
model.enable_input_require_grads() # 确保嵌入层可训练
⚠️ 如果跳过这一步,可能会导致:
- 显存不足
- 梯度无法回传
- LoRA 不生效
🔹 5. LoRA 的 r
和 lora_alpha
的关系公式是什么?
✅ LoRA 的输出是:
ℎ=��+��⋅���h=Wx+rα⋅BAx
其中:
- �W:原始权重(冻结)
- ��BA:低秩分解矩阵(�∈��×�,�∈��×�A∈Rr×d,B∈Rd×r)
- �r:LoRA 秩(rank),控制适配器大小
- �α:缩放系数(
lora_alpha
)
📌 关键点:
-
缩放因子是 ��rα,所以:
- 当
r
增大 → 适配器表达能力增强,但缩放变小 → 实际影响减弱 - 当
alpha
增大 → 适配器影响增强
- 当
-
通常设置
lora_alpha = 2 * r
或lora_alpha = 32
是经验值。
✅ 举例:
r=16, lora_alpha=32 → 缩放因子 = 32 / 16 = 2.0
表示 LoRA 分支的输出会被放大 2 倍后再加到原始输出上。
📌 经验建议:
r
小 → 参数少、轻量,但表达能力弱alpha
大 → 更强调 LoRA 调整,但可能过拟合- 通常
alpha/r ≈ 2
是常见配置(如 r=8, alpha=16)
🔹 6. 除了 get_peft_model(model, peft_config)
,还有其他方法应用 LoRA 吗?
✅ 有!但
get_peft_model
是最推荐的方式。
✅ 方法一:get_peft_model
(推荐)
model = get_peft_model(model, peft_config)
- 自动根据
peft_config
插入 LoRA 层。 - 支持多种 PEFT 方法(LoRA、AdaLoRA、IA³ 等)。
- 易于保存/加载适配器。
✅ 方法二:手动定义 LoRA 层(不推荐)
你可以自己写一个包装类,替换 nn.Linear
为 LoraLinear
,但这非常繁琐且容易出错。
✅ 方法三:使用 peft.LoraModel
类(底层 API)
from peft import LoraModel, LoraConfig
lora_model = LoraModel(peft_config, model)
功能类似,但 get_peft_model
更高层、更易用。
✅ 方法四:使用 transformers
集成方式(Hugging Face 新版本)
某些版本支持直接在 from_pretrained(..., peft_config=...)
中传入,但仍在发展中。
📌 结论:
get_peft_model
是目前最标准、最安全、最通用的方法,无需替换。
🔹 7. 分页 AdamW(paged_adamw_32bit
)是什么?
✅ 是一种 防止 GPU 内存碎片化 的优化版 AdamW 优化器。
🧩 问题背景:
- AdamW 为每个可训练参数维护两个状态(momentum 和 variance),占用 2× 显存。
- 训练中频繁分配/释放显存会导致 内存碎片,最终 OOM(即使总显存足够)。
✅ 解决方案:NVIDIA 的 Page Allocator
paged_adamw_32bit
使用 分页内存管理(类似操作系统虚拟内存)。- 把优化器状态分配在连续的“页面”上,避免碎片。
- 即使显存不连续,也能拼接使用。
✅ 优势:
优点 | 说明 |
---|---|
防止 OOM | 即使有碎片也能分配内存 |
提高稳定性 | 尤其适合长时间训练 |
支持大 batch | 更高效利用显存 |
📌 使用前提:
- 需要安装
accelerate>=0.12.0
和bitsandbytes
- 推荐搭配
optim="paged_adamw_32bit"
🔹 8. tokenizer_function
是不是相当于在进行位置编码?为什么要用 map
包裹?
❌ 不是位置编码。它只是 分词 + 编码成 ID 序列。
✅ tokenizer_function
做了什么?
def tokenizer_function(examples):return tokenizer(examples["text"], truncation=True, padding=False, max_length=512)
- 输入:一批文本(
examples["text"]
) - 输出:每个文本对应的
input_ids
,attention_mask
等 - 不包含位置编码:位置编码是在模型内部(如 Transformer 的 pos_emb)自动添加的。
✅ 为什么用 .map()
?
tokenized_dataset = dataset.map(tokenizer_function, batched=True)
.map()
是datasets.Dataset
的方法,用于 对整个数据集批量预处理。batched=True
表示一次处理多个样本,效率更高。- 结果是:每个样本变成
{"input_ids": [...], "attention_mask": [...], "labels": [...]}
📌 为什么这样做?
- 避免训练时实时 tokenize(太慢)
- 提前把文本转为模型可读的 token ID
- 支持快速随机访问(dataset[i] 直接返回 tokenized 结果)
✅ 与位置编码的关系?
- 位置信息由模型内部处理。
- 你只需要提供
input_ids
和attention_mask
。 - 模型会根据
input_ids
的长度自动添加位置编码。
✅ 总结:关键点回顾
问题 | 核心答案 |
---|---|
4bit加载后为何还要处理? | 为训练做准备(梯度、检查点、嵌入层等) |
计算用 bfloat16 是否反量化? | 是,每次前向/反向都动态反量化 |
pad_token = eos_token 作用? | 批次填充时不干扰语义,兼容 CLM |
prepare_model_for_kbit_training ? | 启用梯度检查点、确保可训练性 |
r 和 lora_alpha 关系? | 缩放因子为 alpha / r ,控制 LoRA 影响力 |
其他应用 LoRA 方法? | 有但不推荐,get_peft_model 最佳 |
分页 AdamW? | 防止显存碎片,提升训练稳定性 |
tokenizer_function 是位置编码? | 否,只是分词;map 用于预处理加速 |