无监督学习与表示探索
到目前为止,每条训练记录旁边都有一个答案:耗时是多少,或者有没有超时。如果只有输入,没有人告诉我们答案,还能学到什么?
我们仍然可以寻找结构:哪些记录彼此相似,哪些方向包含主要变化,哪些点与常见模式相距很远。这些问题属于无监督学习。它可以帮助探索数据和构造表示,但结果的含义需要我们继续检验。
1. “相似”从哪里来
先想象两条记录都包含页数与队列长度。用欧几里得距离衡量它们的差异:
距离公式本身很简单,难点在于我们给它什么输入。如果一列是几万字节的文件大小,另一列是个位数的队列长度,前一列可能支配距离。
标准化可以调整尺度,却不能替我们决定业务含义。两份页数相同、模式不同的文档,算不算相似?用户编号相近是否有意义?聚类结果首先取决于特征和距离的选择。
前面做监督学习时,标签能告诉我们某种表示是否有助于预测;没有标签时,这层约束更弱,更需要回到原始记录查看。
2. K-means:找几个能代表大家的中心
K-means先指定簇数 ,寻找 个中心,让每个样本离它所属簇的中心尽量近。常用目标是:
是第 个样本所属的簇编号, 是对应中心。中心不是某个必须存在的真实样本,而是该簇各坐标的平均值。
基本算法交替做两件事:
- 固定中心,把每个点分给最近的中心。
- 固定分组,把每个中心移到组内样本的平均位置。
重复后,目标通常会下降并稳定,但不保证找到全局最优。初始中心不同,最后结果也可能不同。代码明确设置 n_init=10,尝试十次初始化,保留目标较小的结果;固定随机种子方便复现这次实验。
怎样选 ?可以结合用途、分组稳定性与轮廓系数等指标。轮廓系数大致比较“离自己这一组近不近”和“离其他组远不远”,但仍依赖距离定义。不能只选训练目标最小的 :让每个样本自己成一簇,目标就能降到零,却未必有用。
K-means 偏好在所用距离下较紧凑的团状结构。弯曲的月牙、密度差异很大的群体,未必适合它。聚类算法找到的“簇”也不等于自然界已经存在的“类别”;簇编号 0、1、2 更没有高低顺序。
3. PCA:换个方向看数据
第一章已经见过:向量可以投影到某个方向。如果两列特征大致一起增加,点云就会沿着一条斜线展开。我们能不能沿着这条斜线记录位置,用一个数保留大部分变化?
这就是主成分分析(PCA)的出发点。先把每列中心移到零,再寻找单位方向 ,让投影值:
具有尽可能大的方差。要求 ,否则只需把方向向量无限放大,就能随意放大投影方差。
第一主成分保留变化最大的方向。第二主成分与第一个方向垂直,并在剩余方向中尽量保留变化,后面依此类推。数值软件通过矩阵分解求这些方向,本章先关注投影在做什么。
图 1:这是一幅几何示意图,保留方向取为对角线。投影用更少坐标描述数据,同时舍弃垂直方向的变化。
例如所有点都形如 ,取单位方向:
则投影为 ,再用 可以精确还原原点。若点偏离对角线,重建就会有误差。
保留 个方向时,把它们作为列组成矩阵 ,形状为 。中心化数据矩阵 的形状为 ,于是:
只有 列,是新的表示。还原到原坐标时再加回训练均值。如果先做过标准化,还需要把尺度也换回来。
PCA 会中心化输入,但不会自动把各列缩放到相同方差。是否先标准化,取决于原单位与任务;我们这次先标准化,再做 PCA。
4. 保留了大部分方差,就保留了全部有用信息吗
不一定。PCA 不知道未来要预测什么,方差最大的方向未必最能区分类别。假设一列变化很大却只是背景噪声,另一列只发生细微变化,却能提示故障,压缩时后者可能被丢掉。
二维图也不是原空间的完整地图。高维中相距很远的点,投影后可能重叠;某个方向被舍弃,图上就看不见那部分区别。
因此,“图上分成三团”可以引导我们检查原始数据,不能单独证明存在三种真实类型。坐标轴正负翻转也不会改变 PCA 保留的信息:一根轴朝左或朝右,数学意义可以相同。
接下来做一个小实验。我们生成三组二维点,再构造第三列:
第三列与前两列高度相关,因此用二维表示应该能保留很多变化。数据中的分组来自人为生成规则,程序拟合时没有得到组别标签。
图 2:聚类在标准化后的三维空间进行,PCA 只负责展示。图上的颜色来自聚类编号,不是人工给出的真实标签。
5. 距离很远的点,要不要报警
另一种探索方式,是给新样本计算“离常见模式有多远”的分数。例如,取它到最近簇中心的距离:
分数高的点可以优先检查。这是一种简单的异常筛选思路,不是故障概率。距离远可能意味着录入错误、新模式或合法的罕见任务;距离近也可能隐藏问题。
本例把训练距离的 95% 分位点当作演示阈值。它描述训练样本上的一个位置,并不保证未来误报率恰好为 5%。如果真的要用于报警,应在独立、具有代表性的正常数据上设定阈值,并用标注异常评估发现能力。
如果以 PCA 重建误差作为异常分数,还要问:重要异常会不会恰好沿着被保留的方向变化?不同分数会看见不同的问题,不能只因为有一个数就把它当作完整诊断。
6. 把探索跑起来
完整脚本使用第八章的环境:
python src/examples/unsupervised.py
"""第 11 章:不提供标签,探索聚类、二维投影与离群分数。"""
import numpy as np
from sklearn.cluster import KMeans
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
def make_data():
rng = np.random.default_rng(2026)
xy = np.vstack([rng.normal(center, 0.65, (100, 2))
for center in [(-3, -1), (0, 3), (3, -1)]])
third = 0.8*xy[:, 0] + 0.3*xy[:, 1] + rng.normal(0, 0.2, len(xy))
return np.column_stack([xy, third])
def fit():
x = make_data()
scaler = StandardScaler().fit(x)
z = scaler.transform(x)
clustering = KMeans(n_clusters=3, n_init=10, random_state=2026).fit(z)
pca = PCA(n_components=2, svd_solver="full").fit(z)
return x, scaler, z, clustering, pca
def main():
x, scaler, z, clustering, pca = fit()
counts = np.bincount(clustering.labels_, minlength=3)
print("三簇样本数(排序后):" + str(sorted(counts.tolist())))
ratios = pca.explained_variance_ratio_
print("前两主成分解释方差比例:" + ", ".join(f"{r:.3f}" for r in ratios))
print(f"合计保留方差比例:{ratios.sum():.3f}")
reconstructed = pca.inverse_transform(pca.transform(z))
print(f"标准化空间重建 MSE:{np.mean((z-reconstructed)**2):.4f}")
distances = clustering.transform(z).min(axis=1)
threshold = np.quantile(distances, 0.95)
queries = np.array([[3, -1, 2.1], [0, 6, 1.8], [0, 3, 8.0]])
scores = clustering.transform(scaler.transform(queries)).min(axis=1)
print(f"训练距离的 95% 分位点:{threshold:.3f}")
for i, score in enumerate(scores, start=1):
print(f"新点 {i}:距离={score:.3f},标记待检查={bool(score > threshold)}")
if __name__ == "__main__":
main()
输出为:
三簇样本数(排序后):[100, 100, 100]
前两主成分解释方差比例:0.660, 0.338
合计保留方差比例:0.999
标准化空间重建 MSE:0.0014
训练距离的 95% 分位点:0.916
新点 1:距离=0.055,标记待检查=False
新点 2:距离=1.539,标记待检查=True
新点 3:距离=3.305,标记待检查=True
前两主成分合计保留约 99.9% 的方差;分项显示值经过舍入,所以不必与显示总和逐位相加一致。重建 MSE 在标准化空间中计算,不带原始传感器单位。
三个新点都没有参与标准化、聚类、PCA 或阈值的拟合。第一个接近常见簇中心,第二个在前两列偏离常见区域,第三个的第三列明显破坏了原有相关关系,因此后两个距离更高。
这里用全部 300 个已有样本探索结构,没有宣称独立测试性能。如果后续把 PCA 表示用于监督模型,仍然要像第八章那样,将标准化、PCA 和分类器放进同一个 Pipeline,在每个训练折内拟合。
7. 第二篇实践:完成一个表格数据项目
现在我们把第六至十一章串起来。目标是根据页数、队列长度和打印模式预测耗时,比较线性模型与树模型,并写出一份能被重新运行的结果记录。
数据与问题
为了让你无需下载外部数据就能完成实验,这里仍使用合成数据。生成规则记作 v1:500 条独立记录,页数为 5~80 的整数,队列长度为 0~20,模式为黑白或彩色。
真实耗时由我们设定:
为页数, 为队列长度, 在彩色时为 1、黑白时为 0,噪声标准差为 3 秒。彩色与页数存在交互,超过 50 页还有额外耗时。这个规则让直线与树面对不同的表达挑战,不代表真实设备一定这样工作。
生成标签以后,各有约 5% 的概率把页数或队列长度遮成缺失。模型只能看到遮盖后的输入,不能偷看生成标签时使用的原值。
随机种子为 2026,保留 100 条测试,其余 400 条用于开发。比较均值基线、三个正则化强度的岭回归,以及三个最大深度设置的随机森林。所有候选使用相同五折划分;每折独立拟合填补、缩放与编码。
这里的森林每个叶子至少保留三个样本。最大深度为 None 只表示没有显式深度上限,不代表没有任何复杂度约束。
完整代码
项目脚本独立包含生成数据、预处理、选择模型、最终评估和错误分析:
python src/examples/tabular_project.py
"""第二篇实践:独立可运行的合成打印任务回归项目,数据生成规则 v1。"""
import numpy as np
import sklearn
from sklearn.compose import ColumnTransformer
from sklearn.dummy import DummyRegressor
from sklearn.ensemble import RandomForestRegressor
from sklearn.impute import SimpleImputer
from sklearn.linear_model import Ridge
from sklearn.metrics import mean_absolute_error
from sklearn.model_selection import GridSearchCV, KFold, cross_val_score, train_test_split
from sklearn.pipeline import Pipeline, make_pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
def make_data():
rng = np.random.default_rng(2026)
pages = rng.integers(5, 81, 500)
queue = rng.integers(0, 21, 500)
color = rng.integers(0, 2, 500)
duration = (20 + 2*pages + 1.5*queue + color*(10+0.8*pages)
+ 25*(pages > 50) + rng.normal(0, 3, 500))
x = np.column_stack([pages, queue, np.where(color, "color", "mono")]).astype(object)
x[:, :2] = x[:, :2].astype(float)
x[rng.random(500) < 0.05, 0] = np.nan
x[rng.random(500) < 0.05, 1] = np.nan
ids = np.arange(500)
return train_test_split(x, duration, ids, test_size=0.2, random_state=2026)
def preprocessing():
return ColumnTransformer([
("numeric", make_pipeline(SimpleImputer(strategy="median", add_indicator=True),
StandardScaler()), [0, 1]),
("category", OneHotEncoder(handle_unknown="ignore", sparse_output=False), [2]),
])
def run():
x_train, x_test, y_train, y_test, ids_train, ids_test = make_data()
folds = KFold(n_splits=5, shuffle=True, random_state=2026)
searches = {}
candidates = [
("岭回归", Ridge(), {"model__alpha": [0.1, 1.0, 10.0]}),
("随机森林", RandomForestRegressor(n_estimators=150, min_samples_leaf=3,
random_state=2026, n_jobs=1),
{"model__max_depth": [4, 8, None]}),
]
baseline = DummyRegressor()
baseline_scores = -cross_val_score(baseline, x_train, y_train, cv=folds,
scoring="neg_mean_absolute_error")
for name, estimator, grid in candidates:
pipeline = Pipeline([("prepare", preprocessing()), ("model", estimator)])
search = GridSearchCV(pipeline, grid, cv=folds,
scoring="neg_mean_absolute_error", refit=True, n_jobs=1)
search.fit(x_train, y_train)
searches[name] = search
name = max(searches, key=lambda key: searches[key].best_score_)
selected = searches[name]
prediction = selected.predict(x_test)
baseline.fit(x_train, y_train)
baseline_prediction = baseline.predict(x_test)
return (x_test, y_test, ids_test, prediction, baseline_prediction,
baseline_scores, searches, name)
def main():
x_test, y_test, ids, prediction, baseline_prediction, baseline_scores, searches, name = run()
print(f"环境:scikit-learn {sklearn.__version__};NumPy {np.__version__}")
print("数据规则 v1;种子 2026;开发集 400 条;测试集 100 条")
print(f"均值基线五折 MAE:{baseline_scores.mean():.3f} 秒")
for key, search in searches.items():
print(f"{key}:最佳五折 MAE={-search.best_score_:.3f} 秒;"
f"参数={search.best_params_}")
errors = prediction-y_test
print(f"固定模型:{name}")
print(f"测试 MAE:模型={mean_absolute_error(y_test, prediction):.3f} 秒;"
f"基线={mean_absolute_error(y_test, baseline_prediction):.3f} 秒")
pages = x_test[:, 0].astype(float)
masks = [("黑白", x_test[:, 2] == "mono"), ("彩色", x_test[:, 2] == "color"),
("页数缺失", np.isnan(pages)), ("页数完整", ~np.isnan(pages))]
for label, mask in masks:
if mask.any():
print(f"{label}:{mask.sum()} 条,MAE={np.abs(errors[mask]).mean():.3f} 秒")
print("绝对误差最大的三条:")
for index in np.argsort(-np.abs(errors), kind="stable")[:3]:
print(f"ID={ids[index]},页数={pages[index]:g},模式={x_test[index, 2]},"
f"实际={y_test[index]:.1f},预测={prediction[index]:.1f},"
f"误差={errors[index]:+.1f} 秒")
if __name__ == "__main__":
main()
本次运行结果为:
环境:scikit-learn 1.9.1;NumPy 2.5.4
数据规则 v1;种子 2026;开发集 400 条;测试集 100 条
均值基线五折 MAE:57.224 秒
岭回归:最佳五折 MAE=11.838 秒;参数={'model__alpha': 0.1}
随机森林:最佳五折 MAE=7.262 秒;参数={'model__max_depth': None}
固定模型:随机森林
测试 MAE:模型=4.774 秒;基线=54.920 秒
黑白:45 条,MAE=4.337 秒
彩色:55 条,MAE=5.131 秒
页数缺失:2 条,MAE=15.042 秒
页数完整:98 条,MAE=4.564 秒
绝对误差最大的三条:
ID=447,页数=nan,模式=color,实际=181.8,预测=203.4,误差=+21.6 秒
ID=183,页数=19,模式=color,实际=115.8,预测=100.2,误差=-15.7 秒
ID=280,页数=50,模式=mono,实际=131.5,预测=145.2,误差=+13.7 秒
GridSearchCV 在开发数据的折内比较参数,并自动用最佳参数在完整开发集重新拟合。我们再根据两类模型的最佳交叉验证分数选择模型,最后才计算保留测试的预测。
为便于解释,测试时也报告了事先规定的均值基线。我们没有根据测试结果在两者之间重新选择。最佳交叉验证分数经历过选择,不能当成无偏的最终性能估计。
把结果写成一段有依据的报告
图 3:图中的模型在查看测试结果之前已经确定。缺失页数组只有两条,数值值得调查,却不足以支持稳定的群体结论。
可以这样记录本次实验:
在数据生成规则 v1、种子 2026 和既定划分下,随机森林的最佳五折 MAE 为 7.262 秒,低于当前岭回归配置。固定模型后,100 条保留测试上的 MAE 为 4.774 秒,均值基线为 54.920 秒。页数缺失的两条记录平均误差较大,其中 ID 447 是误差最大的样本。下一步应在开发数据中进一步检验缺失信息的影响,并收集更多类似记录。
这段报告同时说明了比较对象、评估数据和证据范围。它没有把模拟结果包装成真实生产性能,也没有把两个缺失样本推广成确定规律。
三个大误差记录还提示了不同方向:缺失页数让模型失去重要信息;其他误差可能涉及队列、局部数据稀疏或 50 页附近的阈值。要判断原因,需要回看全部可用特征和邻近样本,不能只凭 ID 和一行输出定论。
如果准备改进模型,可以在开发集上增加第九章的“彩色页数”交互特征,或比较其他树参数。测试集已经被用于这次分析,后续报告应说明这一点;若要给更新后的方案提供新的独立证据,就需要新的保留数据。
这份项目也没有强行加入聚类或 PCA。只有当它们服务于明确问题,例如探索输入群体或压缩高度相关的多列特征时,才值得作为候选步骤进入验证流程。学过一个算法,不代表每个项目都需要它。
8. 自测与小实验
- K-means 的中心必须是训练样本中的一个点吗?
- 为什么不能只凭训练目标越来越小就不断增加 ?
- PCA 中为什么要求方向向量长度为 1?
- 保留 99% 方差,能保证保留 99% 的分类能力吗?
- 一个点离簇中心很远,是否说明它一定是错误记录?
- 本例聚类发生在二维 PCA 图上,还是标准化后的三维空间?
- 若原始输入有 20 列,保留 3 个主成分,投影矩阵 和 100 条样本的表示 分别是什么形状?
- 篇末项目中页数缺失组只有两条。这个结果能支持什么,不能支持什么?
展开参考答案
- 不必。中心通常是组内各坐标的均值,可能没有真实样本恰好位于那里。
- 簇越多越容易降低这个目标,极端情况下每个点自成一簇,目标为零,却失去了有用概括。
- 防止通过任意放大向量来放大投影方差,让比较真正反映方向的作用。
- 不能。PCA 不使用标签,低方差方向也可能包含重要的预测信息。
- 不一定。它也可能是合法的罕见模式或新情况,需要进一步检查。
- 三维空间。二维图只是展示聚类结果。
- 为 , 为 。
- 能提示值得调查的错误类型;不能可靠估计所有缺失页数任务的平均表现,也不能单凭这两个样本断言某种处理方法必然更好。
选做一:把无监督脚本的第三列改成独立噪声,观察前两主成分保留比例与重建误差。原来容易压缩的原因是什么?
选做二:为篇末项目增加一个候选线性模型,加入“彩色页数”和“是否超过 50 页”两个特征,只用开发集比较。记录改善来自模型更复杂,还是表示更贴近生成规则;这次已知真函数的练习,也要与真实项目中未知规律的探索区分开。
第二篇到这里结束。我们已经有了一套从数据、训练到评估的工作方式。接下来的神经网络会把“怎样构造表示”更多地交给模型学习。
聚类与降维的具体接口,可查阅 scikit-learn 的 K-means 指南与 PCA 指南。