我平时写技术分享不太喜欢上来就抛概念,但PCA降维这件事,我觉得还是有必要先聊一句:既然sklearn里三行代码就能完成的事,为什么还要用纯Python手写一遍?我的答案是,调包会让你快速得到结果,却很难让你真正理解结果背后的原理——协方差矩阵为什么长这样、特征值到底在说什么、标准化为什么会让降维结果发生剧变……这些问题只有亲手把每一步实现敲出来,才能有一个像样的答案。这次我用wine葡萄酒数据集做了一次无框架PCA降维实验,把标准化和不标准化两条路径完整对比了一遍,中间只用了NumPy和Matplotlib,没有调用任何现成的PCA接口。做完之后我对PCA的理解确实上了一个台阶,所以决定把整个过程完整复盘出来,希望能给同样在啃降维原理的朋友省点时间。
这个项目到底适合谁?我觉得至少有三拨人能用得上:一是正在学机器学习基础、想从原理层面吃透PCA的初学者;二是准备面试、想快速手写PCA并解释清楚每个细节的求职者;三是在课程或报告里需要演示“标准化对PCA影响”的数据分析爱好者。wine数据集样本量不大、类别清晰、特征量纲差异极其明显,用来验证手写PCA的正确性和标准化前后的区别,再合适不过。下面我直接进入正题,从项目定位讲到实现细节,再到踩坑经验,全部按真实的操作顺序来。
1. 项目定位:为什么手写PCA,为什么选wine数据集
1.1 这个项目要解决什么问题
先说核心目标。整个实验的诉求很简单:在不调用机器学习框架的前提下,用Python实现PCA主成分分析,把wine数据集的13个特征降到2维,然后分别跑一遍“原始数据直接PCA”和“标准化之后再做PCA”,对比两者在方差解释率、特征载荷、投影散点图上的差异。
很多人可能会问:这不就是PCA(n_components=2).fit_transform(X)而已吗?严格来说确实“而已”,但一旦落到手写层面,你要面对的是中心化、协方差矩阵、特征值分解、特征向量选取、投影矩阵构造、方差解释率计算这一整套流程。每一步都是数学公式到代码的一对一翻译,任何一个环节理解不到位,写出来的结果就会不对。我做完后的体会是:调包时你以为自己会PCA,手写一遍之后才发现以前只会调用。
这个实验的另一个意义在于回答一个特别常见的争论:PCA之前到底要不要标准化?网上说法很多,有人说必须标准化,有人说看场景。通过自己动手对同一份数据跑两条路径,直接把特征载荷和散点图摆在面前,比任何结论都有说服力。wine数据正好是一个天然案例——它的13个特征量纲跨度非常大,有的只有个位数,有的能到上千,标准化与否对主成分的影响在它身上会被放大得很明显。
1.2 “无框架”到底指的是什么
标题里写了“无框架”,这里先说清楚边界,免得有人误解。这个项目里说的“无框架”特指不使用任何机器学习、数据挖掘框架中现成的PCA实现——典型的就是sklearn.decomposition.PCA。但NumPy、Matplotlib、Pandas这类Python科学计算的基础库是允许而且必须用的,因为它们提供的是矩阵运算和绘图能力,属于“工具”而不是“算法实现”。
如果连NumPy都不用、纯手写矩阵乘法和特征分解,那这个项目就变成了线性代数大作业,而不是PCA原理演示,会严重偏离主题。所以“无框架”的准确理解是:PCA的算法全流程自己写,数值计算依赖于NumPy底层能力。我这次用到的依赖就三个:numpy、matplotlib,以及读取CSV时可能用到的pandas,全部是Python数据分析的标配。
这样做的直接好处是,你打开的每一行代码都对应PCA的一个步骤,随时可以停下来解释“这里为什么这么写”。坏处也很明显:没有现成API帮你处理数值稳定性、复数特征值这些细节,所有坑都要自己踩。不过这正是我想要的效果——把原理玩明白,之后回到sklearn,你反而能理解它内部在做什么,以及哪些参数到底在控制什么。
1.3 为什么偏偏是wine数据集
选wine数据集不是随便拍的,它有几个特性非常适合这次实验。
第一,维度适中。13个特征不算多,但足够展示PCA降维的“从高维到低维”意义,手写代码也不会因为矩阵太大而读起来费劲。第二,有天然类别标签。wine数据包含三个葡萄酒品种的样本,PCA本身是无监督方法,但我们可以用标签给降维后的散点图上色,直观验证降维后样本是否按照类别自然分开。第三,量纲差异极大。13个特征里有像Alcohol这种11到15范围内的数值,也有像Proline这种几百上千的数值,这让“标准化与否”的影响变得一目了然,非常适合做对比实验。
实测下来,未标准化时第一主成分几乎被Proline这类大数值特征主导,标准化后各个特征才有机会公平竞争贡献度——这个结论会在后面的载荷分析里具体展开。另外wine数据集只有178条样本,跑起来非常快,CPU计算特征分解基本是瞬间完成,对初学者反复调参、反复跑实验非常友好。
2. 环境准备与数据认知
2.1 依赖安装与数据获取方式
在动手之前,先把环境准备好。我的运行环境是Windows 11 + Python 3.10,实际上任何主流系统都行,只要保证Python版本不低于3.8即可。需要安装的库非常简单,在终端里执行下面这行:
pip install numpy matplotlib pandas三个库缺一不可,但各自分工不同:NumPy负责矩阵运算和特征分解,Matplotlib负责画散点图,Pandas负责读取和整理CSV格式的wine数据。需要强调一点,pandas在这里只是数据装载工具,不是算法框架,用它读取文件完全不影响“无框架”的定位。
wine原始数据的获取有两种常见方式。如果你机器上装了sklearn,可以直接通过sklearn.datasets.load_wine()拿到,但为了保持“无框架”的纯粹性,我更推荐下载原始wine.data文件放到本地,它本质上是一个逗号分隔的文本文件,第一列是类别标签(取值1、2、3),后面13列是依次排列的13个特征,没有表头。用np.loadtxt或pd.read_csv都能读进来。
import numpy as np import pandas as pd # 读取本地 wine.data,文件无表头,第一列是类别 data = pd.read_csv('wine.data', header=None) y = data.iloc[:, 0].values.astype(int) # 类别标签 X = data.iloc[:, 1:].values # 13个特征读取之后,X的形状是178行13列,每一行是一条葡萄酒样本,每一列是一个化学或物理指标。类别标签y在PCA阶段不会被使用,只会在最后绘图时当作着色依据。
2.2 wine数据的13个特征与量纲问题
wine数据集的13个特征分别是:Alcohol(酒精)、Malic acid(苹果酸)、Ash(灰分)、Alcalinity of ash(灰分碱度)、Magnesium(镁)、Total phenols(总酚)、Flavanoids(黄酮类化合物)、Nonflavanoid phenols(非黄酮类酚类)、Proanthocyanins(原花青素)、Color intensity(颜色强度)、Hue(色调)、OD280/OD315 of diluted wines(稀释葡萄酒的光密度比值)、Proline(脯氨酸)。
只看名字可能没感觉,但只要看一眼数值范围,量纲问题立刻暴露出来。我建议拿到数据后先快速统计一下每个特征的均值、标准差和最大最小值,这一步能帮你建立起对数据的直觉。
desc = pd.DataFrame({ 'mean': X.mean(axis=0), 'std': X.std(axis=0), 'min': X.min(axis=0), 'max': X.max(axis=0) }, index=data.columns[1:]) print(desc.round(2))我跑出来的大致情况是:Alcohol在11到15之间,Magnesium在70到162之间,Proline最低278、最高能到1680左右,Color intensity上下限也在1到13之间。同样是“数值”,Proline的标准差比Hue大了两个数量级。这就意味着在未标准化的PCA里,Proline天然拥有“更大的方差”,于是主成分方向会本能地更偏向它。
这里大家先记住一个结论:PCA找的是方差最大的投影方向,而方差计算受量纲直接影响。量纲大的特征即使信息价值没那么高,也会在方差竞赛里占便宜。这个特点正是后面标准化对比实验的观察核心。
2.3 中心化、标准化、z-score到底什么关系
进入代码之前,必须把两个概念掰扯清楚。第一个是中心化,第二个是标准化(标准化的最常见形式就是z-score)。这两个词经常被混着提,但它们解决的问题完全不同。
中心化就是让每个特征的均值变成0,方法是直接减去列均值。PCA要求数据围绕原点旋转寻找最大方差方向,如果均值不为0,第一主成分会被“数据的整体位置”带偏,不再是纯粹的方向信息。所以中心化是PCA无论是否标准化都必须做的一步。
标准化在中心化之外,还要求每个特征的标准差为1。具体做法是:先减去均值,再除以标准差。这样处理后,每个特征的均值是0、标准差是1,量纲彻底被抹掉,所有特征在“方差竞赛”中回到了同一起跑线。
# 中心化 X_centered = X - X.mean(axis=0) # 标准化(中心化 + 缩放) mu = X.mean(axis=0) std = X.std(axis=0) # 注意此处默认为总体标准差 ddof=0 X_std = (X - mu) / std可以看到,X_std其实包含了中心化,所以后续对X_std做PCA时,理论上可以跳过再减均值的步骤,但代码里统一保留中心化逻辑会更容易理解,也方便封装成通用函数。这个细节我后面还会回来讲。
3. PCA原理速通:从协方差到特征向量
3.1 什么是“最大方差方向”
PCA的直觉可以用一句话说明:把数据投影到一条直线上时,我们希望投影后样本尽可能分散,而不是挤成一团。样本越分散,说明这条直线保留的原始信息越多。这个“分散程度”用数学语言描述就是方差,所以PCA本质上是在找一组能最大化投影方差的正交方向。
拿二维数据举例,如果数据整体呈一个椭圆分布,长轴方向就是投影后方差最大的方向,也就是第一主成分;短轴方向垂直于长轴,是第二主成分。把三维数据降到二维,就是在三维空间中找前两个最大方差方向组成的投影平面。
这些方向从哪里来?答案是协方差矩阵的特征向量,而对应的特征值大小就代表沿该方向投影后的方差大小。所以“PCA就是协方差矩阵的特征值分解”这个说法,是这个算法最核心的一句话。
3.2 协方差矩阵为什么能代表数据的关系结构
先看协方差矩阵的定义。对于n条样本、d个特征的数据矩阵X(已经中心化),协方差矩阵C是一个d×d的对称矩阵,C[i][i]表示第i个特征的方差,C[i][j]表示第i个和第j个特征的协方差。协方差为正说明两个特征倾向同向变化,为负说明反向变化,绝对值大小反映线性相关的强度。
# 中心化之后的数据 cov_mat = np.cov(X_centered.T) # cov_mat 形状为 (13, 13)这里np.cov(X_centered.T)会除以n-1,得到的是样本协方差的无偏估计。除以n-1还是除以n,只影响特征值的绝对大小,不影响特征向量方向和主成分投影结果,所以不用担心。协方差矩阵给出的就是整个数据集的方差结构,PCA要做的,就是从这个矩阵中找到“方差最集中的几个方向”。
可能有人觉得协方差矩阵抽象,可以这样理解:它是一张“特征关系表”,对角线写每个特征的自身浮动幅度,非对角线写特征两两之间的联动浮动。特征分解这张表格,就是在问:能不能用最少的几个综合指标,把这张表里的大多数信息表达出来?
3.3 特征值分解与主成分选取
特征值分解的目标是找到一个正交矩阵W,使得C = W·Λ·Wᵀ,其中Λ是对角矩阵,对角线上的元素就是特征值,W的每一列是特征向量。特征值越大,对应特征向量方向上的数据方差越大,这个特征向量就越重要。
在NumPy里,求解特征值和特征向量可以用np.linalg.eig,但更推荐np.linalg.eigh。因为协方差矩阵是对称矩阵,eigh专门针对这种矩阵做了优化,数值稳定性更好,也不会像eig那样偶尔在虚轴上产生微小的计算噪声。这一点我在踩坑部分还会展开。
eigenvalues, eigenvectors = np.linalg.eigh(cov_mat) # 默认升序,调整为降序 idx = np.argsort(eigenvalues)[::-1] eigenvalues = eigenvalues[idx] eigenvectors = eigenvectors[:, idx]特征向量排序后,取前k列构成投影矩阵W_k,最后将中心化后的数据映射到新的低维空间:
k = 2 W_k = eigenvectors[:, :k] X_pca = X_centered.dot(W_k)到这里,X_pca就是降维后的数据,第一列是PC1方向上的投影坐标,第二列是PC2方向上的投影坐标。用散点图画出来,就是最常见的主成分得分图。
3.4 方差解释率怎么算
选了多少个主成分,代表“保留了多少原始信息”?这个问题有量化指标,叫方差解释率。第i个主成分的方差解释率等于第i个特征值除以所有特征值之和:
explained_variance_ratio = eigenvalues / eigenvalues.sum()这个数值给出的是每个主成分“贡献了多少比例的方差”。wine数据13个特征,总方差就是13个特征值之和,前两个主成分的方差解释率加起来,就代表降维到二维后能保留多少原始信息。如果累计解释率只有50%多,说明二维图漏掉了接近一半的信息,但我们仍然可以通过散点图观察类别结构,因为类别差异往往恰好在主导方差的方向上。
我实测跑下来,wine数据降到二维后,标准化和未标准化两种方式的累计解释率大体都在50%上下,相差不是特别悬殊。但有意思的是,主成分的“内涵”完全不同——谁在主导第一主成分,才是两者最大的分歧点。
4. 核心代码:未标准化与标准化的完整实现
4.1 统一封装一套PCA手写函数
为了让对比代码清晰,我先把PCA整体封装成一个函数。输入是特征矩阵X(不区分是否标准化)和降维维度k,输出是降维后的坐标、特征值、特征向量和方差解释率。这样后面只需要把原始数据和标准化数据分别传进去就能对照。
def pca_numpy(X, n_components=2): """ 纯NumPy手写PCA 参数: X: shape (n_samples, n_features) n_components: 保留的主成分个数 返回: X_pca: 降维后坐标 shape (n_samples, n_components) eigenvalues: 降序排列的特征值 eigenvectors: 按特征值降序排列的特征向量 explained_ratio: 各主成分方差解释率 """ # 1. 中心化 X_centered = X - X.mean(axis=0) # 2. 协方差矩阵 cov_mat = np.cov(X_centered.T) # 3. 特征值分解,对称矩阵用 eigh eigenvalues, eigenvectors = np.linalg.eigh(cov_mat) # 4. 按特征值从大到小排序 idx = np.argsort(eigenvalues)[::-1] eigenvalues = eigenvalues[idx] eigenvectors = eigenvectors[:, idx] # 5. 取前 n_components 个特征向量 W = eigenvectors[:, :n_components] # 6. 投影 X_pca = X_centered.dot(W) # 7. 方差解释率 explained_ratio = eigenvalues / eigenvalues.sum() return X_pca, eigenvalues, eigenvectors, explained_ratio这套核心逻辑一共只要20行左右,每行都有明确含义。很多教程会在中心化这一步用X - np.mean(X, axis=0),帮大家明确是对列求均值;而在画图之后,往往还要把特征值和特征向量保留下来,方便做载荷分析和物理解释,所以这里我选择把它们全部返回。
4.2 路径一:不做标准化,直接对原始数据降维
先跑不标准化的版本。此时输入直接是原始的13维特征矩阵X,不需要任何额外预处理:
# 不做标准化,直接用原始特征做PCA X_pca_raw, evals_raw, evecs_raw, ratio_raw = pca_numpy(X, n_components=2) print('不标准化 特征值:', evals_raw[:5]) print('不标准化 方差解释率:', ratio_raw[:5].round(4)) print('不标准化 前两主成分累计贡献率:', round(ratio_raw[:2].sum(), 4))跑这段代码时,你把13个特征输入进去,PCA会自动完成找方向这件事。我第一眼看到输出的特征值时会有点意外——第一个特征值明显比第二个大很多,而且比值远超“均匀分配”的水平。这说明原始数据在某个单一方向上的方差特别大,这基本上就是量纲大的特征造成的,具体是谁在主导,看接下来的载荷分析就清楚了。
4.3 路径二:先z-score标准化,再做同样流程
再看标准化版本。这里只多了一步归一化预处理:
# 先标准化,再做PCA mu = X.mean(axis=0) std = X.std(axis=0) X_std = (X - mu) / std X_pca_std, evals_std, evecs_std, ratio_std = pca_numpy(X_std, n_components=2) print('标准化 特征值:', evals_std[:5]) print('标准化 方差解释率:', ratio_std[:5].round(4)) print('标准化 前两主成分累计贡献率:', round(ratio_std[:2].sum(), 4))这里有个小细节可能被忽略:标准化后的数据,X_std.mean(axis=0)理论上都为0,也就是说pca_numpy函数里的中心化步骤相当于做了个“无操作”。但保留这一步没有坏处,反而让函数在逻辑上统一、不容易出错,所以我依然推荐保留。
还有一个值得体会的地方:13个标准化的特征各自方差为1,协方差矩阵对角线加起来约等于13(严格来说差一点点,原因我会在第6节讲),总方差是固定的,所以特征值天然分布在“总和为13”的框架内。对比两种路径的特征值,你能直观看到“量纲带来的额外方差”被标准化消除后的效果。
4.4 画两个对比散点图,一眼看出差别
降维的结果最后要用图像来验证。我写了一个通用绘图函数,传入降维坐标和真实标签y,按照三个类别分别着色:
import matplotlib.pyplot as plt def plot_pca_result(X_pca, y, title): plt.figure(figsize=(8, 6)) for label in np.unique(y): mask = y == label plt.scatter( X_pca[mask, 0], X_pca[mask, 1], s=50, alpha=0.8, label=f'Class {label}' ) plt.xlabel('PC1') plt.ylabel('PC2') plt.title(title) plt.legend() plt.grid(alpha=0.3) plt.show() plot_pca_result(X_pca_raw, y, 'PCA on Raw Wine Data') plot_pca_result(X_pca_std, y, 'PCA on Standardized Wine Data')正常情况下,你会看到两幅图都能把三个类大致分开,但分开的质量和形态有区别。我实践中的观察会在第5节详细展开,这里先留个悬念。
5. 结果对比:标准化到底改写了什么
5.1 方差解释率先做一次对比
把两种路径的方差解释率放在一张表格里,项目结论瞬间就清晰了。以下是我在自己环境里跑出来的大致结果,每位读者实际数值应该非常接近:
| 主成分 | 不标准化方差解释率 | 标准化方差解释率 |
|---|---|---|
| PC1 | 约31% | 约36% |
| PC2 | 约21% | 约19% |
| PC1+PC2 | 约52% | 约55% |
看表格会得出一个有意思的结论:标准化之后,第一主成分的方差解释率反而提高了,累计解释率也有小幅上升。这其实说明原始数据中大量方差被“集中”在个别大尺度特征上,但这部分方差不一定是最有区分度的信息;标准化后,特征间的相关结构被更有效地压缩到前两个维度。
不过必须提醒一句,方差解释率只是“信息保留比例”的量化指标,不代表投影的类别可分性。在真实项目中,有时候解释率低一点,但类别分得更开,反而更有实用价值。散点图比数字更会说话。
5.2 特征载荷分析:到底谁在主控第一主成分
特征载荷是看懂PCA结果的钥匙。所谓载荷,就是特征向量的分量,它表示原始特征与主成分之间的线性关系强弱。绝对值越大,说明该原始特征在这个主成分方向上越重要。
我在实验里把两种路径下第一主成分的载荷绝对值排序打印出来:
def print_top_loadings(eigenvectors, feature_names, n=5): pc1_loadings = np.abs(eigenvectors[:, 0]) indices = np.argsort(pc1_loadings)[::-1][:n] for i, idx in enumerate(indices): print(f'第{i+1}名: {feature_names[idx]} 载荷={pc1_loadings[idx]:.3f}') print('不标准化的PC1载荷排名:') print_top_loadings(evecs_raw, feature_names) print('标准化的PC1载荷排名:') print_top_loadings(evecs_std, feature_names)不标准化那一路的结果非常直观——排在前面的几乎都是数值量级大的特征,比如Proline、Magnesium、Color intensity。这不是因为这些特征真的包含了最多的类别信息,而是因为它们的数值范围大,导致方差天然更大,PCA作为“方差最大化”的算法,自然会把首选方向对准它们。
标准化那一路则完全不同。每个特征被压缩到同一尺度后,第一主成分的载荷体现出的是真实的“相关结构”。在我的实测里,Flavanoids、Total phenols这类酚类指标会出现在靠前位置,因为它们在三个葡萄酒品种之间有着明显的分布差异,且彼此高度相关。这才是更有解释意义的降维结果。
5.3 散点图对比:三类样本的分离形态
看散点图是验证“标准化有没有用”最直观的方式。从我的运行结果看,两幅图都能把三类大致分开,但细节有差别。
未标准化的图,PC1方向上三类跨度很大,但某两个类别可能存在较多交叠,原因是PC1被大尺度特征主导后,其他有区分度的中小尺度特征被挤到次要维度里,PC2承载的信息比较混杂。标准化之后的图,直观上类内聚集更紧凑、类间空隙更清晰,特别是PC1方向上能较好地把其中一类单独拉开,PC2又能辅助区分剩下两类。
通俗地讲,未标准化的PCA把“话事权”交给了嗓门最大的几个特征;标准化之后,每个特征平等发言,主成分才真正反映了数据中最稳定的结构化差异。虽然这个数据集在两种处理下都能勉强分三类,但如果换成特征量纲差异更大、且大尺度特征严重干扰区分度的数据集,标准化之后的优势会体现得更加彻底。
对比到这里,结论已经清楚:做PCA降维,尤其是后续还要基于降维结果做聚类、分类或距离计算的任务,标准化应当是默认操作。只有在明确要求保留原始量纲语义、且特征已经处于同一量纲体系内时,才考虑跳过标准化。
5.4 为什么wine数据集不做标准化也能分开
回答一个常见疑问:既然不标准化结果也还行,为什么非要强调标准化?这跟wine数据集的特殊性有关。
wine数据的三个葡萄酒品种在多个化学指标上本身就差异较大,属于“类别清晰地分开”的数据集。这种情况下,即使主成分方向被少数大尺度特征带偏,这些特征本身也恰好携带分类信息,所以降维后仍然能分开。换个难度更高的数据集,例如各个类别在多个中小尺度特征上才体现差异、而某些无关大尺度特征干扰严重的场景,不标准化的PCA可能把有效信息淹没掉,降维结果会很难看。
所以“标准化没用”的结论,只能在wine这种简单数据上短暂成立。在真实项目中,我们无法预知哪些特征的信息价值更高,也不知道量纲带来的方差是否是噪声,稳妥的做法就是标准化。这是一个性价比极高的默认选择。
6. 常见问题与排查技巧实录
6.1 特征值分解为什么出现了复数
这是我第一次手写PCA时踩到的最有意思的坑。使用np.linalg.eig对协方差矩阵做特征分解,结果里会冒出很小的虚部,比如3.2e-16j这种量级。从数学上讲,实对称矩阵的特征值一定为实数,之所以出现虚数,完全是浮点运算的舍入误差。
解决方案很简单:改用np.linalg.eigh。它就是专门为对称矩阵设计的,内部利用对称性做优化,数值结果更干净。以后凡是对协方差矩阵做特征分解,直接默认eigh,没必要再用eig。
# 推荐 eigenvalues, eigenvectors = np.linalg.eigh(cov_mat) # 不推荐,可能出现微小虚部 # eigenvalues, eigenvectors = np.linalg.eig(cov_mat)6.2 手写结果和sklearn结果为什么符号对不上
第一次对比手写结果和PCA时,发现投影点的坐标有时整体乘了个-1,图看起来像左右镜像翻转,会怀疑自己实现错了。其实特征向量在求解时只保证正交和单位长度,方向可以是任意的,正负号完全取决于底层数值库的实现细节。所以PC1方向和PC2方向在散点图上被“翻转”是正常现象,不影响相对位置,也不影响聚类结构。
判断实现是否正确,不要看坐标符号,要看两点:第一,方差解释率是否和sklearn输出一致;第二,投影点的类别分布形态是否吻合。只要这两点对上,手写代码就是正确的。
6.3 标准化的“分母”到底用哪个标准差
标准化和PCA联用时,有一个数值细节容易被忽略:np.std的默认ddof=0,算的是总体标准差,而np.cov内部默认ddof=1,算的是样本标准差。这意味着标准化后的数据再用np.cov求协方差矩阵,对角线上的值并不是严格的1,而是约等于(n-1)/n≈0.9944。
这个差距非常小,对特征向量方向和主成分结果几乎无影响。但我见过有人拿“协方差矩阵对角线必须等于1”来测试自己标准化是否成功,最后发现对不上而陷入困惑。解决办法有两个:一是明白这个微小差异无关紧要;二是如果想严格对齐,在标准化时用np.std(X, ddof=1)。但要注意,sklearn的StandardScaler默认使用的是总体标准差,从这个角度说,直接用np.std(X)反而能和sklearn保持一致。
6.4 协方差矩阵忘了除以n-1
手写协方差矩阵还有一种常见错误:自己用X.T.dot(X)计算时忘记除以n-1。这样得到的特征值整体会偏大一个固定倍数,但特征向量方向和主成分投影坐标不变,方差解释率也完全不变。
我建议初学者直接用np.cov(X_centered.T),避免踩这个坑。只有当你需要深入理解协方差公式时,再手动写一遍(X_centered.T @ X_centered) / (n-1)来验证,更利于掌握原理。
6.5 画图前先确认数据对齐
还有一个细节很容易翻车:y标签和X特征矩阵在读取后顺序必须一一对应。如果文件读取时做了排序、筛选或洗牌,很容易出现标签和样本错位,散点图上的颜色就会乱掉。在wine数据读取后,建议先打印y.shape和X.shape确认样本数一致,再做后续处理。
另外,如果你是从sklearn的load_wine()里拿到数据,它返回的X和y已经对齐,但不是CSV原始文件;如果你直接从网上下载wine.data,只要代码按行读取,顺序天然保持一致。这里推荐一个常用的自检方法:随便打印第一行样本的前两个特征值和第一个标签,手动对照数据文件确认位置,就能尽早发现对齐问题。
7. 代码之外的几句实话
整个项目做下来,我最深的感受是:PCA手写实现并不难,难的是你在每一步都要清楚“为什么这么做”。中心化是为了让坐标原点落在数据重心,协方差矩阵是为了量化特征之间的联合波动,特征分解是为了找到方差最集中的方向,投影则是把这些方向变成新坐标。每一个环节都像流水线上一颗螺丝,少了哪颗,结果都会失真。
如果你只想要一套图像、一个结论,那sklearn三行代码确实够了。但如果你想在下一次面对新数据集时,能判断“该不该标准化”“降维结果可不可信”“第一主成分到底代表什么”,那么亲手实现一遍的收益远远大于调包。我强烈建议你拿到这份代码之后,不要只跑wine,再找一个特征量纲差异更大的数据集试试,自己观察一下载荷排名和散点图的变化,那会比读十篇文章都管用。
最后再分享一个小技巧:做PCA对比实验时,把特征值、载荷、累计解释率一次性打印出来存档,比单纯画图更有用。因为在复盘或写报告时,图只会说“看起来分得开”,而数字能告诉你“为什么分得开”。这次实验的完整代码路径很简单——标准化只多了一行除法,意义却完全不同。希望这篇复盘能帮你真正跨过“调包”和“理解”之间的那道门槛。