主成分分析就是上一节的问题解到底:把协方差矩阵做特征分解,按特征值从大到小排, 前几个特征向量就是主成分。 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 秒实验
- 把
swiss的 h 范围从 0 到 10 改成负 1 到 1(一张薄带子),重跑最后两行。 薄带上 PCA 的保持率会跳到 0.74 上下——因为薄带子近似一根一维曲线,这时候 PCA 反而够用。 想想为什么「卷得薄」会让线性方法变好。 - 把
pca里的Xc = X - X.mean(axis=0)删掉,直接对 X 做特征分解。 结果会完全变味:第一主成分变成「指向数据中心」的方向,而不是「方差最大」的方向。 和 13.4 节核矩阵不中心化的现象对照着看。 - 把 10 近邻改成 3 近邻和 30 近邻各跑一次。近邻数越小,保持率越容易被少数极端点带偏; 近邻数越大,越接近「全局」——也就不再是「局部结构」了。
常见误区
- 「累计贡献率到 95% 就够」。它是按方差定的门槛,跟「保住结构」没有必然关系。 这份瑞士卷上 91.56% 的方差只对应 40.30% 的邻居, 按 95% 这条线判断会得出「降维很成功」的结论,而实际上近邻丢掉了一大半。
- 「PCA 之前一定要标准化」。同量纲、可比大小的列不必标准化, 否则会把「哪个更重要」的信息一起抹掉。 不同量纲(一个是米、一个是秒)必须标准化,否则单位大的那一列直接说了算。
- 「PCA 能去噪,因为噪声方差小、排在后面」。这只有在「噪声各向同性且幅度小于信号」时才成立。 如果噪声集中在某个方向、或者幅度很大,PCA 会把噪声当成第一主成分郑重地保留下来。
- 「主成分是可解释的」。特征是各列的线性组合,权重可正可负, 往往一堆数拼在一起谁也说不清含义。 真要解释,得看载荷的符号和相对大小, 而且换一份数据权重就变了——「PC1 代表经济水平」这种话不能跨数据集迁移。
自测三问(点开对答案)
1. 为什么主成分之间必须正交? —— 因为只有正交方向的方差才能相加。 斜着挑两个方向,它们会分掉同一份波动,加起来超过总方差,解释比就没有意义了。 另外实对称矩阵的特征向量天生正交,所以这一步不需要额外处理。 2. 这份 S 曲线上,方差掉了 20 个百分点,近邻掉了 67 个百分点。怎么解释? —— 被丢掉的 PC3 只占 4.27% 的方差,但方向正好是数据的「宽度」。 宽度窄所以方差小,可「谁挨着谁」恰恰主要由这个窄方向决定——在细长的带子上, 最近的邻居多半是并排的而不是前后的。 3. 瑞士卷上前两个主成分吃掉 91.56% 的方差,为什么近邻只保住 40.30%? —— 因为 PCA 只看三维坐标的欧氏距离,不知道这张纸是卷起来的。 隔着两层的点在三维里直线距离很近,投影之后被压到同一个位置,方差没损失, 但它们在流形上隔着一整层,本来不是邻居。方差这个标准到此为止。下一节换一个目标函数:不看「散得多开」,只看「图上连了边的点, 投影之后还挨不挨着」——这就绕开了「纸看不见」的问题。
- ☐ (本节完,下一节 13.3 13.3)