03 - 对齐轨道:驯服模型

预训练的大模型很强大,但它们是”野生”的——不知道什么该说、什么不该说。对齐(Alignment)技术就是把这些强大但不受控的模型,变成有用、安全、符合人类价值观的助手。


本轨道概览

我们将学习5个关键的对齐技术:

LoRA → QLoRA → DPO → REINFORCE → PPO
  ↓      ↓       ↓        ↓        ↓
高效   量化+   偏好    强化学习   稳定
微调   微调    优化     基础      RLHF

为什么是这个顺序?

  • LoRA/QLoRA:如何高效地微调大模型(参数效率)
  • DPO:如何让模型偏好好的回答(无需强化学习)
  • REINFORCE:强化学习的基础(为PPO铺路)
  • PPO:完整的RLHF流程(最强大但最复杂)

前置要求:理解GPT架构(02-基础轨道)


目录

  1. LoRA - 高效微调的秘密
  2. QLoRA - 量化+微调
  3. DPO - 偏好优化
  4. REINFORCE - 强化学习基础
  5. PPO - 稳定的RLHF

1. LoRA - 高效微调的秘密

引子

假设你有一个预训练的GPT(参数量7B),想让它学会写代码。传统微调需要更新所有70亿参数——需要巨大的GPU内存和计算量。

LoRA提出了一个激进的想法:参数更新其实在低维子空间中!不需要动原始的70亿参数,只需要加上一个秩为8的小矩阵(几百万参数),就能达到全参数微调的效果。


对话 1: 为什么不能直接微调所有参数?

🤔 学生:预训练模型已经很强了,为什么微调还是个难题?

💡 导师:资源问题!看这个对比:

模型参数量全参数微调GPU内存训练时间
GPT-2 (1.5B)15亿~60GB几小时
GPT-3 (175B)1750亿~700GB几天
Llama-2 (70B)700亿~280GB1-2天

普通研究者和公司根本买不起这么多GPU。而且每个任务都要保存一份完整的微调模型(几十GB),成本爆炸。

🤔 学生:那怎么办?

💡 导师:LoRA的洞察是:微调时的权重变化其实是低秩的!

假设原始权重矩阵 W 是 512×512,微调后变成 W’:

ΔW = W' - W  # 权重变化

传统认为 ΔW 也是满秩(512×512),需要262K个参数。

但LoRA发现:ΔW 可以用两个小矩阵表示:

ΔW ≈ A @ B  # A是512×8, B是8×512

只需要 512×8 + 8×512 = 8K 个参数!压缩了30倍。

✨ 关键洞察:

  • 全参数微调内存和计算量巨大
  • 权重变化在低维子空间(低秩)
  • LoRA用秩r的矩阵逼近权重变化

对话 2: 低秩分解的数学直觉

🤔 学生:为什么权重变化是低秩的?

💡 导师:直觉上,微调只是调整行为的细节,不是重新学习一切。

类比:

  • 预训练:学会了”写作”(句法、语法、常识)
  • 微调:学会特定风格(正式 vs 口语,代码 vs 诗歌)

风格调整是低维变换——只需要在某几个”方向”上调整,不需要改变所有512个维度。

数学上,低秩意味着:

ΔW的秩 = r << min(512, 512)

这表示 ΔW 可以用r个基向量线性组合表示,r通常是4-64。

🤔 学生:怎么确定r的大小?

💡 导师:实验选择!

秩r参数量效果适用场景
1-2极少中等简单任务(情感分类)
4-8少良好一般任务(翻译、摘要)
16-64较少优秀复杂任务(代码生成)
>128多边际递减接近全参数微调

一般从 r=8 开始尝试。

✨ 关键洞察:

  • 低秩 = 权重变化在少数方向上
  • r 控制表达能力和参数量的权衡
  • r=8 通常够用

对话 3: LoRA如何注入到模型?

🤔 学生:LoRA的 A 和 B 矩阵具体怎么用?

💡 导师:LoRA的设计很优雅!以Attention的Q投影为例:

原始GPT:

Q = x @ W_q  # x: (seq_len, d_model), W_q: (d_model, d_model)

LoRA版本:

# 冻结原始权重
W_q.requires_grad = False
 
# 加上LoRA adapter
Q = x @ W_q + x @ A @ B  # A: (d_model, r), B: (r, d_model)
     ↑冻结     ↑可训练

训练时:

  • W_q 不变(梯度不回传)
  • 只更新 A 和 B

🤔 学生:为什么要拆成A和B两个矩阵,不直接用一个 ΔW?

💡 导师:因为参数量!

  • 直接存 ΔW:需要 d_model × d_model 个参数(全参数)
  • 拆成 A @ B:需要 d_model × r + r × d_model 个参数

当 r << d_model 时,后者远小于前者。

🤔 学生:哪些层需要加LoRA?

💡 导师:常见选择:

  • Q和V投影(最常用):控制attention的查询和内容
  • 所有线性层:效果最好,但参数多一点
  • 只有Q:最节省,适合极限资源

实验表明:只加Q和V已经能达到全参数微调90%+的效果。

✨ 关键洞察:

  • LoRA = 在原始权重基础上加低秩perturbation
  • A @ B 分解节省参数
  • 通常只加到Attention的Q/V投影

对话 4: LoRA的训练和部署

🤔 学生:训练时怎么保证只更新A和B?

💡 导师:框架自动处理!伪代码:

# 1. 冻结原始模型
for param in model.parameters():
    param.requires_grad = False
 
# 2. 只解冻LoRA参数
for name, param in model.named_parameters():
    if 'lora' in name:  # A和B的名字包含'lora'
        param.requires_grad = True
 
# 3. 正常训练
optimizer = Adam([p for p in model.parameters() if p.requires_grad])

🤔 学生:部署时怎么办?两个矩阵不会拖慢推理吗?

💡 导师:不会!因为可以合并:

# 训练完成后
W_q_new = W_q + A @ B  # 计算一次
 
# 推理时直接用
Q = x @ W_q_new  # 和原始模型一样快!

A @ B 的乘积可以预先算好,推理时零额外成本。

🤔 学生:那如果我有10个不同任务的LoRA adapter呢?

💡 导师:这是LoRA的另一个优势:一个基座+多个adapter!

基座模型 (7GB) + adapter_1 (10MB) → 任务1
                + adapter_2 (10MB) → 任务2
                + adapter_3 (10MB) → 任务3

切换任务只需要换adapter,不需要加载整个模型。

✨ 关键洞察:

  • 训练:冻结基座,只更新A和B
  • 部署:合并权重,零推理开销
  • 多任务:共享基座,切换adapter

代码片段:LoRA层实现

class LoRALinear:
    """带LoRA adapter的线性层"""
    def __init__(self, in_features, out_features, rank=8):
        # 原始权重(冻结)
        self.W = init_weights(out_features, in_features)
        self.W_frozen = True
        
        # LoRA adapter(可训练)
        self.A = init_weights(in_features, rank) * 0.01  # 小初始化
        self.B = init_zeros(rank, out_features)  # B初始化为0
        self.rank = rank
        
        # 缩放因子(保持训练稳定)
        self.scaling = 1.0 / rank
    
    def forward(self, x):
        """
        x: (batch, seq_len, in_features)
        返回: (batch, seq_len, out_features)
        """
        # 原始变换(冻结)
        out = x @ self.W.T
        
        # LoRA perturbation
        lora_out = (x @ self.A) @ self.B  # (batch, seq_len, rank) @ (rank, out)
        lora_out = lora_out * self.scaling  # 缩放
        
        return out + lora_out
    
    def merge_weights(self):
        """合并adapter到基座(部署时调用)"""
        delta_W = (self.A @ self.B) * self.scaling
        self.W = self.W + delta_W
        # 之后可以删除A和B,节省内存
 
# 使用示例
layer = LoRALinear(in_features=512, out_features=512, rank=8)
 
# 训练时
x = torch.randn(4, 16, 512)  # (batch, seq_len, dim)
out = layer.forward(x)
loss = compute_loss(out, target)
# 只对A和B计算梯度,W不动
 
# 部署时
layer.merge_weights()  # 合并
# 之后forward变成: out = x @ W.T  (正常线性层)

动手试试

  1. 秩的影响实验:

    • 在同一任务上用 r=1, 4, 8, 16 训练
    • 记录:参数量、训练时间、最终准确率
    • 画图:秩 vs 性能
  2. 层选择实验:

    • 分别只在Q、只在V、Q+V、所有层加LoRA
    • 对比效果和参数量
  3. 多adapter切换:

    • 训练3个不同风格的adapter(正式、口语、诗歌)
    • 实现快速切换,测试推理速度

连接下一站

LoRA解决了参数效率问题,但还有内存瓶颈——基座模型本身占用大量GPU内存(70B模型需要140GB)。QLoRA更进一步:把基座模型量化到4bit,配合LoRA,让消费级GPU也能微调大模型。


2. QLoRA - 量化+微调

引子

LoRA让我们只训练1%的参数,但基座模型的权重还是要全部加载到GPU——70B模型的FP16权重就要140GB内存。

QLoRA的创新:把基座模型量化到4bit(每个参数0.5字节),内存降到35GB,然后用LoRA微调。这让一张24GB的消费级GPU就能微调70B模型!


对话 5: 量化的基本原理

🤔 学生:什么是”4bit量化”?

💡 导师:量化 = 用更少的bit表示数字。

原始FP16:每个参数16bit(2字节)

W = [-0.234, 1.567, -0.891, ...]  # 每个数16bit

量化到4bit:每个参数4bit(0.5字节)

W_quant = [2, 15, 1, ...]  # 每个数只用0-15表示

怎么转换?用线性映射:

# 量化:FP16 → INT4
scale = (W_max - W_min) / 15  # 15是4bit的最大值
zero_point = -W_min / scale
W_quant = round(W / scale + zero_point)
 
# 反量化:INT4 → FP16(推理时)
W_approx = (W_quant - zero_point) * scale

🤔 学生:这样会损失精度吗?

💡 导师:会,但可控!看这个例子:

量化位数内存占用精度损失适用场景
FP32100%0%训练
FP1650%~0%训练+推理
INT825%<1%推理
INT412.5%1-3%推理(冻结权重)

关键:QLoRA把基座模型冻结在INT4,adapter用FP16训练,所以梯度更新是全精度的。

✨ 关键洞察:

  • 量化 = 用少bit表示数字
  • 4bit可以将内存减少8倍
  • 基座INT4 + adapter FP16 = 高效+精度

对话 6: QLoRA的双重量化

🤔 学生:QLoRA比普通4bit量化还特殊吗?

💡 导师:对!QLoRA用了NF4(Normal Float 4bit)和双重量化。

NF4:专为神经网络设计的4bit格式

  • 普通量化:均匀分布在 [min, max]
  • NF4:更多bit分配给0附近(因为神经网络权重服从正态分布)
普通INT4: [-8, -7, -6, ..., 0, ..., 6, 7]  # 均匀
NF4:      [-1.0, -0.7, -0.5, ..., 0, ..., 0.5, 0.7, 1.0]  # 密集在0附近

双重量化:连量化参数都量化!

记得量化需要 scale 和 zero_point 吗?它们也占内存。QLoRA把它们也量化:

# 第一层量化:权重 → INT4
W_quant, scale_1, zero_1 = quantize(W, bits=4)
 
# 第二层量化:scale也量化!
scale_1_quant, scale_2, zero_2 = quantize(scale_1, bits=8)

省了额外0.4GB内存(对70B模型)。

✨ 关键洞察:

  • NF4适配正态分布权重
  • 双重量化:连量化参数都压缩
  • 每个优化都榨干内存

对话 7: Paged Optimizer

🤔 学生:QLoRA还有个”Paged Optimizer”,是什么?

💡 导师:解决训练时的内存峰值问题。

训练大模型时,optimizer状态(Adam的m和v)比模型本身还大:

模型参数:70B × 2字节(FP16) = 140GB
Adam状态:70B × 4字节(m) + 70B × 4字节(v) = 560GB
总计:700GB!

Paged Optimizer的思路:用CPU内存作为后备,像操作系统的虚拟内存。

# GPU内存不够时
if gpu_memory_full():
    # 把不常用的optimizer状态移到CPU
    move_to_cpu(old_optimizer_states)
    
# 需要时再搬回来
if need_states():
    move_to_gpu(optimizer_states)

这样GPU只需要保存”当前正在用”的状态,其他放CPU。

🤔 学生:CPU-GPU搬运不会很慢吗?

💡 导师:会慢一点,但总比OOM(内存不足)好。而且优化器状态的访问是按梯度更新顺序的,可以预取(prefetch)。

实测:慢10-15%,但能让24GB GPU训练65B模型,值得。

✨ 关键洞察:

  • Optimizer状态是内存大户
  • Paged Optimizer用CPU作为溢出空间
  • 牺牲少量速度换取可行性

对话 8: QLoRA vs LoRA对比

🤔 学生:QLoRA和LoRA性能差距大吗?

💡 导师:实验结果出人意料地接近!

方法基座精度Adapter精度内存(70B)准确率
Full FTFP16FP16~280GB100%(基线)
LoRAFP16FP16~140GB99.5%
QLoRANF4FP16~35GB99.3%

QLoRA只损失0.2%准确率,但内存降了4倍!

为什么损失这么小?

  1. 冻结的基座不需要高精度:推理时反量化回FP16
  2. 梯度流经的adapter是FP16:学习过程无损
  3. NF4专为权重分布设计:量化误差小

🤔 学生:什么时候用QLoRA,什么时候用LoRA?

💡 导师:看GPU内存:

  • 内存够(80GB+ GPU)→ LoRA(稍快)
  • 内存紧张(<40GB)→ QLoRA(能跑起来)
  • 消费级GPU(24GB)→ QLoRA(唯一选择)

✨ 关键洞察:

  • QLoRA = LoRA + 4bit基座 + Paged Optimizer
  • 精度损失<1%,内存降4倍
  • 民主化大模型微调

代码片段:4bit量化核心

import numpy as np
 
def quantize_4bit(W, quant_type='nf4'):
    """
    W: FP16权重矩阵
    返回: 量化后的INT4 + 量化参数
    """
    W_flat = W.flatten()
    
    if quant_type == 'uniform':
        # 普通线性量化
        W_min, W_max = W_flat.min(), W_flat.max()
        scale = (W_max - W_min) / 15  # 4bit: 0-15
        zero_point = -W_min / scale
        
        W_quant = np.clip(np.round(W_flat / scale + zero_point), 0, 15).astype(np.uint8)
        
    elif quant_type == 'nf4':
        # NF4: 专为正态分布设计的非均匀量化
        # 预定义的16个量化级别(优化for正态分布)
        nf4_levels = np.array([
            -1.0, -0.6962, -0.5251, -0.3949, -0.2844, -0.1848,
            -0.0911, 0.0, 0.0911, 0.1848, 0.2844, 0.3949,
            0.5251, 0.6962, 0.8614, 1.0
        ])
        
        # 归一化到[-1, 1]
        absmax = np.abs(W_flat).max()
        W_norm = W_flat / (absmax + 1e-8)
        
        # 找最近的量化级别
        W_quant = np.argmin(np.abs(W_norm[:, None] - nf4_levels), axis=1).astype(np.uint8)
        
        scale = absmax
        zero_point = 0
    
    return W_quant.reshape(W.shape), scale, zero_point
 
def dequantize_4bit(W_quant, scale, zero_point, quant_type='nf4'):
    """反量化:INT4 → FP16"""
    if quant_type == 'uniform':
        W_fp16 = (W_quant.astype(np.float32) - zero_point) * scale
    elif quant_type == 'nf4':
        nf4_levels = np.array([-1.0, -0.6962, ..., 1.0])  # 同上
        W_norm = nf4_levels[W_quant]
        W_fp16 = W_norm * scale
    
    return W_fp16
 
# 使用示例
W_original = np.random.randn(512, 512).astype(np.float16)
 
# 量化
W_quant, scale, zero = quantize_4bit(W_original, 'nf4')
print(f"原始: {W_original.nbytes / 1024:.1f}KB")
print(f"量化: {W_quant.nbytes / 1024:.1f}KB")  # 应该是1/8
 
# 反量化
W_reconstructed = dequantize_4bit(W_quant, scale, zero, 'nf4')
 
# 计算误差
error = np.abs(W_original - W_reconstructed).mean()
print(f"平均误差: {error:.4f}")  # 通常 < 0.01

动手试试

  1. 量化误差分析:

    • 用不同bit数(8/6/4/2)量化同一个权重矩阵
    • 可视化:量化前后的分布直方图
    • 计算:每种bit数的RMSE
  2. NF4 vs Uniform对比:

    • 生成服从正态分布的权重
    • 分别用NF4和uniform量化
    • 对比:哪个误差更小?
  3. 内存占用实测:

    • 加载一个小模型(如GPT-2 small)
    • 测量:FP16/INT8/INT4的实际内存占用
    • 验证:是否真的减少了8倍?

连接下一站

LoRA/QLoRA解决了”如何高效微调”的问题。但微调的目标是什么?如何让模型偏好好的回答、避免有害回答?这就需要对齐技术。

**DPO(Direct Preference Optimization)**是最新的对齐方法——不需要复杂的强化学习,直接用偏好对数据训练。


3. DPO - 偏好优化

引子

想象你要训练一个客服AI。用户问:“怎么取消订单?”

回答A(好):“请登录账户,进入订单页面,点击取消按钮。“
回答B(差):“我不知道。”

传统RLHF需要:

  1. 训练一个奖励模型(判断A比B好)
  2. 用强化学习优化策略(让模型多生成A类回答)

DPO说:不需要奖励模型和RL,直接用(A好, B差)这样的偏好对训练!


对话 9: 偏好学习的本质

🤔 学生:什么是”偏好”数据?

💡 导师:偏好数据 = 同一个问题的两个回答,标注哪个更好。

preference_data = [
    {
        "prompt": "解释量子计算",
        "chosen": "量子计算利用量子叠加和纠缠...",  # 好回答
        "rejected": "量子计算就是很快的计算。"  # 差回答
    },
    ...
]

为什么不用绝对评分(如1-5分)?

因为人类不擅长绝对评分,但很擅长比较:

  • “这个回答值几分?” → 难回答
  • “A和B哪个更好?” → 很容易

🤔 学生:偏好数据怎么获得?

💡 导师:三种方式:

  1. 人工标注:让标注员对比两个回答
  2. AI辅助:用GPT-4评判哪个更好
  3. 隐式收集:用户点击/点赞=偏好信号

✨ 关键洞察:

  • 偏好数据 = (prompt, chosen, rejected) 三元组
  • 比绝对评分更容易标注
  • 是RLHF的替代方案

对话 10: DPO的核心公式

🤔 学生:DPO如何从偏好数据学习?

💡 导师:DPO的损失函数非常优雅:

loss = -log(sigmoid(β * (log_ratio_chosen - log_ratio_rejected)))

看起来复杂,但拆解一下:

# 1. 计算chosen和rejected的log概率
log_pi_chosen = model.log_prob(chosen_response)
log_pi_rejected = model.log_prob(rejected_response)
 
# 2. 计算相对参考模型的ratio
log_ratio_chosen = log_pi_chosen - log_pi_ref(chosen_response)
log_ratio_rejected = log_pi_rejected - log_pi_ref(rejected_response)
 
# 3. DPO loss
diff = β * (log_ratio_chosen - log_ratio_rejected)
loss = -log_sigmoid(diff)

直觉:

  • 如果 chosen 的概率 > rejected → diff > 0 → loss 小(好!)
  • 如果 chosen 的概率 < rejected → diff < 0 → loss 大(惩罚)

🤔 学生:β是什么?

💡 导师:β控制对齐强度:

  • β小(0.01)→ 轻微偏好,模型变化不大
  • β大(1.0)→ 强烈偏好,模型激进调整

类比:β是”老师的严格程度”。太严格(β大)可能导致模型只会说一种答案(mode collapse)。

✨ 关键洞察:

  • DPO loss = 让chosen概率上升,rejected下降
  • β控制偏好强度
  • 只需要一个模型(不需要奖励模型)

对话 11: 为什么需要参考模型?

🤔 学生:公式里的 log_pi_ref 是什么?

💡 导师:参考模型 = 训练前的模型副本(冻结)。

为什么需要它?

防止模式崩溃!如果只优化”chosen概率高”,模型可能学会:

  • 只生成一种”万能回答”
  • 或者生成极短的回答(概率高但没用)

参考模型提供”锚点”:

log_ratio = log(p_new / p_ref)

这个ratio告诉我们”新模型相比原始模型变化了多少”。如果变化太大,loss会惩罚。

效果:

  • 模型在保持原有能力(p_ref)的基础上
  • 微调偏好(p_new 向 chosen靠拢)

🤔 学生:这不就是正则化吗?

💡 导师:完全正确!DPO隐式地包含了KL散度正则:

min KL(p_new || p_ref) subject to chosen > rejected

保持和参考模型接近,防止过度优化。

✨ 关键洞察:

  • 参考模型 = 训练前的原始模型
  • 防止模型偏离太远
  • 相当于KL正则化

对话 12: DPO vs RLHF

🤔 学生:DPO比RLHF好在哪?

💡 导师:看这个对比:

维度RLHF (PPO)DPO
模型数量3个(policy, value, reward)1个
训练复杂度高(RL不稳定)低(监督学习)
数据需求奖励标注偏好对
超参数10+2-3
训练速度慢快
效果优秀接近RLHF

DPO的优势:

  1. 简单:就是个分类任务(chosen vs rejected)
  2. 稳定:没有RL的不稳定性
  3. 高效:单模型,训练快

RLHF的优势:

  1. 灵活:可以优化任意奖励函数
  2. 在线学习:可以边生成边优化

🤔 学生:什么时候用哪个?

💡 导师:

  • 有偏好对数据 + 追求简单 → DPO
  • 需要复杂奖励函数 → RLHF
  • 资源有限 → DPO(省内存)

实际上,大部分场景DPO够用了。Llama-2-Chat、Zephyr都用DPO对齐。

✨ 关键洞察:

  • DPO = 简化版RLHF
  • 牺牲了灵活性,换来简单和稳定
  • 适合90%的对齐场景

代码片段:DPO训练循环

def dpo_loss(model, ref_model, prompt, chosen, rejected, beta=0.1):
    """
    DPO损失计算
    """
    # 1. 计算chosen的log概率
    logits_chosen = model(prompt + chosen)
    logprob_chosen = log_prob(logits_chosen, chosen)
    
    # 参考模型的chosen概率(冻结,不计算梯度)
    with torch.no_grad():
        logits_ref_chosen = ref_model(prompt + chosen)
        logprob_ref_chosen = log_prob(logits_ref_chosen, chosen)
    
    # 2. 计算rejected的log概率
    logprob_rejected = log_prob(model(prompt + rejected), rejected)
    
    with torch.no_grad():
        logprob_ref_rejected = log_prob(ref_model(prompt + rejected), rejected)
    
    # 3. 计算ratio
    log_ratio_chosen = logprob_chosen - logprob_ref_chosen
    log_ratio_rejected = logprob_rejected - logprob_ref_rejected
    
    # 4. DPO loss
    diff = beta * (log_ratio_chosen - log_ratio_rejected)
    loss = -torch.log(torch.sigmoid(diff)).mean()
    
    return loss
 
# 训练循环
model = load_pretrained_model()
ref_model = copy.deepcopy(model)  # 参考模型
ref_model.eval()  # 冻结
 
optimizer = Adam(model.parameters(), lr=1e-5)
 
for batch in preference_dataloader:
    prompt, chosen, rejected = batch
    
    loss = dpo_loss(model, ref_model, prompt, chosen, rejected, beta=0.1)
    
    loss.backward()
    optimizer.step()
    optimizer.zero_grad()
    
    if step % 100 == 0:
        print(f"Step {step}, Loss: {loss.item():.4f}")

动手试试

  1. 偏好数据构造:

    • 用GPT生成同一问题的好/坏回答
    • 构造100条偏好对
    • 可视化:chosen和rejected的长度/情感差异
  2. β参数实验:

    • 用 β=0.01, 0.1, 0.5, 1.0 训练
    • 观察:模型生成的多样性和质量
    • β过大会不会mode collapse?
  3. DPO vs 监督微调:

    • 同一数据集,分别用DPO和SFT(只用chosen)
    • 对比:偏好准确率和生成质量

连接下一站

DPO简化了对齐,但如果你需要更灵活的控制——比如优化”有用性+无害性+诚实性”的组合奖励,就需要强化学习。

REINFORCE是策略梯度的基础算法,理解它是理解PPO的前提。


🎯 算法四: REINFORCE - 策略梯度基础

什么是REINFORCE?

REINFORCE (Williams, 1992) 是最基础的策略梯度算法——它让模型”试错”,通过奖励信号学习什么是好行为。

核心思想:

  • 让模型生成轨迹(trajectory)
  • 收到奖励R(高奖励=好行为,低奖励=差行为)
  • 调整策略:增大高奖励行为的概率,减小低奖励行为的概率

公式:

∇J(θ) = E_τ [ R(τ) · ∇ log π_θ(a_t | s_t) ]

翻译:期望奖励的梯度 = 奖励 × 对数概率梯度


对话13: 强化学习vs监督学习有什么本质区别?

🤔 学生: 强化学习和监督学习都是在学习,区别在哪里?

💡 导师: 想象你在教小孩:

监督学习: “这是猫,这是狗,记住!” (直接给正确答案) 强化学习: “去抓玩具,抓到了给你糖,抓不到什么都没有” (只给奖励信号)

核心区别:

维度监督学习强化学习
输入(x, y)标注对(状态, 动作, 奖励)
学习目标最小化预测误差最大化累积奖励
反馈每步都有正确答案只有奖励(可能延迟)
探索不需要必须探索好/坏行为

在LLM对齐中:

  • DPO: “这个回答好,那个回答差” (监督学习,需要标注对)
  • REINFORCE: “生成回答,看奖励模型打分,高分的多生成” (强化学习,只需奖励)

✨ 关键洞察: 强化学习适合奖励可计算但正确答案难枚举的任务——比如”回答要有用+无害+诚实”,这是一个复合奖励,很难提前标注”最佳回答”。


对话14: log概率梯度为什么能表示”让好行为更可能”?

🤔 学生: 公式里的 R · ∇ log π(a|s) 是什么意思?为什么乘以奖励就能强化行为?

💡 导师: 拆开看这个魔法:

1. π(a|s) 是什么?

  • 策略(policy):在状态s下选择动作a的概率
  • 例子:在写作任务中,s=“The cat”,a=“sat”,π(sat|The cat)=0.3

2. log π(a|s) 为什么有用?

  • 概率范围[0,1]难优化(容易数值下溢)
  • log映射到(-∞, 0],梯度更稳定
  • 性质:∇ log π = (1/π) · ∇π (链式法则)

3. 为什么乘以R? 假设采样到动作a,奖励R=+10(很好):

Loss = -R · log π(a|s) = -10 · log π(a|s)
∇Loss = -10 · ∇ log π(a|s)

梯度下降会增大 π(a|s),因为:

  • Loss越小越好 → 要让 -10·log π 小 → π接近1
  • 结果:高奖励行为的概率被提升

如果R=-5(差行为):

  • Loss = +5 · log π(a|s)
  • 梯度下降会减小 π(a|s)

数学证明(策略梯度定理):

∇_θ E[R] = E[ R · ∇_θ log π_θ(a|s) ]

证明跳过(见Williams 1992论文),但直觉是:

  • ∇ log π 指向”让a更可能”的方向
  • 乘以R后,高奖励动作被推向这个方向,低奖励被拉离

✨ 关键洞察: log概率梯度是个”方向指示器”,奖励是个”力度旋钮”——两者结合实现”试错学习”。


对话15: 为什么REINFORCE方差特别高?

🤔 学生: 听说REINFORCE训练不稳定,方差高,为什么?

💡 导师: 想象你在玩游戏,每次都随机到不同结果:

例子:生成名字任务

  • 第1次生成”alice”,奖励R=5.0
  • 第2次生成”bob”,奖励R=0.1
  • 第3次生成”zxy”,奖励R=-2.0

问题:

  1. 蒙特卡洛估计:每条轨迹只采样一次

    • R=5.0那次可能是运气好(采样噪声)
    • R=-2.0那次可能只是这个采样差
    • 梯度估计 = 单次奖励 × ∇log π,噪声直接传播
  2. 信用分配(credit assignment):

    • “alice”奖励高,是因为’a’好?还是’lice’好?
    • REINFORCE用整条轨迹奖励R加权所有步的log π
    • 结果:好坏行为都被同等对待

数学上:

Var[R · ∇log π] = E[R²] · Var[∇log π] + Var[R] · E[∇log π]²
  • Var[R]很大(轨迹奖励波动)
  • Var[∇log π]也大(策略不确定性)
  • 两个方差相乘 → 爆炸

解决方案预告:

  1. Baseline减法(下个对话):R - b,减去期望奖励
  2. 批量平均:多条轨迹求平均,方差降低√N
  3. 价值函数(PPO):用神经网络估计期望奖励,替代单次R

✨ 关键洞察: REINFORCE是”纯蒙特卡洛”——完全靠采样,没有利用任何结构(如动态规划)。高方差是其简单性的代价。


对话16: Baseline减法如何降低方差?

🤔 学生: 公式里看到 (R - b) · ∇log π,这个b是什么?为什么减去它方差就小了?

💡 导师: Baseline b 是期望奖励的估计——告诉你”平均水平是多少”。

没有baseline的问题: 假设所有奖励都是正数:

  • 轨迹1:R=3.0 → 所有动作都被强化(增大概率)
  • 轨迹2:R=2.8 → 所有动作也被强化
  • 但2.8其实比平均差!应该减弱,不是强化

加上baseline后: 设 b=2.9(期望奖励)

  • 轨迹1:R-b=3.0-2.9=+0.1 → 微弱强化(确实比平均好)
  • 轨迹2:R-b=2.8-2.9=-0.1 → 微弱减弱(比平均差)

为什么不改变期望梯度? 数学性质(score function identity):

E[ ∇log π ] = 0  (期望梯度和为零)
E[ (R-b) · ∇log π ] = E[ R · ∇log π ] - b · E[∇log π]
                    = E[ R · ∇log π ]  (第二项为0)

证明:∇log π 的期望在所有动作上积分为0(因为∇∫π=∫∇π,而∫π=1是常数)

方差为什么降低? 直觉:R-b把奖励”中心化”到0附近

  • 原始R: [0, 10],均值5,方差大
  • R-b: [-5, +5],均值0,方差小(因为去掉了均值的偏移)

数学上:

Var[R · ∇log π] 大
Var[(R-b) · ∇log π] 小  (当b=E[R]时方差最小)

实践中的baseline:

  • 移动平均:b = 0.9·b_old + 0.1·R_new (指数移动平均)
  • 价值网络(PPO):b = V(s),神经网络预测期望回报

✨ 关键洞察: Baseline是个”锚点”——不改变梯度方向(期望),但减少噪声(方差)。类似标准化:x → (x-μ)/σ,减去均值不改变相对关系。


对话17: REINFORCE在实际RLHF中够用吗?

🤔 学生: 听起来加了baseline的REINFORCE就很好了,为什么OpenAI用PPO而不是REINFORCE?

💡 导师: REINFORCE是”可行但不实用”——理论正确,工程上太脆弱。

REINFORCE的三大缺陷:

1. 样本效率低

  • 每条轨迹只用一次(on-policy)
  • 策略稍微更新,旧轨迹就不能再用(分布不匹配)
  • LLM生成一条回答很贵(几秒),浪费不起

2. 梯度爆炸/消失

  • 单步更新可能让策略剧变:π_new(a|s) / π_old(a|s) = 100x
  • 下一次采样完全不同,训练震荡/崩溃
  • Baseline只减少方差,不约束步长

3. 奖励稀疏问题

  • 如果大部分R≈0,只有少数R很大,梯度信号噪声极大
  • 例子:安全性奖励,99%回答R=0(安全),1%是R=-100(有害)
  • REINFORCE会被那1%支配,不稳定

PPO的改进:

  • 重要性采样:旧轨迹可以复用多次(sample efficiency)
  • Clip约束:限制策略更新幅度(stability)
  • 价值网络:更准确的baseline(lower variance)
  • GAE(Generalized Advantage Estimation):平衡bias/variance

真实数据:

  • OpenAI InstructGPT:PPO稳定训练,REINFORCE崩溃
  • DeepMind Sparrow:用PPO + 多个奖励模型

✨ 关键洞察: REINFORCE是”教科书算法”——理解策略梯度的最小实现,但工业界需要PPO的稳定性。类似SGD vs Adam:SGD理论干净,Adam工程实用。


代码片段: REINFORCE核心循环

# === REINFORCE with Baseline ===
def reinforce_update(policy, optimizer, baseline):
    """
    一次REINFORCE更新
    policy: 语言模型策略 π_θ(a|s)
    optimizer: Adam优化器
    baseline: 移动平均奖励
    """
    # 1. 生成轨迹(batch)
    trajectories = []
    rewards = []
    log_probs_list = []
    
    for _ in range(batch_size):
        # 生成一条回答
        prompt = sample_prompt()
        tokens, log_probs = policy.generate(prompt)  # 采样 + 记录log π
        
        # 计算奖励(调用奖励模型)
        reward = reward_model.score(prompt, tokens)
        
        trajectories.append(tokens)
        rewards.append(reward)
        log_probs_list.append(log_probs)
    
    # 2. 更新baseline(指数移动平均)
    avg_reward = sum(rewards) / len(rewards)
    baseline.update(avg_reward)
    
    # 3. 计算损失
    loss = 0
    for log_probs, reward in zip(log_probs_list, rewards):
        # Advantage = R - baseline
        advantage = reward - baseline.value
        
        # REINFORCE损失: -advantage · Σ log π(a_t|s_t)
        total_log_prob = sum(log_probs)
        loss += -advantage * total_log_prob
    
    loss = loss / batch_size
    
    # 4. 反向传播 + 梯度下降
    optimizer.zero_grad()
    loss.backward()
    
    # 梯度裁剪(防止爆炸)
    torch.nn.utils.clip_grad_norm_(policy.parameters(), max_norm=1.0)
    
    optimizer.step()
    
    return loss.item(), avg_reward
 
# === 训练循环 ===
baseline = ExponentialMovingAverage(alpha=0.1)
 
for step in range(num_steps):
    loss, avg_r = reinforce_update(policy, optimizer, baseline)
    
    if step % 100 == 0:
        print(f"Step {step}: Loss={loss:.4f}, Reward={avg_r:.2f}, Baseline={baseline.value:.2f}")

关键细节:

  1. log_probs记录: 生成时存每步的log π(a_t|s_t)
  2. Advantage: R - baseline,而不是原始R
  3. 梯度裁剪: clip_grad_norm,防止单个高奖励轨迹导致的梯度爆炸
  4. 批量平均: 多条轨迹求平均,降低方差

🚀 算法五: PPO - 近端策略优化

什么是PPO?

PPO (Proximal Policy Optimization, Schulman et al. 2017) 是当前RLHF的工业标准——ChatGPT、Claude、Llama都用它对齐模型。

核心改进:在REINFORCE基础上加了”近端约束”——限制每次更新的步长,防止策略崩溃。

公式:

L_CLIP = E[ min(r_t · A_t, clip(r_t, 1-ε, 1+ε) · A_t) ]
其中 r_t = π_new(a|s) / π_old(a|s)  (重要性采样比率)
     A_t = R - V(s)  (优势函数)

翻译:目标函数 = 在”原始目标”和”裁剪目标”之间取小者


对话18: REINFORCE的步长失控问题具体是什么?

🤔 学生: 前面说REINFORCE会”策略剧变”,能举个具体例子吗?

💡 导师: 想象你在训练模型写诗:

初始策略:

Prompt: "写一首关于春天的诗"
生成: "春风拂面花开艳"  π_old(这句话) = 0.01
奖励: R = 8.0 (挺好)

REINFORCE更新后:

Loss = -8.0 · log(0.01) = -8.0 · (-4.6) = +36.8
梯度很大 → 参数剧变
新策略: π_new(这句话) = 0.85  (暴涨85倍!)

问题来了: 下一次采样时,模型疯狂生成这句话的变体:

"春风拂面花开艳"
"春风拂面花开好"
"春风拂面花真艳"
...

为什么坏?

  1. Mode collapse:策略坍缩到单一输出
  2. 过拟合:那句话可能是偶然高分,不代表真的好
  3. 探索停止:不再尝试其他风格,卡在局部最优

数学上: 重要性采样比 r = π_new / π_old = 0.85 / 0.01 = 85

  • r >> 1 意味着新策略把概率提升了85倍
  • 单次更新太激进,策略分布完全变了

✨ 关键洞察: 强化学习的核心矛盾:利用(exploit)高奖励行为 vs 探索(explore)新行为。REINFORCE倾向过度利用,丧失探索。


对话19: PPO的Clip约束怎么解决步长问题?

🤔 学生: 公式里的 clip(r, 1-ε, 1+ε) 是什么意思?怎么限制步长?

💡 导师: Clip函数:把比率r夹到 [1-ε, 1+ε] 范围内

clip(r, 1-ε, 1+ε) = max(1-ε, min(r, 1+ε))

例如 ε=0.2:

  • r=0.5 → clip到0.8 (不让降太多)
  • r=1.1 → 保持1.1 (在范围内)
  • r=3.0 → clip到1.2 (不让涨太多)

PPO目标函数拆解:

L = min( r·A, clip(r,1-ε,1+ε)·A )

情况1: 优势A > 0 (好行为,要强化)

  • r·A = 1.5 · 2.0 = 3.0 (原始目标:大力强化)
  • clip(1.5, 0.8, 1.2)·A = 1.2 · 2.0 = 2.4 (裁剪目标:温和强化)
  • min(3.0, 2.4) = 2.4 ← 选择保守的

情况2: 优势A < 0 (坏行为,要减弱)

  • r·A = 0.5 · (-2.0) = -1.0 (原始:大力减弱)
  • clip(0.5, 0.8, 1.2)·A = 0.8 · (-2.0) = -1.6 (裁剪:更大力减弱)
  • min(-1.0, -1.6) = -1.6 ← 选择保守的(因为A<0,小=更保守)

图示(A>0时):

目标函数
   │     ╱
   │    ╱ 原始r·A (斜率=A)
   │   ╱
   │  ╱________  裁剪后(斜率=0,在r>1+ε时)
   │ ╱
   └─────────→ 比率r
   0   1   1+ε

当r超过1+ε后,目标函数不再增长 → 梯度为0 → 停止强化

为什么取min?

  • 保守原则:宁可更新不够,不要更新过头
  • 防止”lucky sample”(偶然高奖励)导致的策略崩溃

✨ 关键洞察: Clip是个”刹车”——策略想大步前进时,强制减速。ε=0.2意味着”单次更新最多让概率变化20%“。


对话20: PPO为什么需要价值网络V(s)?

🤔 学生: 优势函数 A = R - V(s),这个V(s)和前面的baseline有什么区别?

💡 导师: Baseline(REINFORCE中):

  • 简单标量,全局共享
  • 更新: b ← 0.9·b + 0.1·R (指数移动平均)
  • 含义: “所有状态的平均期望奖励”

价值网络V(s) (PPO中):

  • 神经网络,输入状态s,输出标量
  • 更新: 最小化MSE: (V(s) - R)²
  • 含义: “状态s的期望奖励”

为什么需要V(s)?

问题: Baseline是全局常数,不考虑状态差异

  • 状态1: “写诗的开头”,期望奖励 E[R|s1]=3.0
  • 状态2: “写诗已写一半”,期望奖励 E[R|s2]=5.0
  • 全局baseline b=4.0 (两者平均)

结果:

  • s1得到R=4.0 → A=4.0-4.0=0,没信号!(实际比s1期望高)
  • s2得到R=4.0 → A=4.0-4.0=0,没信号!(实际比s2期望低)

用V(s)后:

  • s1: A = 4.0 - V(s1) = 4.0 - 3.0 = +1.0 (确实比这个状态期望好)
  • s2: A = 4.0 - V(s2) = 4.0 - 5.0 = -1.0 (比这个状态期望差)

价值网络架构(在LLM RLHF中):

class ValueHead(nn.Module):
    def __init__(self, transformer):
        self.transformer = transformer  # 共享策略的主干
        self.v_head = nn.Linear(hidden_size, 1)  # 额外的价值头
    
    def forward(self, input_ids):
        hidden = self.transformer(input_ids)  # [batch, seq_len, hidden]
        values = self.v_head(hidden)  # [batch, seq_len, 1]
        return values[:, -1, 0]  # 取最后一个token的价值

训练V(s):

# 收集轨迹后
for state, actual_return in zip(states, returns):
    v_pred = value_network(state)
    v_loss = (v_pred - actual_return) ** 2
    v_loss.backward()

✨ 关键洞察: 价值网络是个”状态感知的baseline”——把全局常数升级为条件期望,梯度信号更精准。代价:要训练额外网络,增加计算量。


对话21: 完整RLHF流程是怎样的?

🤔 学生: 从预训练模型到ChatGPT,中间具体经历了什么?

💡 导师: 完整RLHF三阶段:

阶段1: 监督微调(SFT)

  • 数据: 高质量人类演示对话 (prompt, ideal_response)
  • 方法: 标准语言建模损失
  • 目的: 让模型学会对话格式,基本遵循指令
  • 输出: SFT模型(例如GPT-3.5-SFT)

阶段2: 奖励模型训练(RM)

  • 数据: 偏好对 (prompt, response_A, response_B, label_human_prefers)
  • 方法: 排序损失(类似DPO,但不直接优化策略)
  • 架构: 复用SFT模型主干 + 标量head
  • 输出: 奖励模型RM(prompt, response) → 分数

阶段3: PPO优化

  • 策略网络: SFT模型(会被更新)
  • 价值网络: 随机初始化,输出V(s)
  • 参考网络: SFT模型快照(冻结,用于KL惩罚)
  • 奖励模型: 阶段2训练的RM(冻结)

训练循环:

for step in range(ppo_steps):
    # 1. 生成回答(从策略网络)
    prompts = sample_prompts()
    responses, log_probs_old = policy.generate(prompts)
    
    # 2. 计算奖励
    rewards = reward_model(prompts, responses)
    
    # 3. 计算KL惩罚(相对参考网络)
    log_probs_ref = reference_model.log_prob(prompts, responses)
    kl_penalty = (log_probs_old - log_probs_ref)
    shaped_rewards = rewards - kl_coef * kl_penalty
    
    # 4. 价值网络预测baseline
    values = value_network(prompts)
    advantages = shaped_rewards - values
    
    # 5. PPO更新策略网络
    for _ in range(ppo_epochs):  # 多轮复用数据
        log_probs_new = policy.log_prob(prompts, responses)
        ratio = torch.exp(log_probs_new - log_probs_old)
        
        surr1 = ratio * advantages
        surr2 = torch.clamp(ratio, 1-eps, 1+eps) * advantages
        policy_loss = -torch.min(surr1, surr2).mean()
        
        policy_loss.backward()
        optimizer_policy.step()
    
    # 6. 更新价值网络
    v_loss = (value_network(prompts) - shaped_rewards) ** 2
    v_loss.mean().backward()
    optimizer_value.step()

四个模型的角色:

模型更新?用途
策略网络✅ 是生成回答
价值网络✅ 是预测期望奖励(baseline)
参考网络❌ 否KL惩罚锚点(防止崩溃)
奖励模型❌ 否评分回答质量

✨ 关键洞察: RLHF是”四模型协作”——策略学习生成,价值学习评估,参考保持稳定,奖励提供信号。PPO把它们编织成稳定的训练循环。


对话22: PPO vs DPO,实际中如何选择?

🤔 学生: DPO不是更简单吗?为什么OpenAI还用PPO?

💡 导师: PPO优势:

  1. 灵活的奖励函数

    • PPO: 任意奖励,可组合(有用性0.6 + 无害性0.3 + 诚实性0.1)
    • DPO: 只能用成对偏好,无法直接优化组合目标
  2. 在线探索

    • PPO: 策略生成新数据 → 奖励模型打分 → 发现新的好行为
    • DPO: 离线,只能在现有偏好对上学习
  3. 长序列任务

    • PPO: 逐步奖励(每个token都有V(s)),信用分配更精确
    • DPO: 整体偏好,难处理”前半段好,后半段差”的情况

DPO优势:

  1. 简单

    • 无需4个模型,只训练策略
    • 不需要调PPO的超参(ε, KL系数, value_lr…)
  2. 稳定

    • 无强化学习的探索噪声
    • 收敛更快(几千步 vs PPO几万步)
  3. 数据效率

    • 每条偏好对都充分利用
    • PPO需要大量on-policy采样(expensive)

实际选择:

场景推荐理由
通用对话(ChatGPT)PPO需要组合奖励,在线探索
特定任务微调(摘要)DPO偏好清晰,数据固定
安全对齐PPO需要红队测试 + 即时奖励
资源受限DPO单模型,训练快
研究原型DPO快速迭代

最新趋势(2024-2026):

  • GRPO (Group Relative Policy Optimization): PPO的简化版,减少超参
  • Offline PPO: 在固定数据集上跑PPO,结合两者优点
  • DPO变体: RDPO, IPO, cDPO,改进DPO的理论缺陷

✨ 关键洞察: PPO是”瑞士军刀”(万能但复杂),DPO是”剪刀”(专精但简单)。工业界倾向PPO因为需要应对复杂多变的对齐需求。


代码片段: PPO核心更新

def ppo_update(policy, value_net, ref_policy, optimizer_p, optimizer_v, 
               prompts, responses, old_log_probs, rewards, epsilon=0.2):
    """
    一次PPO更新(简化版)
    
    Args:
        policy: 当前策略网络(会更新)
        value_net: 价值网络
        ref_policy: 参考策略(冻结)
        old_log_probs: 生成时的log π_old(a|s)
        rewards: 奖励模型打分
    """
    # 1. 计算KL惩罚
    with torch.no_grad():
        ref_log_probs = ref_policy.log_prob(prompts, responses)
    kl_div = old_log_probs - ref_log_probs
    shaped_rewards = rewards - kl_coef * kl_div
    
    # 2. 价值网络预测 + 优势计算
    values = value_net(prompts)
    advantages = shaped_rewards - values
    advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)  # 标准化
    
    # 3. PPO epoch (复用数据多次)
    for _ in range(ppo_epochs):
        # 重新计算log probs(梯度图)
        new_log_probs = policy.log_prob(prompts, responses)
        
        # 重要性采样比率
        ratio = torch.exp(new_log_probs - old_log_probs)
        
        # Clipped surrogate loss
        surr1 = ratio * advantages
        surr2 = torch.clamp(ratio, 1.0 - epsilon, 1.0 + epsilon) * advantages
        policy_loss = -torch.min(surr1, surr2).mean()
        
        # 熵正则(鼓励探索)
        entropy = policy.entropy(prompts)
        entropy_bonus = 0.01 * entropy.mean()
        
        total_loss = policy_loss - entropy_bonus
        
        # 策略网络更新
        optimizer_p.zero_grad()
        total_loss.backward()
        torch.nn.utils.clip_grad_norm_(policy.parameters(), 1.0)
        optimizer_p.step()
    
    # 4. 价值网络更新(MSE loss)
    for _ in range(value_epochs):
        v_pred = value_net(prompts)
        v_loss = F.mse_loss(v_pred, shaped_rewards)
        
        optimizer_v.zero_grad()
        v_loss.backward()
        optimizer_v.step()
    
    # 返回监控指标
    metrics = {
        'policy_loss': policy_loss.item(),
        'value_loss': v_loss.item(),
        'mean_reward': rewards.mean().item(),
        'mean_kl': kl_div.mean().item(),
        'mean_advantage': advantages.mean().item()
    }
    return metrics

关键细节:

  1. Advantage标准化: (A - μ) / σ,稳定训练
  2. 多轮epoch: PPO_epochs=4,复用每批数据
  3. 熵正则: 防止策略过早收敛(探索vs利用)
  4. 梯度裁剪: 必须!防止离群奖励导致的梯度爆炸

📊 五大对齐算法对比

算法训练数据模型数量稳定性灵活性适用场景
监督微调(SFT)(x,y)标注对1⭐⭐⭐⭐⭐⭐⭐有明确正确答案
LoRA同SFT1⭐⭐⭐⭐⭐⭐⭐⭐参数高效微调
QLoRA同SFT1⭐⭐⭐⭐⭐⭐⭐极低资源微调
DPO偏好对(x,y+,y-)1(+ref)⭐⭐⭐⭐⭐⭐⭐偏好对齐,离线
REINFORCE奖励函数1⭐⭐⭐⭐⭐⭐原型/教学
PPO奖励函数4(策略+价值+参考+RM)⭐⭐⭐⭐⭐⭐⭐⭐复杂对齐,工业界

🎯 本章总结

你已经掌握了驯服大模型的完整工具箱:

  1. LoRA/QLoRA: 参数高效微调,用1%参数适配新任务
  2. DPO: 直接从偏好学习,无需奖励模型
  3. REINFORCE: 策略梯度基础,理解”试错学习”
  4. PPO: RLHF工业标准,稳定优化复杂奖励

核心理念:

  • 微调是让模型”适应”,对齐是让模型”听话”
  • 没有万能方法,只有合适的权衡
  • 实践中常组合使用:SFT → DPO → PPO

下一站: 04-推理优化轨道 - 学习如何让训练好的模型跑得更快!