4. 量化感知训练:让模型学会“低精度生存”
各位好,欢迎来到第四章。今天聊的话题,是量化里最实用、也最需要小心对待的技术——量化感知训练(QAT)。
说白了,QAT 就是让模型在训练阶段就“体验”低精度带来的误差,然后自己学会去适应。我最早接触这个概念时,觉得挺反直觉的——训练时用浮点,推理时转定点,不是天经地义吗?后来被现实狠狠教育了一顿……嗯,咱们慢慢聊。
4.1 为什么需要 QAT?PTQ 不够吗?
先问个问题:上一章讲的 PTQ(训练后量化),难道不行吗?
行,但有限度。PTQ 适合大模型、宽网络、激活分布比较规整的场景。一旦遇到小模型、窄网络、或者激活值有长尾分布的情况,PTQ 的精度损失可能让你怀疑人生。
我遇到过一个小型语音唤醒模型,PTQ 后精度掉了 5%。5% 啊,在唤醒率上就是“喊破嗓子也不理你”和“正常说话就响应”的区别。后来换成 QAT,精度只掉了 0.3%。
所以,什么时候该上 QAT?
- 模型本身很小(MobileNet、TinyBERT 这类)
- 精度要求极高(医疗、自动驾驶)
- 激活值分布诡异(有大量离群点)
- 目标位宽很低(4-bit 甚至 2-bit)
核心原则:PTQ 是“先训练,后量化”;QAT 是“边训练,边量化”。后者让模型在训练过程中就学会对抗量化误差。
4.2 直通估计器:反向传播的“作弊器”
QAT 最大的技术难点,就是量化这个操作本身不可导。
你想想看,取整函数 round() 的导数,除了零点之外全是 0。梯度传到这里就断了,网络没法更新权重。怎么办?
学术界想了个办法——直通估计器(STE)。
STE 的核心思想很简单:前向传播时老老实实做量化,反向传播时假装量化不存在。具体来说,STE 把量化函数的梯度近似为 1(在量化区间内),这样梯度就能“直通”过去。
数学上长这样:
前向:x_q = Q(x) = round(x / scale) * scale
反向:∂L/∂x ≈ ∂L/∂x_q (假设 ∂x_q/∂x = 1)
我刚开始用 STE 时总觉得不踏实——这跟“骗”网络有什么区别?后来想通了:工程上,有效比严格更重要。STE 在实践中被验证了无数次,已经是 QAT 的事实标准。
我的习惯:STE 虽然好用,但要注意梯度裁剪。如果量化误差太大,梯度可能会爆炸。我一般会在 STE 后面加一个 clamp 操作,把梯度限制在 [-1, 1] 之间。
4.3 伪量化节点:QAT 的核心组件
在 PyTorch 等框架里,QAT 是通过插入“伪量化节点”(FakeQuantize)来实现的。
伪量化节点做了三件事:
- 记录统计量——跟踪激活值和权重的 min/max 范围
- 模拟量化误差——前向时做量化再反量化,引入精度损失
- 传递梯度——通过 STE 让梯度绕过量化操作
伪量化节点通常插在:
- 卷积层/全连接层的权重之后
- 卷积层/全连接层的激活值之后(ReLU 之前或之后)
- concat、add 等合并操作之前
来看一个 PyTorch 的伪代码示例:
import torch
import torch.nn as nn
class QATConv2d(nn.Module):
def __init__(self, in_ch, out_ch, kernel_size):
super().__init__()
self.conv = nn.Conv2d(in_ch, out_ch, kernel_size)
# 伪量化节点:权重和激活各一个
self.weight_quant = torch.quantization.FakeQuantize(
observer=torch.quantization.MinMaxObserver,
quant_min=-128, quant_max=127, # 8-bit 对称量化
dtype=torch.qint8
)
self.act_quant = torch.quantization.FakeQuantize(
observer=torch.quantization.MinMaxObserver,
quant_min=0, quant_max=255, # 8-bit 非对称量化
dtype=torch.quint8
)
def forward(self, x):
# 量化权重,再做卷积
w_q = self.weight_quant(self.conv.weight)
x = nn.functional.conv2d(x, w_q, self.conv.bias,
self.conv.stride, self.conv.padding)
# 量化激活
x = self.act_quant(x)
return x
注意看:weight_quant 和 act_quant 就是伪量化节点。它们在前向时做量化-反量化,反向时用 STE 传梯度。
我曾经踩过的坑:伪量化节点的 observer 初始化很关键。如果一开始统计范围设得太宽,量化步长会很大,精度损失严重;设得太窄,又容易截断。我建议先用几百个 batch 做 warmup,让 observer 稳定下来再开始正式训练。
4.4 QAT 训练流程:从浮点到定点
完整的 QAT 流程,我一般分成 5 步:
| 步骤 | 操作 | 说明 |
|---|---|---|
| 1 | 预训练浮点模型 | 用常规方法训练一个高精度浮点模型作为起点 |
| 2 | 插入伪量化节点 | 在权重和激活路径上插入 FakeQuantize |
| 3 | 校准统计量 | 用少量数据跑几次前向,让 observer 收集 min/max |
| 4 | 微调训练 | 用较小的学习率继续训练,让模型适应量化误差 |
| 5 | 转换推理图 | 移除伪量化节点,导出真正的量化模型 |
这里有个细节:微调时的学习率要调低。我一般用原学习率的 1/10 到 1/100。为什么呢?因为量化引入的梯度噪声本来就大,学习率太高容易震荡。
另外,微调的 epoch 数不用太多。我见过有人微调 50 个 epoch,其实 5-10 个 epoch 就足够了。量化误差的“适应”过程很快,模型很快就知道哪些权重需要“躲开”量化边界。
4.5 QAT 知识体系总览
下面这张图,把 QAT 的核心逻辑串起来了:
4.6 实战中的几个关键点
最后,分享几个我在项目中积累的经验:
- 权重和激活的量化位宽可以不同。权重通常用 8-bit 对称量化,激活用 8-bit 非对称量化。如果资源紧张,权重可以降到 4-bit,激活保持 8-bit。
- BN 层要小心。QAT 微调时,BN 的 running statistics 可能会漂移。我建议在微调初期冻结 BN 的统计量,只更新权重。
- 导出时做一次完整校准。微调结束后,用验证集再跑一次前向,重新校准量化 scale 和 zero_point。这一步能挽回 0.1-0.5% 的精度。
一个小技巧:如果你用的是 PyTorch,可以用 torch.quantization.prepare_qat() 自动插入伪量化节点。但注意,它默认的 observer 配置不一定适合你的模型。我习惯手动指定 observer 类型和量化范围,尤其是对激活值分布有特殊要求的场景。