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

避免信息泄漏地训练和评估分类器

先保留分层测试集,在训练集内选择 k,再报告测试混淆矩阵与宏平均 F1。

数据科学与机器学习 · 第 03 课🔋 离线核心分析⏱ 60 分钟内嵌真实数据📦 numpy · pandas · 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 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='ds03-iris-knn'
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 stratified_holdout(labels,test_fraction=.3,seed=42):
    """Reserve a deterministic class-stratified test set before fitting anything."""
    labels=np.asarray(labels)
    if labels.ndim!=1 or not 0<test_fraction<1:raise ValueError('Invalid labels or test fraction')
    rng=np.random.default_rng(seed);train=[];test=[]
    for label in np.unique(labels):
        indices=np.flatnonzero(labels==label)
        if len(indices)<3:raise ValueError('Each class needs at least three observations')
        indices=rng.permutation(indices)
        count=max(1,min(len(indices)-1,int(round(len(indices)*test_fraction))))
        test.extend(indices[:count]);train.extend(indices[count:])
    return np.sort(train),np.sort(test)

print("Method ready: stratified_holdout")
Method ready: stratified_holdout
In [5] · Python
def stratified_folds(labels,count=5,seed=42):
    """Return validation indices relative to the training subset only."""
    labels=np.asarray(labels);rng=np.random.default_rng(seed);folds=[[] for _ in range(count)]
    if count<2:raise ValueError('At least two folds are required')
    for label in np.unique(labels):
        indices=np.flatnonzero(labels==label)
        if len(indices)<count:raise ValueError('A class has fewer observations than folds')
        for j,chunk in enumerate(np.array_split(rng.permutation(indices),count)):folds[j].extend(chunk)
    return [np.sort(fold) for fold in folds]

print("Method ready: stratified_folds")
Method ready: stratified_folds
In [6] · Python
def knn_predict(train_values,train_labels,test_values,k=5):
    """Euclidean kNN with a deterministic smallest-label tie rule."""
    train_values=np.asarray(train_values,dtype=float);test_values=np.asarray(test_values,dtype=float)
    train_labels=np.asarray(train_labels)
    if train_values.ndim!=2 or test_values.ndim!=2 or train_values.shape[1]!=test_values.shape[1]:
        raise ValueError('Training/test feature dimensions differ')
    if len(train_labels)!=len(train_values) or not isinstance(k,(int,np.integer)) or not 1<=k<=len(train_values):raise ValueError('Invalid k or labels')
    if not np.isfinite(train_values).all() or not np.isfinite(test_values).all():raise ValueError('Non-finite features')
    classes=np.unique(train_labels);predictions=[]
    for row in test_values:
        neighbors=np.argsort(np.sum((train_values-row)**2,axis=1),kind='stable')[:k]
        votes=[np.sum(train_labels[neighbors]==label) for label in classes]
        predictions.append(classes[np.argmax(votes)])
    return np.asarray(predictions)

print("Method ready: knn_predict")
Method ready: knn_predict
In [7] · Python
def classification_metrics(truth,predictions,classes):
    truth=np.asarray(truth);predictions=np.asarray(predictions);classes=np.asarray(classes)
    if truth.shape!=predictions.shape or truth.size==0:raise ValueError('Invalid comparison')
    if not np.isin(truth,classes).all() or not np.isin(predictions,classes).all():raise ValueError('Unknown class')
    confusion=np.array([[np.sum((truth==a)&(predictions==b)) for b in classes] for a in classes])
    true_positive=np.diag(confusion)
    precision=np.divide(true_positive,confusion.sum(axis=0),out=np.zeros(len(classes),dtype=float),where=confusion.sum(axis=0)>0)
    recall=np.divide(true_positive,confusion.sum(axis=1),out=np.zeros(len(classes),dtype=float),where=confusion.sum(axis=1)>0)
    f1=np.divide(2*precision*recall,precision+recall,out=np.zeros(len(classes),dtype=float),where=precision+recall>0)
    return dict(confusion=confusion,accuracy=float(np.mean(truth==predictions)),macro_f1=float(f1.mean()),precision=precision,recall=recall)

print("Method ready: classification_metrics")
Method ready: classification_metrics

数据与模型边界

这些是冻结的公开实测数据,不是新的总体随机样本。保留原始 UCI iris.data,包括来源说明的两处转录差异。舍入后的重复特征值本身不能证明两行属于同一株植物。

分步分析

1. 先保留测试集

固定随机种子,按类别保留 30% 测试记录。不能用测试特征计算均值/标准差、选择 k,或看到测试错误后再改算法。保留并说明重复的舍入测量向量。

In [8] · 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))
values=frame[features].to_numpy(float);labels=frame.species.to_numpy()
train_indices,test_indices=stratified_holdout(labels,test_fraction=.3,seed=42)
assert not set(train_indices)&set(test_indices)
assert len(train_indices)+len(test_indices)==150
print("Training/test observations:",len(train_indices),len(test_indices))
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
Training/test observations: 105 45

2. 只用训练集内交叉验证选择 k

每折只用折内训练记录拟合缩放器。验证索引相对于保留的训练子集。误差条表示折间标准差,不是独立实验的置信区间。

In [9] · Python
candidates=[1,3,5,7,9];folds=stratified_folds(labels[train_indices],5,seed=7)
cv=[]
for k in candidates:
 scores=[]
 for validation in folds:
  fitting=np.setdiff1d(np.arange(len(train_indices)),validation)
  x=values[train_indices[fitting]];mean=x.mean(axis=0);scale=x.std(axis=0)
  prediction=knn_predict((x-mean)/scale,labels[train_indices[fitting]],(values[train_indices[validation]]-mean)/scale,k)
  scores.append(np.mean(prediction==labels[train_indices[validation]]))
 cv.append(scores)
cv=np.asarray(cv);chosen_k=candidates[int(np.argmax(cv.mean(axis=1)))]
print("Training-only selected k:",chosen_k,"; ties choose the smallest candidate")
fig,ax=plt.subplots(figsize=(7,4));ax.errorbar(candidates,cv.mean(axis=1),yerr=cv.std(axis=1,ddof=1),fmt="o-")
ax.set(xlabel="k",ylabel="Fold accuracy",ylim=(0,1.05),title="Training-only stratified CV; bars = fold SD");plt.tight_layout();plt.show()
Training-only selected k: 3 ; ties choose the smallest candidate
Computed figure from this code cell

3. 评估保留测试集

现在仅用全部训练记录拟合一个缩放器,并预测保留测试集。混淆矩阵行是真实类别、列是预测类别,数值为记录数。宏平均 F1 对各类别赋予相同权重。

In [10] · Python
mean=values[train_indices].mean(axis=0);scale=values[train_indices].std(axis=0)
prediction=knn_predict((values[train_indices]-mean)/scale,labels[train_indices],(values[test_indices]-mean)/scale,chosen_k)
classes=np.unique(labels);metrics=classification_metrics(labels[test_indices],prediction,classes)
print("Held-out accuracy:",metrics["accuracy"],"; macro-F1:",metrics["macro_f1"])
fig,ax=plt.subplots(figsize=(6,5));im=ax.imshow(metrics["confusion"],cmap="Blues")
short=[v.replace("Iris-","") for v in classes];ax.set_xticks(range(3),short,rotation=25);ax.set_yticks(range(3),short)
for i in range(3):
 for j in range(3):ax.text(j,i,str(metrics["confusion"][i,j]),ha="center",va="center")
ax.set(xlabel="Predicted class",ylabel="True class",title="Untouched test set");fig.colorbar(im,ax=ax,label="Observations");plt.tight_layout();plt.show()
Held-out accuracy: 0.9333333333333333 ; macro-F1: 0.9332591768631814
Computed figure from this code cell

4. 定位测试错误并保持评估边界

投影使用两个原始测量,单位为厘米。叉号标记错误预测,可以辅助解释错误,但不能据此对测试集调参。说明新的独立评估数据需要哪些条件。

In [11] · Python
fig,ax=plt.subplots(figsize=(7,5))
for label in classes:
 mask=labels[test_indices]==label;ax.scatter(values[test_indices][mask,2],values[test_indices][mask,3],label=label,s=35)
wrong=prediction!=labels[test_indices]
ax.scatter(values[test_indices][wrong,2],values[test_indices][wrong,3],marker="x",color="black",s=90,label="Incorrect prediction")
ax.set(xlabel="Petal length (cm)",ylabel="Petal width (cm)",title="Test errors in an observed feature plane");ax.legend(fontsize=8);plt.tight_layout();plt.show()
Computed figure from this code cell

5. 核对指标并导出预测

用直接计数核对矩阵总数及对角线准确率。导出固定划分与全部测试预测,并保留缩放器和选择的 k。

In [12] · Python
assert metrics["confusion"].sum()==len(test_indices)
assert np.isclose(np.trace(metrics["confusion"])/len(test_indices),np.mean(prediction==labels[test_indices]))
OUTPUT_DIR.mkdir(parents=True,exist_ok=True)
pd.DataFrame({"source_row":test_indices,"true":labels[test_indices],"predicted":prediction}).to_csv(OUTPUT_DIR/"test-predictions.csv",index=False)
pd.DataFrame(cv,index=candidates).to_csv(OUTPUT_DIR/"training-cv.csv")
(OUTPUT_DIR/"model.json").write_text(json.dumps({"k":chosen_k,"seed":42,"train_indices":train_indices.tolist(),"test_indices":test_indices.tolist(),"training_mean":mean.tolist(),"training_SD":scale.tolist()},indent=2))
print("Metric reconciliation passed; exports:",OUTPUT_DIR)
Metric reconciliation passed; exports: /Users/zhiluo/Developer/vibeit-cookbook/cookbook/downloads/results/ds03-iris-knn

自主练习与参考答案

  1. 解释为何每个交叉验证折需要自己的缩放器。
  2. 用保存的混淆矩阵比较准确率和宏平均 F1。

参考解释。 验证折不能参与预处理拟合。准确率按观测加权,宏平均 F1 对类别 F1 等权;某些平衡预测下两者可能相同。

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

可选的 AI 编程练习

提示词 1

仅用 NumPy、Pandas、Matplotlib 在训练集 CV 中加入 k=11、13,保留固定测试划分。

提示词 2

使用同样的包,从测试混淆矩阵报告每类精确率和召回率。

人工验收。 确认训练/测试无重叠,每折缩放只用拟合记录,混淆矩阵总数 45,准确率与直接计数一致。

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

可选联网扩展

In [13] · Python
RUN_ONLINE_UPDATE=False
if RUN_ONLINE_UPDATE:
    import requests
    try:
        response=requests.get('https://archive.ics.uci.edu/dataset/53',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 网页。