开始前,会这些就够了
- 会做分数乘加
- 知道矩阵的一行怎样与输入相乘
- 能运行Python标准库脚本
你拿一把只能标出有限格子的尺子,同时量零点一和十二。为了把十二放进去,每格就得变宽;零点一可能连半格都不到,最后被记成零。这种问题发生在同一组数共用量化刻度时,不能只看它们都用了相同位数。
我们用六个虚构权重做精确分数实验,比较整张共用刻度、每行单独定刻度,以及主动裁掉大值。然后让两种输入经过同一张矩阵。你会得到逐项误差与输出误差账本,也会看到为何一个更小的权重误差不能直接代替任务评价。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- quantization_scale.py:完整示例
- README.md:环境、范围与独立练习
- observed.stdout.txt:制作时的实际输出
- verification.json:源码与产物验证记录
- SHA256SUMS.txt:文件内容摘要
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B quantization_scale.py解压到独立练习文件夹,在含源文件的目录打开终端运行。程序会在当前目录写入教学报告;先预测结果,再查看当次输出。固定 2×3 矩阵与精确分数运算;不打包 int4、不运行语言模型、不测速度或真实任务质量。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
一个大权重,怎样改变其他数的刻度?
矩阵第一行是 [0.1, 0.2, 0.3],第二行是 [0.1, 0.2, 可调数字]。每行对应一个输出,互动范围为 0.3–20.0,正文固定为 12。先把最后一个数调大,再切换刻度选择方式。
权重均方误差:0.0317
当前输入的输出均方误差:0.0900
第一行刻度 1.7143;第二行刻度 1.7143。
| 位置 | 原权重 | 整数 | 恢复的权重 |
|---|---|---|---|
| 第 1 行,第 1 列 | 0.1000 | 0 | 0.0000 |
| 第 1 行,第 2 列 | 0.2000 | 0 | 0.0000 |
| 第 1 行,第 3 列 | 0.3000 | 0 | 0.0000 |
| 第 2 行,第 1 列 | 0.1000 | 0 | 0.0000 |
| 第 2 行,第 2 列 | 0.2000 | 0 | 0.0000 |
| 第 2 行,第 3 列 | 12.0000 | 7 | 12.0000 |
| 输出 | 使用原权重 | 使用恢复的权重 |
|---|---|---|
| 第 1 个 | 0.3000 | 0.0000 |
| 第 2 个 | 0.3000 | 0.0000 |
保持权重与刻度方式不变,只切换输入。权重误差没变,输出误差还会一样吗?
本面板计算固定的 2×3 教学矩阵,输入未量化。刻度选择与整数舍入用十分之一单位的整数计算,显示值和误差使用浏览器 Number;正文脚本另用精确分数核对。这里没有打包真实 int4、加载模型或测量速度,也不能凭两组输入推断真实模型质量。
先把坐标和尺子的规则写清楚
矩阵W有两行三列,行代表输出通道,列对应输入的三个特征。第一行为[0.1,0.2,0.3],第二行为[0.1,0.2,12]。对输入x,每一行分别与x逐项相乘再相加,得到两个输出。这里没有偏置、激活函数或训练,输入本身也不量化。
我们自定义整数刻度从−7到7,零点固定为0。原数除以正步长s,四舍五入到最近整数,正好在中间时选偶数,再限制到刻度范围;还原值是整数乘s。这个对称范围能放进四位有符号编码,但故意不用−8,不能把它说成所有int4格式的唯一规则。
整张共用刻度,最大的数决定了格子宽度
第一种方案取整张矩阵的最大绝对值12,令s=12/7。这样12正好对应整数7,但0.1、0.2、0.3除以s都不到半格,舍入后全部变成0。还原矩阵因此是[[0,0,0],[0,0,12]]:最大的数保住了,其余五个非零数却全部丢失。
这不是程序漏算小数,而是有限格子覆盖大范围的结果。把一组数放在同一把尺子上,它们就共享分辨能力。本文称12为离群大值,只是相对于这六个数的大小而言;没有对真实模型做异常检测,也没有据此判定这个权重无用。
每行一把尺子,能缩小影响范围
第二种方案按输出行分别取最大绝对值。第一行步长为0.3/7=3/70,第二行仍为12/7。第一行得到整数[2,5,7],还原为[3/35,3/14,3/10]。前两项误差分别为−1/70、1/70,第三项没有误差;第二行的两个小数仍然变成零。
这说明分组可以隔开不同尺度,却不能消除同组内的大值影响。说“逐通道”时必须同时说明矩阵方向和哪个轴是通道;本例就是逐输出行。它需要两份scale,整张方案只需一份。真实格式还要保存这些辅助信息,所以参数位数不是完整存储账单。
试着手算第一项:0.1除以3/70得到7/3,离整数2更近,还原为6/70。这个值不是直接把原小数保留一位,而是先按当前组的尺子转换。把同一个整数2与第二行的scale配在一起,会还原成24/7;因此整数值离开所属组和scale,不能单独解释成权重。
把大值裁掉,小值能保住,但代价没有消失
第三种方案主动把可还原范围设为[−0.7,0.7],步长于是为0.1。五个小值都能精确表示,12却只能还原成0.7,误差为−11.3。这相当于承认尺子量不了这么大,而不是把大数偷偷变成合法的小数。导出的表会标记原值是否超出格子范围。
按六个位置等权求误差平方的平均,三种方案依次得到19/600、247/29400、12769/600。裁剪方案的权重均方误差最大,因为12这一个位置损失很大。但这张分数表还没有告诉我们,实际输入会不会用到第三个特征。
代码把误差定义为还原值减原值,保留正负号;均方误差则先平方再平均,不会让不同位置的正负误差抵消。本文所有权重方案的分母都是六,所有单次输出误差的分母都是二。若先把正负误差平均再平方,算到的将是另一件事,也会隐藏成对出现的误差。
换一种输入,误差的后果也跟着变
先输入[1,1,0]。原矩阵输出[0.3,0.3],第三列因为乘零没有贡献。整张方案输出[0,0];分行方案输出[0.3,0],第一行的两个小误差正好抵消;裁剪方案完全恢复原输出。按两个输出等权算均方误差,分别为0.09、0.045、0。
再输入[0,0,1],只读取第三列。原输出[0.3,12];整张方案输出[0,12],分行方案完全正确,裁剪方案输出[0.3,0.7]。对应输出误差变成0.045、0、63.845。裁剪的好坏因此依赖输入;不能只挑第一种输入,就宣布它比其他方案更好。
计算关系是输出误差等于量化误差矩阵乘输入,即Δy=ΔW×x。输入会放大、忽略或抵消某些误差。权重均方误差描述了参数改动的一种大小,任务质量还要看真实输入、后续运算和最终评价标准。这里两个输入都是人为挑选的机制反例,不是代表性测试集。
分行方案第一行的误差在[1,1,0]下抵消,也不是以后一直抵消。若两个输入系数不同,正负误差会乘上不同份量,原来的抵消关系就可能消失。这提醒我们,输出偶然一致只能作为该输入下的观测;要说明矩阵本身完全相同,必须逐项检查,而不能只喂一个向量。
运行账本,逐个位置追到最终输出
下面脚本使用Fraction保存分数,避免把0.1的二进制近似混入这次比较。它生成quantization-report.json、quantization-values.csv和quantization-outputs.csv。前一张CSV有18行,记录三种方案的每个权重;后一张有12行,记录两个输入、两个输出和三种方案。
先核对原值、scale、整数和还原值,再从输出CSV手算均方误差。零点与舍入规则写在报告里;全零组约定scale为1,所有整数仍为0,避免由最大值为零造成除零。脚本还检查正负半整数的偶数舍入,防止不同语言的默认规则悄悄改变结果。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""Exact uniform-grid experiment, not packed int4 storage or a model benchmark."""
import csv
import json
from fractions import Fraction as F
from pathlib import Path
QMAX = 7 # Custom symmetric grid [-7, 7]; the int4 code -8 is deliberately unused.
WEIGHTS = ((F(1, 10), F(1, 5), F(3, 10)),
(F(1, 10), F(1, 5), F(12))) # [output_channel][input_feature]
INPUTS = {"small_features": (F(1), F(1), F(0)),
"outlier_feature": (F(0), F(0), F(1))}
def quantize(value, scale):
if scale <= 0:
raise ValueError("scale must be positive")
rounded = round(value / scale) # Fraction implements nearest, ties to even.
integer = max(-QMAX, min(QMAX, rounded))
return integer, integer * scale
def choose_scale(values, cap=None):
if not values or (cap is not None and cap <= 0):
raise ValueError("nonempty values and positive cap required")
peak = max(abs(v) for v in values) if cap is None else cap
return peak / QMAX if peak else F(1) # All-zero group has no division by zero.
def matvec(matrix, vector):
if any(len(row) != len(vector) for row in matrix):
raise ValueError("shape mismatch")
return [sum((w * x for w, x in zip(row, vector)), F(0)) for row in matrix]
def mse(first, second):
if not first or len(first) != len(second):
raise ValueError("equal nonempty vectors required")
return sum(((a - b) ** 2 for a, b in zip(first, second)), F(0)) / len(first)
def main():
flat = [v for row in WEIGHTS for v in row]
policies = {"whole_tensor": [choose_scale(flat)] * 2,
"per_output_row": [choose_scale(row) for row in WEIGHTS],
"clip_at_7_over_10": [choose_scale(flat, F(7, 10))] * 2}
reports, weight_rows, output_rows = {}, [], []
for name, scales in policies.items():
restored = []
for r, (row, scale) in enumerate(zip(WEIGHTS, scales)):
restored_row = []
for c, value in enumerate(row):
integer, recovered = quantize(value, scale)
restored_row.append(recovered)
weight_rows.append({"policy": name, "row": r, "column": c,
"original": str(value), "scale": str(scale),
"integer": integer, "restored": str(recovered),
"error": str(recovered - value),
"outside_grid_range": abs(value) > QMAX * scale})
restored.append(restored_row)
outputs = {}
for input_name, vector in INPUTS.items():
reference, actual = matvec(WEIGHTS, vector), matvec(restored, vector)
outputs[input_name] = {"reference": list(map(str, reference)),
"quantized": list(map(str, actual)),
"output_mse": str(mse(reference, actual))}
for r, (original, recovered) in enumerate(zip(reference, actual)):
output_rows.append({"policy": name, "input": input_name, "row": r,
"reference": str(original), "actual": str(recovered),
"error": str(recovered - original)})
reports[name] = {"scales": list(map(str, scales)),
"scale_count": 2 if name == "per_output_row" else 1,
"restored": [[str(v) for v in row] for row in restored],
"weight_mse": str(mse(flat, [v for row in restored for v in row])),
"outputs": outputs}
assert reports["whole_tensor"]["restored"] == [["0", "0", "0"], ["0", "0", "12"]]
assert reports["per_output_row"]["scales"] == ["3/70", "12/7"]
assert reports["clip_at_7_over_10"]["restored"] == [["1/10", "1/5", "3/10"], ["1/10", "1/5", "7/10"]]
expected_weight_mse = {"whole_tensor": "19/600", "per_output_row": "247/29400", "clip_at_7_over_10": "12769/600"}
expected_outputs = {"whole_tensor": ("9/100", "9/200"),
"per_output_row": ("9/200", "0"),
"clip_at_7_over_10": ("0", "12769/200")}
for name in policies:
assert reports[name]["weight_mse"] == expected_weight_mse[name]
assert tuple(reports[name]["outputs"][i]["output_mse"] for i in INPUTS) == expected_outputs[name]
assert choose_scale([F(0), F(0)]) == 1
assert quantize(F(0), choose_scale([F(0)])) == (0, 0)
assert [quantize(F(x, 2), F(1))[0] for x in (1, 3, -1, -3)] == [0, 2, 0, -2]
assert quantize(F(100), F(1)) == (7, 7)
try:
quantize(F(1), F(0))
except ValueError:
pass
else:
raise AssertionError("zero scale accepted")
report = {"scope": "Exact rational arithmetic on one 2-by-3 matrix; no model inference.",
"convention": {"shape": "[output_channel,input_feature]", "qmin": -7,
"qmax": 7, "zero_point": 0, "rounding": "nearest_ties_even",
"all_zero_scale": "1", "inputs_unquantized": True},
"weights": [[str(v) for v in row] for row in WEIGHTS],
"inputs": {k: list(map(str, v)) for k, v in INPUTS.items()}, "policies": reports}
Path("quantization-report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
for filename, rows in (("quantization-values.csv", weight_rows), ("quantization-outputs.csv", output_rows)):
with Path(filename).open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
writer.writeheader()
writer.writerows(rows)
print(json.dumps({"status": "PASS", "policies": 3, "weight_rows": len(weight_rows),
"output_rows": len(output_rows), "weight_mse": expected_weight_mse}))
if __name__ == "__main__":
main()
怎样运行:Python 3.10+标准库;保存为quantization_scale.py,在空练习目录运行 python quantization_scale.py。不要加-O,验收使用assert。 2026-10-15 已用 Python 3.14.0 执行这份代码并通过自带检查;下方是当次输出。固定 2×3 矩阵与精确分数运算;不打包 int4、不运行语言模型、不测速度或真实任务质量。
运行后对照结果
{"status": "PASS", "policies": 3, "weight_rows": 18, "output_rows": 12, "weight_mse": {"whole_tensor": "19/600", "per_output_row": "247/29400", "clip_at_7_over_10": "12769/600"}}把小实验接到真实量化方法时,要保留范围区别
PyTorch官方量化文章解释了整张与逐通道的参数粒度;ONNX QuantizeLinear的第23版规则明确列出scale形状、饱和范围和最近偶数舍入。本文借这些资料检查术语与约定,实际执行的仍是自己的标准库公式,没有导出或运行ONNX模型。
SmoothQuant论文重点讨论大模型激活中的大值,并通过等价缩放在激活和权重之间转移量化困难。本文只量化虚构权重,没有实现这种变换,也不能把这里的权重矩阵说成论文观测的激活分布。它提供的是理解格子为何被拉宽的入口,不是该论文的复现。
下一步该交付的是对照记录
面对真实模型,先保存量化前后的数值约定、分组轴、scale和裁剪政策,再用事先确定的输入集比较输出与任务表现。看见某一组误差很大,应检查它经过哪些输入和后续层,而不是直接删除那个位置。裁剪阈值也要说明如何选择,不能根据测试答案反复调到最好看。
本实验的整数是Python对象,分数也是普通软件计算,没有进行位打包或调用低位矩阵内核。因此它只证明这些约定下的数值后果,不提供模型文件大小、显存或延迟数据。要回答速度问题,需要另做固定硬件和工作负载的实测。
读者可以先把三种还原矩阵抄在纸上,遮住程序的汇总结果,独立算两个输入的输出。若手算和CSV不同,先查行列对应、scale归属以及误差正负号;这些检查比盯着最后一个平均数更容易找到错误,也能作为以后审查真实量化日志的起点。
想接着往下学,可以阅读量化与低秩适配路线。先分清本篇均匀量化的误差,再接着比较不同量化格式与适配方法。
记住这些要点
- 共用scale时,大值可能把小值挤到同一个整数格子。
- 分组轴决定谁共享一把尺子,逐行并不会消除行内离群值。
- 裁剪改变数值,必须记录被截到端点的位置。
- 输出误差由ΔW与输入共同决定,权重MSE不是任务质量。
别把例子用过头
- 把自定−7到7当成所有int4格式的默认范围。
- 只说逐通道,却不说明矩阵方向与坐标轴。
- 只用不经过大值的输入证明裁剪有效。
- 把Python整数列表称作已经打包的四位权重。
自己试一小步
你能构造一份不会只挑有利输入的量化审查表吗?
- 自行设计一张三行四列矩阵,至少两行有不同数值范围,并事先固定四个输入。
- 分别实现整张、逐行和自选分组策略,记录零点、舍入、scale与零范围规则。
- 导出逐权重和逐输出CSV,另外写一个只读这些文件的核对程序。
- 提交各输入的误差与选择理由,保留至少一个所选方案较差的输入;不填写未测量的速度。
做到这些,就算完成
- 矩阵轴和输入维度一致,所有输出都能沿CSV追查。
- 方案选择不只依据一个权重平均分。
- 练习只提交自己的数据与结论,不把标准库机制实验叫做真实模型评测。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- PyTorch:Practical Quantization in PyTorch (新标签页)↗
scale、zero-point及整张与逐通道量化的基本约定。
- ONNX:QuantizeLinear,第23版规则 (新标签页)↗
最近偶数舍入、int4饱和范围以及scale形状与量化粒度。
- SmoothQuant原论文,第6版 (新标签页)↗
第2至3节的scale、量化粒度及激活大值挤压有效刻度。