qinghuan
← 回到知识探索

AI 直觉 / 动手实验

两个模型算得一样,平均参数为什么反而算错?

把两个隐藏单元交换位置,逐项比较参数平均、输出平均和重新对齐后的结果。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先看清每个单元连着的两侧权重
  4. 只换内部编号,要把前后连线一起移动
  5. 两个正确端点,逐位置平均却变成全零
  6. 平均输出是在另一个位置做运算
  7. 按参数找对应关系,不用探针答案挑排列
  8. 把一次平均展开成五个插值点
  9. 运行脚本,核对参数、配对和预测三层证据
  10. 把这个反例放回真实模型合并问题
  11. 记住这些要点
  12. 别把例子用过头
  13. 自己试一小步
  14. 查原始资料
开始前,会这些就够了
  • 知道ReLU把负数变成零、保留正数
  • 能读懂加权求和与平方误差
  • 知道模型参数和模型计算结果是不同对象

两份模型文件形状完全相同,逐条预测也一致。为了只保留一份,你把对应位置的参数相加再除以二;结果却所有输入都输出零。问题不一定来自文件损坏:两个数组的第一项,可能承担着不同的内部工作。形状相同,只说明数字放得下,不说明它们已经一一对应。

本篇用两个隐藏单元构造完整反例。所有参数都是手工指定的,没有训练、下载或合并真实检查点;四个输入只是事先选定的演示探针。我们会保存参数、配对依据、逐点输出和插值曲线,验证“同一函数可以有不同参数排列”,再解释为什么这影响参数平均。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本;制作时实测 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • weight_alignment.py:完整示例
  • README.md:环境、范围与独立练习
  • observed.stdout.txt:制作时的观察记录
  • verification.json:对应源码的验证范围
  • SHA256SUMS.txt:文件内容摘要

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B weight_alignment.py

解压后在含示例文件的文件夹打开 PowerShell,按命令运行。先预测结果,再与当次输出对照。 程序会在当前目录写入教学报告,建议使用独立练习文件夹。 手工构造的双隐藏单元 ReLU 网络;对齐只比较参数,不使用探针标签,不代表模型训练、检查点合并或论文复现。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

两个网络都算出 x,平均参数以后呢?

网络 A 和 B 的两个隐藏单元顺序相反,配套的输出权重也一起交换,所以两者对同一输入都返回 x。先直接按位置平均,再试着把 B 的单元顺序对齐。

参数平均的均方误差:5.000

输出平均的均方误差:0.000

各取一半时,相反位置的参数都变成 0;两个正确网络合成了处处输出 0 的网络。

-3-1130xy

蓝色实线:原网络与输出平均橙色虚线:平均参数后的网络

固定探针 [-3, -1, 1, 3],以原函数 x 为比较依据
输入 x原网络参数平均输出平均
-3-3.0000.000-3.000
-1-1.0000.000-1.000
11.0000.0001.000
33.0000.0003.000

平均后的输入侧权重:[0.000, 0.000];输出侧权重也相同。两个隐藏偏置与输出偏置始终为 0。

本面板计算固定的两个 ReLU 单元,没有训练或加载真实模型。对齐使用事先已知的交换关系,不从探针标签选择排列;误差只针对这四个教学输入。这个反例不能证明任意模型都可通过对齐合并,输出平均也不是参数平均或免费推理。

先看清每个单元连着的两侧权重

网络只接收一个数字x。第一个隐藏单元计算ReLU(x),第二个计算ReLU(−x);最后以前者减去后者。所有偏置为零,输出没有再经过激活函数。因此f(x)=ReLU(x)−ReLU(−x):当x非负时结果是x,当x为负时结果仍是x。这个分情况推导适用于任意实数,不只适用于后面的四个探针。

把它记成模型A:进入两个隐藏单元的权重是[1,−1],离开它们、进入输出的权重也是[1,−1]。前一组决定每个单元读到什么,后一组决定这个单元的结果怎样参与最终求和。两个隐藏偏置与一个输出偏置也属于参数,所以程序完整保存七个数字。

可以把隐藏单元看成两个带编号的工作位:一个处理正侧,一个处理负侧。编号本身没有业务含义。后面要交换的是工作位的顺序;输入x的意义和最终输出的意义都保持一致。

只换内部编号,要把前后连线一起移动

构造模型B时,把两个隐藏单元交换:进入它们的权重变成[−1,1],离开它们的权重也变成[−1,1]。B计算的是−ReLU(−x)+ReLU(x),与A完全一样。中间向量顺序变了,最终每一项仍然乘着原本与它配套的系数。

如果只交换进入隐藏层的权重和隐藏偏置,却不交换输出权重,得到的就不是同一模型。它会计算ReLU(−x)−ReLU(x)=−x。对x=3,正确输出为3,这个错误版本输出−3;程序保留它作为单独对照,避免把任何数组重排都叫作保持功能的置换。

对一般稠密层,重排隐藏输出的那一维,还必须同步重排下一层读取它的对应维;该层偏置也要跟随单元移动。本例把输入和输出都缩成一个数字,省掉矩阵记号,但前后配套这一要求仍然完整存在。

两个正确端点,逐位置平均却变成全零

现在逐位置平均A与B。进入隐藏层的第一项是(1−1)/2=0,第二项也为零;输出侧同样全部变成零,偏置原本就是零。于是这个新网络对每个输入都输出零。A和B表达同一个函数,并不意味着它们之间这条参数直线上的每个点也表达那个函数。

我们预先选定探针x=[−3,−1,1,3],目标也就是这些输入本身。A与B的平均平方误差都为零;逐位置平均参数后的误差是(9+1+1+9)/4=5。只换前层的错误版本输出−x,误差则是20。这些数字描述这个具体探针集合,不是测试集泛化成绩。

错位平均把处理正侧的权重与处理负侧的权重放进了同一个算术格子。程序能正常执行,维度也完全合法,所以只检查能否加载和前向运行并不足够。需要核对内部对应关系,以及合并后实际计算的函数。

平均输出是在另一个位置做运算

若让A与B各算一次,再平均两个结果,得到的是(fA(x)+fB(x))/2。因为两者都等于x,所以输出平均仍然等于x,四个探针误差为零。参数平均则先制造一套新权重,再让新网络前向一次;这两个操作发生的位置不同,不能因都叫“平均”就互换。

本例即使不讨论真实训练,也足以否定“平均模型参数必定等于平均预测”的普遍说法。前向计算中有多层参数组合和非线性,不能把平均号随意移过整个计算过程。这里的输出是普通实数,尚未涉及分类概率、logit平均或投票规则;那些规则也应分别定义。

输出平均通常需要保留多个模型的计算路径;参数平均完成后可以形成一套参数。我们没有测量两者的延迟、显存或吞吐,不能从这个几十次标量运算的脚本推导生产节省比例。先认清结果是否等价,才有基础讨论实现成本。

按参数找对应关系,不用探针答案挑排列

两个隐藏单元只有两种排列。脚本对B分别保持原顺序、交换顺序,并计算变换后七个参数与A的平方距离:每个对应数字相减、平方,再求和。保持原顺序的距离为16,交换后的距离为0,于是选择后者。如果距离相同,程序按排列本身的固定字典顺序决定。

这次选择只读取两组参数。align_by_parameters函数没有接收输入或目标,先完成对齐,再建立演示探针和误差报告。我们没有根据探针上的误差寻找最好排列,也没有把这样筛选后的同一批输入包装成独立测试集。alignment-audit.json会把两种候选、距离和最终选择全部留下。

对齐后的B与A七个参数逐项相同,所以再平均参数,网络仍然是A。这一成功有明确原因:B本来就是由A的一次已知隐藏单元置换构成。距离最小化在这里可以恢复精确对应;不能据此保证不同训练过程得到的复杂网络也存在这种完美配对。

把一次平均展开成五个插值点

不只检查二分之一。令α依次为0、0.25、0.5、0.75、1,构造(1−α)A+αB。它们分别表示完全采用A、逐步移向B,最后完全采用B。未经对齐的每组进入和离开权重,都等于A的相应权重乘以t=1−2α。

两层权重都参与平均,前向计算是 fα(x)=t·ReLU(tx)−t·ReLU(−tx)。利用 ReLU(z)−ReLU(−z)=z,得到 fα(x)=t·(tx)=t²x。这一步对 t 为负也成立。相应的探针误差为5(t²−1)²,所以五点曲线依次是0、2.8125、5、2.8125、0:两端正确,中间明显变差。脚本把逐点前向结果与这个独立代数公式比较。

先对齐再插值,五点误差都为零;按相同比例混合A和B的输出,五点也都为零。CSV并排保存三条曲线,让“途中变差”有可检查的具体数字。它只刻画这一条已指定的参数直线,不证明整个参数空间的几何形状,也没有搜索所有可能的连接路径。

运行脚本,核对参数、配对和预测三层证据

把下面代码保存到空练习目录,使用Python 3运行即可,不需要第三方包。它输出五个文件:constructed-models.json保存六套具体参数,alignment-audit.json保存两种配对的选择依据,comparison-report.json保存汇总与隐藏层轨迹,另外两个CSV分别保存逐点预测和五点曲线。

predictions.csv含二十八行数据:六个具体网络加一个输出平均方案,每种方案检查四个输入。输出平均没有伪装成另一套网络参数。验收时先看B和错误单层交换是否真的不同,再看未对齐平均与对齐后平均,最后核对输出平均;只盯最终打印的PASS会错过反例来自哪里。

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

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

小实验 · python
"""Constructed two-unit ReLU example; no training or checkpoint merging."""
import csv
import json
import math
from dataclasses import asdict, dataclass
from itertools import permutations
from pathlib import Path


def finite(value):
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        raise ValueError("finite numeric value required")
    if not math.isfinite(value):
        raise ValueError("finite numeric value required")
    return value


@dataclass(frozen=True)
class Network:
    incoming: tuple
    hidden_bias: tuple
    outgoing: tuple
    output_bias: float = 0.0

    def __post_init__(self):
        if not (len(self.incoming) == len(self.hidden_bias) == len(self.outgoing) == 2):
            raise ValueError("this experiment requires exactly two hidden units")
        if not all(isinstance(v, tuple) for v in (self.incoming, self.hidden_bias, self.outgoing)):
            raise ValueError("immutable tuple parameters required")
        for value in self.flat():
            finite(value)

    def flat(self):
        return self.incoming + self.hidden_bias + self.outgoing + (self.output_bias,)

    def trace(self, x):
        finite(x)
        pre = [finite(w * x + b) for w, b in zip(self.incoming, self.hidden_bias)]
        hidden = [max(0.0, z) for z in pre]
        output = finite(sum(w * z for w, z in zip(self.outgoing, hidden)) + self.output_bias)
        return {"x": x, "preactivation": pre, "hidden": hidden, "prediction": output}

    def predict(self, x):
        return self.trace(x)["prediction"]


def permute(network, order, incoming_only=False):
    if any(type(i) is not int for i in order) or sorted(order) != [0, 1]:
        raise ValueError("order must be a permutation of (0, 1)")
    return Network(tuple(network.incoming[i] for i in order),
                   tuple(network.hidden_bias[i] for i in order),
                   network.outgoing if incoming_only else tuple(network.outgoing[i] for i in order),
                   network.output_bias)


def interpolate(a, b, alpha):
    finite(alpha)
    if not 0 <= alpha <= 1:
        raise ValueError("alpha must be between zero and one")
    values = tuple((1 - alpha) * x + alpha * y for x, y in zip(a.flat(), b.flat()))
    return Network(values[0:2], values[2:4], values[4:6], values[6])


def distance(a, b):
    return sum((x - y) ** 2 for x, y in zip(a.flat(), b.flat()))


def align_by_parameters(reference, candidate):
    # Deliberately takes no probe inputs or target labels.
    trials = [{"order": list(order), "parameter_distance_squared": distance(reference, permute(candidate, order))}
              for order in permutations((0, 1))]
    chosen = min(trials, key=lambda row: (row["parameter_distance_squared"], row["order"]))
    return permute(candidate, chosen["order"]), {"criterion": "squared distance over all seven parameters",
                                                  "selection_uses_inputs_or_targets": False,
                                                  "trials": trials, "chosen_order": chosen["order"]}


def mse(predictions, targets):
    if not predictions or len(predictions) != len(targets):
        raise ValueError("nonempty equal lengths required")
    return sum((finite(p) - finite(y)) ** 2 for p, y in zip(predictions, targets)) / len(targets)


def near(a, b):
    assert math.isclose(a, b, rel_tol=1e-12, abs_tol=1e-12), (a, b)


def rejected(function):
    try:
        function()
    except ValueError:
        return
    raise AssertionError("invalid input was accepted")


def write_json(name, value):
    Path(name).write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")


def write_csv(name, rows):
    with Path(name).open("w", encoding="utf-8", newline="") as handle:
        writer = csv.DictWriter(handle, fieldnames=list(rows[0]))
        writer.writeheader()
        writer.writerows(rows)


def main():
    a = Network((1.0, -1.0), (0.0, 0.0), (1.0, -1.0))
    b = permute(a, (1, 0))
    original_parameters = (a.flat(), b.flat())
    aligned_b, audit = align_by_parameters(a, b)
    assert audit["chosen_order"] == [1, 0]
    assert [row["parameter_distance_squared"] for row in audit["trials"]] == [16.0, 0.0]
    assert aligned_b == a
    # Declared probes illustrate behavior; they are not a held-out benchmark.
    probes = [-3.0, -1.0, 1.0, 3.0]
    targets = list(probes)
    networks = {"A": a, "B_swapped_both_sides": b,
                "wrong_incoming_only": permute(a, (1, 0), incoming_only=True),
                "naive_parameter_mean": interpolate(a, b, 0.5),
                "B_realigned": aligned_b, "aligned_parameter_mean": interpolate(a, aligned_b, 0.5)}
    predictors = {name: network.predict for name, network in networks.items()}
    predictors["output_mean"] = lambda x: (a.predict(x) + b.predict(x)) / 2
    prediction_rows, scores = [], {}
    for name, predict in predictors.items():
        values = [predict(x) for x in probes]
        scores[name] = mse(values, targets)
        prediction_rows += [{"variant": name, "x": x, "target": y, "prediction": p,
                             "squared_error": (p - y) ** 2} for x, y, p in zip(probes, targets, values)]
    expected = {"A": 0, "B_swapped_both_sides": 0, "wrong_incoming_only": 20,
                "naive_parameter_mean": 5, "B_realigned": 0, "aligned_parameter_mean": 0, "output_mean": 0}
    for name, value in expected.items():
        near(scores[name], value)
    curve_rows = []
    for alpha in (0.0, 0.25, 0.5, 0.75, 1.0):
        unaligned = [interpolate(a, b, alpha).predict(x) for x in probes]
        aligned = [interpolate(a, aligned_b, alpha).predict(x) for x in probes]
        output_mix = [(1 - alpha) * a.predict(x) + alpha * b.predict(x) for x in probes]
        for x, value in zip(probes, unaligned):
            near(value, (1 - 2 * alpha) ** 2 * x)
        unaligned_mse = mse(unaligned, targets)
        near(unaligned_mse, 5 * ((1 - 2 * alpha) ** 2 - 1) ** 2)
        near(mse(aligned, targets), 0)
        near(mse(output_mix, targets), 0)
        curve_rows.append({"alpha": alpha, "unaligned_parameter_mse": unaligned_mse,
                           "aligned_parameter_mse": mse(aligned, targets), "output_mix_mse": mse(output_mix, targets)})
    rejected(lambda: Network((1.0,), (0.0,), (1.0,)))
    rejected(lambda: Network((float("nan"), 1.0), (0.0, 0.0), (1.0, 1.0)))
    rejected(lambda: a.predict(float("inf")))
    rejected(lambda: permute(a, (0, 0)))
    rejected(lambda: permute(a, (False, True)))
    rejected(lambda: interpolate(a, b, 1.5))
    rejected(lambda: mse([1], []))
    assert original_parameters == (a.flat(), b.flat())
    write_json("constructed-models.json", {name: asdict(net) for name, net in networks.items()})
    write_json("alignment-audit.json", audit)
    write_json("comparison-report.json", {"scope": "constructed finite example; no training or paper reproduction",
               "probe_role": "predeclared illustration, not an independent generalization estimate",
               "probes": probes, "targets": targets, "mse": scores, "curve": curve_rows,
               "traces": {name: [net.trace(x) for x in probes] for name, net in networks.items()}})
    write_csv("predictions.csv", prediction_rows)
    write_csv("interpolation-curve.csv", curve_rows)
    print("PASS: parameter-only alignment; 28 prediction rows; 5 curve rows; MSE naive=5, aligned=0, outputs=0, wrong=20")


if __name__ == "__main__":
    main()

怎样运行:保存为 weight_alignment.py,在空练习目录运行 python weight_alignment.py。只用Python 3标准库;在当前目录生成三个JSON和两个CSV,不训练或下载模型。 2026-10-13 已用 3.14.0 执行这份示例并通过自带断言;下方为当次输出。手工构造的双隐藏单元 ReLU 网络;对齐只比较参数,不使用探针标签,不代表模型训练、检查点合并或论文复现。

运行后对照结果
PASS: parameter-only alignment; 28 prediction rows; 5 curve rows; MSE naive=5, aligned=0, outputs=0, wrong=20

把这个反例放回真实模型合并问题

Git Re-Basin论文研究隐藏单元的置换及对齐,并讨论不能普遍保证线性连接的反例。本文借用的是“同步移动相邻层对应维可以保持函数”的数学关系,使用自己的两单元算例;没有实现论文的多层匹配算法,也没有复现它的训练实验。

Model Soups讨论从共同预训练初始化出发、再用不同设置微调的模型平均。这有具体的训练背景与评估证据,不能把论文标题读成“任何两份同形状权重都可以直接平均”。反过来,本篇构造的失败例也不能否定满足相应条件时参数平均可能有效。

排列只是内部表示差异的一种。同一个ReLU函数还可能通过正比例缩放前层、按倒数比例缩放后层对应权重保持不变;真实网络又可能含归一化状态等非参数内容。PyTorch的AveragedModel文档专门说明参数平均及缓冲区、归一化统计的处理。这些问题超出本脚本,需要按结构和训练过程另行验证。

本例证明的是一个具体不等价关系,并给出可复查的修复条件。对实际模型,应先说明结构、输入输出语义与来源是否兼容,再记录合并方法,并在未用于挑选方案的数据上评价。一次对齐成功不等于所有模型可合并,四个探针的零误差也不等于真实任务表现不变。

记住这些要点

  • 两个模型可以计算同一个函数,却用不同顺序保存隐藏单元。
  • 保持函数的重排要同步移动隐藏层及下一层的对应连线。
  • 平均参数与平均输出是不同计算,结果无需相同。
  • 本例按参数距离选择对齐,探针仅用于展示差异。
  • 对齐修复了已构造的置换错位,不提供任意模型合并保证。

别把例子用过头

  • 只检查参数名称与形状相同,就认为内部单元已经一一对应。
  • 只交换前层权重,却忘记移动隐藏偏置和下一层对应维。
  • 用探针误差选择排列,再把同一批探针成绩说成独立评估。
  • 把构造网络的验证称为实际训练检查点合并或论文复现。
  • 将两端性能相同推断为所有中间插值都同样有效。

自己试一小步

如果相同函数还使用了不同的内部缩放,只枚举排列够不够?

  1. 复制本篇实验到独立目录,保留原结果作为对照。
  2. 自行选择一个正缩放系数,构造一个功能保持不变、但参数数值不同的网络;先写出保持函数的理由。
  3. 仍仅根据参数选择排列,再比较直接平均、排列后平均和输出平均,保存逐点预测及插值CSV。
  4. 提交一段结论,分别说明代码验证了什么、代数证明了什么,以及哪些实际模型问题仍未覆盖。

做到这些,就算完成

  • 提交独立脚本、完整参数文件、配对选择记录、逐点预测与插值CSV。
  • 配对选择过程不读取探针标签,并明确说明探针的演示角色。
  • 结论区分实际运行、代数推导与未验证的真实模型问题。

查原始资料

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