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)来实现的。

伪量化节点做了三件事:

  1. 记录统计量——跟踪激活值和权重的 min/max 范围
  2. 模拟量化误差——前向时做量化再反量化,引入精度损失
  3. 传递梯度——通过 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 的核心逻辑串起来了:

量化感知训练(QAT)知识体系 为什么需要 QAT? 核心挑战:量化操作不可导(梯度断裂) 直通估计器(STE) 前向量化,反向直通(∂≈1) 伪量化节点(FakeQuantize) 量化→反量化→记录统计量 QAT 训练流程:预训练 → 插入节点 → 校准 → 微调 → 导出 关键技巧:梯度裁剪 · 学习率调低 · Warmup · 短微调 STE 解决梯度断裂,FakeQuantize 实现模拟量化,两者配合完成 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 类型和量化范围,尤其是对激活值分布有特殊要求的场景。