哈喽,大家好~
在我们的机器学习里,常说“降维”,常见工具之一就是主成分分析,也就是 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.0, 0.0, 0.0]),
np.array([5.0, 2.0, 1.0]),
np.array([-3.0, 4.0, 2.0])]
covs = [
np.array([[3.0, 1.2, 0.8],
[1.2, 1.0, 0.3],
[0.8, 0.3, 0.5]]),
np.array([[1.0, -0.6, 0.2],
[-0.6, 2.5, 0.9],
[0.2, 0.9, 1.5]]),
np.array([[0.6, 0.1, 0.0],
[0.1, 0.8, -0.3],
[0.0, -0.3, 0.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.8, 0.3],
[0.4, 1.2, -0.2],
[-0.2, 0.5, 0.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.01, f'{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%)、或根据下游任务(如分类、聚类)的验证结果。

