Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

无监督学习与表示探索

到目前为止,每条训练记录旁边都有一个答案:耗时是多少,或者有没有超时。如果只有输入,没有人告诉我们答案,还能学到什么?

我们仍然可以寻找结构:哪些记录彼此相似,哪些方向包含主要变化,哪些点与常见模式相距很远。这些问题属于无监督学习。它可以帮助探索数据和构造表示,但结果的含义需要我们继续检验。

1. “相似”从哪里来

先想象两条记录都包含页数与队列长度。用欧几里得距离衡量它们的差异:

d(x,z)=∑j=1d(xj−zj)2 d(\boldsymbol x,\boldsymbol z) =\sqrt{\sum_{j=1}^d(x_j-z_j)^2}

距离公式本身很简单,难点在于我们给它什么输入。如果一列是几万字节的文件大小,另一列是个位数的队列长度,前一列可能支配距离。

标准化可以调整尺度,却不能替我们决定业务含义。两份页数相同、模式不同的文档,算不算相似?用户编号相近是否有意义?聚类结果首先取决于特征和距离的选择。

前面做监督学习时,标签能告诉我们某种表示是否有助于预测;没有标签时,这层约束更弱,更需要回到原始记录查看。

2. K-means:找几个能代表大家的中心

K-means先指定簇数 KK,寻找 KK 个中心,让每个样本离它所属簇的中心尽量近。常用目标是:

J=∑i=1n∥xi−μci∥22 J=\sum_{i=1}^n \|\boldsymbol x_i-\boldsymbol\mu_{c_i}\|_2^2

cic_i 是第 ii 个样本所属的簇编号,μci\boldsymbol\mu_{c_i} 是对应中心。中心不是某个必须存在的真实样本,而是该簇各坐标的平均值。

基本算法交替做两件事:

  1. 固定中心,把每个点分给最近的中心。
  2. 固定分组,把每个中心移到组内样本的平均位置。

重复后,目标通常会下降并稳定,但不保证找到全局最优。初始中心不同,最后结果也可能不同。代码明确设置 n_init=10,尝试十次初始化,保留目标较小的结果;固定随机种子方便复现这次实验。

怎样选 KK?可以结合用途、分组稳定性与轮廓系数等指标。轮廓系数大致比较“离自己这一组近不近”和“离其他组远不远”,但仍依赖距离定义。不能只选训练目标最小的 KK:让每个样本自己成一簇,目标就能降到零,却未必有用。

K-means 偏好在所用距离下较紧凑的团状结构。弯曲的月牙、密度差异很大的群体,未必适合它。聚类算法找到的“簇”也不等于自然界已经存在的“类别”;簇编号 0、1、2 更没有高低顺序。

3. PCA:换个方向看数据

第一章已经见过:向量可以投影到某个方向。如果两列特征大致一起增加,点云就会沿着一条斜线展开。我们能不能沿着这条斜线记录位置,用一个数保留大部分变化?

这就是主成分分析(PCA)的出发点。先把每列中心移到零,再寻找单位方向 u\boldsymbol u,让投影值:

ti=uTxi t_i=\boldsymbol u^\mathsf T\boldsymbol x_i

具有尽可能大的方差。要求 ∥u∥2=1\|\boldsymbol u\|_2=1,否则只需把方向向量无限放大,就能随意放大投影方差。

第一主成分保留变化最大的方向。第二主成分与第一个方向垂直,并在剩余方向中尽量保留变化,后面依此类推。数值软件通过矩阵分解求这些方向,本章先关注投影在做什么。

图 1:这是一幅几何示意图,保留方向取为对角线。投影用更少坐标描述数据,同时舍弃垂直方向的变化。

例如所有点都形如 (a,a)(a,a),取单位方向:

u=12[11] \boldsymbol u=\frac1{\sqrt2} \begin{bmatrix}1\\1\end{bmatrix}

则投影为 t=2at=\sqrt2a,再用 tut\boldsymbol u 可以精确还原原点。若点偏离对角线,重建就会有误差。

保留 kk 个方向时,把它们作为列组成矩阵 WW,形状为 d×kd\times k。中心化数据矩阵 XcX_c 的形状为 n×dn\times d,于是:

T=XcW,X^c=TWT T=X_cW,\qquad \hat X_c=TW^\mathsf T

TT 只有 kk 列,是新的表示。还原到原坐标时再加回训练均值。如果先做过标准化,还需要把尺度也换回来。

PCA 会中心化输入,但不会自动把各列缩放到相同方差。是否先标准化,取决于原单位与任务;我们这次先标准化,再做 PCA。

4. 保留了大部分方差,就保留了全部有用信息吗

不一定。PCA 不知道未来要预测什么,方差最大的方向未必最能区分类别。假设一列变化很大却只是背景噪声,另一列只发生细微变化,却能提示故障,压缩时后者可能被丢掉。

二维图也不是原空间的完整地图。高维中相距很远的点,投影后可能重叠;某个方向被舍弃,图上就看不见那部分区别。

因此,“图上分成三团”可以引导我们检查原始数据,不能单独证明存在三种真实类型。坐标轴正负翻转也不会改变 PCA 保留的信息:一根轴朝左或朝右,数学意义可以相同。

接下来做一个小实验。我们生成三组二维点,再构造第三列:

x3=0.8x1+0.3x2+εε∼N(0,0.22) \begin{aligned} x_3&=0.8x_1+0.3x_2+\varepsilon\\ \varepsilon&\sim\mathcal N(0,0.2^2) \end{aligned}

第三列与前两列高度相关,因此用二维表示应该能保留很多变化。数据中的分组来自人为生成规则,程序拟合时没有得到组别标签。

图 2:聚类在标准化后的三维空间进行,PCA 只负责展示。图上的颜色来自聚类编号,不是人工给出的真实标签。

5. 距离很远的点,要不要报警

另一种探索方式,是给新样本计算“离常见模式有多远”的分数。例如,取它到最近簇中心的距离:

a(x)=min⁡k∥x−μk∥2 a(\boldsymbol x)=\min_k \|\boldsymbol x-\boldsymbol\mu_k\|_2

分数高的点可以优先检查。这是一种简单的异常筛选思路,不是故障概率。距离远可能意味着录入错误、新模式或合法的罕见任务;距离近也可能隐藏问题。

本例把训练距离的 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,模式为黑白或彩色。

真实耗时由我们设定:

y=20+2p+1.5q+c(10+0.8p)+251{p>50}+ε \begin{aligned} y={}&20+2p+1.5q\\ &+c(10+0.8p)\\ &+25\mathbf1_{\{p>50\}}+\varepsilon \end{aligned}

pp 为页数,qq 为队列长度,cc 在彩色时为 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. 自测与小实验

  1. K-means 的中心必须是训练样本中的一个点吗?
  2. 为什么不能只凭训练目标越来越小就不断增加 KK?
  3. PCA 中为什么要求方向向量长度为 1?
  4. 保留 99% 方差,能保证保留 99% 的分类能力吗?
  5. 一个点离簇中心很远,是否说明它一定是错误记录?
  6. 本例聚类发生在二维 PCA 图上,还是标准化后的三维空间?
  7. 若原始输入有 20 列,保留 3 个主成分,投影矩阵 WW 和 100 条样本的表示 TT 分别是什么形状?
  8. 篇末项目中页数缺失组只有两条。这个结果能支持什么,不能支持什么?
展开参考答案
  1. 不必。中心通常是组内各坐标的均值,可能没有真实样本恰好位于那里。
  2. 簇越多越容易降低这个目标,极端情况下每个点自成一簇,目标为零,却失去了有用概括。
  3. 防止通过任意放大向量来放大投影方差,让比较真正反映方向的作用。
  4. 不能。PCA 不使用标签,低方差方向也可能包含重要的预测信息。
  5. 不一定。它也可能是合法的罕见模式或新情况,需要进一步检查。
  6. 三维空间。二维图只是展示聚类结果。
  7. WW 为 20×320\times3,TT 为 100×3100\times3。
  8. 能提示值得调查的错误类型;不能可靠估计所有缺失页数任务的平均表现,也不能单凭这两个样本断言某种处理方法必然更好。

选做一:把无监督脚本的第三列改成独立噪声,观察前两主成分保留比例与重建误差。原来容易压缩的原因是什么?

选做二:为篇末项目增加一个候选线性模型,加入“彩色页数”和“是否超过 50 页”两个特征,只用开发集比较。记录改善来自模型更复杂,还是表示更贴近生成规则;这次已知真函数的练习,也要与真实项目中未知规律的探索区分开。

第二篇到这里结束。我们已经有了一套从数据、训练到评估的工作方式。接下来的神经网络会把“怎样构造表示”更多地交给模型学习。

聚类与降维的具体接口,可查阅 scikit-learn 的 K-means 指南与 PCA 指南。