🤖 第13章 无监督降维
📄 一句话概括

主成分分析就是上一节的问题解到底:把协方差矩阵做特征分解,按特征值从大到小排, 前几个特征向量就是主成分。 S 曲线上三个特征值 1.29695、0.33869、0.07289,解释比 75.91%、19.82%、4.27%; 降到二维保住 89.60% 的 10 近邻,降到一维只剩 22.80%。 两个软肋:把第三个坐标乘 3,PC1 的解释比从 75.91% 跳到 94.20%; 换到瑞士卷上,前两个主成分吃掉 91.56% 的方差,近邻却只保住 40.30%。

📝 本节和前面几节的关系

上一节证明了「丢掉一个方向的重建误差等于那个方向的方差」,并且给出了最优解的形式。 这一节把那个解完整算出来,给它起名字、看它的性质、然后专门找它的麻烦。 PCA 是这一章里唯一「没有任何参数」的方法:不用调 k、不用调带宽、不用建图。 输入矩阵,输出特征向量,中间没有可拧的旋钮。 这种「无参数」常常被当成优点,但它真正的含义是「你没法通过调参绕过它的偏见」。

一、从「一个方向」到「一组正交方向」

上一节只丢了最小的那个方向。如果还想再降一维呢?那就再丢一个。 但第二个方向不能随便挑——它必须和第一个正交,否则两个方向重叠,等于有一个白丢了。 于是问题变成:在协方差矩阵的所有正交特征方向里,按特征值从大到小排队,要留几维就留前几个。 这组方向叫主成分(principal component),第一个叫 PC1,第二个叫 PC2。 为什么必须正交?因为「方差可以相加」这件事只在正交方向之间成立。斜着挑两个方向, 它们各占一点同一份波动,加起来会超过总方差,解释比就没法算了。 正交是让「贡献」这个概念有意义的前提。 实对称矩阵的特征向量天生互相正交,所以这件事不用额外做——直接调 eigh 出来就是正交的。 这也解释了为什么 PCA 的代码短得离谱:整个算法就是一行特征分解。

二、协方差矩阵的特征分解

具体算法四步:① 每列减均值;② 算 C 等于 X'X 除以 n;③ 对 C 做特征分解; ④ 按特征值降序排,取前 k 个特征向量组成投影矩阵。 投影就是 X 乘这个矩阵,出来的就是 k 维坐标。 在 S 曲线这份数据上,三个特征值是 1.29695、0.33869、0.07289。 注意它们加起来正好是协方差矩阵的迹 1.70853——这就是「方差可以按正交方向拆开、 拆完加起来还原」的体现。 顺带把上一节的结论接上:把这三个特征值从大到小加起来,就是「保留前 k 维能保住的方差」。 所以降维阶梯和特征分解是同一件事的两种说法。 还有一点必须点明:特征值的正负。 协方差矩阵是半正定的,特征值理论上不会为负; 实测出现 -1e-17 这种数就是浮点误差,取 max(0, λ) 就行,别当回事。 但这件事在 13.4 节的核矩阵里会变成真问题——那里不中心化就会出现负数平方根。

三、解释比与累计贡献率

解释比就是「某个特征值除以特征值之和」,表示这一维吃掉了多少波动。 累计贡献率就是从第一维加到第 k 维的解释比之和,工程上常用它决定留几维。

主成分 特征值 解释比 累计
PC1 1.29695 0.7591 0.7591
PC2 0.33869 0.1982 0.9573
PC3 0.07289 0.0427 1.0000
PC1 一个方向就吃掉 75.91%,前两个加起来 95.73%。按「95% 就够了」这条常见惯例,
这份数据降到二维是合理的。
但「解释比」这个词有个陷阱:它量的是方差,不是信息。
方差大只说明「在这个方向上散得开」,不说明「这个方向对你要做的事有用」。
一个纯噪声的坐标如果量程特别大,解释比也会很高——这正是下面第五节的坑。

四、方差保住了,邻居呢

光看方差是不够的,得看降维之后「点与点的相对位置」有没有留住。这里用两个指标: 10 近邻保持率(原始空间的 10 个最近邻, 在低维空间里还剩几个)和 stress(两两距离归一化之后的均方根差)。

降到几维 10 近邻保持率 stress 剩下多少方差
1 维 0.2280 — 0.7591
2 维 0.8960 0.0468 0.9573
这份数据上二维保住了 89.60% 的近邻,一维只剩 22.80%。
方差从 95.73% 掉到 75.91% 只掉了 20 个百分点,
邻居却掉了 67 个百分点——降维对「局部结构」的伤害,比对「总体波动」的伤害大得多。
为什么会这样?因为 PC3 虽然只占 4.27% 的方差,但它对应的正是数据的「宽度」方向。
宽度方向方差小,是因为它窄;可「谁挨着谁」恰恰主要由这个窄方向决定——在一条细长的带子上,
最近的邻居往往是并排的,不是前后的。
这条经验可以推广:只要数据是「细长」的,降到一维就会大量丢邻居,不管方差看起来很漂亮。
所以判断降维够不够,光看累计贡献率是不负责任的,必须另外量一次近邻。

五、软肋一:对坐标单位敏感

PCA 有一个性质和对旋转、平移的不变性正好相反:它对每个坐标轴的「单位」极其敏感。 把第三个坐标乘 3,物理上什么都没变(只是换了个单位,比如从米换成三分米), 三个解释比从 75.91%、19.82%、4.27% 变成 94.20%、4.33%、1.47%。 PC1 一下子从 75.91% 冲到 94.20%,主成分方向被第三个坐标硬拽了过去。 原因是特征值本身就是「方差」,而方差带单位的平方。 谁的单位大,谁的数值范围大,谁就主导特征分解。 这不是 bug,是定义决定的:PCA 找的是「波动最大的方向」,你把某一列的数放大十倍, 它当然就成了波动最大的方向。 工程上的标准做法是标准化:每列减均值、除标准差。这样所有列的方差都是 1,谁也不占便宜。 注意这一步必须在降维之前做,顺序反了就没意义。 但标准化也不是无脑的好: 如果两列本来就是同一量纲、可比大小(比如都是同一个指标的两次测量), 标准化会把「哪个更重要」这个信息一起抹掉。 所以正确的说法是「同量纲不必标准化,不同量纲必须标准化」,不是「一律标准化」。

六、软肋二:只认方差,不认邻接

换一份真的弯了的数据:瑞士卷,600 个点,一张纸卷了一圈半,内圈半径 4.7、外圈 14.1。

主成分 特征值 解释比
PC1 48.4229 0.4862
PC2 42.7728 0.4294
PC3 8.4051 0.0844
前两个主成分加起来 91.56% 的方差,可 10 近邻只保住 40.30%,stress 0.1029。
这两个数放在一起,就是 PCA 的软肋:它保的是「离中心多远、往哪个方向」,不是「谁挨着谁」。
卷起来以后,隔着两层的两个点投影到同一个位置——它们之间的直线距离没变形,于是方差一点没丢;
可它们在三维里隔着一整层纸,本来根本不是邻居。
换句话说:PCA 看不见「纸」。
它只看见点云的三维坐标,不知道这些点是卷出来的、折出来的还是本来就长这样。
要让它看见纸,就得把「谁和谁挨着」这件事显式地告诉它——这正是下一节局部保持投影要做的事。

七、动手 Demo:主成分分析与它的两个软肋

这段代码把 S 曲线和瑞士卷两份数据都跑一遍:先看特征值与解释比,再量降维之后的近邻保持率, 然后验「旋转不变」和「尺度敏感」这两件性质相反的事。 注意看三处:二维和一维的保持率差多少、旋转前后是不是完全一样、 以及瑞士卷上「91.56% 方差对 40.30% 邻居」那一对数字。

import numpy as np

def dmat(A, B):
    return np.sqrt(np.maximum(((A[:, None, :] - B[None, :, :]) ** 2).sum(-1), 0.0))

def pca(X, k=2):
    Xc = X - X.mean(axis=0)
    C = Xc.T @ Xc / len(X)
    w, V = np.linalg.eigh(C)
    o = np.argsort(w)[::-1]
    return Xc @ V[:, o][:, :k], w[o]

def keep_ratio(X, Z, k=10):
    D1, D2 = dmat(X, X), dmat(Z, Z)
    np.fill_diagonal(D1, np.inf)
    np.fill_diagonal(D2, np.inf)
    a = np.argsort(D1, axis=1)[:, :k]
    b = np.argsort(D2, axis=1)[:, :k]
    hit = sum(len(set(a[i]) & set(b[i])) for i in range(len(a)))
    return hit / float(len(a) * k)

def stress(X, Z):
    iu = np.triu_indices(len(X), 1)
    a, b = dmat(X, X)[iu], dmat(Z, Z)[iu]
    a, b = a / a.mean(), b / b.mean()
    return float(np.sqrt(((a - b) ** 2).mean()))

def scurve(n=600, seed=1, span=0.75):
    rg = np.random.default_rng(seed)
    t = rg.uniform(-span * np.pi, span * np.pi, n)
    h = rg.uniform(-1.0, 1.0, n)
    return np.column_stack([np.sin(t), h,
                             np.sign(t) * (np.cos(t) - 1.0)]), t

def swiss(n=600, seed=1):
    rg = np.random.default_rng(seed)
    t = rg.uniform(1.5 * np.pi, 4.5 * np.pi, n)
    h = rg.uniform(0.0, 10.0, n)
    return np.column_stack([t * np.cos(t), h, t * np.sin(t)]), t

X, t = scurve(600, 1)
Z2, w = pca(X, 2)
Z1 = pca(X, 1)[0]
print('特征值', np.round(w, 5), '解释比', np.round(w / w.sum(), 4))
print('二维 保持 %.4f stress %.4f' % (keep_ratio(X, Z2), stress(X, Z2)))
print('一维 保持 %.4f' % keep_ratio(X, Z1))

rg = np.random.default_rng(11)
Q, _ = np.linalg.qr(rg.normal(0.0, 1.0, (3, 3)))
XR = X @ Q
print('旋转后 保持 %.4f stress %.4f'
      % (keep_ratio(XR, pca(XR, 2)[0]), stress(XR, pca(XR, 2)[0])))

X3 = X.copy()
X3[:, 2] *= 3.0
print('z乘3 解释比', np.round(pca(X3, 2)[1] / pca(X3, 2)[1].sum(), 4))

W, tw = swiss(600, 1)
ZW, ww = pca(W, 2)
print('瑞士卷 特征值', np.round(ww, 4), '解释比', np.round(ww / ww.sum(), 4))
print('瑞士卷 保持 %.4f stress %.4f' % (keep_ratio(W, ZW), stress(W, ZW)))

实跑输出:

--------------------------------------------------------------------
前两个主成分吃掉 91.6% 方差,却只保住四成邻居
--------------------------------------------------------------------
  数据                     600 个三维点,S 曲线,t 在正负 0.75π 之间对折

  先看协方差矩阵的三个特征值,它们是三个正交方向上的方差:
  主成分    特征值   解释比     累计
  ---------------------------------
  PC1      1.29695   0.7591   0.7591
  PC2      0.33869   0.1982   0.9573
  PC3      0.07289   0.0427   1.0000
  **PC1 一个方向就吃掉 75.9%,前两个加起来 95.73%。**

  降下去之后到底损失了多少,用两个指标量:
  降到几维   10 近邻保持率   距离失真 stress   剩下多少方差
  --------------------------------------------------------
  1 维              0.2280                 -         0.7591
  2 维              0.8960            0.0468         0.9573
  二维保住 89.60% 的近邻,一维只剩 22.80%。
  **方差只掉了 4 个百分点,邻居掉了一半多——因为扔掉的那个方向正好是数据的「宽度」。**

  第一件必须知道的事:PCA 对坐标的平移无所谓,对旋转也无所谓。
  把数据整体转一个随机角度再跑一遍:
  原始 保持率 / stress     0.8960 / 0.0468
  旋转后 保持率 / stress   0.8960 / 0.0468
  四个小数位上看不出任何区别——欧氏距离本来就对旋转不变,这是必然的,不是巧合。

  第二件必须知道的事:PCA 对每个坐标轴的「单位」极其敏感。
  把第三个坐标乘 3,物理上什么都没变,只是换了个单位:
  处理     PC1 解释比      PC2      PC3
  ------------------------------------
  原样         0.7591   0.1982   0.0427
  z 乘 3       0.9420   0.0433   0.0147
  **PC1 从 75.9% 猛涨到 94.2%,主成分方向被第三个坐标硬拽了过去。**
  所以工程上的惯例是:**降维之前先标准化(每个坐标减均值、除标准差)。**
  不标准化,等于让「量得粗」的那个坐标替你做决定。

  最后,把这套办法拿到一卷真正的瑞士卷上去:
  600 个点,一张纸卷了一圈半,内圈半径 4.7、外圈 14.1。
  主成分    特征值   解释比
  ------------------------
  PC1      48.4229   0.4862
  PC2      42.7728   0.4294
  PC3       8.4051   0.0844
  二维 10 近邻保持率       0.4030
  二维 stress              0.1029
  **前两个主成分加起来 91.56% 的方差,近邻只保住 40.30%。**
  这两个数放在一起,就是 PCA 的软肋:
  **它保的是「离原点有多远、往哪个方向」,不是「谁挨着谁」。**
  卷起来以后,隔着两层的两个点投影到同一个位置——远近距离没变形,
  于是方差一点没丢,可它们在三维里隔着一整层纸,本来根本不是邻居。
  下一节的局部保持投影,就是冲着这件事来的。

第一段的三行是这一节的主线:特征值 1.29695、0.33869、0.07289,解释比 75.91%、19.82%、4.27%; 二维保持 89.60%,一维只剩 22.80%。 方差从 95.73% 掉到 75.91%,邻居从 89.60% 掉到 22.80%——后者掉得凶得多, 这就是「方差不是邻居」的第一次实证。 旋转那一行打出来和原始完全一样: 0.8960 和 0.0468,四个小数位上看不出区别。这是必然的——欧氏距离对正交变换不变, PCA 的一切都建立在欧氏距离上。所以「先转一下坐标轴」对 PCA 毫无意义,千万别当成预处理步骤。 z 乘 3 那一行,解释比从 75.91% 跳到 94.20%。 这一行最能说明 PCA 的价值观:它找的是「散得最开」的方向,你把某一列的数值放大, 它当然就往那边倒。所以标准化那一步不是可选的礼貌,是必须做的。 瑞士卷最后两行是这一节的落点:91.56% 的方差、40.30% 的邻居。 这两个数并排放着,就说明了「累计贡献率 95% 就够了」这句话在什么情况下会害人。

30 秒实验

  1. 把 swiss 的 h 范围从 0 到 10 改成负 1 到 1(一张薄带子),重跑最后两行。 薄带上 PCA 的保持率会跳到 0.74 上下——因为薄带子近似一根一维曲线,这时候 PCA 反而够用。 想想为什么「卷得薄」会让线性方法变好。
  2. 把 pca 里的 Xc = X - X.mean(axis=0) 删掉,直接对 X 做特征分解。 结果会完全变味:第一主成分变成「指向数据中心」的方向,而不是「方差最大」的方向。 和 13.4 节核矩阵不中心化的现象对照着看。
  3. 把 10 近邻改成 3 近邻和 30 近邻各跑一次。近邻数越小,保持率越容易被少数极端点带偏; 近邻数越大,越接近「全局」——也就不再是「局部结构」了。

常见误区

  1. 「累计贡献率到 95% 就够」。它是按方差定的门槛,跟「保住结构」没有必然关系。 这份瑞士卷上 91.56% 的方差只对应 40.30% 的邻居, 按 95% 这条线判断会得出「降维很成功」的结论,而实际上近邻丢掉了一大半。
  2. 「PCA 之前一定要标准化」。同量纲、可比大小的列不必标准化, 否则会把「哪个更重要」的信息一起抹掉。 不同量纲(一个是米、一个是秒)必须标准化,否则单位大的那一列直接说了算。
  3. 「PCA 能去噪,因为噪声方差小、排在后面」。这只有在「噪声各向同性且幅度小于信号」时才成立。 如果噪声集中在某个方向、或者幅度很大,PCA 会把噪声当成第一主成分郑重地保留下来。
  4. 「主成分是可解释的」。特征是各列的线性组合,权重可正可负, 往往一堆数拼在一起谁也说不清含义。 真要解释,得看载荷的符号和相对大小, 而且换一份数据权重就变了——「PC1 代表经济水平」这种话不能跨数据集迁移。
自测三问(点开对答案) 1. 为什么主成分之间必须正交? —— 因为只有正交方向的方差才能相加。 斜着挑两个方向,它们会分掉同一份波动,加起来超过总方差,解释比就没有意义了。 另外实对称矩阵的特征向量天生正交,所以这一步不需要额外处理。 2. 这份 S 曲线上,方差掉了 20 个百分点,近邻掉了 67 个百分点。怎么解释? —— 被丢掉的 PC3 只占 4.27% 的方差,但方向正好是数据的「宽度」。 宽度窄所以方差小,可「谁挨着谁」恰恰主要由这个窄方向决定——在细长的带子上, 最近的邻居多半是并排的而不是前后的。 3. 瑞士卷上前两个主成分吃掉 91.56% 的方差,为什么近邻只保住 40.30%? —— 因为 PCA 只看三维坐标的欧氏距离,不知道这张纸是卷起来的。 隔着两层的点在三维里直线距离很近,投影之后被压到同一个位置,方差没损失, 但它们在流形上隔着一整层,本来不是邻居。

方差这个标准到此为止。下一节换一个目标函数:不看「散得多开」,只看「图上连了边的点, 投影之后还挨不挨着」——这就绕开了「纸看不见」的问题。

相关笔记