1. 为什么 sensitivity 计算值得单独拎出来讲搞深度学习的人尤其是做模型压缩、量化、剪枝这一块的迟早会碰到一个词sensitivity。中文一般叫敏感度也有叫灵敏度的。它衡量的是——当你对模型的某一部分动手脚比如把某个层的权重精度降下来、把某个通道砍掉、把某段计算图合并掉模型的输出或者精度指标会掉多少。说白了sensitivity 就是一把尺子量的是“模型对某个改动有多敏感”。这个尺子在你做以下事情的时候特别关键量化想把 FP32 的模型压成 INT8但你不能一刀切全压因为有些层对精度极其敏感压完直接崩。你得先算 sensitivity找出哪些层能压、哪些层得保。剪枝想砍掉冗余通道但砍哪一层、砍多少比例不能拍脑袋。sensitivity 告诉你每层能承受多大的裁剪力度。混合精度训练哪些算子适合用 FP16 跑哪些必须保 FP32sensitivity 分析能给出依据。层融合与图优化某些融合操作会改变数值行为sensitivity 可以帮你评估影响。我在实际项目里见过太多人跳过这一步直接拿个量化工具一把梭结果精度掉得亲妈都不认识然后回头一层一层试浪费大量时间。先算 sensitivity再动手改模型这是省时间的做法不是多此一举。这篇文章我会把 PyTorch 下 sensitivity 计算的完整思路、代码实现、参数选择、踩坑经验全部摊开讲。不管你是刚接触模型压缩的新手还是已经做过几轮量化想补补理论的老手应该都能拿到能直接用的东西。2. sensitivity 的核心思路与方案选型2.1 sensitivity 到底在算什么先把这个概念钉死。假设你有一个训练好的模型 ( M )有一个评估数据集 ( D )有一个精度指标 ( metric )比如 top-1 accuracy。现在你对模型的某个部分施加一个扰动 ( \delta )得到新模型 ( M )。那么 sensitivity 可以粗略定义为[ S metric(M) - metric(M) ]差值越大说明这个部分越敏感。但实际操作中这个“扰动”的定义方式不同sensitivity 的计算方法也不同。常见的几种扰动类型适用场景计算方式权重量化量化感知分析把某层权重从 FP32 量化到低比特看精度掉多少通道裁剪剪枝把某层按比例裁剪通道看精度掉多少激活量化量化对某层激活值做量化看精度变化随机噪声注入鲁棒性分析给某层权重加高斯噪声看精度变化我下面主要围绕权重量化和通道裁剪这两种最常用的场景来讲因为它们在模型压缩里出现频率最高。2.2 为什么不用现成工具要自己写你可能会问现在不是有现成的量化工具包吗里面不是自带 sensitivity 分析吗没错有些工具有但问题在于工具绑定了特定量化方案它算的 sensitivity 是针对它自己的量化算法的你换个方案就不适用了。粒度不够灵活工具可能只支持按层分析你想按通道、按块分析就不行了。黑盒你不知道它内部怎么算的结果异常时没法排查。自己写的好处是完全可控扰动怎么加、指标怎么算、粒度多细全由你定。而且代码量并不大核心逻辑一两百行就能搞定。2.3 整体方案设计我的方案分三步基线评估用原始模型在验证集上跑一遍记录基线精度。逐层扰动对每一层或每个待分析的模块单独施加扰动其余部分保持不变重新评估精度。汇总分析把每层的精度下降量整理成表排序找出敏感层和不敏感层。这里有个关键设计决策每次只扰动一层还是累积扰动单层扰动每次只动一层其他层保持原样。优点是能精确归因缺点是忽略了层之间的交互效应。累积扰动按顺序逐层叠加扰动。优点是更接近真实压缩场景缺点是前面的扰动会影响后面的结果。我的建议是先做单层扰动快速定位敏感层。如果时间充裕再做累积扰动做二次验证。单层扰动的结果虽然不完全等于实际压缩后的表现但作为第一轮筛选已经足够。3. PyTorch 实现 sensitivity 计算的完整细节3.1 环境准备与基础代码框架先确认环境。PyTorch 版本建议 1.10 以上因为要用到一些较新的 API。其他依赖就是常规的 torchvision、numpy 这些。import torch import torch.nn as nn import copy import numpy as np from collections import OrderedDict device torch.device(cuda if torch.cuda.is_available() else cpu)核心框架我设计成一个类叫SensitivityAnalyzer。它接收模型、数据加载器、评估函数然后提供analyze方法。class SensitivityAnalyzer: def __init__(self, model, dataloader, eval_fn, devicecuda): self.model model self.dataloader dataloader self.eval_fn eval_fn self.device device self.base_metric None def evaluate(self, model): model.eval() model.to(self.device) return self.eval_fn(model, self.dataloader, self.device) def get_baseline(self): self.base_metric self.evaluate(self.model) return self.base_metric这里的eval_fn需要你自己实现接收模型、数据加载器、设备返回一个标量指标。比如分类任务就是 top-1 accuracy。3.2 权重量化的扰动函数权重量化最常用的模拟方式是伪量化把权重量化到低比特再反量化回 FP32这样模型结构不变但数值精度已经损失了。def fake_quantize_weight(weight, bits8, symmetricTrue): if symmetric: qmin -(2 ** (bits - 1)) qmax 2 ** (bits - 1) - 1 scale weight.abs().max() / qmax scale torch.clamp(scale, min1e-8) q_weight torch.round(weight / scale) q_weight torch.clamp(q_weight, qmin, qmax) return q_weight * scale else: qmin 0 qmax 2 ** bits - 1 w_min weight.min() w_max weight.max() scale (w_max - w_min) / (qmax - qmin) scale torch.clamp(scale, min1e-8) zero_point qmin - torch.round(w_min / scale) q_weight torch.round(weight / scale zero_point) q_weight torch.clamp(q_weight, qmin, qmax) return (q_weight - zero_point) * scale这里有几个细节值得说对称量化 vs 非对称量化对称量化把零点固定在 0适合权重分布近似对称的情况非对称量化有零点偏移适合分布偏斜的情况。权重一般用对称量化就够了。scale 的计算用weight.abs().max()是 per-tensor 的方式。如果你想更精细可以用 per-channel即对每个输出通道单独算 scale。clamp 防除零scale可能为 0权重全零时加个1e-8兜底。3.3 逐层扰动的实现关键来了。怎么遍历模型的每一层只扰动当前层def analyze_weight_quant(self, bits8, per_channelFalse): self.get_baseline() results OrderedDict() for name, module in self.model.named_modules(): if not isinstance(module, (nn.Conv2d, nn.Linear)): continue original_weight module.weight.data.clone() if per_channel and isinstance(module, nn.Conv2d): quantized torch.zeros_like(original_weight) for i in range(original_weight.size(0)): quantized[i] fake_quantize_weight(original_weight[i], bits) else: quantized fake_quantize_weight(original_weight, bits) module.weight.data quantized metric self.evaluate(self.model) module.weight.data original_weight drop self.base_metric - metric results[name] { base: self.base_metric, quantized: metric, drop: drop } return results这段代码有几个要点只处理 Conv2d 和 Linear这两类层有权重是量化的主要对象。BatchNorm 和激活层的处理方式不同后面单独说。保存原始权重扰动前 clone 一份评估完立刻还原。这是必须的否则模型就被改坏了。per-channel 模式对卷积层按输出通道逐个量化更接近真实量化方案。3.4 通道裁剪的扰动实现通道裁剪的 sensitivity 计算稍微复杂一点因为裁剪会改变张量形状影响后续层。def analyze_channel_prune(self, prune_ratio0.1): self.get_baseline() results OrderedDict() for name, module in self.model.named_modules(): if not isinstance(module, nn.Conv2d): continue original_weight module.weight.data.clone() out_channels original_weight.size(0) num_prune max(1, int(out_channels * prune_ratio)) # 用 L1 范数排序裁剪最小的通道 channel_norms original_weight.abs().sum(dim(1, 2, 3)) _, prune_indices torch.topk(channel_norms, num_prune, largestFalse) # 把被裁通道的权重置零模拟裁剪 pruned_weight original_weight.clone() pruned_weight[prune_indices] 0 module.weight.data pruned_weight metric self.evaluate(self.model) module.weight.data original_weight drop self.base_metric - metric results[name] { base: self.base_metric, pruned: metric, drop: drop, prune_ratio: prune_ratio, num_pruned: num_prune } return results这里我用置零来模拟裁剪而不是真的删掉通道。原因是真删通道会改变张量维度需要同步修改后续层的输入通道数代码复杂度高很多。置零的效果在 sensitivity 分析阶段已经足够——被置零的通道对输出没有贡献等价于被裁掉。注意置零模拟和真实裁剪在数值上不完全等价因为 BatchNorm 的统计量会受影响。如果你的模型有 BN 层建议在置零后重新校准 BN 统计量或者直接用真实裁剪。3.5 激活量化的 sensitivity激活量化也是常见需求尤其是做全整数推理的时候。激活的扰动不能像权重那样直接改因为激活是运行时产生的。我的做法是用 hook 拦截。def analyze_activation_quant(self, bits8): self.get_baseline() results OrderedDict() hooks [] def make_hook(name): def hook(module, input, output): return fake_quantize_weight(output, bits) return hook for name, module in self.model.named_modules(): if isinstance(module, (nn.ReLU, nn.Conv2d, nn.Linear)): h module.register_forward_hook(make_hook(name)) hooks.append((name, h)) # 逐个启用 hook 评估 for target_name, target_hook in hooks: # 先移除所有 hook for _, h in hooks: h.remove() # 只注册目标 hook target_hook dict(hooks)[target_name] # 重新注册 ...这段代码写起来比较绕因为 hook 的注册和移除需要小心管理。更简洁的做法是每次只注册一个 hook评估完就移除循环进行。def analyze_activation_quant(self, bits8): self.get_baseline() results OrderedDict() target_modules [(n, m) for n, m in self.model.named_modules() if isinstance(m, (nn.ReLU, nn.Conv2d, nn.Linear))] for name, module in target_modules: def hook(module, input, output): return fake_quantize_weight(output, bits) h module.register_forward_hook(hook) metric self.evaluate(self.model) h.remove() drop self.base_metric - metric results[name] { base: self.base_metric, quantized: metric, drop: drop } return results这个版本干净多了。每次循环只注册一个 hook评估完立刻移除不会互相干扰。4. 实操过程中的关键参数与避坑经验4.1 评估数据集的选择sensitivity 计算需要跑很多次评估所以数据集不能太大。我的经验是分类任务从验证集里随机抽 500 到 1000 个样本就够了。太少比如 100 个噪声大太多比如 50000 个跑一轮 sensitivity 要几个小时。检测/分割任务指标计算更复杂样本数可以再少一点300 到 500 个。关键原则基线精度和扰动后精度必须用同一批数据否则对比没意义。我一般会固定一个随机种子把抽样的数据索引存下来保证每次评估用的是同一批。def get_subset_loader(dataloader, num_samples500, seed42): torch.manual_seed(seed) dataset dataloader.dataset indices torch.randperm(len(dataset))[:num_samples].tolist() subset torch.utils.data.Subset(dataset, indices) return torch.utils.data.DataLoader(subset, batch_sizedataloader.batch_size, shuffleFalse, num_workersdataloader.num_workers)4.2 量化比特数的选择做权重量化 sensitivity 时比特数选多少这取决于你的目标目标 INT8直接用 8 bit 算 sensitivity。但有时候 8 bit 对所有层都不敏感区分度不够。这时候可以降到 4 bit 甚至 2 bit 来放大差异。目标 INT4 或更低直接用目标比特数算。经验做法先用 8 bit 跑一轮如果所有层的 drop 都小于 0.5%说明模型对量化很鲁棒可以放心压。如果某些层 drop 超过 2%这些就是敏感层需要特殊处理。我通常会跑 8 bit 和 4 bit 两轮对比看哪些层在低比特下突然变敏感。这种层往往是量化的瓶颈。4.3 BN 层的处理BatchNorm 层在量化里是个特殊存在。它的权重和偏置也会被量化但更关键的是它的运行统计量running_mean 和 running_var。如果你在 sensitivity 分析里只动卷积层BN 层保持原样那结果可能偏乐观。我的做法是把 BN 层和它前面的卷积层视为一个整体。扰动卷积权重后重新跑一遍前向传播让 BN 的统计量更新用momentumNone强制用当前 batch 统计然后再评估。这样更接近真实量化后的行为。def reset_bn_stats(model, dataloader, num_batches10): model.train() for i, (inputs, _) in enumerate(dataloader): if i num_batches: break inputs inputs.to(device) model(inputs) model.eval()4.4 常见问题速查表问题现象可能原因解决方法所有层 drop 都是 0比特数太高扰动不够降低比特数到 4 或 2某些层 drop 异常大50%该层权重分布极端或评估数据有问题检查权重分布确认评估数据正确结果波动大每次跑不一样评估数据太少或模型有随机性增加样本数固定随机种子关闭 dropout跑一轮要几个小时模型太大或数据太多减少样本数用更小的子集或用 GPU 加速per-channel 比 per-tensor 还差代码 bug或某些通道确实敏感检查 per-channel 实现打印中间结果4.5 一个容易忽略的坑模型状态评估 sensitivity 时模型必须处于eval()模式。如果忘了切换dropout 和 BN 会用训练模式的行为结果完全不可比。我在代码里每次evaluate都强制model.eval()就是为了防止这个。另一个坑是梯度。评估时不需要梯度用torch.no_grad()包起来能省不少显存和时间。def evaluate(self, model): model.eval() model.to(self.device) with torch.no_grad(): return self.eval_fn(model, self.dataloader, self.device)5. 结果分析与后续决策5.1 怎么读 sensitivity 结果跑完 sensitivity你会得到一张表每层一个 drop 值。怎么用这张表我的做法是按 drop 排序画个分布图。通常会出现三种情况大部分层 drop 很小少数层 drop 很大这是最理想的情况。敏感层保护起来其他层放心压。drop 均匀分布说明模型对量化整体敏感可能需要更精细的方案比如混合精度或量化感知训练。所有层 drop 都很大模型可能太小或太敏感不适合激进量化。考虑换更温和的压缩方式。我一般会设一个阈值比如 drop 1% 的层标记为敏感层drop 0.1% 的层标记为安全层。中间地带看情况。5.2 从 sensitivity 到实际压缩方案拿到 sensitivity 结果后下一步就是制定压缩策略敏感层保持 FP32或者用更高的比特数比如 16 bit。安全层直接压到目标比特数比如 8 bit 或 4 bit。中间层可以先压然后看整体精度不行再回退。这个策略可以直接映射到混合精度量化方案里。很多推理框架都支持按层指定比特数你只需要把 sensitivity 结果导出成配置文件就行。5.3 累积扰动验证单层 sensitivity 做完后如果你要压的层比较多建议做一轮累积验证按 sensitivity 从低到高先压最不敏感的逐层叠加扰动每加一层评估一次看精度下降曲线。如果曲线在某一点突然掉下去说明那里是临界点需要调整策略。如果曲线平缓下降说明你的方案是稳的。def cumulative_analysis(self, sorted_layers, bits8): self.get_baseline() curve [] for name in sorted_layers: module dict(self.model.named_modules())[name] original module.weight.data.clone() module.weight.data fake_quantize_weight(original, bits) metric self.evaluate(self.model) curve.append((name, metric, self.base_metric - metric)) return curve这个累积分析能帮你找到“压到哪一层为止”的答案。5.4 实操心得最后分享几个我在实际项目里总结的点先跑小模型验证流程别一上来就拿大模型跑先用一个小模型比如 ResNet18把整个流程跑通确认代码没问题再换大模型。保存中间结果每层的 sensitivity 结果存成 JSON 或 CSV方便后续分析和对比。别跑完就扔下次换比特数还要用。多比特数对比8 bit、6 bit、4 bit 各跑一轮画个曲线能看出哪些层在哪个比特数下开始崩。这个信息比单一比特数的结果有用得多。别迷信 sensitivitysensitivity 是单层分析实际压缩时层之间有交互。sensitivity 结果作为参考最终方案还是要靠端到端评估来定。我在一个图像分类项目里sensitivity 显示所有卷积层在 8 bit 下 drop 都小于 0.3%于是放心全压 INT8结果端到端精度掉了 1.2%。后来发现是激活量化的问题权重没问题但激活分布太宽。所以权重 sensitivity 和激活 sensitivity 要分开算别混为一谈。这个 sensitivity 计算的框架我后来还扩展到了 Transformer 模型上对 Attention 的 QKV 投影层做敏感度分析效果也不错。核心逻辑是一样的只是评估函数和扰动方式要根据模型结构调整。如果你做的是 NLP 方向的压缩可以把eval_fn换成 perplexity 或 GLUE 指标其他部分基本不用改。
阅读完成 · 觉得有帮助?