60 分钟真实数据快照已执行的网页阅读版

执行置换检验并校正完整检验族

检验预先声明的 Iris 组间比较,使用 Monte Carlo +1 修正,并核对联合 BH 校正。

数学与统计 · 第 06 课🔋 离线核心分析⏱ 60 分钟内嵌真实数据📦 numpy · pandas · scipy · matplotlib · IPython

目标与运行方法

本课适合具备 Python 基础的本科生。先阅读每一步的方法与图注,再修改参数。AI 练习为可选部分,不需要 AI 账号即可完成核心课程。数据写在 notebook metadata 中,不需要另下载数据文件。请保持 notebook 已保存到当前工作文件夹。

学习任务是理解本课的研究问题、执行透明的计算、校验结果,并说明结论的边界。

环境与参数

In [1] · Python
from pathlib import Path
import base64, gzip, hashlib, json, platform
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from IPython.display import display, HTML
from scipy.stats import false_discovery_control
from IPython.display import display as _display
def display(value):
    if isinstance(value,pd.DataFrame):
        table=value.to_html(max_rows=12,escape=True)
        style="<style>.vibeit-readable-table{max-width:100%;overflow-x:auto}.vibeit-readable-table table{width:max-content;max-width:none;border-collapse:collapse}.vibeit-readable-table td,.vibeit-readable-table th{white-space:nowrap!important;word-break:normal!important;overflow-wrap:normal!important}</style>"
        return _display(HTML(style+'<div class="vibeit-readable-table">'+table+'</div>'))
    return _display(value)
RECIPE_ID='ma06-permutation-tests'
OUTPUT_DIR=Path.cwd()/"results"/RECIPE_ID
plt.rcParams.update({"figure.dpi":120,"font.size":10,"axes.spines.top":False,"axes.spines.right":False,
                      "axes.prop_cycle":plt.cycler(color=["#18785f","#416aa6","#bc6541","#9974a5"])})
print("Python:",platform.python_version(),"; NumPy:",np.__version__,"; Pandas:",pd.__version__)
print("Working folder:",Path.cwd())
print("Core data mode: embedded snapshot; no network requests")
Python: 3.13.13 ; NumPy: 2.5.3 ; Pandas: 3.0.6
Working folder: /Users/zhiluo/Developer/vibeit-cookbook/cookbook/downloads
Core data mode: embedded snapshot; no network requests

读取内嵌快照

In [2] · Python
def load_snapshot(recipe_id, expected_sha256, snapshot_key):
    """Read embedded data from this notebook, including a renamed copy."""
    for path in sorted(Path.cwd().glob("*.ipynb")):
        try:
            notebook = json.loads(path.read_text(encoding="utf-8"))
            metadata = notebook.get("metadata", {}).get("vibeit_cookbook", {})
            blob = metadata.get("snapshots", {}).get(snapshot_key)
            if metadata.get("recipe_id") != recipe_id or not blob:
                continue
            if blob.get("sha256") != expected_sha256:
                continue
            raw = gzip.decompress(base64.b64decode(blob["gzip_base64"], validate=True))
            if hashlib.sha256(raw).hexdigest() != expected_sha256:
                raise ValueError("Embedded snapshot checksum mismatch: " + snapshot_key)
            return json.loads(raw)
        except (OSError, json.JSONDecodeError):
            continue
    raise FileNotFoundError(
        "Save/open the complete .ipynb in the active working folder. "
        "The embedded snapshot could not be located for " + recipe_id)

SNAPSHOT_HASHES={'iris': 'cb2512f34911979eef3a42b7a9e6fbc6ca566fd2c0eaa7bd86daefd7ac1cf5ad'}
def snapshot(key):
    return load_snapshot(RECIPE_ID,SNAPSHOT_HASHES[key],key)
print("Embedded snapshots:",list(SNAPSHOT_HASHES))
Embedded snapshots: ['iris']

方法函数

In [3] · Python
def audit_numeric_table(frame,columns):
    """Audit observed rows; never silently impute or remove them."""
    if frame.empty or frame.index.has_duplicates:
        raise ValueError('Expected a nonempty table with unique row IDs')
    missing=[name for name in columns if name not in frame.columns]
    if missing:raise ValueError('Missing required columns: '+str(missing))
    values=frame[columns].to_numpy(dtype=float)
    if not np.isfinite(values).all():raise ValueError('Non-finite measurements; inspect missing values explicitly')
    return {'rows':len(frame),'features':len(columns),'finite':True,'duplicate_measurement_rows':int(frame[columns].duplicated().sum())}

print("Method ready: audit_numeric_table")
Method ready: audit_numeric_table
In [4] · Python
def permutation_mean_test(a,b,repetitions=1999,seed=42):
    """Two-sided independent-group mean permutation test, with the +1 correction."""
    a=np.asarray(a,dtype=float);b=np.asarray(b,dtype=float)
    if a.ndim!=1 or b.ndim!=1 or min(len(a),len(b))<2 or not np.isfinite(np.r_[a,b]).all() or repetitions<1:
        raise ValueError('Need two finite groups and positive permutation count')
    observed=float(a.mean()-b.mean());pool=np.r_[a,b];rng=np.random.default_rng(seed)
    null=np.empty(repetitions)
    for j in range(repetitions):
        shuffled=rng.permutation(pool);null[j]=shuffled[:len(a)].mean()-shuffled[len(a):].mean()
    exceedances=int(np.sum(np.abs(null)>=abs(observed)))
    return dict(difference=observed,p_value=(exceedances+1)/(repetitions+1),null_differences=null,exceedances=exceedances)

print("Method ready: permutation_mean_test")
Method ready: permutation_mean_test
In [5] · Python
def bh_adjust(pvalues):
    p = np.asarray(pvalues,dtype=float)
    if not len(p):
        return p.copy()
    if not np.isfinite(p).all() or (p<0).any() or (p>1).any():
        raise ValueError("p-values must be finite and in [0,1]")
    order = np.argsort(p,kind="stable")
    adjusted = np.minimum.accumulate((p[order]*len(p)/np.arange(1,len(p)+1))[::-1])[::-1]
    result = np.empty_like(p); result[order] = np.minimum(adjusted,1)
    return result

print("Method ready: bh_adjust")
Method ready: bh_adjust

数据与模型边界

解析函数与 Monte Carlo 点属于数学教学模型,不是实验测量。使用 Iris 观测时,可交换性和采样设计仍是统计假设;方便样本不自动代表总体。数值离散误差与采样不确定性应分别解释。

分步分析

1. 预先声明完整比较族

对三组的所有成对组合及四个测量特征进行比较,共 12 个检验。每次比较的零假设要求合并记录的组标签可交换。分类方便样本不提供随机处理分配,也不保证总体代表性。

In [6] · Python
iris=snapshot("iris")
frame=pd.DataFrame(iris["rows"],columns=iris["columns"])
features=list(frame.columns[:4])
audit=audit_numeric_table(frame,features)
assert len(frame)==150 and frame["species"].nunique()==3
assert (frame[features]>0).all().all()
print("Audit:",audit,"; measurement unit: cm")
print("Source variant:",iris["variant"])
display(frame.head(8))
groups=sorted(frame.species.unique());pairs=[(groups[i],groups[j]) for i in range(3) for j in range(i+1,3)]
records=[];first=None
for feature in features:
 for a,b in pairs:
  result=permutation_mean_test(frame.loc[frame.species.eq(a),feature],frame.loc[frame.species.eq(b),feature],repetitions=1999,seed=42)
  records.append({"feature":feature,"group_a":a,"group_b":b,"mean_difference_cm":result["difference"],"p_value":result["p_value"],"exceedances":result["exceedances"]})
  if first is None:first=result
results=pd.DataFrame(records);assert len(results)==12
display(results.head(8))
Audit: {'rows': 150, 'features': 4, 'finite': True, 'duplicate_measurement_rows': 3} ; measurement unit: cm
Source variant: Original UCI iris.data; no silent correction of the two documented transcription discrepancies
sepal_length_cm sepal_width_cm petal_length_cm petal_width_cm species
0 5.1 3.5 1.4 0.2 Iris-setosa
1 4.9 3.0 1.4 0.2 Iris-setosa
2 4.7 3.2 1.3 0.2 Iris-setosa
3 4.6 3.1 1.5 0.2 Iris-setosa
4 5.0 3.6 1.4 0.2 Iris-setosa
5 5.4 3.9 1.7 0.4 Iris-setosa
6 4.6 3.4 1.4 0.3 Iris-setosa
7 5.0 3.4 1.5 0.2 Iris-setosa
feature group_a group_b mean_difference_cm p_value exceedances
0 sepal_length_cm Iris-setosa Iris-versicolor -0.930 0.0005 0
1 sepal_length_cm Iris-setosa Iris-virginica -1.582 0.0005 0
2 sepal_length_cm Iris-versicolor Iris-virginica -0.652 0.0005 0
3 sepal_width_cm Iris-setosa Iris-versicolor 0.648 0.0005 0
4 sepal_width_cm Iris-setosa Iris-virginica 0.444 0.0005 0
5 sepal_width_cm Iris-versicolor Iris-virginica -0.204 0.0050 9
6 petal_length_cm Iris-setosa Iris-versicolor -2.796 0.0005 0
7 petal_length_cm Iris-setosa Iris-virginica -4.088 0.0005 0

2. 检查零假设重采样分布

直方图展示首次比较在合并组标签随机置换后产生的均值差,观测差异用竖线标记。使用 (极端次数+1)/(B+1),有限 Monte Carlo 检验不会报告 p=0。

In [7] · Python
fig,ax=plt.subplots(figsize=(7,4));ax.hist(first["null_differences"],bins=40,edgecolor="white")
ax.axvline(first["difference"],color="red",label="Observed difference")
ax.set(xlabel="Permuted mean difference (cm)",ylabel="Permutations",title="First predeclared comparison under exchangeability");ax.legend();plt.tight_layout();plt.show()
Computed figure from this code cell

3. 对全部十二个检验联合校正

BH 应用于完整预先声明检验族,包括较大的 p 值。图上对数坐标展示 p/q 值及 0.05 参考线。BH 错误率保证具有依赖结构假设;校正不能修复采样偏差,也不把关联变成因果。

In [8] · Python
results["BH_q"]=bh_adjust(results.p_value.to_numpy())
results["Bonferroni"]=np.minimum(results.p_value*len(results),1)
labels=[f"{r.feature.replace('_cm','')} {r.group_a.replace('Iris-','')} / {r.group_b.replace('Iris-','')}" for r in results.itertuples()]
fig,ax=plt.subplots(figsize=(9,6));ax.plot(results.p_value,np.arange(12),"o",label="Raw p");ax.plot(results.BH_q,np.arange(12),"x",label="BH q")
ax.axvline(.05,color="gray",linestyle="--");ax.set_yticks(np.arange(12),labels,fontsize=8)
ax.set(xscale="log",xlabel="Probability / adjusted value",ylabel="Predeclared test",title="All comparisons share one adjustment family");ax.legend();plt.tight_layout();plt.show()
Computed figure from this code cell

4. 将测量效应量与 p 值分开

热图显示均值差,单位为厘米,而不是校正 p 值。符号取决于已声明组序。小 p 值不会自动给出效应量单位、总体结论或因果解释。

In [9] · Python
effect=results.mean_difference_cm.to_numpy().reshape(4,3)
limit=np.max(np.abs(effect));fig,ax=plt.subplots(figsize=(7,4));im=ax.imshow(effect,cmap="coolwarm",vmin=-limit,vmax=limit)
ax.set_yticks(range(4),features);ax.set_xticks(range(3),[a.replace("Iris-","")+" / "+b.replace("Iris-","") for a,b in pairs],rotation=20,ha="right")
for i in range(4):
 for j in range(3):ax.text(j,i,f"{effect[i,j]:.2f}",ha="center",va="center")
fig.colorbar(im,ax=ax,label="Mean A − mean B (cm)");ax.set_title("Observed measurement differences");plt.tight_layout();plt.show()
Computed figure from this code cell

5. 核对 Monte Carlo 下限及独立 BH

核对 +1 公式、1/(B+1) 下限及与 SciPy 独立 BH 实现的一致性。导出完整检验族,不能只保存超过阈值的比较。

In [10] · Python
assert np.allclose(results.p_value,(results.exceedances+1)/2000)
assert results.p_value.between(1/2000,1).all()
assert np.allclose(results.BH_q,false_discovery_control(results.p_value.to_numpy(),method="bh"))
assert (results.BH_q+1e-15>=results.p_value).all()
OUTPUT_DIR.mkdir(parents=True,exist_ok=True);results.to_csv(OUTPUT_DIR/"all-permutation-tests.csv",index=False)
pd.DataFrame({"null_mean_difference_cm":first["null_differences"]}).to_csv(OUTPUT_DIR/"first-null-distribution.csv",index=False)
print("Joint family:",len(results),"; Monte Carlo p floor:",1/2000,"; independent BH check passed")
Joint family: 12 ; Monte Carlo p floor: 0.0005 ; independent BH check passed

自主练习与参考答案

  1. 解释 Monte Carlo p 值的最小可取值。
  2. 解释为何校正须包含全部预先声明检验。

参考解释。 +1 公式的最小值是 1/(B+1)。观察结果后只校正小 p 值会改变检验族;BH 还有依赖结构假设。

先保留当前 notebook 副本,再修改参数。把新图与原图一起比较,并记录改变了哪些输入、哪些计算和哪些结论。

可选的 AI 编程练习

提示词 1

仅用 NumPy、SciPy、Pandas、Matplotlib 将置换增加至 9999,保留十二个检验。

提示词 2

使用同样的包比较 BH 和 Bonferroni,标明其错误率目标的差别。

人工验收。 核对 +1 修正、导出全部十二行、SciPy BH 一致性及厘米效应量单位。

检查修改后的源代码,再手动运行受影响单元及后续校验。要求助手解释修改的目的、使用的包和数据来源,并用实际输出核对解释。

可选联网扩展

In [11] · Python
RUN_ONLINE_UPDATE=False
if RUN_ONLINE_UPDATE:
    import requests
    try:
        response=requests.get('https://archive.ics.uci.edu/dataset/53/iris',timeout=20)
        response.raise_for_status()
        print("Source:",response.url)
        print(response.text[:700])
    except requests.RequestException as error:
        print("Online extension unavailable:",type(error).__name__,str(error)[:160])
else:
    print("Online extension skipped; embedded core data unchanged")
Online extension skipped; embedded core data unchanged

来源、快照与下一步

  • UCI iris — CC BY 4.0; credit UCI and original dataset authors; retrieved 2026-10-09. SHA-256 d11fe30213d36434a0879aab7cb00ce3c812eb7ba2495874438abff7b7b762e9. Extract iris.data from original download; preserve all rows and declared units; no imputation. Fisher, R. (1936). Iris. UCI Machine Learning Repository. DOI: 10.24432/C56C76.

下一步。 在保留本课的数据检查、参数记录和结果校验之后,将相同方法用于自己的公开或已获授权数据。记录研究设计与方法限制,不把示例结果当成新实验的验证。完整数据许可与生成说明见 cookbook 网页。