补2 · 补2 · KNN、机器学习词汇与 K-Means
UC Berkeley EECS 189/289A《机器学习导论》2026 秋季(fa26) 官方讲义 · 中文重构 本书是一次补充讲次:2026 春季(sp26)课堂实录版电子书里没有这一讲(或只有零星提及), 内容依据 fa26 官方讲义 notebook 补全。 这份讲义从原始数据出发,一路走到我们的第一个机器学习模型,再走到第一个无监督模型。我们先掌握看数据所需的工具(
pandas、numpy与绘图),然后引入能想到的最简单的模型——k 近邻(k-nearest neighbors, KNN)。在 KNN 的推动下,我们将被迫发明出泛化(generalization)、训练/测试划分(train/test split)和超参数(hyperparameter)这几个概念。最后我们以 k-means 收尾:它解决一个相关的问题,却完全不需要标签。 正文是课堂上要走过的主线;文末的附录会深入得多地讲解pandas、numpy与可视化的语法细节,请在课后自行完成。
| 原始标题 | Lecture 02: KNN, ML Vocabulary, and K-Means – CS 189, Fall 2026 |
| 讲义来源 | 课程官方 Jupyter notebook《Lecture 02: KNN, ML Vocabulary, and K-Means》,随 fa26 讲义包发布 |
| 课程主页 | https://eecs189.org/fa26/ |
| 对应 fa26 讲次 | Lecture 02 |
| 材料版权 | 课程材料版权归 UC Berkeley 及授课教师所有,本书仅供学习 |
对应讲次:补2(UC Berkeley EECS 189 机器学习导论)
制作模型:deepseek-flash(vb compose,据 run.log)
Part 1:先看数据
在建模之前,先看数据。这一节只是对工具的一次快速巡览;附录里有这里用到的每一个函数的完整用法。
Palmer Penguins 数据集
这是南极 Palmer 群岛三个岛上 344 只企鹅的测量数据,每一行是一只企鹅。今天的所有内容都只用这一个数据集。
# 下载数据与安装依赖
import os
import requests
os.makedirs("data", exist_ok=True)
data_files = {
"data/penguins.csv":
"https://raw.githubusercontent.com/mwaskom/seaborn-data/master/penguins.csv",
}
for path, url in data_files.items():
if not os.path.exists(path):
r = requests.get(url)
r.raise_for_status()
with open(path, "wb") as f:
f.write(r.content)
print(f"Downloaded {path}")
else:
print(f"Found {path}")
import numpy as np
import pandas as pd
import plotly.express as px
px.defaults.width = 800
pd.set_option("plotting.backend", "plotly")
os.makedirs("images", exist_ok=True)
penguins = pd.read_csv("data/penguins.csv")
penguins.head()
penguins.info()
# describe() 对数值列做汇总统计
penguins.describe()
我们手上到底有多少数据、看到的又是什么?
shape给出(行数, 列数)value_counts()统计某一列中每个取值出现了多少次
print("shape:", penguins.shape)
penguins["species"].value_counts()
选取数据:loc 与 iloc
从 DataFrame 里取子集有两种方式。它们的区别我们之后会反复用到,所以现在就要弄明白。
iloc[]按整数位置选取,就像给 numpy 数组做索引一样。切片是不含结尾的。loc[]按标签选取,也就是按索引值和列名。切片是包含结尾的。
# iloc:按位置。第 0-4 行,前三列。
penguins.iloc[0:5, 0:3]
# loc:按标签。第 0-4 行(含 4!),指定列名。
penguins.loc[0:4, ["species", "island", "bill_length_mm"]]
# 单列是 Series;用列表取多列得到的是 DataFrame
print(type(penguins["bill_length_mm"]))
print(type(penguins[["bill_length_mm"]]))
过滤与缺失值
真实数据里总有缺失信息。isna() 找出它们,dropna() 删掉含有缺失 NULL 值的行。
penguins.isna().sum()
# 布尔过滤:哪些企鹅既重、鳍又长?
mask = (penguins["body_mass_g"] > 5000) & (penguins["flipper_length_mm"] > 220)
penguins[mask].head()
df = penguins.dropna()
print(len(penguins), "rows ->", len(df), "rows after dropping missing values")
df.head()
分组与汇总
groupby() 把数据拆成若干组,对每组施加一个函数,再把结果合并起来。
df.groupby("species")[["bill_length_mm", "flipper_length_mm", "body_mass_g"]].mean().round(1)
numpy:底层的数组
pandas 是建立在 numpy 之上的。而 scikit-learn 里的模型需要 numpy 数组,所以我们要这样把数据交出去。
FEATURES = ["bill_length_mm", "flipper_length_mm"]
X = df[FEATURES].to_numpy() # 特征矩阵,形状 (n_samples, n_features)
y = df["species"].to_numpy() # 标签,形状 (n_samples,)
print("X shape:", X.shape, "| y shape:", y.shape)
print("X dtype:", X.dtype, "\n")
print("The first three rows of X:\n", X[:3])
# 向量化运算一次作用于整个数组,不需要写 Python 循环。
print("column means:", X.mean(axis=0).round(2))
print("column stds: ", X.std(axis=0).round(2))
可视化:我们能看见物种吗?
如果物种在这两个维度上视觉上就能分开,那模型就有戏。
fig = px.scatter(
df, x="bill_length_mm", y="flipper_length_mm", color="species",
title="Palmer Penguins: bill length vs flipper length",
labels={
"bill_length_mm": "Bill length (mm)",
"flipper_length_mm": "Flipper length (mm)",
"species": "Species",
},
height=520,
)
# Plotly 默认图例在右侧,在 notebook 里经常被裁掉,所以要手动挪位置。
fig.update_layout(
showlegend=True,
legend=dict(
title_text="Species",
x=0.01, y=0.99, xanchor="left", yanchor="top",
bgcolor="white", bordercolor="lightgray", borderwidth=1,
),
)
fig.show()
看看这张图在告诉我们什么。 三个物种占据了不同的区域。没有任何东西能把它们完美分开,但彼此靠近的点往往属于同一个物种。
编者说明:这里已经埋下了 KNN 的全部直觉——"相似的输入有相似的输出"(i.i.d. 假设下的平滑性假设)。KNN 把这句话直接当作算法本身,而不是当作一个需要学习的函数形式。
Part 2:最朴素的模型
我们想根据喙长(bill length)和鳍长(flipper length)预测一只企鹅的物种。于是我们问自己:能奏效的、最不聪明的方法是什么?
k = 1 的 K 近邻(KNN)
要预测一只新企鹅的物种,就找出数据里离它最近的那只企鹅,然后把那只企鹅的物种抄过来。
这就是整个算法。注意它没有优化。所谓"训练",无非是把数据存下来而已。
from sklearn.neighbors import KNeighborsClassifier
knn1 = KNeighborsClassifier(n_neighbors=1)
knn1.fit(X, y) # “训练”:把数据存下来
print("Model is trained.")
# 推断:预测一只新企鹅的物种
new_penguin = np.array([[45.0, 200.0]]) # 喙长 45mm,鳍长 200mm
print("Predicted species:", knn1.predict(new_penguin)[0])
编者拓展|KNN 到底在算什么:KNN 的"模型"只是一个距离函数。对欧氏距离,预测点 $x$ 到训练点 $x'$ 的距离是
$$d(x, x') = \sqrt{\sum_{j=1}^{d} (x_j - x'_j)^2}.$$
分类时,取 $k$ 个最近邻 $\mathcal{N}_k(x)$,输出它们的多数票:
$$\hat{y}(x) = \arg\max_{c} \sum_{i \in \mathcal{N}_k(x)} \mathbf{1}\{y_i = c\}.$$
回归时换成均值:
$$\hat{y}(x) = \frac{1}{k} \sum_{i \in \mathcal{N}_k(x)} y_i.$$
注意 $\hat{y}$ 完全由训练集决定,训练阶段没有任何参数被"学"出来——这正是一个干净的例子,让我们后面区分参数与超参数。 代价是推断很贵:每次预测都要算 $n$ 个距离,复杂度 $O(nd)$,而训练是 $O(1)$(存下来就行)。
flowchart LR
A["新样本 x"] --> B["计算 x 到所有训练点的距离"]
B --> C["取距离最近的 k 个点"]
C --> D{"分类还是回归?"}
D -- "分类" --> E["k 个邻居投票(多数票)"]
D -- "回归" --> F["k 个邻居的目标值取平均"]
它有多好?
我们让模型去预测数据集中每一只企鹅的物种,然后数它对了多少次。
accuracy = knn1.score(X, y)
print(f"Accuracy on our data: {accuracy:.1%}")
100%。
我们用一个最笨的想法造出了一个完美分类器。
这不对劲。 问题出在哪?
失败案例:最笨的模型拿到 100% 准确率。
# 为什么是 100%?看看离第一只企鹅最近的点是哪一个。
distances, indices = knn1.kneighbors(X[:1], n_neighbors=1)
print("Nearest neighbour of penguin 0 is penguin index:", indices[0][0])
print("at a distance of:", distances[0][0])
数据集中每只企鹅的最近邻就是它自己,距离为零。我们让模型回答的问题,它早就把答案背下来了。
这说明不了模型是否学到了任何东西。我们必须用它在从未见过的企鹅上评测。
编者说明:机制上,$k=1$ 的 KNN 在训练集上的准确率永远是 100%(只要没有完全重复且标签冲突的样本)。这是恒等式,不是实验发现。所以训练误差在 KNN 上是一个零信息量的指标——这一点会在后面反复出现。
Part 3:泛化与训练/测试划分
泛化指的是在从同一分布中抽出的、未见过的数据上表现良好的能力。要度量它,我们必须留出一部分数据。
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
print(f"Training set: {X_train.shape[0]} penguins")
print(f"Test set: {X_test.shape[0]} penguins")
knn1 = KNeighborsClassifier(n_neighbors=1).fit(X_train, y_train)
print(f"Training accuracy: {knn1.score(X_train, y_train):.1%}")
print(f"Test accuracy: {knn1.score(X_test, y_test):.1%}")
训练准确率仍然是 100%,而且对 $k=1$ 来说它永远会是 100%,因为每个训练点仍然是它自己的最近邻。测试准确率才是诚实的那个数字,它明显更低。
这两个数字之间的差距,就是我们这学期接下来要一直管理的东西。
flowchart TD
D["全部数据(344 只企鹅)"] --> A["训练集(80%)"]
D --> B["测试集(20%)"]
A --> A1["训练集(训练用)"]
A --> A2["验证集(调超参数用)"]
B --> B1["只在最后用一次<br/>衡量泛化"]
编者说明:
stratify=y让每个类别在训练集和测试集中的比例与原始数据一致。这一步在类别不均衡时很关键,否则可能某个类别在测试集里只有一两只。编者拓展|为什么"看一眼测试集"就毁了它:测试集的作用是估计泛化误差 $\mathbb{E}[\text{error on new data}]$。一旦你根据测试集的表现做任何选择(选 $k$、选特征、选模型),测试集的信息就通过你的决策泄露进了模型,它测出来的就不再是"对新数据的表现",而是"对这份特定数据的表现"。这个现象叫数据窥探(data snooping),是实践中最高频的隐蔽错误之一。正确的做法是:把选择权交给验证集,测试集只在最后用一次(见 Part 4)。
Part 4:k 是一个选择
为什么只看一个邻居?让最近的 $k$ 个邻居各自投票,取多数。
在决策边界里看 k
决策边界(decision boundary)展示模型在特征空间每个点上会预测什么。它让 $k$ 的作用变得可见。
import plotly.graph_objects as go
from sklearn.preprocessing import LabelEncoder
def decision_boundary_figure(k, X_tr, y_tr, resolution=250):
le = LabelEncoder().fit(y_tr)
model = KNeighborsClassifier(n_neighbors=k).fit(X_tr, le.transform(y_tr))
# 在特征空间上打一张细密的网格,对每个格点做预测
pad = 1.0
xs = np.linspace(X_tr[:, 0].min() - pad, X_tr[:, 0].max() + pad, resolution)
ys = np.linspace(X_tr[:, 1].min() - pad, X_tr[:, 1].max() + pad, resolution)
xx, yy = np.meshgrid(xs, ys)
zz = model.predict(np.c_[xx.ravel(), yy.ravel()]).reshape(xx.shape)
fig = go.Figure()
# 用热力图铺出类别区域
fig.add_trace(go.Heatmap(x=xs, y=ys, z=zz, showscale=False, opacity=0.30,
colorscale=[[0, "#002675"], [0.5, "#FDB515"], [1, "#028842"]]))
# 再把训练点叠上去
for i, cls in enumerate(le.classes_):
m = y_tr == cls
fig.add_trace(go.Scatter(x=X_tr[m, 0], y=X_tr[m, 1], mode="markers", name=cls))
fig.update_layout(title=f"KNN decision boundary, k = {k}",
xaxis_title="Bill length (mm)", yaxis_title="Flipper length (mm)",
width=800, height=520)
return fig
decision_boundary_figure(1, X_train, y_train).show()
k = 1 得到的边界非常毛糙,在个别点周围长出小岛。模型正在扭曲自己以求把每一只训练企鹅都分对,包括那些不寻常的。这就是过拟合(overfitting):把噪声也当作信号一起拟合了。
decision_boundary_figure(15, X_train, y_train).show()
decision_boundary_figure(100, X_train, y_train).show()
k = 15 让边界平滑成看起来像真趋势的样子。
k = 100 平滑得过头,模型几乎不再理会数据。这就是欠拟合(underfitting)。
所以 $k$ 控制着一个权衡,中间某个位置存在一个好的取值。
寻找一个好的 k
我们不能用测试集来挑 $k$。一旦我们针对测试集调任何东西,它就不再度量泛化能力,而变成另一个训练集了。
所以再切一次:从训练数据里划出一个验证集(validation set)。
X_tr, X_val, y_tr, y_val = train_test_split(
X_train, y_train, test_size=0.25, random_state=42, stratify=y_train
)
print(f"train {len(X_tr)} | validation {len(X_val)} | test {len(X_test)}")
ks = list(range(1, 101))
train_acc, val_acc = [], []
for k in ks:
m = KNeighborsClassifier(n_neighbors=k).fit(X_tr, y_tr)
train_acc.append(m.score(X_tr, y_tr))
val_acc.append(m.score(X_val, y_val))
best_k = ks[int(np.argmax(val_acc))]
print(f"Best k on the validation set: {best_k} (validation accuracy {max(val_acc):.1%})")
fig = go.Figure()
fig.add_trace(go.Scatter(x=ks, y=train_acc, name="Training accuracy"))
fig.add_trace(go.Scatter(x=ks, y=val_acc, name="Validation accuracy"))
fig.add_vline(x=best_k, line_dash="dash",
annotation_text=f"best k = {best_k}")
fig.update_layout(title="Training and validation accuracy as k increases",
xaxis_title="k (number of neighbours)", yaxis_title="Accuracy",
width=800, height=520)
fig.show()
(省略了原讲义中曲线的配色与线宽等纯样式参数。)
仔细读这张图,因为它的形状这学期会反复出现。
- 左边($k$ 小):训练准确率完美,验证准确率偏低。过拟合。
- 右边($k$ 大):两条准确率一起下降。欠拟合。
- 最佳点:验证准确率取到峰值的位置。
$k$ 是一个超参数(hyperparameter):一个在训练之前由我们选定的值;与之相对的是参数(parameter),它是在训练中从数据里学到的。KNN 比较特殊,它压根没有参数,只有这一个超参数。这使它成为一个看清二者区别的干净范本。
# 只有现在,在选好 k 之后,我们才碰测试集。而且只碰一次。
final = KNeighborsClassifier(n_neighbors=best_k).fit(X_train, y_train)
print(f"Final test accuracy with k={best_k}: {final.score(X_test, y_test):.1%}")
编者拓展|更省数据的做法:k 折交叉验证:划出一个固定验证集有一个缺点——留出的那部分数据不参与训练,在数据量小的时候尤其浪费。k 折交叉验证(k-fold cross-validation)把训练集切成 $k$ 份,轮流用其中一份当验证集、其余当训练集,取 $k$ 次结果的平均。代价是训练 $k$ 次,好处是估计更稳、数据利用更充分。
scikit-learn的cross_val_score直接实现了它,参见 https://scikit-learn.org/stable/modules/cross_validation.html。编者拓展|测试集为什么"只能用一次":这不是教条,而是统计问题。测试集上的准确率本身是一个随机变量,样本量 $n$ 时标准误约为 $\sqrt{p(1-p)/n}$。反复使用测试集做决策,等价于在噪声上做优化,你会系统性地选出"碰巧在这份测试集上表现好"的模型,这个偏差无法通过增加测试集大小来自我纠正——因为你已经在用它做选择了。
一个细节:距离依赖于单位
KNN 完全建立在距离之上,所以它在意特征的尺度。鳍长的跨度大约是 172–231 mm,喙长的跨度大约是 32–60 mm,因此鳍长对距离的贡献要大得多——仅仅因为它的数值更大。
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
scaled = make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=best_k))
scaled.fit(X_train, y_train)
print(f"Unscaled test accuracy: {final.score(X_test, y_test):.1%}")
print(f"Scaled test accuracy: {scaled.score(X_test, y_test):.1%}")
标准化(standardization)把每个特征重新缩放到均值为 0、标准差为 1,于是每个特征在距离中的地位就平等了。对任何基于距离的模型,这一步都不是可选项。
编者说明:标准化的公式是
$$z_j = \frac{x_j - \mu_j}{\sigma_j},$$
其中 $\mu_j, \sigma_j$ 是第 $j$ 个特征在训练集上的均值和标准差。注意
make_pipeline把缩放和模型串成一体,这样fit时用训练集统计量、predict时用同一套统计量——如果把整个数据集(含测试集)拿去算均值方差,就是又一次数据泄露。
Part 5:同样的想法用于回归
到目前为止我们预测的是类别(物种)。如果我们想预测一个数值,比如体重呢?
算法几乎不用改。找到最近的 $k$ 个邻居,然后把"多数投票"换成取平均值。
from sklearn.neighbors import KNeighborsRegressor
Xr = df[["flipper_length_mm"]].to_numpy()
yr = df["body_mass_g"].to_numpy()
Xr_train, Xr_test, yr_train, yr_test = train_test_split(Xr, yr, test_size=0.2, random_state=42)
print("Predicting body mass (g) from flipper length (mm)")
grid = np.linspace(Xr.min(), Xr.max(), 400).reshape(-1, 1)
fig = go.Figure()
fig.add_trace(go.Scatter(x=Xr_train.ravel(), y=yr_train, mode="markers", name="Training data"))
for k in [1, 25, 200]:
reg = KNeighborsRegressor(n_neighbors=k).fit(Xr_train, yr_train)
fig.add_trace(go.Scatter(x=grid.ravel(), y=reg.predict(grid), mode="lines", name=f"k = {k}"))
fig.update_layout(title="KNN regression: body mass vs flipper length",
xaxis_title="Flipper length (mm)", yaxis_title="Body mass (g)",
width=800, height=520)
fig.show()
(省略了原讲义中的配色与线宽参数。)
同一个权衡,换了件衣服。$k=1$ 是一条追着每个点跑的锯齿阶梯函数。$k=200$ 几乎是一条水平线。真正的趋势住在中间。
过拟合与欠拟合不是分类问题专有的现象。它们是关于模型复杂度的事实。
for k in [1, 25, 200]:
reg = KNeighborsRegressor(n_neighbors=k).fit(Xr_train, yr_train)
print(f"k={k:>3} train R^2 = {reg.score(Xr_train, yr_train):.3f} "
f"test R^2 = {reg.score(Xr_test, yr_test):.3f}")
在 k=1 时训练分数远高于测试分数:模型在死记硬背。 在 k=200 时两个分数一起塌掉,而且都很差:模型太简单,抓不住趋势。分类曲线讲过的故事,这里又讲了一遍。
编者拓展|$R^2$ 是什么:$R^2 = 1 - \frac{\sum_i (y_i - \hat{y}_i)^2}{\sum_i (y_i - \bar{y})^2}$,即"1 减去残差平方和与总平方和之比"。它可以理解为:相对于"永远预测训练集均值"这个基准,模型减少了多少平方误差。$R^2 = 0$ 表示与均值基线持平,$R^2 < 0$ 表示比均值基线还差——这在测试集上是可能发生的,尤其当模型严重过拟合或数据分布有偏移时。
Part 6:如果我们根本没有标签呢?
到目前为止的一切都是监督的(supervised):每只企鹅都带着它的物种。
现在假设一位野外研究员把同样的测量数据交给我们,完全没有任何物种标签,然后问:这里面有自然的分组吗?
这就是无监督学习(unsupervised learning),具体来说是聚类(clustering)。
# 把标签扔掉。
fig = px.scatter(df, x="bill_length_mm", y="flipper_length_mm",
title="The same penguins, with no labels",
labels={"bill_length_mm": "Bill length (mm)",
"flipper_length_mm": "Flipper length (mm)"},
height=520)
fig.update_traces(marker=dict(color="gray", size=7))
fig.show()
scikit-learn 中的 K-means
K-means 把数据划分成 $K$ 个簇,每个簇由一个质心(centroid)代表。它把每个点分配给最近的质心,然后把每个质心移动到属于它的那些点的均值处,如此反复。
from sklearn.cluster import KMeans
Xs = StandardScaler().fit_transform(X) # 又是基于距离的方法,所以要先标准化
kmeans = KMeans(n_clusters=3, n_init=10, random_state=42)
cluster = kmeans.fit_predict(Xs)
df_c = df.copy()
df_c["cluster"] = cluster.astype(str)
px.scatter(df_c, x="bill_length_mm", y="flipper_length_mm", color="cluster",
title="K-means with K = 3",
labels={"bill_length_mm": "Bill length (mm)",
"flipper_length_mm": "Flipper length (mm)"},
height=520).show()
Lloyd 算法,一步一步看
看着质心移动。每一次迭代都是两步:分配,然后更新。
def lloyd_steps(Xs, K=3, n_iter=6, seed=1):
rng = np.random.default_rng(seed)
centres = Xs[rng.choice(len(Xs), K, replace=False)].copy()
history = []
for _ in range(n_iter):
# 用广播一次算出所有点到所有质心的平方距离,形状 (n_points, K)
d = ((Xs[:, None, :] - centres[None, :, :]) ** 2).sum(axis=2)
assign = d.argmin(axis=1) # 分配步
history.append((centres.copy(), assign.copy()))
for j in range(K): # 更新步
if (assign == j).any():
centres[j] = Xs[assign == j].mean(axis=0)
return history
history = lloyd_steps(Xs)
print(f"{len(history)} iterations recorded")
# 把每一轮迭代做成动画的一帧
fig = go.Figure()
frames = []
for step, (centres, assign) in enumerate(history):
data = []
for j in range(3):
m = assign == j
data.append(go.Scatter(x=Xs[m, 0], y=Xs[m, 1], mode="markers",
name=f"cluster {j}"))
# 质心用黑色叉号标出
data.append(go.Scatter(x=centres[:, 0], y=centres[:, 1], mode="markers",
name="centroids"))
frames.append(go.Frame(data=data, name=str(step)))
fig.add_traces(frames[0].data)
fig.frames = frames
fig.update_layout(title="Lloyd's algorithm: assign, then update",
xaxis_title="Bill length (standardized)",
yaxis_title="Flipper length (standardized)",
width=800, height=560)
fig.show()
(省略了原讲义中动画播放按钮的配置与配色参数。)
flowchart TD
A["初始化:随机挑 K 个点当质心"] --> B["分配步<br/>每个点归到离它最近的质心"]
B --> C["更新步<br/>每个质心移到本簇所有点的均值"]
C --> D{"质心还在移动吗?"}
D -- "是(未收敛)" --> B
D -- "否(收敛或达到迭代上限)" --> E["输出每个点的簇标签"]
编者拓展|K-means 在优化什么:Lloyd 算法在最小化簇内平方和(within-cluster sum of squares, WCSS),又称惯性(inertia):
$$J(\{C_j\}, \{\mu_j\}) = \sum_{j=1}^{K} \sum_{x \in C_j} \|x - \mu_j\|_2^2.$$
分配步固定 $\mu_j$ 对每个点选最优簇,更新步固定簇归属对 $\mu_j$ 求最优解(均值正是平方误差的最优解)。所以每一步都不会让 $J$ 上升,而 $J \ge 0$,因此算法必然收敛——但只收敛到局部最优。这就是为什么
scikit-learn默认要跑n_init=10次不同初始化,取惯性最小的那一次。初始化方式k-means++正是为了缓解随机初始化太差的问题。
选择 K
K 是一个超参数,和 KNN 里的 $k$ 一模一样。但这里没有验证准确率可优化,因为没有标签。常用的启发式是肘部法(elbow method):把簇内平方和(惯性)对 K 画出来,找拐点。
Ks = range(1, 11)
inertia = [KMeans(n_clusters=k, n_init=10, random_state=42).fit(Xs).inertia_ for k in Ks]
fig = px.line(x=list(Ks), y=inertia, markers=True,
title="Elbow method: inertia vs K",
labels={"x": "K (number of clusters)", "y": "Within-cluster sum of squares"})
fig.update_layout(width=800, height=480)
fig.show()
拐点大约在 K=3 附近,这挺鼓舞人心。但请注意,读出这个结论需要多大的主观判断。肘部是一个启发式,不是一条准则。
编者说明:惯性随着 K 单调下降(K 越大,簇越小,每个点到质心的距离只会更近),所以不能选"惯性最小"的 K,只能看"下降速度在哪儿突然变缓"。
Part 7:聚类不是标签
我们在同样的两列上跑了两个模型。一个被告知了物种,一个没有。那么标签到底给了我们多少东西?
comparison = pd.crosstab(df_c["cluster"], df_c["species"])
comparison
fig = px.scatter(df_c, x="bill_length_mm", y="flipper_length_mm",
color="species", symbol="cluster",
title="True species (colour) vs discovered clusters (symbol)",
labels={"bill_length_mm": "Bill length (mm)",
"flipper_length_mm": "Flipper length (mm)"},
height=560)
fig.show()
K-means 在完全接触不到标签的情况下,基本重新发现了物种。
但要对这件事的含义保持警惕。 簇不是物种。K-means 找到的是在喙长和鳍长上彼此靠近的企鹅分组,只不过在这个数据集里,这些分组恰好和物种对齐了。换一组特征,簇就会变。算法里没有任何东西知道"物种"是什么,也没有任何东西保证它找到的分组对应你在意的任何事物。
编者拓展|怎么给聚类打分:在真正的无监督场景(没有标签来对照
crosstab)里,我们通常用内部指标衡量聚类质量,例如轮廓系数(silhouette coefficient),它同时考虑簇内紧致度和簇间分离度。但所有这些指标编码的都是"什么样的簇算好"的假设,没有一种能替代"这些簇对下游任务是否有用"这个判断。参见 https://scikit-learn.org/stable/modules/clustering.html#clustering-performance-evaluation。编者拓展|标签到底买到了什么:一个常被忽略的差别是语义。监督学习里的数字标签 $0,1,2$ 有明确含义(Adelie/Gentoo/Chinstrap),预测错了可以直接对应到具体代价;聚类给出的簇编号 $0,1,2$ 每次重跑都可能重新排列,必须人工对照才能赋予意义。这也是为什么聚类通常出现在探索性分析的早期,而不是最终交付的模型里。
附录:深入一步
以下材料课堂上没有讲。请自行完成,它是上面所用语法以及作业 1 的参考手册。
A1. pandas 的数据结构
DataFrame 是一张二维表。Series 是单独的一列。两者都建立在一个 Index 之上。
s = pd.Series([3750, 3800, 3250], index=["p0", "p1", "p2"], name="body_mass_g")
print(s)
print("\nindex:", s.index.tolist())
print("values:", s.values)
frame = pd.DataFrame({
"species": ["Adelie", "Gentoo", "Chinstrap"],
"bill_length_mm": [39.1, 46.1, 46.5],
"island": ["Torgersen", "Biscoe", "Dream"],
})
frame
A2. 探索一个 DataFrame
print(penguins.head(3)) # 前几行
print(penguins.tail(3)) # 后几行
print(penguins.sample(3)) # 随机几行
print(penguins.columns.tolist()) # 列名
print(penguins.dtypes) # 各列类型
print(penguins["island"].unique())
A3. loc 与 iloc 完整版
要记住的规则:iloc 按位置,不含终点;loc 按标签,含终点。
print(penguins.iloc[0]) # 第 0 行,作为一个 Series
print(penguins.iloc[0:3]) # 第 0、1、2 行
print(penguins.iloc[:, 2]) # 位置为 2 的那一列
print(penguins.iloc[[0, 5, 10], [0, 2]]) # 任意行与任意列
print(penguins.loc[0:2]) # 标签为 0、1、2 的行(含 2)
print(penguins.loc[:, "species":"bill_length_mm"]) # 按列名切片
print(penguins.loc[penguins["species"] == "Gentoo", "body_mass_g"].mean())
A4. 修改与排序
tmp = df.copy()
tmp["mass_kg"] = tmp["body_mass_g"] / 1000 # 新增一列
tmp["bill_ratio"] = tmp["bill_length_mm"] / tmp["bill_depth_mm"]
tmp = tmp.drop(columns=["bill_ratio"]) # 删除一列
tmp.sort_values("body_mass_g", ascending=False).head()
A5. 聚合、groupby 与透视表
print(df.groupby("species")["body_mass_g"].agg(["mean", "std", "count"]).round(1))
print()
print(df.groupby(["species", "island"])["flipper_length_mm"].mean().round(1))
print()
print(df.pivot_table(index="species", columns="island",
values="body_mass_g", aggfunc="mean").round(0))
A6. 连接两个 DataFrame
islands = pd.DataFrame({
"island": ["Torgersen", "Biscoe", "Dream"],
"latitude": [-64.77, -65.43, -64.73],
})
merged = df.merge(islands, on="island", how="left") # 也可以试 how="inner" 和 how="outer"
merged[["species", "island", "latitude"]].head()
A7. numpy 要点
a = np.arange(12).reshape(3, 4)
print(a)
print("shape:", a.shape, "| ndim:", a.ndim)
print("row sums:", a.sum(axis=1))
print("col means:", a.mean(axis=0))
print("boolean mask:", a[a > 6])
print("broadcasting:", (a - a.mean(axis=0)).round(2))
# 手算欧氏距离,这正是 KNN 计算的东西
p, q = X[0], X[1]
print("manual :", np.sqrt(((p - q) ** 2).sum()))
print("numpy :", np.linalg.norm(p - q))
A8. 可视化参考
matplotlib 与 seaborn 是静态绘图的标准工具;plotly 提供交互式图形,所以课堂上用的是它。
import matplotlib.pyplot as plt
import seaborn as sns
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
axes[0].hist(df["body_mass_g"], bins=25, color="#002675")
axes[0].set_title("Body mass"); axes[0].set_xlabel("g")
sns.scatterplot(data=df, x="bill_length_mm", y="flipper_length_mm",
hue="species", ax=axes[1])
axes[1].set_title("By species")
plt.tight_layout(); plt.show()
# Plotly:直方图、箱线图,以及按岛屿分面的散点图
px.histogram(df, x="body_mass_g", color="species", nbins=30, height=420).show()
px.box(df, x="species", y="flipper_length_mm", color="species", height=420).show()
px.scatter(df, x="bill_length_mm", y="flipper_length_mm",
color="species", facet_col="island", height=420).show()
编者拓展|把这一讲串起来的一张图:今天出现的所有概念其实围绕同一个张力展开——模型复杂度。KNN 的 $k$ 越大、K-means 的 K 越大(在这个方向上是簇越细),模型能表达的边界就越复杂或越简单。而"复杂度该取多少"这个问题,在监督场景里由验证集回答,在无监督场景里只能由人的判断(肘部法、轮廓系数、领域知识)回答。后面几讲会把这个直觉形式化为偏差–方差分解(bias–variance decomposition),并把"用验证集选复杂度"推广成模型选择的一般框架。