量化感知训练(QAT)原理:模拟量化、伪量化节点、直通估计器(STE)、QAT训练流程

大家好,欢迎来到第二章。上一章我们聊了PTQ(训练后量化),那种“一刀切”的方式虽然快,但精度损失有时候让人头疼。尤其是当模型要部署到边缘设备上,精度掉个1%都受不了。

这时候,就该QAT(量化感知训练)登场了。说白了,QAT就是在训练过程中,让模型“提前适应”量化带来的噪声。我个人的习惯是,如果PTQ掉点超过0.5%,我就会毫不犹豫地切换到QAT。今天,我们就来彻底拆解QAT的四个核心:模拟量化、伪量化节点、STE,以及完整的训练流程。

QAT 核心知识体系 量化感知训练 (QAT) 模拟量化 伪量化节点 直通估计器 (STE) QAT 训练流程 前向:量化+反量化 反向:STE近似梯度 插入在权重/激活后 记录scale/zero_point 前向:量化函数 反向:梯度直通 预训练浮点模型 插入伪量化节点 微调/训练 核心思想:让模型在训练时就“感受”量化误差,从而调整权重以适应量化

1. 模拟量化:让模型提前“体验”低精度生活

模拟量化,英文叫 Fake Quantization。名字很直白——它不是真的把模型转成int8,而是在浮点计算中模拟量化效果。

具体怎么做?前向传播时,先把浮点值量化成int8,再反量化回浮点。这样模型看到的虽然是浮点数,但已经带上了量化误差。我刚开始接触这个概念时,觉得有点绕:既然最终要量化,为什么不直接训int8?

原因很简单:梯度下降需要连续可导。int8的取整操作不可导,没法直接训练。所以模拟量化就是“中间人”——前向走量化流程,反向走浮点梯度更新。

模拟量化的数学表达:

# 伪代码:模拟量化过程
def fake_quantize(x, scale, zero_point, num_bits=8):
    # 量化
    x_int = torch.round(x / scale + zero_point)
    # 截断到 [0, 2^num_bits - 1]
    x_int = torch.clamp(x_int, 0, 2**num_bits - 1)
    # 反量化
    x_fake = (x_int - zero_point) * scale
    return x_fake

你看,输入x是浮点,输出x_fake也是浮点,但中间经历了量化截断。这个截断误差,就是模型需要去“适应”的东西。

2. 伪量化节点:量化过程的“监控摄像头”

伪量化节点(FakeQuantize Node)是模拟量化的具体实现。在PyTorch中,它就是torch.quantization.FakeQuantize这个模块。

这些节点会被插入到模型的权重和激活值后面。我习惯在卷积层、全连接层后面都加上。它们的作用有两个:

  • 记录量化参数:scale和zero_point会在训练中通过移动平均更新
  • 执行模拟量化:前向时做量化+反量化,反向时传递梯度

嗯,这里要注意:伪量化节点只在训练时生效。推理时,它们会被移除,替换成真正的量化算子。

我的经验: 伪量化节点插入的位置很关键。只量化权重不量化激活,精度可能不够;全量化又可能训练不稳定。我一般先量化权重,再逐步加入激活量化,观察loss曲线。

3. 直通估计器(STE):解决“不可导”问题的神来之笔

这是QAT最核心的trick。量化中的round操作,梯度几乎处处为0。你想想看,梯度为0,网络还怎么学?

STE(Straight-Through Estimator)的做法很粗暴:前向传播用真实的量化函数,反向传播假装量化函数是恒等映射。也就是说,梯度直接“穿透”量化节点,原封不动地传回去。

数学上就是:

# 前向:y = quantize(x)
# 反向:∂Loss/∂x ≈ ∂Loss/∂y  (忽略量化带来的梯度变化)

我曾经在一个语音模型上试过,不加STE,loss直接炸了;加上STE,训练稳稳的。当然,STE是个近似,它假设量化误差对梯度的影响可以忽略。实践中这个假设基本成立,因为量化误差相对于梯度来说是小量。

避坑指南: 我曾经在某个项目中,把STE的梯度直通写成了y = x + (quantize(x) - x).detach()。这个写法虽然等价,但容易让人误解。记住:detach()是切断梯度,而STE需要保留梯度。正确做法是直接用torch.round,然后依赖STE的默认行为。

4. QAT训练流程:从浮点到量化就绪

完整的QAT流程,我总结为三步走:

  1. 预训练浮点模型:先用float32训一个高精度baseline。这一步不能省,QAT不是从零训练,而是微调。
  2. 插入伪量化节点:在模型所有需要量化的地方(权重、激活)插入FakeQuantize。我建议先用默认的对称量化,跑通后再调非对称。
  3. 微调训练:用较小的学习率(通常是预训练的1/10)继续训练几个epoch。观察loss收敛后,导出量化模型。

这里有个细节:微调时,BN层需要特殊处理。因为伪量化节点改变了激活值的分布,BN的running mean/std需要重新计算。我一般会在微调初期冻结BN,等量化参数稳定后再解冻。

PyTorch QAT 示例代码片段:

import torch
import torch.quantization as quant

# 1. 加载预训练模型
model = torch.load('float_model.pth')

# 2. 设置为QAT模式
model.qconfig = quant.get_default_qat_qconfig('fbgemm')
quant.prepare_qat(model, inplace=True)

# 3. 微调
optimizer = torch.optim.SGD(model.parameters(), lr=1e-4)
for epoch in range(5):
    for data, target in dataloader:
        optimizer.zero_grad()
        output = model(data)
        loss = criterion(output, target)
        loss.backward()
        optimizer.step()

# 4. 转换为量化模型
model.eval()
model = quant.convert(model, inplace=True)

你看,代码其实很简洁。但背后的原理——模拟量化、STE、伪量化节点——才是真正需要理解的东西。我见过不少同学直接调API,精度掉了也不知道怎么调,就是因为没搞懂这些底层机制。

最后说一句:QAT不是万能的。如果模型本身过拟合严重,QAT反而可能放大量化误差。这时候先做正则化,再上QAT,效果会好很多。