第4章 PCA算法三步走:中心化、协方差与特征分解

很多人学PCA,一上来就被“特征降维”四个字唬住了。其实拆开看,核心就三个步骤:数据中心化 → 协方差矩阵 → 特征分解。我个人习惯把这比作“把数据重新摆正、量一量各个方向上的分散程度、再找出最肥的那个方向”。说白了,PCA就是个找主方向的过程。

我先用一张图把整体流程铺开,再一步步细讲。你跟着走一遍,后面写代码就顺了。

PCA三步核心流程图 PCA 算法核心流程 原始数据 X 数据中心化 协方差矩阵 C 特征分解 主成分 (k 维) 💡 三步依次执行:中心化消除偏移 → 协方差矩阵量化特征关系 → 特征分解找到方差最大的方向 最后取前 k 个特征值对应的特征向量,完成降维。

步骤一:数据中心化

为什么要中心化?我刚开始学的时候也觉得多此一举。直到有一次,我处理一批身高体重数据,发现PCA出来的第一主成分几乎全是均值偏移——说白了,就是数据没在原点站好队,坐标轴被带偏了。

中心化的目标很简单:让每一维特征的均值变成0。数学上就是:

X_centered = X - mean(X, axis=0)

举个例子,假设你有3个样本,每个样本2个特征:

原始数据 X:
[[2.0, 3.0],
 [3.0, 5.0],
 [4.0, 4.0]]

每列均值: [3.0, 4.0]

中心化后:
[[-1.0, -1.0],
 [ 0.0,  1.0],
 [ 1.0,  0.0]]
小技巧: 我建议在中心化之后顺便检查一下每列的均值是否接近0(浮点误差允许)。如果偏差超过1e-10,多半是数据类型或者计算精度出了问题。

步骤二:协方差矩阵计算

中心化之后,我们要回答一个问题:各个特征之间是怎么“联动”的? 协方差矩阵就是干这个的。

协方差矩阵 C 的维度是 d×d(d是原始特征数)。C[i][j] 表示第 i 个特征与第 j 个特征的协方差。如果C[i][j] > 0,说明这两个特征同向变化;反之间接变化。

C = (1 / (n - 1)) * X_centered.T @ X_centered

注意我用的是 n-1 而不是 n —— 这是样本协方差的无偏估计。我见过不少新手直接用 n,结果算出来的特征值总是偏小一点。虽然对主方向影响不大,但如果你要做方差解释性分析,那差别就出来了。

核心认识: 协方差矩阵是对称半正定矩阵。它把特征之间的“关系网”浓缩成了一个数学对象。后面特征分解能从中拆出“主方向”,全靠这个对称性。

步骤三:特征分解

到了最关键的一步。协方差矩阵 C 可以被分解为:

C = Q Λ Qᵀ

其中 Λ 是对角矩阵,对角线上的值就是特征值 λ₁ ≥ λ₂ ≥ … ≥ λd。Q 的列向量是对应的特征向量。

特征值的大小,直接反映了“这个方向上的方差有多大”。我打个比方:你从各个角度去看一团点云,有些角度看起来散得很开,有些角度挤成一团。特征值就是那个“散开程度”的量化指标。

特征向量呢?它就是那个“角度”本身——也就是新的坐标轴方向。

# Python 示例:特征分解
import numpy as np

C = np.array([[2.5, 0.8],
              [0.8, 1.2]])

eigenvalues, eigenvectors = np.linalg.eigh(C)

# 默认升序,翻转成降序
idx = np.argsort(eigenvalues)[::-1]
eigenvalues = eigenvalues[idx]
eigenvectors = eigenvectors[:, idx]

print("特征值:", eigenvalues)
print("特征向量:\n", eigenvectors)

# 选择 top-k 特征向量,这里 k=1
k = 1
W = eigenvectors[:, :k]
X_reduced = X_centered @ W
避坑指南: 我曾经直接用 np.linalg.eig() 对协方差矩阵做分解,结果因为浮点误差出现了微小的虚部。后来我固定用 np.linalg.eigh()——这个函数专门针对对称矩阵,返回的实特征值更稳定,速度也更快。记住:协方差矩阵是对称的,永远用 eigh 而非 eig。

三步汇总与代码实战

把三个步骤串起来,就是一个完整的PCA实现。我个人写教学代码时,喜欢把每一步的中间结果都打印出来,方便调试和理解。

import numpy as np

def pca_manual(X, k):
    # 1. 数据中心化
    mean = np.mean(X, axis=0)
    X_centered = X - mean

    # 2. 协方差矩阵
    n = X.shape[0]
    C = (1 / (n - 1)) * (X_centered.T @ X_centered)

    # 3. 特征分解
    eigenvalues, eigenvectors = np.linalg.eigh(C)
    idx = np.argsort(eigenvalues)[::-1]
    eigenvalues = eigenvalues[idx]
    eigenvectors = eigenvectors[:, idx]

    # 4. 取前 k 个特征向量
    W = eigenvectors[:, :k]
    X_reduced = X_centered @ W

    return X_reduced, eigenvalues[:k], W, mean

# 测试
X = np.array([[2.0, 3.0],
              [3.0, 5.0],
              [4.0, 4.0],
              [5.0, 6.0],
              [6.0, 5.0]])

X_red, ev, W, mu = pca_manual(X, k=1)
print("降维结果:", X_red.flatten())
print("Top-1 特征值:", ev[0])

步骤对比速查表

步骤 输入 输出 关键公式 注意事项
1. 数据中心化 原始矩阵 X (n×d) 中心化矩阵 X_centered X_centered = X - μ μ 是列均值;保存 μ 用于后续变换
2. 协方差计算 X_centered 协方差矩阵 C (d×d) C = (X_centeredᵀ X_centered)/(n-1) 用 n-1 做无偏估计;C 是对称矩阵
3. 特征分解 协方差矩阵 C 特征值 λ, 特征向量 Q C = Q Λ Qᵀ 使用 np.linalg.eigh();按 λ 降序排列
4. 投影降维 特征向量 Q, 中心化数据 降维矩阵 Y (n×k) Y = X_centered · Q[:, :k] k 通常通过方差解释率确定
三个步骤,一个闭环: 中心化保证坐标原点对齐 → 协方差矩阵量化特征相关性 → 特征分解找出方差最大的方向。每一步的输出就是下一步的输入,结构非常干净。

关于特征分解的两种实现路径

除了直接对协方差矩阵做特征分解,还有一条路——奇异值分解 (SVD)。你想想看,X_centered 本身就是 n×d 的矩阵,对它做 SVD 可以直接得到右奇异向量,恰好就是特征向量。

# 用 SVD 实现 PCA(更推荐)
U, S, Vt = np.linalg.svd(X_centered / np.sqrt(n-1), full_matrices=False)

# Vt 的行就是特征向量(按奇异值降序)
eigenvectors_svd = Vt.T

# 奇异值的平方就是特征值
eigenvalues_svd = S ** 2
为什么我偏爱 SVD? 项目中数据经常是 n < d(样本少、特征多),此时直接做协方差矩阵特征分解会得到一个 d×d 的大矩阵,计算量爆炸。而 SVD 可以只算非零奇异值,效率高得多。scikit-learn 的 PCA 底层用的就是 SVD。

避坑与经验

说几个我实际踩过的坑:

  • 中心化后忘记保存均值向量: 训练集中心化了,预测时也要用同样的均值去减测试集,否则结果全乱套。我一开始就因为这个,上线后模型表现直接崩了。
  • 特征值符号翻转: 特征分解得到的特征向量方向不唯一(正负都可以)。这不是错误,但会导致不同库跑出来的主成分方向相反。处理方式:统一用第一个元素的符号做对齐。
  • k 值拍脑袋选: 我曾经图省事直接选 k=2,结果丢了40%的方差。后来老老实实画方差解释率曲线,才找到合适的拐点。
“方差解释率”一定要算: 选 k 之前,先算一下到第几个特征值时累计方差占比超过85%或90%。这是降维质量的硬指标,不要偷懒。

好了,PCA的三个核心步骤到这里就拆完了。中心化、协方差、特征分解——每个步骤都不复杂,但串起来就是整个降维领域的基石。你动手把代码跑一遍,中间变量都打印出来,会比看十遍文章都管用。