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

三维 S 曲线、600 个点。线性降维只有一个动作:挑一个方向丢掉,把点正交投影到剩下的平面上。 丢掉方向 u 之后的重建误差,精确等于 u 这个方向上的方差——实测 0.07288847 对 0.07288847, 一位不差。 随机撒 400 个方向:平均要丢掉 0.56208,正好是总方差的三分之一; 撒两万个方向,最好的一个离 PCA 的解只差 0.62 度,一个更低的都没撞出来。

📝 本节和前面几节的关系

第 1 到第 12 章,每一个算法都有明确的答案要对齐:回归对数值、分类对类别、 异常检测对「像不像」。从这一章开始,题目里连「正确答案」这个词都消失了——只有一堆数据, 目标是「换一个更省地方的描述方式」。 这一节先把「降维」这个词的物理含义钉死:它就是一个正交投影,没有别的花样。 弄清了代价怎么算,后面四节才有共同的尺子。 PCA、LPP、核 PCA、拉普拉斯特征映射,四个方法争的其实是同一件事:到底该丢掉哪个方向。

一、降维只有一个动作:投影

先把最容易含糊的地方说明白。所谓「把三维降到二维」,干的事就是找一张过原点的平面, 把每个点垂直压上去;降到一维,就是找一条过原点的直线,把每个点垂直压上去。 点的个数一个没少,坐标从三个数变成两个数,这就是全部。 那为什么不干脆只留第一个坐标、扔掉后两个?因为那等于硬性规定「平面就是 xy 平面」。 数据摆在哪儿是不知道的,真正的问题是:在所有可能的平面里,哪一张压下去损失最小。 所以线性降维的完整表述是:在所有过原点的低维子空间里,挑一个让「压扁的损失」最小的。 「线性」这两个字,说的就是「只能压、不能弯」——这一条限制后面会反复咬人。 这段话说起来简单,但它有一个立刻能验证的推论: 损失只跟「丢掉哪个方向」有关,跟「保留的平面怎么摆」无关。因为三维丢一维, 丢掉的那一个方向定了,剩下的平面就定了。于是问题从「挑平面」缩成「挑一个单位向量」。

二、代价怎么量:重建误差

损失得有个数。定义很朴素:把点压到平面上,再按原路抬回去,看它离原来的位置有多远。 这个距离叫重建误差。 写成式子:原始点 x,单位方向 u,压到正交补之后再抬回来,抬回来的位置是 x 减去它在 u 上的投影。 所以残差就是 (u'x) 乘以 u,长度是 |u'x|。 把所有点的这个长度平方加起来取平均,就是我们要最小化的量。 这一步有个容易搞混的地方: 「降到二维」丢掉的是一个方向,不是两个。「降到一维」丢掉的才是两个。三维降到二维、 二维降到一维,丢的方向数分别是 1 和 2,所以重建误差也是两个不同的量级。 这一点在后面的阶梯表里会看得非常清楚。 还要记住一个前提:先减均值。 不减均值的话,所有点整体偏在一边,投影误差里就混进了「离原点多远」这个和降维无关的量。 PCA 的标准做法是先中心化,这一节所有实验也都先中心化了。

三、把代价算成一个数:被丢方向的方差

这一节最硬的一句话:丢掉方向 u 的重建误差,等于数据在 u 这个方向上的方差。 不是近似,不是「大概」,是恒等。推导只有三行:把中心化后的点写成三个正交方向上的分量之和, 投影把 u 那一项整个抹掉,剩下的残差就只剩 u 那一项,平方求和取平均,正好是「投影到 u 上的方差」。 空口无凭,用代码验:拿 PCA 解出来的最小特征方向当 u, 实测重建误差 0.07288847,这个方向上的方差 0.07288847, 协方差矩阵的最小特征值也是 0.07288847——三个数一位不差。 这句话把整个降维问题翻译成了一个纯代数问题:要最小化 u'Cu,其中 C 是协方差矩阵、 u 是单位向量。 这个形式的好处是它不再提「点」,只提一个三阶方阵,于是可以用特征分解直接给出答案, 一个循环都不用写。 反过来看也一样好用:你想保住多少方差,就等于「少丢几个方向」。降到二维只丢一个方向, 只丢那一份方差;降到一维要丢两个方向,代价立刻翻几倍。

四、随机挑方向有多亏

在解出来之前,先量一下「不做功课」的代价,心里才有底。 在三维单位球面上随便撒 400 个方向,每个都算一遍重建误差:

统计量 重建误差
最小 0.07786
中位数 0.49628
平均 0.56208
最大 1.29443
总方差除以 3 0.56951
最后两行值得盯一会儿:随机方向的平均重建误差 0.56208,
和「总方差除以 3」的 0.56951 几乎相等。这不是巧合。
各向同性地随机挑方向,u'Cu 的期望就是迹的三分之一。
换个说法:闭着眼睛挑一个方向丢,平均要丢掉 33.3% 的方差。
这个数就是「不做降维、直接扔掉一列坐标」的期望表现——任何人都能做到的水平。
最小 0.07786 和最大 1.29443 差了 16 倍,也说明挑方向这件事非常值钱:挑得好和挑得差,
代价能差一个数量级。

五、最优平面有闭式解

那最好的方向会不会藏在某个很怪的地方,得靠搜索才找得到? 撒两万个随机方向试一下:最好的一个是 0.07293,和 PCA 解出来的最小特征方向夹角只有 0.62 度。 两万个方向里,没有一个低于 PCA 的 0.07289。 而且随着方向数增加,最好的那个会越来越贴近 PCA 的解, 但永远只能从上方逼近——因为它就是下界本身。 道理在特征分解里:C 是实对称矩阵,它一定可以写成 三个正交方向乘以三个非负特征值。 要最小化 u'Cu,把 u 按这三个方向展开,答案就是「把全部权重压在最小特征值那个方向上」。 于是最优解是协方差矩阵最小特征值对应的特征向量,最优值是那个最小特征值。 这就是「有闭式解」的含义:不用搜、不用迭代、不用调学习率,一次特征分解就到底。 对比一下第 5 章那些要靠坐标下降一轮一轮磨的算法,这里可以说是白送。 顺带一个副产品:把 C 的三个特征向量全算出来,就得到了三个互相正交的方向, 按特征值从大到小排好——这三个方向就是下一节的主成分。 所以 PCA 不是另一个算法,它就是这一节的问题解到底之后的名字。

六、降维阶梯与有效维数

把每一级的代价排成一张表,降维这件事就彻底透明了:

降到几维 丢掉几个方向 最低重建误差 占总方差
3 维 0 0.00000 0.00%
2 维 1 0.07289 4.27%
1 维 2 0.41158 24.09%
0 维 3 1.70853 100.00%
「占总方差」那一列是这张表的重点:降到二维只丢 4.27%,降到一维要丢 24.09%。
这说明这份数据「差不多是二维的」——第三个方向几乎没有信息,丢掉它几乎不心疼。反过来说,
从二维再往一维降,就得扔一个真正有内容的维度,代价一下跳到 24%。
「有效维数」这个词就是从这张表来的:累计解释比到 95% 就够了,再往后加维度,
付的代价和拿到的信息不成比例。
但请注意,95% 这个门槛是人定的,不是数据定的;
下一节会看到,「方差保住了」离「该保的都保住了」还差得远。
还有一件事得提前说清:这一节的「有效维数」是按方差算的,而方差只跟「离中心多远」有关,
跟「谁挨着谁」无关。
如果数据的用途是找近邻、做聚类、看局部结构,那这个阶梯量的根本不是你要的东西。
第 13.3 和 13.5 两节,就是为了修这个错位。

七、动手 Demo:量出「丢掉一个方向要付多少代价」

下面这段代码只做四件事:造一份三维数据、验证「重建误差等于被丢方向的方差」、撒随机方向看平均代价、 再把两万个方向里最好的一个和 PCA 的解对上。 重点看第二段:三个数(实测重建误差、方向方差、最小特征值)打出来是一模一样的。 这一节所有结论都挂在这个恒等式上。

import numpy as np

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

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)
    X = np.column_stack([np.sin(t), h, np.sign(t) * (np.cos(t) - 1.0)])
    return X, t

def recon_var(X, u):      # 丢掉方向 u 后的重建误差
    Xc = X - X.mean(axis=0)
    flat = Xc - np.outer(Xc @ u, u)
    return float(((Xc - flat) ** 2).sum(1).mean())

def dir_var(X, u):        # 数据在方向 u 上的方差
    Xc = X - X.mean(axis=0)
    p = Xc @ u
    return float(p @ p / len(X))

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

X, t = scurve(600, 1)
C = np.cov(X.T, bias=True)
w, V = pca(X)

print('迹', np.trace(C))
print('最小特征方向', V[:, 2])
print('实测重建误差', recon_var(X, V[:, 2]))
print('该方向方差  ', dir_var(X, V[:, 2]))
print('最小特征值  ', w[2])

rg = np.random.default_rng(7)
U = rg.normal(0.0, 1.0, (400, 3))
U /= np.sqrt((U ** 2).sum(1))[:, None]
rv = np.array([recon_var(X, u) for u in U])
print('随机方向 最小 %.5f 中位 %.5f 平均 %.5f 最大 %.5f'
      % (rv.min(), np.median(rv), rv.mean(), rv.max()))
print('迹除以3', np.trace(C) / 3.0)

U2 = rg.normal(0.0, 1.0, (20000, 3))
U2 /= np.sqrt((U2 ** 2).sum(1))[:, None]
rv2 = np.array([recon_var(X, u) for u in U2])
b = int(np.argmin(rv2))
print('两万个里最好 %.5f' % rv2[b])
print('与PCA最小方向夹角 %.2f度'
      % np.degrees(np.arccos(min(1.0, abs(U2[b] @ V[:, 2])))))

for k in (3, 2, 1, 0):
    print('降到 %d 维 最低重建误差 %.5f 占总方差 %.2f%%'
          % (k, w[k:].sum(), w[k:].sum() / np.trace(C) * 100.0))

实跑输出:

--------------------------------------------------------------------
线性降维丢掉的那部分,正好是被丢方向上的方差
--------------------------------------------------------------------
  数据                     600 个三维点,S 曲线,t 在正负 0.75π 之间对折

  协方差矩阵(三个方向两两之间的相关强度):
  行 列          x          y          z
  -------------------------------------
  x        0.60465    0.01229   -0.60645
  y        0.01229    0.33920   -0.02069
  z       -0.60645   -0.02069    0.76468
  迹                       1.70853  <- 三个方向的方差加起来

  这一节只有一句话要记住:
  **线性降维=找一个低维平面把点压上去;压不掉的那部分(重建误差),正好等于「被丢掉的那个方向上的方差」。**

  先拿一个具体方向验一遍,别信我,看数:
  方向                     PCA 解出来的最小特征方向 -0.75162  -0.01655  -0.65939
  实测重建误差             0.07288847
  该方向的方差             0.07288847
  最小特征值               0.07288847
  三个数一模一样。这不是巧合:把 X = sum(z_i u_i) 代进重建误差,
  正交方向上剩下的只有 u 那一项,平方和就是 u' C u。

  那随便挑个方向会怎样?撒 400 个随机方向:
  统计量   重建误差
  ----------------
  最小      0.07786
  中位数    0.49628
  平均      0.56208
  最大      1.29443
  迹 / 3    0.56951
  **最后两行值得盯一会儿:随机方向的平均重建误差,正好等于总方差的三分之一。**
  也就是说,什么都不想、闭着眼挑一个方向丢,平均要丢掉 33.3% 的方差。

  再撒两万个,看看瞎撞能撞到什么程度:
  两万个里最好             0.07293
  与 PCA 最小方向夹角      0.62 度
  **两万个方向里没有一个低于 PCA 的 0.07289,最好的那个离 PCA 的解只差 0.62 度。**
  **这就是「有闭式解」的含义:最优平面不用搜,它就是协方差矩阵最小特征向量的正交补。**

  把降维当成一个阶梯来看,每一级的代价一目了然:
  降到几维   丢掉几个方向   最低重建误差   占总方差
  ------------------------------------------------
  3 维                  0   0.00000           0.00%
  2 维                  1   0.07289           4.27%
  1 维                  2   0.41158          24.09%
  0 维                  3   1.70853         100.00%
  **注意「占总方差」那一列:降到二维只丢 4.27%,降到一维要丢 24.09%。**
  **换句话说,这份数据「差不多是二维的」——第三维几乎没有信息。**
  这也解释了下一节的主成分分析为什么好用:它只做一件事,就是把这个阶梯算出来。

输出第一段就把这一节的骨架打出来了:实测重建误差、该方向方差、最小特征值, 三个 0.07288847 排在一起。 这不是数值巧合——它就是把残差式子展开之后必然得到的结果。 随机 400 个方向那一行,平均 0.56208 对着迹除以 3 的 0.56951,差的那一点是抽样误差。 随机挑方向平均丢掉三分之一的方差,这句听起来像经验的话, 其实是「各向同性随机向量的二次型期望等于迹除以维数」的直接推论。 两万个方向那一行,最好的 0.07293 比 PCA 的 0.07289 大一点点,夹角 0.62 度。 方向数越多,这两个数就越贴近,但永远不会更好——因为 PCA 的解就是下界。 最后那四行阶梯里,降到二维只占 4.27%、降到一维要占 24.09%,这个跳变说明第三个方向基本是空的, 第二个方向才开始有真东西。

30 秒实验

  1. 把 scurve(600, 1) 的 span 从 0.75 改成 1.5,再跑一遍。 看协方差矩阵的第三特征值从 0.07289 涨到多少—— 折得越狠,第三维越有内容,「差不多是二维的」这句话就不成立了。
  2. 把 recon_var 里的 Xc = X - X.mean(axis=0) 改成 Xc = X,不中心化。 再跑一遍,三个数就对不上了。想清楚:多出来的那一块误差是什么?
  3. 把随机方向的个数从 400 加到 4000,看平均值和迹除以 3 的差是不是变小了。 如果变小,说明那个三分之一是数学期望不是巧合。

常见误区

  1. 「降维就是把不重要的列删掉」。删列是降维的一个特例(丢掉的正好是坐标轴方向), 而最优方向几乎总是斜的。 这份数据的 PC1 是 [-0.752, -0.017, -0.659],三列都占——删哪一列都到不了这个效果。
  2. 「重建误差越小,说明降维效果越好」。重建误差只量「点离平面多远」, 不量「点之间的相对位置对不对」。 一份数据压到二维重建误差很小,也可能把两团分开的东西搅成一团。
  3. 「先标准化还是先降维,随便,反正都要做」。顺序有讲究:标准化必须在降维之前。 13.2 节会看到,PCA 对坐标单位极其敏感,先降维再标准化会把主成分方向弄歪。
  4. 「95% 的方差就够了」。这个门槛是从「保住波动」的角度定的。 如果你的用途是找近邻、做聚类, 保住了 91.6% 的方差也可能只保住 40.3% 的邻居(13.2 节实测)。
自测三问(点开对答案) 1. 三维数据降到二维,要丢掉几个方向?重建误差等于什么? —— 丢一个方向。重建误差精确等于「被丢掉的那个方向上的方差」,也就是 u'Cu,其中 C 是协方差矩阵。 这道题的关键是别把「丢一个方向」和「保留两个方向」混成两件事——三维到二维只丢一个。 2. 为什么随机挑方向平均要丢掉三分之一的方差? —— 因为各向同性地随机取单位向量 u,u'Cu 的期望等于迹除以维数。三维就是迹的三分之一。 这不是这份数据的特性,是任何数据都成立的恒等式。 3. 「最优方向有闭式解」在实际工程里意味着什么? —— 意味着不需要搜索、不需要迭代、不需要调学习率,一次特征分解就得到全局最优。 代价是 O(d³) 的特征分解,d 是原始维数。 撒两万个方向只能逼近它、不能超过它,这一点在 Demo 里已经量过了。

这一节只解决了「怎么挑一个方向」,而且只用了方差这一个标准。 下一节把三个方向一起解出来,看看按方差排出来的坐标到底长什么样,再看它在什么情况下会骗人。

相关笔记