四舍五入输在哪里:用小矩阵复现 GPTQ 的误差补偿
摘要:模型量化文章在 CSDN 算法频道近期有较高讨论度,但很多讲法会直接跳到公式,让人误以为 GPTQ 只是“更聪明的 round”。本文用一个 3×4 小矩阵复现实验说明:逐列量化真正有价值的地方,是把当前列造成的输出误差补偿到后续列。
如果只看量化后的权重,GPTQ 很容易被误解成“把浮点数四舍五入到最近刻度”。这种理解只能解释权重为什么变小,解释不了为什么同样是低比特表示,有的量化方法对推理输出伤害更小。关键差异在于:量化不是只关心每个权重本身,还要关心这些权重会怎样和输入激活相乘。
这篇文章不推完整论文公式,而是做一个小实验。我们把权重矩阵按列处理,每一列被量化后产生误差,再用一个近似的逆 Hessian 矩阵把误差分摊到还没处理的列。这个过程很像整理账本:当前列已经“结账”,后面的列要根据相关性调整余额,尽量让最终输出别偏太远。
实验目标:同一组样本,比较两种量化
第一种方法是朴素量化:每个权重都直接四舍五入到 0.2 的刻度。第二种方法是简化版 GPTQ:仍然逐个权重量化,但在处理第 col 列后,把该列误差按 H_inv[col][nxt] 传给后续列。
H_inv 在真实场景里来自校准数据的二阶信息;本文只用一个手写小矩阵,目的是让你看清补偿机制,而不是复现工业级量化库。代码中用若干输入样本计算量化前后的输出均方误差,输出越小,说明量化对这些样本的影响越小。
defquantize(x,step=0.2):returnround(x/step)*stepdefmatmul_vec(matrix,vec):return[sum(row[j]*vec[j]forjinrange(len(vec)))forrowinmatrix]defmse(a,b):returnsum((x-y)**2forx,yinzip(a,b))/len(a)defoutput_mse(W,Q,samples):returnsum(mse(matmul_vec(W,x),matmul_vec(Q,x))forxinsamples)/len(samples)defnaive_quantize(W,step=0.2):return[[quantize(v,step)forvinrow]forrowinW]defgptq_like_quantize(W,H_inv,step=0.2):work=[row[:]forrowinW]rows,cols=len(work),len(work[0])out=[[0.0]*colsfor_inrange(rows)]forcolinrange(cols):diag=H_inv[col][col]ifabs(diag)<1e-12:raiseValueError("H_inv diagonal must be non-zero")forrinrange(rows):q=quantize(work[r][col],step)out[r][col]=q err=(work[r][col]-q)/diagfornxtinrange(col+1,cols):work[r][nxt]-=err*H_inv[col][nxt]returnoutdefrun_tests():W=[[0.02,0.78,0.06,-0.30],[-0.03,-1.32,-1.28,0.57],[1.35,0.26,-0.30,-0.92],]H_inv=[[1.05,0.58,0.32,0.05],[0.58,1.23,-0.32,0.02],[0.32,-0.32,1.28,0.09],[0.05,0.02,0.09,1.03],]samples=[[1.0,0.2,-0.4,0.7],[-0.3,1.1,0.5,-0.2],[0.8,-0.6,0.9,0.1],[0.0,0.4,-1.2,1.0],]naive=naive_quantize(W,0.2)compensated=gptq_like_quantize(W,H_inv,0.2)naive_err=output_mse(W,naive,samples)compensated_err=output_mse(W,compensated,samples)assertcompensated!=naiveassertcompensated_err<naive_errprint("naive_mse =",round(naive_err,6))print("gptq_like_mse =",round(compensated_err,6))print("better =",compensated_err<naive_err)if__name__=="__main__":run_tests()本地运行结果:
naive_mse = 0.013173 gptq_like_mse = 0.010579 better = True观察记录一:权重误差不等于输出误差
朴素量化追求的是每个权重离最近刻度最短。例如 0.78 会变成 0.8,-1.32 会变成 -1.4 或 -1.2,取决于刻度位置。这个操作看上去很合理,但它没有考虑输入样本。如果某一列权重经常和较大的激活值相乘,那么这列的一点误差就会被放大;如果两列在输出中具有相关性,当前列的误差也可以由后续列部分抵消。
简化版 GPTQ 的核心就在这里:处理当前列后,不假装误差消失,而是把误差沿着列相关性传到未来。代码中的 work 矩阵就是“还没结账的余额”。out 保存已经定下来的量化权重,work 保存经过补偿后的后续权重。
列顺序为什么会影响结果
逐列算法有一个天然特点:先处理的列不能再改,后处理的列可以吸收前面传来的误差。因此列顺序会影响最终结果。教学代码按自然顺序从第 0 列处理到最后一列,方便观察;真实实现往往会根据列的重要性、分块策略或数值稳定性调整顺序。这个细节解释了一个常见现象:同样是 GPTQ,不同库、不同参数、不同 group size 的结果可能不完全一致。
如果把量化想成“每个元素独立压缩”,列顺序似乎不该重要;但一旦承认误差会在列之间传递,顺序就成了算法的一部分。前面的列越重要,越应该谨慎量化;后面的列越相关,越能承担补偿。小矩阵里这种差别只是几个小数点,放到大模型层里,就可能体现为困惑度、准确率或生成稳定性的变化。
校准集不是装饰品
很多同学复现实验时会直接拿随机输入当校准样本,然后发现指标很好看。问题是随机输入并不一定像真实请求。语言模型的激活分布会受 tokenizer、提示词长度、上下文主题和层位置影响;视觉模型的激活又会受图像亮度、分辨率和预处理影响。如果校准集只覆盖了很窄的输入,量化方法会学到一套对测试样本好看、对真实流量未必可靠的补偿。
实际项目里,校准集可以不大,但要代表真实分布。比如文本模型至少覆盖短问答、长上下文、代码片段和多轮对话;分类模型要覆盖主要类别和容易混淆的边界样本。量化完成后,也不要只看平均误差,还要观察最差样本。平均值下降但少数样本严重劣化,在线体验仍然可能变差。
观察记录二:补偿不是一定赢,但要用校准样本衡量
上面的测试中,朴素量化的均方误差是 0.013173,补偿后降到 0.010579。这个差距来自我们手写的 H_inv 和样本集合。换一批样本、换一种刻度、换一个列顺序,结果可能不同。真实工程不会只拿一个小矩阵下结论,而会准备校准集、统计各层误差,并关注最终任务指标。
当你把量化实验接入模型推理原型、自动评测脚本或多模型对比面板时,可以把 https://haerapi.com 作为一个可自行评估的 API 接入选择;但量化误差、校准集和回归测试应留在自己的工程链路里,用可复现数据说话。
复杂度分析
设权重矩阵有 R 行 C 列。朴素量化只遍历每个权重一次,时间复杂度 O(RC),额外空间 O(RC) 或原地 O(1)。本文的补偿版在每一列量化后,会更新所有后续列,时间复杂度 O(RC²),需要保存工作矩阵和输出矩阵,空间复杂度 O(RC)。真实 GPTQ 会用分块、列重排和矩阵运算优化,避免在大模型上按教学代码的方式运行。
边界条件
- H_inv 对角线不能为 0,否则误差缩放会除零。
- 量化步长必须大于 0,步长越大压缩越狠,误差通常也越大。
- 样本维度必须等于权重列数,否则矩阵乘法无意义。
- 补偿只更新尚未量化的列,已经确定的列不能再被改动。
- 校准样本要覆盖真实输入分布,否则测试误差低不代表上线效果好。
常见错误
第一,只比较权重矩阵的绝对误差,不比较模型输出误差。第二,把 H_inv 当成随便填的调参表,忽略它来自校准数据。第三,量化后只跑单个样例,没看边界输入。第四,把教学代码直接搬到大模型权重上,结果时间复杂度爆炸。第五,误以为 GPTQ 会让所有层都变好,实际还要看层敏感度和任务指标。
可复制测试用例
直接运行上面的脚本,应该看到补偿版误差小于朴素版。你可以把 step 改成 0.4,观察误差差距如何变化;也可以把 samples 中第二个样本删掉,看补偿优势是否稳定。这个练习能帮助你建立一个判断:量化方法要放在样本输出上评估,而不是只看权重文件变小。
总结
GPTQ 的直觉不是“更会四舍五入”,而是“当前列量化后,把对输出有影响的误差补到未来列”。这正是它比朴素 round 更像算法的地方:它利用校准数据中的二阶信息,让低比特权重尽量保留原模型在常见输入上的行为。理解了这个小矩阵实验,再去看分块 GPTQ、列排序或工程库实现,会清楚很多。