qinghuan
← 回到知识探索

AI 直觉 / 动手实验

同一个样本,为什么和谁一起计算会影响结果?

固定一个数字,只换同批其他数字,手算 BatchNorm 的输出,再比较当前批统计和固定已存统计。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先把它看成一次相对位置计算
  4. 固定二,换一批伙伴
  5. 改用固定统计,结果为什么稳定了
  6. 拆成两批再累积,为什么也可能不同
  7. 把当前方差和更新方差各记一笔
  8. 把批次伙伴检查带回自己的模型
  9. 记住这些要点
  10. 别把例子用过头
  11. 自己试一小步
  12. 查原始资料
开始前,会这些就够了
  • 会算均值与平方差
  • 了解一次批次包含多个样本
  • 知道训练模式与梯度记录是两个开关

同一张图片单独排在一批里,与另一组图片一起处理,输出分数可能不同。一定是随机数没固定吗?不一定。有些层会拿当前这批数据一起计算统计量;就算没有抽样,换了同批伙伴也改变了计算。

已有归一化文章主要手算 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;下方同时显示“按当前四个数计算”与“使用同一份已存统计”两种结果。

  1. 始终关注它2
  2. 伙伴 10
  3. 伙伴 21
  4. 伙伴 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表示新统计在更新中占多少,不是优化器中保留运动方向的那个动量。本脚本只做一次公式更新,没有模拟完整训练历史;固定评估快照与这次更新示例也是两份独立材料,不把不同阶段的统计偷换在一起。

运行统计也属于模型需要保存的状态。即使可学习的缩放、偏移和其他权重都没变,运行均值与方差改变后,后续评估输出仍可能变化。因此诊断时只比较参数列表还不够,要连同相关缓冲状态一起查看。“没有执行优化器更新”不自动表示所有影响推理的状态都没动。

展开收起代码 · 小实验python · 89 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""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模型已真实验证。

自己试一小步

若只改变批内顺序,和改变批次的分组方式,分别会发生什么?

  1. 自拟六个不全相同的数,指定一个要跟踪的样本。
  2. 先写下对整批重排、不同三三分组和固定统计三种情况的预测。
  3. 在脚本副本中保存每个样本ID与输出,避免重排后比错位置。
  4. 提交原始与改变后的报告,解释哪些结论依赖这份标量公式。

做到这些,就算完成

  • 按样本ID对齐结果,不能直接比较列表相同下标。
  • 保持gamma、beta、epsilon及固定统计不变。
  • 区分顺序变化和统计分组变化,并解释观察结果。
  • 若未运行真实框架,报告明确写成公式模拟。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • PyTorch 2.9:BatchNorm1d (新标签页)↗PyTorch 官方文档 · 英文

    读公式、训练前向方差与运行方差估计的区别、momentum更新规则、track_running_stats=False及输入形状;这些是本文配置边界的依据。

    2026-10-13 核对固定2.9版本正文;未调用PyTorch执行本文案例。

  • Batch Normalization 原论文 (新标签页)↗Ioffe 与 Szegedy / PMLR 2015 · 英文

    读论文第3节与Algorithm 1,以及3.1节的训练/推理区分:按微批计算均值方差、样本输出依赖同批其他样本。本文数字与检查器是原创例子。

    2026-10-13 核对可读PDF中的相关段落与算法;不引用论文性能数字作为本实验结果。

  • PyTorch 2.9:Autograd 与评估模式 (新标签页)↗PyTorch 官方文档 · 英文

    只读 Locally disabling gradient computation 与 Evaluation Mode:eval不关闭梯度,与no-grad/inference mode独立,并可能影响BatchNorm运行统计。

    2026-10-13 核对固定2.9文档正文;本文不执行自动微分。