فهرست مقاله
بازنویسی نمودار محاسبه میتواند بدون عوض کردنِ تابعِ ریاضی انجام شود، اما این برابری باید با قراردادِ درست بررسی شود. در این درس یک لایهٔ آفین و سپس BatchNorm با آمارِ ثابت را به یک لایهٔ آفین تبدیل میکنیم. مثال کوچک، تألیفی و بیبعد است؛ وزن، میانگین و واریانس را خودمان از قبل میدهیم. هیچ شبکهای آموزش نمیبیند و نتیجه، گزارشِ سرعت یا کیفیتِ یک پاسخگوی صوتی نیست.
۱. حالت استنتاج و آمارِ ثابت را مشخص کنیم

لایهٔ نخست، بردار ورودی را در ماتریس وزن ضرب و بایاس را اضافه میکند؛ خروجیِ آن را z=W*x+b مینامیم. هر سطرِ وزن یک خروجی و هر ستون یک ویژگیِ ورودی است. پس از آن، برای هر خروجی یک میانگین، واریانس، ضریبِ مقیاس و جابهجایی داریم. این پارامترهای همکانال باید با همان سطر وزن همراه بمانند.
فرمولِ مرحلهٔ دوم y=gamma*(z-mu)/sqrt(var+epsilon)+beta است. اینجا میانگین و واریانس ثابتاند و از اعضای دستهٔ جاری دوباره محاسبه نمیشوند. مستندات ادغام Linear و BatchNorm در PyTorch نیز حالت ارزیابیِ هر دو لایه و آمادهبودنِ بافرهای آماری را شرط میگذارد. برنامهٔ ما این چارچوب را اجرا نمیکند؛ حسابِ کوچکِ خودمان را بررسی میکند.
در تعریف BatchNorm1d نسخهٔ ۲٫۱۴، واریانسِ دسته در گذرِ آموزش با برآوردِ دارای مخرجِ تعداد نمونهها محاسبه میشود، اما برآوردِ واردشونده به بهروزرسانیِ واریانسِ متحرک، تصحیحِ بیاریبی دارد. خودِ بافرِ متحرک، همان واریانسِ یک دسته نیست. جایگزین کردنِ بیاعلانِ یکی با دیگری میتواند تابعِ مورد ادغام را عوض کند.
اگر ردیابیِ آمارِ متحرک خاموش باشد، حتی حالتِ ارزیابیِ آن رابط میتواند به آمارِ دسته وابسته بماند. در آن وضعیت، فرضِ این درس برقرار نیست. همچنین در تعریف استنتاجِ عملگر BatchNormalization نسخهٔ ۱۵ در ONNX، میانگین و واریانسِ دادهشده در فرمول استفاده میشوند. از این اشتراکِ فرمول، یکسان بودنِ قواعدِ آموزش و بهروزرسانیِ دو چارچوب را نتیجه نمیگیریم.
۲. وزن و بایاسِ ادغامشده را استخراج کنیم

برای هر خروجی، نسبتِ alpha=gamma/sqrt(var+epsilon) را یک بار از پارامترهای ثابت میسازیم. با جایگذاریِ خروجیِ آفین در فرمول، ضریبِ هر عضوِ همان سطر وزن در آلفا ضرب میشود. بایاسِ تازه برابر b_new=alpha*(b-mu)+beta است. این بازکردنِ جبری به ورودیِ خاصی وابسته نیست، به شرطی که آمار ثابت بماند.
میانگین باید پیش از مقیاسدهی کم شود. رابطهٔ «آلفا ضربدر بایاس، منهای میانگین، بهعلاوهٔ بتا» عموماً همان جواب را نمیدهد. بایاسِ لایهٔ نخست نیز اگر وجود نداشته باشد، صفر است؛ به معنیِ صفر بودنِ بایاسِ نهایی نیست. کم کردنِ میانگین و افزودنِ بتا هنوز میتوانند جابهجاییِ غیرصفر بسازند. رابطِ رسمیِ ادغام پارامترها نیز بایاسِ خطی را از بایاس و آمارِ BatchNorm جدا میگیرد.
واریانس نامنفی و اپسیلون مثبت است؛ هر دو داخل ریشهاند. گامای منفی مجاز است و جهتِ سطر وزن را برمیگرداند؛ این موضوع با واریانسِ منفی فرق دارد. گامای صفر همهٔ وزنهای آن خروجی را صفر و جواب را ثابتِ بتا میکند. واریانسِ صفر نیز با اپسیلونِ مثبت، در همین فرمول تعریفشده باقی میماند؛ صفر بودنِ آن را خودکار خطای داده فرض نمیکنیم.
۳. کد کامل و قرارداد محورهای آفین

کد با Python 3.14.5 و فقط کتابخانهٔ استاندارد اجرا شده است. ماتریسِ وزن یک تا چهار سطر و یک تا چهار ستون دارد و همهٔ ظرفها تاپلِ دقیقاند. پارامترهای اولیه و ورودی، اعداد صحیح یا شناورِ واقعی با قدرمطلقِ حداکثر هشت هستند؛ واریانس صفر تا شانزده و اپسیلون از یکدویستوپنجاهوششم تا چهار پذیرفته میشود. بولی، مقدار نامتناهی و شکلِ ناسازگار رد میشوند.
"""Original fixed-statistics inference BN/affine folding. Python stdlib only."""
from dataclasses import asdict, dataclass
from decimal import Decimal, localcontext
from itertools import product
import json
import math
import platform
def scalar(value, low, high):
# Compare bounds before float conversion/isfinite: huge Python ints reject safely.
if type(value) not in (int, float) or not low <= value <= high:
raise ValueError("bounded actual int/float required")
return float(value)
def vector(values, size, bound=8.0, low=None):
if type(values) is not tuple or len(values) != size:
raise ValueError("exact tuple and matching channel dimension required")
return tuple(scalar(v, -bound if low is None else low, bound) for v in values)
def matrix(weights, bound=8.0):
if type(weights) is not tuple or not 1 <= len(weights) <= 4:
raise ValueError("1..4 output rows required")
if type(weights[0]) is not tuple or not 1 <= len(weights[0]) <= 4:
raise ValueError("1..4 input columns required")
width = len(weights[0])
return tuple(vector(row, width, bound) for row in weights)
def parameters(weights, bias, mean, variance, gamma, beta, epsilon):
w = matrix(weights)
c = len(w)
b = (0.0,) * c if bias is None else vector(bias, c)
mu = vector(mean, c)
var = vector(variance, c, 16.0, low=0.0)
scale, shift = vector(gamma, c), vector(beta, c)
eps = scalar(epsilon, 1.0 / 256, 4.0)
return w, b, mu, var, scale, shift, eps
@dataclass(frozen=True)
class Folded:
weights: tuple
bias: tuple
def fold_bn(weights, bias, mean, variance, gamma, beta, epsilon):
w, b, mu, var, scale, shift, eps = parameters(
weights, bias, mean, variance, gamma, beta, epsilon)
alpha = tuple(g / math.sqrt(v + eps) for g, v in zip(scale, var, strict=True))
return Folded(
tuple(tuple(a * value for value in row) for a, row in zip(alpha, w, strict=True)),
tuple(a * (old_b - m) + q for a, old_b, m, q in zip(alpha, b, mu, shift, strict=True)))
def affine(weights, bias, x):
"""Explicit output-row/input-column convention, including folded coefficients."""
w = matrix(weights, bound=4096.0)
b = vector(bias, len(w), bound=4096.0)
x = vector(x, len(w[0]))
return tuple(math.fsum([offset] + [a * v for a, v in zip(row, x, strict=True)])
for row, offset in zip(w, b, strict=True))
def unfused(weights, bias, mean, variance, gamma, beta, epsilon, x):
w, b, mu, var, scale, shift, eps = parameters(
weights, bias, mean, variance, gamma, beta, epsilon)
z = affine(w, b, x)
return tuple(g * (value - m) / math.sqrt(v + eps) + q
for value, m, v, g, q in zip(z, mu, var, scale, shift, strict=True))
def decimal_reference(weights, bias, mean, variance, gamma, beta, epsilon, x):
"""Independent high-precision unreduced two-stage formula; no fold/affine helper."""
def dec(v):
return Decimal(v) # Preserve exact actual binary-float value if v is float.
with localcontext() as ctx:
ctx.prec = 70
result = []
for i, row in enumerate(weights):
z = sum((dec(a) * dec(t) for a, t in zip(row, x, strict=True)),
Decimal(0) if bias is None else dec(bias[i]))
y = dec(gamma[i]) * (z - dec(mean[i])) / (dec(variance[i]) + dec(epsilon)).sqrt()
result.append(float(y + dec(beta[i])))
return tuple(result)
def close(a, b):
return len(a) == len(b) and all(math.isclose(x, y, rel_tol=1e-12, abs_tol=1e-10)
for x, y in zip(a, b, strict=True))
def rejected(call):
try:
call()
except ValueError:
return True
return False
def main():
w, b = ((2, -1), (1, 3)), (1, -2)
mu, var, gamma, beta, eps = (3, 1), (3, 8), (1, -6), (4, 5), 1
args = (w, b, mu, var, gamma, beta, eps)
folded = fold_bn(*args)
x = (2, -1)
output = affine(folded.weights, folded.bias, x)
checks = {}
checks["literal_folded_weight_and_bias"] = (
folded == Folded(((1.0, -.5), (-2.0, -6.0)), (3.0, 11.0)))
checks["literal_two_channel_output_and_three_paths"] = (
output == (5.5, 13.0) and unfused(*args, x) == output
and decimal_reference(*args, x) == output)
shapes = (((2, -1), (1, 3)), ((.1, 2, -3),), ((2,), (-1,), (.5,)))
oracle_cases, worst = 0, 0.0
for weights in shapes:
channels, inputs = len(weights), len(weights[0])
for vv, ee, gg in product((0.0, .5, 3.0), (1 / 256, .1, 1.0), (-2.0, 0.0, 1.0)):
bias = tuple((i - 1) / 2 for i in range(channels))
mean = tuple(i / 4 for i in range(channels))
variance = (vv,) * channels
scale, shift = (gg,) * channels, tuple(2 - i for i in range(channels))
pack = (weights, bias, mean, variance, scale, shift, ee)
f = fold_bn(*pack)
for seed in (-1, 0, 2):
xx = tuple(seed + j / 10 for j in range(inputs))
expected = decimal_reference(*pack, xx)
actual = affine(f.weights, f.bias, xx)
assert close(actual, expected) and close(unfused(*pack, xx), expected)
worst = max(worst, *(abs(a - e) for a, e in zip(actual, expected, strict=True)))
oracle_cases += 1
checks["independent_decimal_two_stage_oracle_243_profiles_one_group"] = oracle_cases == 243
no_bias = fold_bn(w, None, mu, var, gamma, beta, eps)
checks["missing_affine_bias_means_zero_not_omitted_shift"] = (
no_bias.bias == (2.5, 7.0)
and close(affine(no_bias.weights, no_bias.bias, x), unfused(w, None, mu, var, gamma, beta, eps, x)))
zero_scale = fold_bn(w, b, mu, var, (0, 0), beta, eps)
checks["zero_gamma_makes_constant_beta"] = (
zero_scale.weights == ((0.0, -0.0), (-0.0, -0.0))
and affine(zero_scale.weights, zero_scale.bias, x) == (4.0, 5.0)
and affine(zero_scale.weights, zero_scale.bias, (-8, 8)) == (4.0, 5.0))
zero_var = fold_bn(((2,),), (1,), (3,), (0,), (-1,), (5,), .25)
checks["zero_variance_positive_epsilon_and_negative_gamma"] = (
zero_var == Folded(((-4.0,),), (9.0,))
and affine(zero_var.weights, zero_var.bias, (2,)) == (1.0,))
wrong_scale = 1 / (math.sqrt(3) + 1)
checks["epsilon_outside_root_changes_hand_output"] = (
not math.isclose(wrong_scale * (6 - 3) + 4, 5.5, abs_tol=1e-4))
wrong_bias = tuple(g / math.sqrt(v + eps) * old_b - m + q
for g, v, old_b, m, q in zip(gamma, var, b, mu, beta, strict=True))
checks["subtracting_mean_after_scaling_is_wrong"] = (
wrong_bias == (1.5, 8.0) and wrong_bias != folded.bias)
changed_mean = (4, 1)
changed = fold_bn(w, b, changed_mean, var, gamma, beta, eps)
changed_output = affine(changed.weights, changed.bias, x)
stale_output = affine(folded.weights, folded.bias, x)
checks["changed_stored_statistics_require_refolding_not_stale_parameters"] = (
changed.weights == folded.weights and changed.bias == (2.5, 11.0)
and changed_output == (5.0, 13.0)
and unfused(w, b, changed_mean, var, gamma, beta, eps, x) == changed_output
and stale_output == (5.5, 13.0) and stale_output != changed_output)
# This is an explicit alternative population-batch formula, not a PyTorch run.
values = (6.0, 1.0)
batch_mean = math.fsum(values) / 2
batch_var = math.fsum((v - batch_mean) ** 2 for v in values) / 2
wrong_batch_first = (values[0] - batch_mean) / math.sqrt(batch_var + 1) + 4
checks["batch_dependent_statistics_are_not_fixed_inference_statistics"] = (
not math.isclose(wrong_batch_first, output[0], abs_tol=1e-4)
and batch_var == 6.25)
boundary = fold_bn(((8, -8, 8, -8),) * 4, (8,) * 4, (-8,) * 4,
(0,) * 4, (8,) * 4, (8,) * 4, 1 / 256)
boundary_out = affine(boundary.weights, boundary.bias, (8, -8, 8, -8))
checks["inclusive_bounds_keep_finite_coefficients_and_outputs"] = (
all(math.isfinite(z) for z in boundary_out) and boundary.bias == (2056.0,) * 4)
checks["parameter_shape_alignment_and_actual_tuple_contract"] = all(
rejected(lambda args=args: fold_bn(*args)) for args in (
(list(w), b, mu, var, gamma, beta, eps),
(((2,), (1, 3)), b, mu, var, gamma, beta, eps),
(w, b, (3,), var, gamma, beta, eps),
((), (), (), (), (), (), eps),
(((1, 2, 3, 4, 5),), (0,), (0,), (1,), (1,), (0,), eps)))
checks["strict_scalar_and_variance_epsilon_validation"] = all(
rejected(lambda value=value: fold_bn(w, b, mu, var, gamma, beta, value))
for value in (True, 0, -1, float("nan"), float("inf"), 10**400, 1e-5)) and all(
rejected(lambda value=value: fold_bn(w, b, mu, (value, 8), gamma, beta, 1))
for value in (True, -1, float("nan"), float("inf"), 10**400))
checks["input_vector_shape_and_values_rejected"] = all(
rejected(lambda value=value: affine(folded.weights, folded.bias, value))
for value in ([2, -1], (2,), (True, 0), (float("nan"), 0), (9, 0), (10**400, 0)))
permuted = fold_bn(tuple(reversed(w)), tuple(reversed(b)), tuple(reversed(mu)),
tuple(reversed(var)), tuple(reversed(gamma)), tuple(reversed(beta)), eps)
checks["output_channel_permutation_moves_all_channel_parameters"] = (
permuted.weights == tuple(reversed(folded.weights))
and permuted.bias == tuple(reversed(folded.bias))
and affine(permuted.weights, permuted.bias, x) == tuple(reversed(output)))
assert all(type(v) is bool and v for v in checks.values()), checks
print(json.dumps({
"python": platform.python_version(), "dependencies": "stdlib only",
"torch_executed": False, "onnx_executed": False,
"hand_original": {"weights": w, "bias": b, "mean": mu, "variance": var,
"gamma": gamma, "beta": beta, "epsilon": eps, "input": x},
"hand_folded": asdict(folded), "hand_output": output,
"wrong_epsilon_outside_root_output_channel0": wrong_scale * 3 + 4,
"wrong_batch_statistics_output_channel0": wrong_batch_first,
"decimal_oracle_cases_inside_one_group": oracle_cases,
"decimal_oracle_max_abs_error": worst,
"checks": checks, "unique_check_groups": len(checks), "all_passed": True,
"limits": "Fixed supplied inference statistics, dimensionless synthetic features. No model training, statistics update, framework execution, quantized equivalence or latency/accuracy benchmark."
}, ensure_ascii=False, indent=2, allow_nan=False))
if __name__ == "__main__":
main()
این محدودهٔ اپسیلون، انتخابِ تمرینِ محدود است؛ مقدارِ پیشفرضِ رابطهای مرجع، یعنی 1e-5، در آن قرار نمیگیرد. کد، جایگزینِ کاملِ آن رابطها نیست. تابعِ اعمالِ آفین برای وزن و بایاسِ پس از ادغام، کرانِ جداگانهٔ ۴۰۹۶ دارد تا ضرایبِ بزرگترِ حاصل را بپذیرد. ورودِ ضریبِ بزرگ در مرحلهٔ دوم، مجوزِ عبورِ همان مقدار از قراردادِ پارامترهای اولیه نیست.
در درسِ چیدمان Conv1d، محورِ خروجی و ورودی و ترتیبِ کرنل موضوعِ اصلی بود. اینجا کرنلِ زمانی یا جابهجاییِ چیدمان نداریم؛ یک آفینِ معمولی را بازنویسی میکنیم. با این حال، اگر آمارِ خروجی را به ستونِ ورودی نسبت دهیم، شکلِ ماتریس شاید هنوز قابلاستفاده باشد ولی تابعِ محاسبهشده غلط خواهد بود.
۴. پاسخ دستی و مرجع اعشاریِ مستقل

وزنها دو سطرِ «دو، منفی یک» و «یک، سه» و بایاسها یک و منفی دو هستند. میانگینها سه و یک، واریانسها سه و هشت، گاماها یک و منفی شش و بتاها چهار و پنجاند؛ اپسیلون یک است. بنابراین مخرجها دو و سه و آلفاها نیم و منفی دو میشوند. وزنهای تازه «یک، منفی نیم» و «منفی دو، منفی شش» و بایاسها سه و یازدهاند.
برای ورودیِ دو و منفی یک، لایهٔ نخست شش و منفی سه میدهد. مرحلهٔ نرمالسازی پاسخِ پنجونیم و سیزده را میسازد؛ مسیرِ ادغامشده هم همین جواب را دارد. اگر اپسیلون را بیرون ریشه بگذاریم، خروجیِ نخست حدود ۵٫۰۹۸۱ میشود. اگر میانگین را بعد از مقیاسدهی کم کنیم، بایاسهای نادرستِ یکونیم و هشت به دست میآیند. این شاهدها از دادهٔ خودماناند و خطای ابزارِ بیرونی گزارش نمیشوند.
مرجع مستقل با Decimal و دقتِ هفتاد رقم، ضربِ ماتریس و سپس فرمولِ نرمالسازیِ ادغامنشده را حساب میکند؛ تابعِ ادغام و تابعِ آفینِ اصلی را صدا نمیزند. شناورِ ورودی با مقدارِ دودوییِ واقعیاش وارد مرجع میشود، نه اینکه عددِ ۰٫۱ بیاعلان برابرِ کسرِ دقیقِ یکدهم فرض شود. این مسیر، مقایسهٔ خودِ خروجی با خودش نیست.
۲۴۳ ترکیبِ کوچکِ شکل، ورودی، واریانس، اپسیلون و گاما در یک گروه بررسی شدند؛ بیشترین اختلاف حدود 7.11e-15 بود. تلورانسِ انتخابی مطلقِ 1e-10 و نسبیِ 1e-12 است؛ این حاشیه برای دادهها و کرانهای همین تمرین است، نه اثباتِ خطای همهٔ شبکهها. ترتیبِ ضرب و جمع پس از بازنویسی میتواند گردکردنِ شناور را تغییر دهد.
{
"python": "3.14.5",
"dependencies": "stdlib only",
"torch_executed": false,
"onnx_executed": false,
"hand_original": {
"weights": [
[
2,
-1
],
[
1,
3
]
],
"bias": [
1,
-2
],
"mean": [
3,
1
],
"variance": [
3,
8
],
"gamma": [
1,
-6
],
"beta": [
4,
5
],
"epsilon": 1,
"input": [
2,
-1
]
},
"hand_folded": {
"weights": [
[
1.0,
-0.5
],
[
-2.0,
-6.0
]
],
"bias": [
3.0,
11.0
]
},
"hand_output": [
5.5,
13.0
],
"wrong_epsilon_outside_root_output_channel0": 5.098076211353316,
"wrong_batch_statistics_output_channel0": 4.928476690885259,
"decimal_oracle_cases_inside_one_group": 243,
"decimal_oracle_max_abs_error": 7.105427357601002e-15,
"checks": {
"literal_folded_weight_and_bias": true,
"literal_two_channel_output_and_three_paths": true,
"independent_decimal_two_stage_oracle_243_profiles_one_group": true,
"missing_affine_bias_means_zero_not_omitted_shift": true,
"zero_gamma_makes_constant_beta": true,
"zero_variance_positive_epsilon_and_negative_gamma": true,
"epsilon_outside_root_changes_hand_output": true,
"subtracting_mean_after_scaling_is_wrong": true,
"changed_stored_statistics_require_refolding_not_stale_parameters": true,
"batch_dependent_statistics_are_not_fixed_inference_statistics": true,
"inclusive_bounds_keep_finite_coefficients_and_outputs": true,
"parameter_shape_alignment_and_actual_tuple_contract": true,
"strict_scalar_and_variance_epsilon_validation": true,
"input_vector_shape_and_values_rejected": true,
"output_channel_permutation_moves_all_channel_parameters": true
},
"unique_check_groups": 15,
"all_passed": true,
"limits": "Fixed supplied inference statistics, dimensionless synthetic features. No model training, statistics update, framework execution, quantized equivalence or latency/accuracy benchmark."
}
۵. چهار پرسش و مرز تغییر نمودار مدل

پرسش اول: اگر بایاسِ اولیه نباشد، بایاسِ تازه چیست؟ پاسخ: صفر را بهجای بایاسِ اولیه در فرمول میگذاریم؛ در مثال، نتیجه دوونیم و هفت است. حذفِ کلِ جابهجاییِ نرمالسازی درست نیست.
پرسش دوم: گامای منفی شش را رد کنیم؟ پاسخ: خیر؛ در خروجیِ دوم با مخرجِ مثبتِ سه، آلفا منفی دو است. آنچه این قرارداد رد میکند واریانسِ منفی است، نه ضریبِ مقیاسِ منفی. برای گامای صفر، پاسخِ همان کانال فقط بتا خواهد بود.
پرسش سوم: با عوض شدنِ آمارِ ذخیرهشده، وزنِ ادغامشده معتبر میماند؟ پاسخ: عموماً خیر؛ آلفا و بایاسِ تازه به آن آمار وابستهاند و باید دوباره محاسبه و بررسی شوند. آمارِ وابسته به اعضای دسته نیز از ابتدا فرضِ آفینِ ثابتِ این درس را نقض میکند.
پرسش چهارم: برابریِ این خروجی یعنی سامانه سریعتر شده است؟ پاسخ: خیر؛ فقط بازنویسیِ حسابِ محدود بررسی شده است. زمان، حافظه، پوششِ عملگر، گردکردنِ کمدقت و کیفیتِ وظیفه سنجشِ جدا میخواهند. قراردادِ int8 و نقطهٔ صفر نیز مسئلهای جداست؛ برابریِ این دو مسیرِ شناور، برابریِ بیتی پس از کوانتیزهسازی را ثابت نمیکند.
پانزده گروهِ معنادار اجرا شدند؛ حالتهای درونِ مرجع و اجرای دوبارهٔ داور به آن تعداد افزوده نمیشوند. منابع در ۲۶ مهر ۱۴۰۵ خوانده شدند؛ مستنداتِ PyTorch نسخهٔ ۲٫۱۴ و ONNX نسخهٔ ۱٫۲۴، با نسخهٔ عملگرِ ۱۵، از اجرای واقعی جدا هستند. تاریخِ نامعلومِ انتشارِ اولیه حدس زده نشده است. این آموزشِ پایه، اجرای چارچوب، متلب، آموزشِ مدل یا خبر تازه نیست.
منابع و مطالعهٔ بیشتر
- PyTorch 2.14؛ BatchNorm1d و آمارِ آموزش و ارزیابی
- PyTorch 2.14؛ شرطِ حالت ارزیابی برای ادغام Linear و BatchNorm
- PyTorch 2.14؛ پارامترهای وزن و بایاسِ ادغام
- ONNX 1.24؛ فرمول استنتاجِ BatchNormalization نسخهٔ ۱۵
بررسی منابع: ۲۶ مهر ۱۴۰۵. تحلیلها و پیشنهادهای اجرایی، جمعبندی نویسندهاند.

