4、激活量化与校准:校准数据集的选择,KL散度与MinMax校准方法

聊到激活量化,我得先坦白一件事。

几年前我第一次做模型量化时,天真地以为只要把权重和激活值都转成INT8就完事了。结果模型跑起来,精度直接崩了——分类任务从85%掉到30%。我当时就懵了。后来才发现,问题出在激活值的分布上。权重还好说,但激活值这东西,不同层、不同输入,分布差异太大了。

所以今天咱们重点聊两个事:校准数据集怎么选,以及KL散度和MinMax这两种校准方法

4.1 校准数据集:选对了,量化就成功了一半

激活量化需要先跑一遍推理,收集每层激活值的统计信息。这个过程就叫「校准」。校准用的数据集,直接决定了量化参数的准确性。

我个人习惯,校准数据集至少要满足三个条件:

  • 代表性:数据分布要和真实场景一致。比如你做图像分类,校准集里不能全是猫,得包含各类物体。
  • 多样性:覆盖尽可能多的激活值范围。我见过有人只用100张图片做校准,结果模型在边缘场景下直接崩了。
  • 适量性:不是越多越好。一般来说,500~2000张图片就够用了。太多反而增加校准时间,收益却微乎其微。
我的经验: 校准数据集最好从训练集中随机抽取,但不要包含验证集。我曾经犯过这个错——用验证集做校准,结果量化后的模型在验证集上表现很好,一到真实场景就露馅了。说白了,就是过拟合了校准数据。

另外,校准数据要尽量覆盖激活值的极端情况。比如ReLU层的输出,大部分是0,但偶尔会有很大的值。如果校准集里没有这些大值,量化时就会把它们截断,造成精度损失。

4.2 MinMax校准:简单直接,但有坑

MinMax是最直观的校准方法。说白了,就是找到激活值的最小值和最大值,然后线性映射到INT8的[-128, 127]范围。

公式很简单:

scale = (max - min) / 255
zero_point = -round(min / scale)

嗯,这里要注意:MinMax对异常值非常敏感。你想想看,如果激活值里有一个离群点,比如某个神经元输出了1000,其他值都在0~10之间。那MinMax会把整个量化范围拉宽,导致大部分值的精度都浪费了。

我曾经踩过的坑: 有一次量化一个BERT模型,某层激活值里有个异常大的值(可能是训练时的噪声)。MinMax直接把这个值当成了最大值,结果量化后的模型输出全是乱码。后来我改用KL散度,问题就解决了。

所以,MinMax适合激活值分布比较均匀、没有明显离群点的情况。比如一些简单的CNN模型,激活值分布很规整,用MinMax完全没问题。

4.3 KL散度校准:更聪明,也更复杂

KL散度校准,说白了就是找一个阈值,把激活值截断到某个范围,使得截断后的分布和原始分布尽可能相似。

为什么需要截断?因为INT8只有256个离散值,而激活值可能是连续的。如果直接映射,那些分布很长的尾巴会浪费大量量化精度。截断掉尾巴,反而能提高主体部分的精度。

具体做法是这样的:

  1. 收集激活值的直方图(比如2048个bin)。
  2. 从某个阈值开始,尝试截断直方图的右边部分。
  3. 把截断后的直方图量化到128个bin(INT8的正半轴)。
  4. 计算原始分布和量化后分布的KL散度。
  5. 遍历所有可能的阈值,找到KL散度最小的那个。

代码实现大致如下:

def calibrate_kl(hist, bin_edges, num_bins=128):
    # hist: 原始直方图
    # bin_edges: 直方图的边界
    # num_bins: INT8的bin数量
    
    best_kl = float('inf')
    best_threshold = 0
    
    for threshold in range(num_bins, len(hist)):
        # 截断直方图
        truncated_hist = hist[:threshold].copy()
        truncated_hist[-1] += hist[threshold:].sum()
        
        # 量化到num_bins个bin
        quantized_hist = np.zeros(num_bins)
        bin_size = threshold / num_bins
        for i in range(num_bins):
            start = int(i * bin_size)
            end = int((i + 1) * bin_size)
            quantized_hist[i] = truncated_hist[start:end].sum()
        
        # 归一化
        truncated_hist = truncated_hist / truncated_hist.sum()
        quantized_hist = quantized_hist / quantized_hist.sum()
        
        # 计算KL散度
        kl = (truncated_hist * np.log(truncated_hist / quantized_hist)).sum()
        
        if kl < best_kl:
            best_kl = kl
            best_threshold = threshold
    
    return bin_edges[best_threshold]

你可能会问:为什么KL散度比MinMax好?

原因很简单:KL散度会主动舍弃那些不重要的尾巴。对于大部分神经网络来说,激活值的分布是类似高斯分布的,中间密集、两边稀疏。KL散度会找到一个平衡点,把稀疏的尾巴截掉,把宝贵的量化精度留给中间的主体部分。

实际效果对比:
方法 ResNet-50 Top-1 BERT F1 YOLOv5 mAP
FP32 76.13% 88.5% 50.4%
MinMax INT8 74.82% 85.1% 48.7%
KL散度 INT8 75.91% 87.8% 49.9%

从表格能看出来,KL散度在大部分场景下都比MinMax好。尤其是BERT这种对精度敏感的模型,差距更明显。

4.4 如何选择?我的建议

说了这么多,到底该用哪种方法?

  • 如果你追求简单、快速:用MinMax。适合那些激活值分布很规整的模型,比如MobileNet、SqueezeNet。
  • 如果你在意精度:用KL散度。适合大模型、精度敏感的场景,比如BERT、YOLO。
  • 如果你不确定:两种都试一下。我一般会在校准后跑一遍验证集,对比精度损失。哪个好就用哪个。
一个小技巧: 对于某些层,比如Softmax的输出(值域在0~1之间),用MinMax就够了。但对于ReLU的输出(可能有很大的值),KL散度更合适。你可以逐层选择校准方法,而不是一刀切。

最后说一句:校准数据集的质量,比校准方法更重要。我见过有人用KL散度但校准集选得不好,结果还不如MinMax。所以,先把校准数据集搞对,再谈方法优化。

嗯,今天就聊到这儿。下一节咱们聊聊权重量化,那个坑更多,到时候再细说。