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

检查 Wine 化学测量的聚类

标准化 13 个真实 Wine 特征,执行可复现的 k-means,并对照来源中的品种代码。

数据科学与机器学习 · 第 04 课🔋 离线核心分析⏱ 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='ds04-wine-clusters'
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={'wine': '9ee00bf430da1fd1a13b7ee8a1efc74427730e4bd5fb22c6543673b57e37da6b'}
def snapshot(key):
    return load_snapshot(RECIPE_ID,SNAPSHOT_HASHES[key],key)
print("Embedded snapshots:",list(SNAPSHOT_HASHES))
Embedded snapshots: ['wine']

方法函数

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 standardized_pca(values,components=2):
    """Population-SD standardization followed by an SVD of centered observations."""
    values=np.asarray(values,dtype=float)
    if values.ndim!=2 or min(values.shape)<2 or not np.isfinite(values).all():
        raise ValueError('PCA requires a finite observations × features matrix')
    if not 1<=components<=min(values.shape):raise ValueError('Invalid number of components')
    mean=values.mean(axis=0);scale=values.std(axis=0,ddof=0)
    if np.any(scale==0):raise ValueError('Constant features must be removed or handled explicitly')
    standardized=(values-mean)/scale
    left,singular,loadings=np.linalg.svd(standardized,full_matrices=False)
    # Fix signs for reproducible plots, without changing distances or explained variance.
    for j in range(len(singular)):
        pivot=np.argmax(np.abs(loadings[j]))
        if loadings[j,pivot]<0:loadings[j]*=-1;left[:,j]*=-1
    variance=singular**2/(len(values)-1)
    return dict(mean=mean,scale=scale,scores=left[:,:components]*singular[:components],
                loadings=loadings[:components],explained_ratio=variance/variance.sum(),standardized=standardized)

print("Method ready: standardized_pca")
Method ready: standardized_pca
In [5] · Python
def kmeans(values,k=3,seed=42,restarts=10,max_iterations=100):
    """Lloyd's algorithm with k-means++ seeds; labels are excluded from fitting."""
    values=np.asarray(values,dtype=float)
    if values.ndim!=2 or not isinstance(k,(int,np.integer)) or not np.isfinite(values).all() or not 1<=k<=len(np.unique(values,axis=0)):
        raise ValueError('Invalid observations or number of distinct clusters')
    if not isinstance(restarts,(int,np.integer)) or not isinstance(max_iterations,(int,np.integer)) or min(restarts,max_iterations)<1:
        raise ValueError('Restarts and maximum iterations must be positive integers')
    rng=np.random.default_rng(seed);best=None
    for restart in range(restarts):
        centers=[values[rng.integers(len(values))]]
        for _ in range(1,k):
            distance=np.min(np.sum((values[:,None,:]-np.asarray(centers)[None,:,:])**2,axis=2),axis=1)
            centers.append(values[rng.choice(len(values),p=distance/distance.sum())])
        centers=np.asarray(centers);history=[]
        for _ in range(max_iterations):
            distance=np.sum((values[:,None,:]-centers[None,:,:])**2,axis=2)
            assigned=np.argmin(distance,axis=1)
            updated=np.array([values[assigned==j].mean(axis=0) if np.any(assigned==j) else centers[j] for j in range(k)])
            objective=float(np.sum((values-updated[assigned])**2));history.append(objective)
            if np.allclose(updated,centers,atol=1e-10,rtol=0):centers=updated;break
            centers=updated
        assigned=np.argmin(np.sum((values[:,None,:]-centers[None,:,:])**2,axis=2),axis=1)
        objective=float(np.sum((values-centers[assigned])**2))
        if best is None or objective<best['inertia']:
            best=dict(centers=centers,labels=assigned,inertia=objective,history=np.asarray(history))
    return best

print("Method ready: kmeans")
Method ready: kmeans

数据与模型边界

公开 Wine 表包含化学测量及品种代码。来源未完整声明单位,因此标准化分析使用无量纲坐标,不编造浓度单位。品种代码不参与无监督拟合。

分步分析

1. 将特征与来源类别代码分开

Wine 包含 178 条观测及 13 个化学特征。来源未完整声明单位,应保留原始特征名,不能编造浓度单位。品种代码不作为聚类输入。

In [6] · Python
wine=snapshot("wine");frame=pd.DataFrame(wine["rows"],columns=wine["columns"])
features=list(frame.columns[1:]);audit=audit_numeric_table(frame,features)
assert len(frame)==178 and len(features)==13
values=frame[features].to_numpy(float);standardized=(values-values.mean(axis=0))/values.std(axis=0)
print("Audit:",audit,"; standardized features are dimensionless")
display(frame.head(6))
Audit: {'rows': 178, 'features': 13, 'finite': True, 'duplicate_measurement_rows': 0} ; standardized features are dimensionless
cultivar alcohol malic_acid ash alkalinity_of_ash magnesium total_phenols flavanoids nonflavanoid_phenols proanthocyanins color_intensity hue od280_od315 proline
0 1 14.23 1.71 2.43 15.6 127.0 2.80 3.06 0.28 2.29 5.64 1.04 3.92 1065.0
1 1 13.20 1.78 2.14 11.2 100.0 2.65 2.76 0.26 1.28 4.38 1.05 3.40 1050.0
2 1 13.16 2.36 2.67 18.6 101.0 2.80 3.24 0.30 2.81 5.68 1.03 3.17 1185.0
3 1 14.37 1.95 2.50 16.8 113.0 3.85 3.49 0.24 2.18 7.80 0.86 3.45 1480.0
4 1 13.24 2.59 2.87 21.0 118.0 2.80 2.69 0.39 1.82 4.32 1.04 2.93 735.0
5 1 14.20 1.76 2.45 15.2 112.0 3.27 3.39 0.34 1.97 6.75 1.05 2.85 1450.0

2. 比较不同 k 的目标函数

在 k=1 至 6 下各执行十次 k-means++ 初始化。惯量是到所属中心的标准化欧氏距离平方和,随 k 增加通常下降。本课预先选择 k=3 检查,不把它作为最优分类的证明。

In [7] · Python
solutions={k:kmeans(standardized,k,seed=42,restarts=10) for k in range(1,7)}
solution=solutions[3]
fig,ax=plt.subplots(figsize=(7,4));ax.plot(list(solutions),[solutions[k]["inertia"] for k in solutions],"o-")
ax.axvline(3,color="gray",linestyle="--");ax.set(xlabel="k",ylabel="Standardized squared-distance sum",title="Inertia is an objective, not classification accuracy");plt.tight_layout();plt.show()
Computed figure from this code cell

3. 投影匿名聚类

PCA 仅提供显示坐标,k-means 使用全部 13 个标准化特征。聚类编号和颜色是任意标识。两个主成分中的可见分组可能在被舍弃的方向上不同。

In [8] · Python
pca=standardized_pca(values,2)
fig,ax=plt.subplots(figsize=(7,5))
for cluster in range(3):
 mask=solution["labels"]==cluster;ax.scatter(*pca["scores"][mask].T,label=f"Cluster {cluster}",s=25)
ax.set(xlabel="PC1 score",ylabel="PC2 score",title="Wine clusters; fitted in 13 dimensions");ax.legend();plt.tight_layout();plt.show()
Computed figure from this code cell

4. 对照未参与拟合的品种代码

拟合完成后使用列联表进行描述性比较。行是来源品种代码,列是匿名聚类 ID,不能直接将这两套无关编号相等当作准确率。

In [9] · Python
table=pd.crosstab(frame.cultivar,pd.Series(solution["labels"],name="cluster"))
display(table)
fig,ax=plt.subplots(figsize=(6,4));im=ax.imshow(table,cmap="Blues")
ax.set_xticks(range(3),table.columns);ax.set_yticks(range(3),table.index)
for i in range(3):
 for j in range(3):ax.text(j,i,str(table.iloc[i,j]),ha="center",va="center")
ax.set(xlabel="Anonymous cluster",ylabel="Source cultivar code",title="Post-fit contingency counts");fig.colorbar(im,ax=ax,label="Observations");plt.tight_layout();plt.show()
cluster 0 1 2
cultivar
1 59 0 0
2 3 65 3
3 0 0 48
Computed figure from this code cell

5. 核对目标函数并导出分配

用观测和中心直接重新计算目标函数。更新历史不应增加。导出标准化中心、分配结果及目标值;非法聚类数或非有限输入应报错。

In [10] · Python
assert table.to_numpy().sum()==178
assert np.isclose(solution["inertia"],np.sum((standardized-solution["centers"][solution["labels"]])**2))
assert np.all(np.diff(solution["history"])<=1e-8)
OUTPUT_DIR.mkdir(parents=True,exist_ok=True)
frame.assign(cluster=solution["labels"]).to_csv(OUTPUT_DIR/"wine-clusters.csv",index=False)
pd.DataFrame(solution["centers"],columns=features).to_csv(OUTPUT_DIR/"standardized-centres.csv",index=False)
pd.DataFrame({"k":list(solutions),"inertia":[solutions[k]["inertia"] for k in solutions]}).to_csv(OUTPUT_DIR/"inertia.csv",index=False)
print("Objective and count checks passed; exports:",OUTPUT_DIR)
Objective and count checks passed; exports: /Users/zhiluo/Developer/vibeit-cookbook/cookbook/downloads/results/ds04-wine-clusters

自主练习与参考答案

  1. 说明聚类 ID 为何不能直接等同于品种代码。
  2. 将十次初始化的随机种子改为 7,比较目标值。

参考解释。 聚类 ID 可任意置换而不改变成员关系。不同初始化可能进入不同局部最优;与来源代码的比较属于拟合后的描述分析。

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

可选的 AI 编程练习

提示词 1

仅用 NumPy、Pandas、Matplotlib 增加标准化中心热图,不将品种代码加入距离计算。

提示词 2

使用同样的包,在 k=3 时比较一次与十次初始化,展示各自目标更新历史。

人工验收。 重算惯量一致,历史不增加,分配数量为 178,全部中心有限。

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

可选联网扩展

In [11] · Python
RUN_ONLINE_UPDATE=False
if RUN_ONLINE_UPDATE:
    import requests
    try:
        response=requests.get('https://archive.ics.uci.edu/dataset/109',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 wine — CC BY 4.0; credit UCI and original dataset authors; retrieved 2026-10-09. SHA-256 2bae62c4481220623579d4c4fb36b55652b6b75e06e49fa1981b8198362dfdab. Extract wine.data from original download; preserve all rows and declared units; no imputation. Aeberhard, S. & Forina, M. (1992). Wine. UCI Machine Learning Repository. DOI: 10.24432/C5PC7J.

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