大数跨境

面试官皱眉:“都用Codex写机器学习了,PCA还要自己学?”我:“会调用就行吧!”面试官:“不懂降维,数据泄漏了你都不知道!”

面试官皱眉:“都用Codex写机器学习了,PCA还要自己学?”我:“会调用就行吧!”面试官:“不懂降维,数据泄漏了你都不知道!” 机器学习和人工智能AI
2026-07-16
0

哈喽,大家好~

在我们的机器学习里,常说“降维”,常见工具之一就是主成分分析,也就是 PCA,Principal Component Analysis。

简单的先举个例子:

想象你有一堆数据点,比如很多人的身高、体重、年龄、收入等,这些数据维度很多、彼此可能强相关,很难直接可视化或建模。

PCA 的目标是找到一组“新坐标轴”(互相正交),把原数据投影到这些新坐标轴上,使得投影后的第一个轴尽可能“解释”最多的方差(也就是信息量),第二个轴在与第一个正交的情况下解释剩下最多的方差,以此类推。

直观上来说,如果数据像一条细长的椭圆,PCA 会找到椭圆最长的方向作为第一主成分(PC1),第二长的方向作为第二主成分(PC2)。

把数据投影到前几个主成分上,就相当于找到了最能“代表”原始数据的低维表示,噪声和冗余信息在降低维度的过程中被舍弃。

数学表达

数学上常用的方法是对“中心化后的”数据求协方差矩阵并做特征分解(或直接做奇异值分解 SVD):

给定数据矩阵   个样本,  个特征),先中心化:

计算协方差矩阵(无偏估计):

做特征分解:

其中特征向量(列向量)   就是主成分方向,对应的对角矩阵   包含每个方向的方差(特征值)。

投影到前   个主成分:

基于   的线性重构(从低维还原到原空间):

通常我们用特征值占总特征值之比来衡量每个主成分的“重要性”,称为“解释方差比”(explained variance ratio):

PCA 就是找一组正交方向,把数据按能“解释方差”的优先级排序,投影到前几个方向,既能降维,也能压缩信息、减少噪声;

PCA 是线性的,不能捕捉非线性结构(例如卷成“瑞士卷”的数据),但常常是一个很好且简单的预处理工具。

完整案例

下面我们用 PyTorch 从头实现 PCA,对一个合成的 3D 数据集做一系列分析和可视化:包括原始 3D 散点、主成分方向、投影后的 2D 散点、方差 Scree 图、不同维度重构误差随 k 的变化等。

数据集

我们构造一个 3D 数据集,由三类点组成:

  • 每类是一个各向异性的高斯簇(协方差非等于单位矩阵,以产生相关性和不同伸展方向);
  • 再加入一部分全局线性变换(旋转 + 缩放),以模拟特征相关性;
  • 目的是制造一个既可通过 PCA 降维又有明显主方向的数据集。
import torch
import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

def make_anisotropic_gaussian(center, cov, n, seed=0):
    rng = np.random.RandomState(seed)
    x = rng.multivariate_normal(center, cov, size=n)
    return x

# 三类数据参数(不同中心、不同协方差)
centers = [np.array([0.00.00.0]),
           np.array([5.02.01.0]),
           np.array([-3.04.02.0])]

covs = [
    np.array([[3.01.20.8],
              [1.21.00.3],
              [0.80.30.5]]),
    np.array([[1.0-0.60.2],
              [-0.62.50.9],
              [0.20.91.5]]),
    np.array([[0.60.10.0],
              [0.10.8-0.3],
              [0.0-0.30.5]])
]

n_per = 3000
parts = [make_anisotropic_gaussian(c, cov, n_per, seed=42+i) for i,(c,cov) in enumerate(zip(centers,covs))]
X = np.vstack(parts)
y = np.hstack([[i]*n_per for i in range(len(parts))])

# 加入一个线性变换(旋转+缩放),使得变量之间更相关
A = np.array([[1.5-0.80.3],
              [0.41.2-0.2],
              [-0.20.50.9]])
X = X.dot(A.T)

# 转为 torch 张量
X_t = torch.tensor(X, dtype=torch.float32)
y_t = torch.tensor(y, dtype=torch.long)

我们生成了 3x300 = 900 个样本,分成 3 个类,各类各向异性(协方差不同),通过矩阵 A 做线性变换增加了不同维度间的线性相关性,这样 PCA 会发现一些“主方向”。

最后把数据转换为 PyTorch 张量以便后续计算。

从头实现 PCA

中心化 -> SVD -> 投影 -> 重构~

实现步骤:

  • 先中心化数据(很重要);
  • 使用 SVD 或特征分解得到主成分方向(我们用 torch.linalg.svd 更稳定);
  • 计算特征值(或奇异值平方 / (n-1))以得到方差;
  • 投影、重构并计算重构误差。
def pca_torch(X, k=None):
    """
    返回:
      mean: 数据均值 (d,)
      components: 主成分向量矩阵 V (d, d)(按重要性排序)
      explained_variance: 各主成分对应的方差 (d,)
      singular_values: 奇异值 (d,)
    """

    n, d = X.shape
    mean = X.mean(dim=0, keepdim=True)  # (1, d)
    Xc = X - mean  # 中心化
    # SVD 分解 Xc = U S V^T
    # 注意 torch.linalg.svd 返回 U, S, Vh (Vh = V^T)
    U, S, Vh = torch.linalg.svd(Xc, full_matrices=False)
    V = Vh.T  # (d, d)
    # 奇异值 S 与协方差矩阵特征值的关系:
    # 协方差矩阵 C = (1/(n-1)) Xc^T Xc = V diag(S^2/(n-1)) V^T
    explained_variance = (S**2) / (n-1)
    if k isNone:
        k = d
    return {
        'mean': mean.squeeze(0),  # (d,)
        'components': V,         # (d, d)
        'explained_variance': explained_variance,
        'singular_values': S
    }

pca_res = pca_torch(X_t)
mean = pca_res['mean']
V = pca_res['components']
explained_variance = pca_res['explained_variance']

我们用 SVD 分解中心化后的数据矩阵  ,得到  (右奇异向量)就是我们要的主成分方向。主成分对应的方差(特征值)为 

注意:PCA 的分解等价于对协方差矩阵做特征分解,但 SVD 数值更稳。

原始 3D 数据散点

先把原始数据在 3D 空间中画出来,看看数据的结构和类别分布:

# 3D 散点图(原始数据)
fig = plt.figure(figsize=(9,7))
ax = fig.add_subplot(111, projection='3d')
colors = ['#ff5252''#42a5f5''#66bb6a']
for i, c in enumerate(colors):
    idx = (y == i)
    ax.scatter(X[idx,0], X[idx,1], X[idx,2], color=c, s=20, alpha=0.8, label=f'class {i}')
ax.set_title('原始 3D 数据散点')
ax.set_xlabel('x1'); ax.set_ylabel('x2'); ax.set_zlabel('x3')
ax.legend()
plt.show()
  • 这是原始数据在原始坐标系下的分布。我们可以看到每个簇在 3D 空间各自的伸展方向和位置关系。
  • 但人眼很难直接判断哪些方向最能解释整体方差,这时 PCA 就派上用场。

计算并绘制 Scree 图

Scree 图展示每个主成分对应的方差(特征值)以及累计解释方差,便于选择保留多少主成分。

# Scree 图(解释方差与累计解释方差)
var = explained_variance.numpy()
explained_ratio = var / var.sum()
cum_ratio = np.cumsum(explained_ratio)

plt.figure(figsize=(8,5))
plt.bar(np.arange(1, len(var)+1), explained_ratio, color=['#ff7043','#ffa726','#ffd54f'], alpha=0.9)
plt.plot(np.arange(1, len(var)+1), cum_ratio, '-o', color='#8e24aa', linewidth=2)
plt.xlabel('主成分索引')
plt.ylabel('解释方差比')
plt.title('Scree 图:每个主成分的解释方差比与累计解释方差')
plt.xticks(np.arange(1, len(var)+1))
plt.ylim(0,1.05)
plt.grid(alpha=0.3)
for i,r in enumerate(explained_ratio):
    plt.text(i+1, r+0.01f'{r:.2f}', ha='center')
plt.show()
  • 条形图表示每个主成分解释的方差比例(越高说明该方向越重要)。
  • 折线表示累计解释方差,例如前两个主成分可能已经解释了 90%+ 的方差,说明可以把 3D 降到 2D 仍然保留大部分信息。

在 3D 图上画出主成分方向

把主方向叠加到 3D 数据图上,便于直观理解 PCA 找到的方向。箭头从数据均值开始,指向均值 + 主方向 * 伸缩系数。

# 在 3D 中画出前 3 个主成分方向
mean_np = mean.numpy()
V_np = V.numpy()
fig = plt.figure(figsize=(9,7))
ax = fig.add_subplot(111, projection='3d')
for i, c in enumerate(colors):
    idx = (y == i)
    ax.scatter(X[idx,0], X[idx,1], X[idx,2], color=c, s=15, alpha=0.6)

# 画三个主成分向量(从均值出发)
scale = 6.0# 放大系数以便可视化
pc_colors = ['#d32f2f''#1976d2''#2e7d32']
for i in range(3):
    vec = V_np[:, i]
    ax.quiver(mean_np[0], mean_np[1], mean_np[2],
              vec[0], vec[1], vec[2],
              length=scale, color=pc_colors[i], linewidth=3, arrow_length_ratio=0.12)
    ax.text(mean_np[0] + vec[0]*scale*1.05, mean_np[1] + vec[1]*scale*1.05, mean_np[2] + vec[2]*scale*1.05,
            f'PC{i+1}', color=pc_colors[i], fontsize=12)

ax.set_title('原始数据与前三个主成分方向(箭头)')
ax.set_xlabel('x1'); ax.set_ylabel('x2'); ax.set_zlabel('x3')
plt.show()
  • 箭头表示主成分方向,长度人为放大以便观察。可以看到 PC1 通常为数据整体伸展最明显的方向。
  • 这有助于理解 PCA 是如何“选方向”的:它优先选取能覆盖数据最大方差的轴。

投影到前两主成分并可视化

把数据投影到前两个主成分上,看类之间在低维空间是否分开,以及 PCA 是否保留了类别信息。

def project_to_k(X, mean, components, k):
    Xc = X - mean
    V_k = components[:, :k]  # (d, k)
    Z = Xc @ V_k  # (n, k)
    return Z

Z2 = project_to_k(X_t, mean, V, 2).numpy()

plt.figure(figsize=(8,6))
plt.scatter(Z2[y==0,0], Z2[y==0,1], color='#ff5252', s=18, alpha=0.8, label='class 0')
plt.scatter(Z2[y==1,0], Z2[y==1,1], color='#42a5f5', s=18, alpha=0.8, label='class 1')
plt.scatter(Z2[y==2,0], Z2[y==2,1], color='#66bb6a', s=18, alpha=0.8, label='class 2')
plt.xlabel('PC1'); plt.ylabel('PC2'); plt.title('投影到前两个主成分(2D)')
plt.legend()
plt.grid(alpha=0.2)
plt.show()
  • 这是数据在 PC1-PC2 平面上的投影。若类别在这个平面上分开,说明前两个主成分保留了对类别判别有用的信息。
  • 对于我们的合成数据,通常可以看到类别在低维空间仍能区分(说明 PCA 保留了结构)。

重构、并绘制重构误差随 k 的变化

一个常见的分析是计算不同 k(保留前 k 个主成分)下的重构均方误差(MSE),来判断保留多少个主成分比较合适。

def reconstruct_from_k(X, mean, components, k):
    Z = project_to_k(X, mean, components, k)  # (n, k)
    V_k = components[:, :k]  # (d, k)
    X_recon = Z @ V_k.T + mean  # (n, d)
    return X_recon

n, d = X_t.shape
mses = []
ks = list(range(1, d+1))
for k in ks:
    Xr = reconstruct_from_k(X_t, mean, V, k)
    mse = ((X_t - Xr)**2).mean().item()
    mses.append(mse)

plt.figure(figsize=(8,5))
plt.plot(ks, mses, '-o', color='#ef5350', linewidth=2)
plt.xlabel('保留主成分个数 k')
plt.ylabel('重构 MSE')
plt.title('重构误差随主成分数 k 的变化')
plt.grid(alpha=0.2)
plt.xticks(ks)
plt.show()

随着 k 增加,重构误差快速下降并最终趋近于 0(当 k = d 时完全重构)。

从曲线的拐点可以判断“收益递减”处,常见做法是选择能解释 90%-95% 方差(或在误差曲线拐点处)作为合适的 k。

总结

PCA 的核心是通过线性变换把数据映射到解释方差最多的几个方向上,既能降维也能压缩噪声。

实践中常用 SVD 来实现 PCA,步骤:中心化 -> SVD -> 取右奇异向量作为主成分 -> 计算解释方差 -> 投影 / 重构。

但是大家要注意,PCA 对特征尺度敏感,单位不同的特征会影响主成分,通常需要先做标准化,例如均值为 0,方差为 1的标准化;

另外,PCA 是线性的,不能揭示非线性结构,对非线性数据可尝试核 PCA、t-SNE、UMAP 等。

选择保留 k 的常见方法:看 Scree 图的拐点、累计解释方差(例如 90% 或 95%)、或根据下游任务(如分类、聚类)的验证结果。

【声明】内容源于网络
0
0
机器学习和人工智能AI
让我们一起期待 AI 带给我们的每一场变革!推送最新行业内最新最前沿人工智能技术!
内容 355
粉丝 0
机器学习和人工智能AI 让我们一起期待 AI 带给我们的每一场变革!推送最新行业内最新最前沿人工智能技术!
总阅读3.1k
粉丝0
内容355