qinghuan
← 回到知识探索

AI 直觉 / 动手实验

同样预测全对,为什么重要特征排名相反?

训练两个只差平局选择顺序的小模型,穷举同一留出集的720种打乱方式,看清特征重要性究竟在描述什么。

展开本文目录
  1. 下载完整练习包
  2. “重要”前面,先补上四个约定
  3. 先训练:两列一样,平局时选哪列
  4. 再打乱:同一张表,得到相反的单列排名
  5. 数字没超范围,整条记录却可能不成立
  6. 把两列一起搬,回答的是另一种问题
  7. 删掉一列后重训,不能混进这份排名
  8. 运行后,把五个文件连起来核对
  9. 最后把结论写成一句能追溯的记录
  10. 记住这些要点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
  • 知道训练集与留出集承担不同任务
  • 能用错误条数除以总条数计算错误率
  • 会运行 Python 列表、函数与断言

假设一张表里有两列完全相同的测量值。两个模型在所有原始样本上给出同样的答案,检查重要性时,一个把第一列排在前面,另一个却把第二列排在前面。谁错了?先别急着评判:这份排名可能是在描述各自已学到的计算规则,而不是给数据里的信息排一个唯一顺序。

本篇会真正拟合两个很小的分类规则,再固定它们做列打乱实验。数据全部自拟,只有六条留出记录,所以能够检查全部720种行排列。交付物包括训练数据、留出数据、拟合规则、汇总报告和逐次结果表;它是一份可复算的反例,不是真实业务性能评测。

把例子带到自己的电脑上

下载完整练习包

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

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

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

python --version
python -B permutation_reliance.py

解压后在含示例文件的文件夹打开 PowerShell,按命令运行。先预测结果,再与当次输出对照。 程序会在当前目录写入教学报告,建议使用独立练习文件夹。 在固定教学训练集拟合树桩、对六条留出数据枚举置换;不代表因果关系、真实人群效果或总体置信区间。

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

“重要”前面,先补上四个约定

本篇的问题是:对这一份已经拟合好的模型,把这一批留出记录的某一列重新排列后,错误率平均增加多少?这个做法叫置换重要性。这里的“置换”只是把已有值换个行位置,不会凭空生成新的单列取值,也不会在每次打乱后重新训练模型。

我们用错误率增加量作为结果:打乱后的错误率减去原错误率。原来六条全部答对,打乱后平均错三条,增加量就是0.5,也就是五十个百分点。这个数不表示该特征贡献了百分之五十的现实原因,更不表示删除它会让所有未来模型都损失同样的性能。

所以报告必须写清四件事:是哪份固定模型、在哪批记录上、用什么指标、怎样打乱。更换其中任何一项,都可能更换你正在回答的问题。若原模型本来就没学好,还应先说明这个前提,不能把接近零的结果直接解释成字段没有信息。

先训练:两列一样,平局时选哪列

每条记录有 signal、copy 和 background 三列。前两列数值完全相同;最后一列只是按行交替出现零和一。标签约定为:signal 至少为零时是一,否则是零。这个刻意简单的设定让两份相同信息同时摆在学习程序面前。

训练集用八个数值:−8、−6、−4、−2、2、4、6、8。程序枚举每列相邻取值中间的阈值,再枚举阈值两边怎样分到零或一,选训练错误最少的候选。这就是只有一次判断的小决策树;本程序只实现这些候选,不冒充完整的决策树库。

训练误差相同怎么办?模型A按 signal、copy、background 的顺序优先选择列,模型B交换前两列的优先顺序。这个规则在看留出结果前就固定。拟合后,A检查 signal 是否至少为零,B检查 copy 是否至少为零;两者在训练集都没有错误。

留出集另用 −7、−3、−1、1、3、7,记录编号和数值都不与训练集重复。它只用于本次预先约定的报告。两份模型在原始留出集都答对六条;这只是合成数据上的核对结果,不能外推为任何真实分类任务的满分表现。

再打乱:同一张表,得到相反的单列排名

对六条留出记录,一共有6!也就是720种行排列。每次只把选中列按该排列搬到新位置,记录编号、正确答案和其他列保持不动。即使两个位置上的标签一样,它们仍是不同记录;程序枚举的是全部行编号排列,也包含完全没换位置的那一种。

模型A只读取 signal,所以打乱 signal 的平均错误率增加0.5,打乱 copy 或 background 都增加零。模型B的结果正好交换:打乱 copy 增加0.5,打乱 signal 增加零。这不是随机种子碰巧造成的排名波动,因为这一次把所有排列都算完了。

对于模型实际读取的列,720次中有36次错零条、324次错两条、324次错四条、36次错六条。把这些错误条数加总,除以720,再除以每批六条,就得到平均错误率0.5。你可以从 CSV 独立重算这个数,不必只相信汇总字段。

穷举消除的是“本次只抽了少数打乱方式”的随机差异。它没有增加原始样本数,也没有消除只有六条记录带来的代表性问题。报告没有计算未知总体的置信区间,更没有把720次排列当成720批独立新数据。

数字没超范围,整条记录却可能不成立

只打乱一列还有一个容易藏在排名图后面的问题。原始数据约定 signal 与 copy 永远相等。把第一条和第四条的 signal 对调后,第一条会从 signal=−7、copy=−7 变成 signal=1、copy=−7,而它原来的标签零仍然留在这一行。

每一列单独看,都还是原先出现过的数值;两列放在同一条记录里,却违反了数据生成时的关系。程序把这种行数记为 broken_copy_rows。六个取值互不相同,单列全排列平均会产生五条不相等的记录,联合搬动两列则不会破坏这项相等关系。

本例故意采用完全复制,使“原本不可能出现的组合”一眼可见。现实里的相关关系通常没有这么绝对,不能照搬相等断言;但仍应检查打乱是否让模型接触到缺少真实数据支持的组合。此时测到的下降会混入模型在这些组合上的行为,解释要保留这个条件。

把两列一起搬,回答的是另一种问题

程序另外把 signal 和 copy 作为一组,使用同一份行排列一起搬。第一条会变成 signal=1、copy=1,标签仍留为零。这保留了组内的复制关系,同时切断这组输入与原行答案的对应关系;标签不跟着走,正是我们测量依赖程度的安排。

两个模型对这组输入的平均错误率增加量都是0.5。这个结果说明:在本次固定模型和数据上,它们虽然各用不同列,却依赖这组共享的信息。不能把两个单列的重要性机械相加,当作普遍适用的联合结果;组作为整体被改动时,问题本身已发生变化。

联合打乱也不是通用修复。它只保证我们一起搬走的这些列仍保留彼此的行内关系,可能破坏它们与组外字段的关系。本实验甚至没有任务要求它保留所有其他结构。组怎么划、扰动样本是否合理、指标是否适合,仍然需要根据实际问题解释。

删掉一列后重训,不能混进这份排名

如果删除 signal,再让学习程序训练一次,它可能改用 copy。这问的是“这个学习程序失去一列后,还能否从剩余信息学到好规则”。本篇单列打乱问的则是“这一份已经固定的规则,在指定扰动下会怎样”。前者允许换规则,后者不允许,结论不能互相替代。

因此,模型A给 copy 的置换重要性为零,并不证明 copy 对重新训练没有帮助;同样,signal 的数值较高也不证明现实中主动改变 signal 会导致标签改变。这里的标签来源是事先写好的合成规则,不是一次能识别现实因果效应的干预研究。

Fisher、Rudin 与 Dominici 的研究进一步考察一整个预先规定的模型类别中,表现好的模型可能依赖哪些变量。本篇只拟合两份特定规则,用它们构成一个反例;没有搜索整个模型类别,也没有实现论文的范围估计或统计界限。

运行后,把五个文件连起来核对

将下面完整脚本保存到自己的空练习目录,使用 Python 3 运行。它只使用标准库,不安装模型库,不下载数据。训练只读取 training,随后拟合规则保持冻结;每次扰动复制留出记录,断言原始数据、记录编号、标签和拟合规则都没有被悄悄修改。

五个产物按用途分开:training-data.json 和 holdout-data.json 留下输入证据,fitted-models.json 留下选中的列、阈值和平局规则,importance-report.json 提供汇总与可见反例,permutation-results.csv 保存每一次排列。CSV共5760行数据,即两个模型、四种列组各做720次;首行表头另外计数。

运行成功后先打开拟合规则,确认A与B到底读取哪一列;再核对原始错误率;最后抽取 CSV 重算一个平均值,并查看 visible_example 里的整行变化。这条检查顺序能把“程序跑完了”和“结果回答了正确问题”分开。

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

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

小实验 · python
"""Fit two toy stumps, then enumerate permutation reliance on one fixed holdout.

Only Python's standard library is used. Data is synthetic. This is not causal
inference, a real dataset benchmark, a full decision-tree package, or a p-value.
"""
import csv
import json
import math
from collections import Counter
from copy import deepcopy
from itertools import permutations
from pathlib import Path

FEATURES = ("signal", "copy", "background")


def make_rows(values, prefix):
    return [{"id": f"{prefix}-{i}", "signal": x, "copy": x,
             "background": i % 2, "y": int(x >= 0)} for i, x in enumerate(values)]


def validate_rows(rows):
    if not rows or len({row["id"] for row in rows}) != len(rows):
        raise ValueError("nonempty rows with unique IDs required")
    for row in rows:
        if type(row["y"]) is not int or row["y"] not in (0, 1):
            raise ValueError("integer binary labels required")
        for feature in FEATURES:
            x = row[feature]
            if isinstance(x, bool) or not isinstance(x, (int, float)) or not math.isfinite(x):
                raise ValueError("finite numeric features required")


def predict(model, row):
    return model["high_label"] if row[model["feature"]] >= model["threshold"] else 1 - model["high_label"]


def errors(model, rows):
    return sum(predict(model, row) != row["y"] for row in rows)


def fit_stump(training_rows, feature_order):
    validate_rows(training_rows)
    if len(feature_order) != len(FEATURES) or set(feature_order) != set(FEATURES):
        raise ValueError("feature order must contain each declared feature once")
    candidates = []
    for rank, feature in enumerate(feature_order):
        values = sorted({row[feature] for row in training_rows})
        for left, right in zip(values, values[1:]):
            threshold = left / 2 + right / 2
            for high_label in (0, 1):
                model = {"feature": feature, "threshold": threshold, "high_label": high_label}
                count = errors(model, training_rows)
                candidates.append(((count, rank, threshold, high_label), model))
    if not candidates:
        raise ValueError("this toy learner requires at least one varying feature")
    key, model = min(candidates, key=lambda pair: pair[0])
    return {**model, "training_errors": key[0], "feature_tie_order": list(feature_order),
            "candidate_count": len(candidates)}


def permute_features(rows, columns, order):
    if len(order) != len(rows) or sorted(order) != list(range(len(rows))):
        raise ValueError("order must be a permutation of every row index")
    if not columns or len(set(columns)) != len(columns) or not set(columns) <= set(FEATURES):
        raise ValueError("nonempty, unique declared feature group required")
    changed = deepcopy(rows)
    for target, source in enumerate(order):
        for column in columns:
            changed[target][column] = rows[source][column]
    return changed


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


training = make_rows([-8, -6, -4, -2, 2, 4, 6, 8], "train")
holdout = make_rows([-7, -3, -1, 1, 3, 7], "holdout")
validate_rows(holdout)
assert not {row["id"] for row in training} & {row["id"] for row in holdout}
assert not {row["signal"] for row in training} & {row["signal"] for row in holdout}
frozen_training, frozen_holdout = deepcopy(training), deepcopy(holdout)
models = {"A": fit_stump(training, ["signal", "copy", "background"]),
          "B": fit_stump(training, ["copy", "signal", "background"])}
assert models["A"]["feature"] == "signal" and models["B"]["feature"] == "copy"
assert all(model["threshold"] == 0 and model["training_errors"] == 0 for model in models.values())
assert [predict(models["A"], row) for row in holdout] == [predict(models["B"], row) for row in holdout]
assert all(errors(model, holdout) == 0 for model in models.values())
model_snapshot = deepcopy(models)

groups = {"signal": ("signal",), "copy": ("copy",), "background": ("background",),
          "signal+copy": ("signal", "copy")}
orders = list(permutations(range(len(holdout))))
assert len(orders) == 720
summary = {}
csv_rows = []
for name, model in models.items():
    baseline = errors(model, holdout)
    summary[name] = {}
    for group_name, columns in groups.items():
        counts, broken_counts = [], []
        for permutation_id, order in enumerate(orders):
            changed = permute_features(holdout, columns, order)
            count = errors(model, changed)
            broken = sum(row["signal"] != row["copy"] for row in changed)
            assert [row["id"] for row in changed] == [row["id"] for row in holdout]
            assert [row["y"] for row in changed] == [row["y"] for row in holdout]
            counts.append(count)
            broken_counts.append(broken)
            csv_rows.append([name, group_name, permutation_id, ":".join(map(str, order)),
                             baseline, count, (count - baseline) / len(holdout), broken])
        summary[name][group_name] = {
            "permutations": len(orders), "baseline_error_rate": baseline / len(holdout),
            "mean_error_increase": (sum(counts) / len(orders) - baseline) / len(holdout),
            "error_count_histogram": dict(sorted(Counter(counts).items())),
            "mean_broken_copy_rows": sum(broken_counts) / len(orders)}

for name, used, unused in (("A", "signal", "copy"), ("B", "copy", "signal")):
    assert summary[name][used]["mean_error_increase"] == 0.5
    assert summary[name][unused]["mean_error_increase"] == 0
    assert summary[name]["background"]["mean_error_increase"] == 0
    assert summary[name]["signal+copy"]["mean_error_increase"] == 0.5
    assert summary[name]["signal+copy"]["mean_broken_copy_rows"] == 0
    assert summary[name][used]["error_count_histogram"] == {0: 36, 2: 324, 4: 324, 6: 36}
    assert summary[name][used]["mean_broken_copy_rows"] == 5

visible_order = [3, 1, 2, 0, 4, 5]  # Swap a negative row and a positive row.
single_example = permute_features(holdout, ("signal",), visible_order)
group_example = permute_features(holdout, ("signal", "copy"), visible_order)
assert single_example[0]["signal"] == 1 and single_example[0]["copy"] == -7
assert all(row["signal"] == row["copy"] for row in group_example)
assert training == frozen_training and holdout == frozen_holdout and models == model_snapshot
assert len(csv_rows) == 5760  # 2 fitted models x 4 groups x 720 permutations.
rejected(lambda: fit_stump([], FEATURES))
rejected(lambda: fit_stump(training, ["signal", "signal", "background"]))
rejected(lambda: fit_stump([{**training[0], "y": False}], FEATURES))
rejected(lambda: permute_features(holdout, ("y",), range(len(holdout))))
rejected(lambda: permute_features(holdout, ("signal",), [0] * len(holdout)))

report = {"scope": "synthetic fitted stumps; finite-holdout permutation enumeration, not causal inference",
          "protocol": {"training_rows": 8, "holdout_rows": 6, "permutations_per_group": 720,
                       "metric": "permuted error rate minus baseline error rate",
                       "retraining_during_permutations": False, "random_seed_selection": "none"},
          "summary": summary,
          "visible_example": {"order": visible_order, "original": holdout,
                              "single_signal_permutation": single_example,
                              "joint_signal_copy_permutation": group_example},
          "limits": ["No p-values or population confidence intervals are calculated.",
                     "Group permutation preserves only the declared within-group relationship.",
                     "Deleting a column and retraining asks a different question."]}
for filename, value in (("training-data.json", training), ("holdout-data.json", holdout),
                        ("fitted-models.json", models), ("importance-report.json", report)):
    Path(filename).write_text(json.dumps(value, indent=2), encoding="utf-8")
with Path("permutation-results.csv").open("w", encoding="utf-8", newline="") as stream:
    writer = csv.writer(stream)
    writer.writerow(["model", "feature_group", "permutation_id", "source_row_order",
                     "baseline_errors", "permuted_errors", "error_rate_increase", "broken_copy_rows"])
    writer.writerows(csv_rows)
for name in models:
    print(name, "uses", models[name]["feature"], "baseline_error_rate=0.0")
    print({group: stats["mean_error_increase"] for group, stats in summary[name].items()})
print("PASS: 720 permutations/group; 5760 CSV rows; five artifacts saved; no causal claim")

怎样运行:保存为 permutation_reliance.py,在空练习目录运行 python permutation_reliance.py。仅需 Python 3 标准库;运行会在当前目录写出四个 JSON 和一个 CSV。 2026-10-13 已用 3.14.0 执行这份示例并通过自带断言;下方为当次输出。在固定教学训练集拟合树桩、对六条留出数据枚举置换;不代表因果关系、真实人群效果或总体置信区间。

运行后对照结果
A uses signal baseline_error_rate=0.0
{'signal': 0.5, 'copy': 0.0, 'background': 0.0, 'signal+copy': 0.5}
B uses copy baseline_error_rate=0.0
{'signal': 0.0, 'copy': 0.5, 'background': 0.0, 'signal+copy': 0.5}
PASS: 720 permutations/group; 5760 CSV rows; five artifacts saved; no causal claim

最后把结论写成一句能追溯的记录

可以写:“在六条指定合成留出记录上,固定模型A后,单独重排 signal 的全部720种排列,使平均错误率增加五十个百分点;同样处理 copy 时没有变化。模型B的单列结果相反,联合重排两列时两者增加量相同。”每个限定词都能在产物中找到对应证据。

不要写“第一列是唯一重要因素”或“第二列没有价值”。还应补上:单列重排会破坏原有复制关系;全排列只覆盖这批有限数据;两个已拟合模型不代表所有可训练模型;本次没有做删除后重训实验、因果分析或真实数据验证。

换成真实项目后,先按任务固定评估数据、指标和比较协议,再查看扰动后的实际样例。若比较多个训练得到的模型,应明确保留了哪些模型、如何选择;若调整分组或方法,也要把原因记下。一个可解释的结果,需要读者知道它测了什么,以及还没有测什么。

记住这些要点

  • 置换重要性描述固定模型在指定数据、指标和扰动下的表现变化。
  • 原始预测相同的模型,可能依赖不同的列。
  • 相关列单独打乱可能产生不符合原数据关系的记录。
  • 分组打乱、删除后重训和因果干预各自回答不同的问题。

别把例子用过头

  • 把0.5的错误率增加量称为百分之五十的因果贡献。
  • 只看单列数值范围,没有检查整行组合是否成立。
  • 把720次打乱当成720批独立真实样本。
  • 打乱后重新拟合,却仍把结果报告为原固定模型的置换重要性。
  • 认为联合搬动几列就保留了整张表的所有相关关系。

自己试一小步

如果 copy 不再完全等于 signal,这份排名和联合结果会怎样变化?

  1. 先写一份固定协议:怎样生成带有少量偏差的 copy,训练和留出各用哪些记录,仍用什么指标。
  2. 保留原实验作为对照,再拟合A、B并冻结规则;不要根据看到的留出排名反复挑选数据。
  3. 分别保存单列与联合打乱结果,并抽取实际发生的整行变化。
  4. 另外设计一次删除一列后重新训练的对照,把它放在独立报告里。

做到这些,就算完成

  • 所有训练输入与留出输入分别保存,模型选择规则在报告前写明。
  • 能从原始逐次记录独立重算汇总值,没有把排列次数当作新增样本数。
  • 检查标准适合新的数据关系,没有继续把不相等一律当作不可能。
  • 解释固定模型的依赖与重新训练的能力有什么区别,并写出本实验仍不能支持的一项结论。

查原始资料

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