开始前,会这些就够了
- 会算均值与平方差
- 了解一次批次包含多个样本
- 知道训练模式与梯度记录是两个开关
同一张图片单独排在一批里,与另一组图片一起处理,输出分数可能不同。一定是随机数没固定吗?不一定。有些层会拿当前这批数据一起计算统计量;就算没有抽样,换了同批伙伴也改变了计算。
已有归一化文章主要手算 LayerNorm,FashionMNIST 入门网络没有 BatchNorm。本篇单独补上“批次伙伴”的实验:固定数值二,只改其他三个数,用标准库复现标量公式。它不调用 PyTorch,不训练网络,也不测梯度或准确率。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本;制作时实测 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- batch_neighbors.py:完整示例
- README.md:环境、范围与独立练习
- observed.stdout.txt:制作时的观察记录
- verification.json:对应源码的验证范围
- SHA256SUMS.txt:文件内容摘要
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B batch_neighbors.py解压后在含示例文件的文件夹打开 PowerShell,按命令运行。先预测结果,再与当次输出对照。 程序会在当前目录写入教学报告,建议使用独立练习文件夹。 教学数据与标准库公式,不代表 PyTorch 执行、训练效果或真实模型准确率。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
一直盯住第一个 2,只换它的伙伴
四个样本各只有一个数字。第一个样本始终是 2;下方同时显示“按当前四个数计算”与“使用同一份已存统计”两种结果。
- 始终关注它2
- 伙伴 10
- 伙伴 21
- 伙伴 33
使用当前批统计
- 均值
- 1.500000
- 方差(除以 4)
- 1.250000
- 第一个 2 的输出
- 0.447212
伙伴变了,计算均值和方差的数字也变了。
使用固定已存统计
- 固定均值
- 1.500000
- 固定方差
- 1.250000
- 第一个 2 的输出
- 0.447212
输入 2 与这份统计都没变,因此输出相同。
计算式:(输入 − 均值) / √(方差 + 0.00001);固定 γ=1、β=0。
这里只计算一维教学公式,没有运行 PyTorch,也没有更新统计或训练模型。默认保存统计的 BatchNorm 在训练时使用当前批统计,切到评估模式后使用已存统计;如果设置 track_running_stats=False,评估时仍使用当前批统计。完整张量还要按该层规定的维度计算。
先把它看成一次相对位置计算
假设每个样本在某个通道上只有一个数。本批数据先一起算均值,再一起算离均值有多远,接着把每个数减均值、除以标准差。原来的二有没有变并不重要:如果大家从零、一、三换成十、十一、十二,二在队伍里的相对位置已经不同。
BatchNorm 的标量归一化公式可读成:(当前值-本批均值)÷平方根(本批方差+epsilon)。通常还乘一个可学习缩放量、加一个可学习偏移量;本实验固定为一和零,让问题只剩统计量来自哪里。epsilon是分母中的小正数,避免常量批次直接除以零。
这里的“伙伴”指参与同一组统计的数,并不表示网络在理解样本之间的语义关系。真实 BatchNorm 会按通道组织这些数;输入形状不同,参与统计的轴也不同,不能只看屏幕上写着多少个样本。
固定二,换一批伙伴
第一批是[2,0,1,3],均值1.5,方差1.25。二比均值大0.5,归一化结果为正。第二批是[2,10,11,12],均值8.75,方差15.6875;二比均值小6.75,结果变成负数。两次都固定同一个输入二、同一组缩放偏移和同一个epsilon。
这里的方差用平方差之和除以本批数量,也就是四。它是在描述眼前四个数的离散程度。后面维护运行统计时会遇到另一种方差计算,先不要混用分母。本文代码会把两个输出同时保存在报告里,方便查到变化确实来自哪一组统计。
这不是所有网络都会出现的现象。若某层只对每个样本内部的特征计算,比如指定对应轴的 LayerNorm,更换其他样本通常不会通过这层的统计改变当前样本。本篇不把一种归一化机制推广成所有模型的性质。
改用固定统计,结果为什么稳定了
PyTorch 2.9 文档中的默认 BatchNorm 会在训练过程中维护运行均值与方差,在评估模式使用已经保存的统计。本实验直接准备一份固定快照:均值1.5、方差1.25,给两批请求共同使用。数值二在两次请求中的归一化结果于是一样;伙伴改变不再修改这一层采用的统计量。
这是一个有条件的结论。若 track_running_stats=False,文档明确说明评估时也会使用当前批统计。因此“调用了eval,就必定与同批伙伴无关”仍不完整。还要查看模块配置,以及自定义层是否存在其他批次相关行为。
no_grad 或 inference_mode 控制梯度记录,eval 控制模块的训练/评估行为。关闭梯度不会自动选择固定统计。本篇代码没有自动微分系统,只能演示两种公式输入;真正核对框架时,要分别检查模块模式、运行统计和梯度开关,不能把标准库通过写成框架行为已实测。
拆成两批再累积,为什么也可能不同
把[0,2,10,12]一次处理,均值来自四个数。把它拆成[0,2]和[10,12]两次处理,两个小批各有自己的均值和方差。即使之后把梯度加起来,前向阶段送入后续层的数字已经不同;不能仅凭总样本数相等就断言与完整大批完全等价。
反过来,若全程采用同一份固定统计,这个标量变换可以逐个元素计算,拆批再拼接会与一次处理一致。代码把两种情况都保留为断言,区分“当前统计随批变化”和“保存统计固定”的计算条件。
已有梯度累积文章讨论了有效目标数量与损失加权;这次实验不重复那些梯度公式。它新增的是前向反例,也没有因此推荐直接冻结真实训练中的 BatchNorm。是否改变归一化策略要另外验证训练效果和部署条件。
把当前方差和更新方差各记一笔
PyTorch 2.9 的文档区分两种用途:当前训练前向用除以n的方差;更新保存的运行方差时用无偏估计,即此处除以n-1。本例另取[0,2]演示:均值为一,当前方差为一,用于更新的方差为二。旧运行方差为一,momentum为0.1时,新值为0.9×1+0.1×2,也就是1.1。
这个momentum表示新统计在更新中占多少,不是优化器中保留运动方向的那个动量。本脚本只做一次公式更新,没有模拟完整训练历史;固定评估快照与这次更新示例也是两份独立材料,不把不同阶段的统计偷换在一起。
运行统计也属于模型需要保存的状态。即使可学习的缩放、偏移和其他权重都没变,运行均值与方差改变后,后续评估输出仍可能变化。因此诊断时只比较参数列表还不够,要连同相关缓冲状态一起查看。“没有执行优化器更新”不自动表示所有影响推理的状态都没动。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""Scalar BatchNorm formula demonstration; does not import or execute PyTorch."""
import json
import math
from pathlib import Path
EPS = 1e-5
def moments(values):
if len(values) < 2 or not all(math.isfinite(x) for x in values):
raise ValueError("this scalar teaching batch needs at least two finite values")
mean = sum(values) / len(values)
square_sum = sum((value - mean) ** 2 for value in values)
return mean, square_sum / len(values), square_sum / (len(values) - 1)
def normalize(values, mean, variance):
if variance < 0 or not math.isfinite(mean) or not math.isfinite(variance):
raise ValueError("invalid stored statistics")
return [(value - mean) / math.sqrt(variance + EPS) for value in values]
def current_batch(values):
mean, biased, _ = moments(values)
return normalize(values, mean, biased)
def stored_eval(values, running):
return normalize(values, running["mean"], running["variance"])
def update_running(values, running, momentum=0.1):
mean, _, unbiased = moments(values)
return {"mean": (1 - momentum) * running["mean"] + momentum * mean,
"variance": (1 - momentum) * running["variance"] + momentum * unbiased}
def close(left, right):
return len(left) == len(right) and all(
math.isclose(a, b, rel_tol=0, abs_tol=1e-12) for a, b in zip(left, right))
near = [2.0, 0.0, 1.0, 3.0]
far = [2.0, 10.0, 11.0, 12.0]
training_near, training_far = current_batch(near), current_batch(far)
assert training_near[0] > 0 > training_far[0]
# Snapshot held fixed for both requests; deliberately not estimated from them.
frozen = {"mean": 1.5, "variance": 1.25}
snapshot = dict(frozen)
evaluation_near, evaluation_far = stored_eval(near, frozen), stored_eval(far, frozen)
assert evaluation_near[0] == evaluation_far[0]
assert frozen == snapshot
data = [0.0, 2.0, 10.0, 12.0]
full = current_batch(data)
split = current_batch(data[:2]) + current_batch(data[2:])
assert not close(full, split)
assert close(stored_eval(data, frozen), stored_eval(data[:2], frozen) + stored_eval(data[2:], frozen))
# In the documented track_running_stats=False setting, eval also uses current-batch stats.
without_running_near, without_running_far = current_batch(near), current_batch(far)
assert without_running_near[0] != without_running_far[0]
updated = update_running([0.0, 2.0], {"mean": 0.0, "variance": 1.0})
assert math.isclose(updated["mean"], 0.1)
assert math.isclose(updated["variance"], 1.1) # Current biased variance=1; unbiased update variance=2.
assert current_batch([7.0, 7.0]) == [0.0, 0.0]
assert not close([], [1.0])
assert not close([1.0], [1.0, 2.0])
for invalid in ([], [1.0], [1.0, float("nan")]):
try:
current_batch(invalid)
except ValueError:
pass
else:
raise AssertionError("invalid teaching batch accepted")
report = {"scope": "stdlib scalar formula, gamma=1 beta=0; no framework, gradients or training",
"epsilon": EPS, "sample_of_interest": 2.0,
"train_mode_sample_outputs": [training_near[0], training_far[0]],
"frozen_eval_sample_outputs": [evaluation_near[0], evaluation_far[0]],
"full_batch": full, "split_batches": split,
"frozen_statistics": frozen, "one_running_update": updated,
"track_running_stats_false_sample_outputs": [without_running_near[0], without_running_far[0]]}
Path("batch-neighbors-report.json").write_text(json.dumps(report, indent=2), encoding="utf-8")
print("same_sample_in_train_mode", [round(x, 6) for x in report["train_mode_sample_outputs"]])
print("same_sample_with_frozen_stats", [round(x, 6) for x in report["frozen_eval_sample_outputs"]])
print("one_running_update", updated)
print("PASS: neighbor, split, frozen, and variance checks; batch-neighbors-report.json saved")
怎样运行:保存为 batch_neighbors.py,在自己的空练习目录运行 python batch_neighbors.py。只用 Python 3 标准库,不下载数据或安装依赖。 2026-10-13 已用 3.14.0 执行这份示例并通过自带断言;下方为当次输出。教学数据与标准库公式,不代表 PyTorch 执行、训练效果或真实模型准确率。
运行后对照结果
same_sample_in_train_mode [0.447212, -1.704224]
same_sample_with_frozen_stats [0.447212, 0.447212]
one_running_update {'mean': 0.1, 'variance': 1.1}
PASS: neighbor, split, frozen, and variance checks; batch-neighbors-report.json saved把批次伙伴检查带回自己的模型
准备一条固定样本,再准备两组不同伙伴。在不更新参数的条件下,用同一模型副本和明确模式比较固定样本的输出;同时记录 BatchNorm 配置、运行统计前后值、设备和容差。若准备测试评估行为,必须从相同统计快照开始,避免第一次调用已经改变了第二次的起点。
不要用“任何分数变化都失败”做跨所有模型的通用规则。先根据结构写出预期:默认评估 BatchNorm 的这一层应使用固定统计;不跟踪运行统计的配置则仍依赖本批。批大小为一也不能一概而论,真实模块还要看参与统计的空间或序列位置数;本教学脚本只处理每个样本一个标量,主动拒绝少于两个数的当前批。
最终交付应包含固定样本、两组伙伴、模块配置与实际输出,而不是只留下“已设eval”。这个小检查帮助定位批次相关行为;它不能独自证明整个模型正确、跨设备完全一致,或真实服务的准确率已经恢复。
最后留一个容易核对的边界:所有数都相同时,当前方差为零,减去均值后的分子也是零;在本例正epsilon下,输出应当是有限的零。脚本把这个例子与两个伙伴对照放在一起,让读者同时检查正常变化和数值边界,而不只挑一个最显眼的正负翻转。
记住这些要点
- 使用当前批统计时,同一数值会受其他参与统计的数影响。
- 固定运行统计只在相应模块模式和配置成立时消除这类依赖。
- 梯度累积无法自动还原拆批前已经改变的前向计算。
- 当前批归一化方差与运行方差更新的估计方式要分清。
别把例子用过头
- 把
no_grad当作eval的替代。 - 忽略
track_running_stats=False的评估行为。 - 把分批前向差异都归因于随机种子或浮点误差。
- 拿标准库公式通过宣称
PyTorch模型已真实验证。
自己试一小步
若只改变批内顺序,和改变批次的分组方式,分别会发生什么?
- 自拟六个不全相同的数,指定一个要跟踪的样本。
- 先写下对整批重排、不同三三分组和固定统计三种情况的预测。
- 在脚本副本中保存每个样本ID与输出,避免重排后比错位置。
- 提交原始与改变后的报告,解释哪些结论依赖这份标量公式。
做到这些,就算完成
- 按样本ID对齐结果,不能直接比较列表相同下标。
- 保持gamma、beta、epsilon及固定统计不变。
- 区分顺序变化和统计分组变化,并解释观察结果。
- 若未运行真实框架,报告明确写成公式模拟。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- PyTorch 2.9:BatchNorm1d (新标签页)↗
读公式、训练前向方差与运行方差估计的区别、momentum更新规则、
track_running_stats=False及输入形状;这些是本文配置边界的依据。 - Batch Normalization 原论文 (新标签页)↗
读论文第3节与Algorithm 1,以及3.1节的训练/推理区分:按微批计算均值方差、样本输出依赖同批其他样本。本文数字与检查器是原创例子。
- PyTorch 2.9:Autograd 与评估模式 (新标签页)↗
只读 Locally disabling gradient computation 与 Evaluation Mode:eval不关闭梯度,与no-grad
/inferencemode独立,并可能影响BatchNorm运行统计。