PCA双标图(Biplot)是论文里最常见的降维可视化图之一,它能在同一张图上同时展示样本在主成分空间的分布,以及原始变量对主成分的“贡献方向”。这篇教程用纯手写 SVD 的方式把 PCA 完整实现一遍,不直接调 sklearn 的 PCA 封装,再逐行注释画出一张可以放进论文的科研级双标图。适合正在写毕业论文、准备会议配图、或者想彻底搞清楚主成分分析内部结构的人。整篇文章最值得关注的地方不是那几行 SVD 调用,而是双标图里得分坐标和载荷坐标之间的缩放关系,这个关系搞不懂,画出来的图看起来很像,解释起来全是错的。
1. 先搞明白双标图在表达什么,再决定要不要手写 SVD
1.1 双标图的两个图层:样本得分层和变量载荷层
先别急着写代码。很多人打开 PCA 教程就直接复制绘图代码,最后得到一张图,却说不清楚图上的点、箭头、横纵坐标各自代表什么。
双标图本质上是把两层信息叠在一起:
- 样本层:每个样本经过 PCA 降维后,在主成分空间里得到一个坐标点。通常取前两个主成分,也就是 PC1 和 PC2,画成散点图。点的距离越近,说明样本在原始特征空间里的行为越相似。
- 变量层:每个原始变量在主成分空间里有一个“载荷向量”,画成从原点出发的箭头。箭头指向哪个方向,说明这个变量主要贡献给哪个主成分;箭头越长,说明这个变量对前两个主成分的解释力度越大;两个箭头之间夹角的余弦,近似等于原始变量经过标准化之后的相关系数。
也就是说,双标图回答三个问题:样本之间像不像、变量之间相关性强不强、样本和变量之间是什么关系。一张图能同时承载这三类信息,所以论文里大量使用。
顺带提一句,经常有人把 PCA 和 ICA 放在一起问。知乎上这类对比问题很多。PCA 找的是方差最大的正交方向,适合降维、去相关、可视化;ICA 找的是统计独立的分量,更多用在信号分离上。双标图属于 PCA 的可视化范畴,ICA 没有这种意义上的双标图,所以不要混着理解。
1.2 能调 sklearn 为什么要手写
这个问题我每次写 PCA 教程都会被问到。sklearn 里PCA类三行代码就能出结果,为什么还要自己写 SVD?
我的理由很直接:调包只能让你得到结果,不能让你理解结果。sklearn 的PCA.fit_transform()背后做了什么、特征向量方向怎么定义、components_和explained_variance_ratio_是怎么算出来的、双标图里的箭头需要怎么缩放,这些在黑盒里是看不见的。
毕业论文和期刊论文的审稿人经常问一个问题:你的 PCA 图是怎么画的,载荷坐标是怎么缩放的?如果你只回答"用了 sklearn 和 matplotlib",基本等于没回答。手写一遍之后,每一个数字、每一个箭头坐标你都能讲清楚来源,这才是论文级配图的底气。
另外,有些实训平台上的 PCA 题目,比如头歌上常见的主成分分析练习,很多时候只要求调用现成接口出结果。做完之后你对内部结构还是没有概念。手写 SVD 相当于把底层的数学链路自己走了一遍,以后换数据、换语言、换平台,你都具备从零实现的能力,而不是被某个包绑死。
1.3 手写 SVD 实现 PCA 的完整数学链路
在给代码之前,先把数学链路用最简单的话说清楚,后面看代码就不会迷路。
假设原始数据矩阵X有 n 个样本、p 个特征。PCA 的目标是找到一组新的正交方向,让样本投影到这些方向上的方差最大。这组方向其实就是协方差矩阵的特征向量。
计算路径有两条:
- 特征值分解路径:先算协方差矩阵
C = XᵀX / (n-1),再对 C 做特征值分解。特征值就是各主成分的方差,特征向量就是主成分方向。 - SVD 路径:直接对中心化后的数据矩阵做奇异值分解
X = U Σ Vᵀ,其中Vᵀ的行就是主成分方向,奇异值Σ的平方除以(n-1)就是各主成分的方差。
两条路径数学上等价,但 SVD 路径数值上更稳定,而且不用先构造协方差矩阵,内存占用也更友好。所以我这里采用 SVD 路径,这也是大多数数值计算库内部的做法。
需要记牢的换算关系只有三个:
- 主成分得分:
scores = X @ V = U @ diag(S) - 载荷向量:
loadings = V,也就是Vt.T - 方差解释率:
S² / sum(S²)
后面所有代码都围绕这三个公式展开。
2. 环境准备和数据标准化
2.1 运行环境与依赖清单
先说明环境。我建议的版本组合是这样的:
| 依赖库 | 版本建议 | 作用 |
|---|---|---|
| Python | 3.9 及以上 | 运行环境 |
| numpy | 1.23 及以上 | 矩阵运算、SVD 分解 |
| matplotlib | 3.6 及以上 | 绘图 |
| scikit-learn | 可选 | 只用来加载示例数据集和对拍验证 |
如果你的机器上还没有这些库,可以用 pip 一次性装好:
pip install numpy matplotlib scikit-learn这里说明一下:手写 SVD 本身只需要 numpy,scikit-learn 并不是必需品。我引入 sklearn 只是为了两件事,一是加载鸢尾花数据集方便演示,二是在验证阶段和官方 PCA 结果做对拍。如果你的项目里不方便装 sklearn,可以把数据换成 CSV 文件,用np.loadtxt或pandas.read_csv读进来,核心 PCA 代码一行都不用改。
低配置机器跑这个示例没有任何压力,数据量只有 150 个样本、4 个特征,内存占用可以忽略。真正要注意的是后面自己换大数据集时,SVD 的计算时间会随矩阵规模上升,尤其是样本数多、特征数多的情况下,需要关注内存和耗时。
2.2 用鸢尾花数据集建立最小可运行样例
为了演示方便,我使用鸢尾花数据集。它是 PCA 教学里的经典数据:150 个样本、4 个特征变量、3 个品种类别。特征分别是花萼长度、花萼宽度、花瓣长度、花瓣宽度。
加载代码如下:
import numpy as np import matplotlib.pyplot as plt # 科研绘图第一步:把中文字体和负号显示问题先解决掉 plt.rcParams['font.sans-serif'] = ['SimHei', 'Arial Unicode MS'] plt.rcParams['axes.unicode_minus'] = False from sklearn.datasets import load_iris from sklearn.preprocessing import StandardScaler # 加载鸢尾花数据 iris = load_iris() X = iris.data # 形状 (150, 4) y = iris.target # 标签:0/1/2,对应三个品种 feature_names = iris.feature_names # 四个特征名plt.rcParams['font.sans-serif']这一段不是可有可无的。matplotlib 默认字体对中文支持不好,如果不设置,图里的中文标签会显示成方块。Windows 下通常用SimHei,macOS 下常碰到的是Arial Unicode MS,两者都列进去可以兼顾跨平台运行。
2.3 标准化这一步为什么决定双标图的可解释性
很多初学者忽略标准化,直接拿原始数据进 PCA,结果发现第一主成分几乎完全被量纲大的变量主宰,双标图里的箭头也明显偏向某个变量,根本看不出结构。
原因很简单:PCA 目标是最大化方差。如果某个特征的数值范围天生就大,比如长度以厘米为单位、重量以克为单位,它的方差会天然压过其他特征,第一主成分就会"偏心"。
解决办法是在 PCA 之前对每个特征做标准化:减去均值,除以标准差。标准化之后每个特征的均值为 0、方差为 1,所有变量处于同一量级,双标图才有公平的比较基础。
# 标准化:每个特征减去均值,再除以标准差 scaler = StandardScaler() X_scaled = scaler.fit_transform(X)注意一个细节:StandardScaler的fit_transform是先在训练数据上算出均值和标准差,再用同一个均值和标准差去转换数据。真实项目里如果涉及训练集和测试集,必须先fit训练集,再transform测试集,不能把测试集的统计量混进来。否则会造成数据泄露,论文里这部分经常被审稿人盯着问。
另外要区分两个概念:只做中心化和做标准化。中心化只减去均值,保留了变量的量纲差异;标准化在中心化基础上还要除以标准差,消除量纲影响。如果所有特征单位一致、量级接近,只做中心化也可以;如果特征来自不同量纲,必须做标准化。我一般默认标准化,原因就是省心、可解释。
3. 手写 SVD 做 PCA 的核心代码
3.1 中心化、SVD 分解、得分、载荷
现在进入核心部分。下面是手写 SVD 实现 PCA 的完整函数,每一行都有注释。
def pca_svd(X_scaled): """ 手写 SVD 实现 PCA。 参数 ---- X_scaled : np.ndarray 标准化后的数据矩阵,形状 (n, p) 返回 ---- scores : np.ndarray 主成分得分矩阵,形状 (n, p) loadings : np.ndarray 载荷矩阵,形状 (p, p) explained_variance_ratio : np.ndarray 每个主成分的方差解释率 U, S, Vt : SVD 分解的三要素,绘图阶段还要用 """ n, p = X_scaled.shape # 1. 中心化。标准化之后均值已经是 0,这里保留中心化步骤, # 是为了让这套代码在“只做中心化不做标准化”的场景下也能直接使用。 X_center = X_scaled - X_scaled.mean(axis=0) # 2. SVD 分解。full_matrices=False 返回经济型分解,省内存。 # X_center ≈ U @ diag(S) @ Vt U, S, Vt = np.linalg.svd(X_center, full_matrices=False) # 3. 主成分得分:scores = X_center @ V = U @ diag(S) # 每一行是一个样本在新坐标系下的坐标。 # U * S 利用 numpy 广播,等价于 U @ np.diag(S) scores = U * S # 4. 载荷矩阵:loadings = V。 # Vt 的第 i 行是第 i 个主成分的方向, # 转置之后每一列是一个变量在主成分空间里的载荷向量。 loadings = Vt.T # 形状 (p, p) # 5. 方差和方差解释率。 # 奇异值平方除以 (n-1) 是每个主成分的方差,对应无偏估计; # 归一化之后就是方差解释率。 explained_variance = S ** 2 / (n - 1) explained_variance_ratio = explained_variance / explained_variance.sum() return scores, loadings, explained_variance_ratio, U, S, Vt调用方式:
scores, loadings, evr, U, S, Vt = pca_svd(X_scaled) print("得分矩阵形状:", scores.shape) # (150, 4) print("载荷矩阵形状:", loadings.shape) # (4, 4) print("方差解释率:", evr)跑出来你会看到前两个主成分的方差解释率通常已经超过 95%,这就是能画二维双标图的基础。如果前两个主成分的累计解释率很低,比如只有 60%,那你就要考虑是不是数据非线性太强、或者需要更多主成分,此时强行画二维双标图意义有限。
3.2 方差解释率与前几个主成分的选择
方差解释率是判断双标图可信度的核心指标。它告诉你前两个主成分到底"代表"了多少原始信息。
判断标准一般有三个:
- 累计解释率法:前 k 个主成分累计解释率达到 80% 或 90%,就认为可以接受。论文里比较常见的是 85% 到 95% 这个区间。
- 特征值大于 1 法:只保留特征值大于 1 的主成分,也叫 Kaiser 准则。因为标准化之后每个原始变量的方差是 1,主成分方差小于 1 说明它还不如一个原始变量有信息量。
- 碎石图法:把特征值从大到小画折线,找拐点,拐点之后的主成分解释力骤降。
实际画双标图时,我建议至少报告前两个主成分各自的方差解释率,并在坐标轴标签里带出来。比如 PC1 (72.77%)、PC2 (22.41%)。这样读者一眼就能判断图的可靠程度。坐标轴带百分比这个细节,很多论文里都有,但不少新手画的时候会漏掉。
3.3 和 sklearn 结果对拍,处理符号翻转
手写代码跑通之后,第一件事是和 sklearn 对拍,确认自己没有算错。
from sklearn.decomposition import PCA # sklearn 的 PCA 也要在标准化后的数据上做 pca_sk = PCA(n_components=4, svd_solver='full') scores_sk = pca_sk.fit_transform(X_scaled) components_sk = pca_sk.components_ # 对比载荷:要注意符号翻转 print("载荷矩阵是否一致:", np.allclose(loadings, components_sk.T)) print("得分矩阵是否一致:", np.allclose(scores, scores_sk))第一次对拍时,大概率allclose返回False。不要慌,这不是你算错了,而是 PCA/SVD 存在一个天然问题:特征向量的方向是任意的,正负号可以同时翻转而不影响结果。
也就是v和-v都是同一个主成分方向,数学上完全等价。你在图上会看到某几个轴的箭头和 sklearn 完全相反,整个图形像被镜像了一样。
解决办法是做一个符号对齐:
def align_sign(loadings, components_sk): """ 把 loadings 的每一列方向对齐到 sklearn 的 components_。 用点积符号判断方向是否一致,不一致就整体取反。 """ aligned = loadings.copy() for j in range(loadings.shape[1]): if np.dot(loadings[:, j], components_sk[j]) < 0: aligned[:, j] = -aligned[:, j] return aligned对齐之后再对拍,基本就是全等了。这里要记住:符号翻转不是 bug,是 SVD 的数学特性。画图前做一次符号对齐,是为了让图和别人跑的图方向一致,方便比较,不代表"正确"或"错误"。
4. 双标图的坐标换算和绘图细节
4.1 得分和载荷的三种缩放约定
这是整篇教程最核心的地方,也是很多人画双标图出错的地方。
得分和载荷是两个不同量纲的量。得分坐标来自样本到主成分方向的投影,载荷坐标是特征向量的分量。直接把两者画在同一坐标系里,通常会出现一个问题:载荷箭头的长度和得分点完全不在一个数量级,箭头要么短到看不见,要么长到飞出图外。
解决方式是引入一个缩放因子alpha,对得分和载荷同时做变换:
scores_plot = U * (S ** alpha)loadings_plot = V * (S ** (1 - alpha))
当X = U @ diag(S) @ Vt时,无论 alpha 取什么值,U * S^alpha和V * S^(1-alpha)相乘都能还原出原始数据矩阵。这就是双标图能同时画两组坐标的根本原因。
三种常见约定:
| alpha 取值 | 得分表现 | 载荷表现 | 适用场景 |
|---|---|---|---|
| alpha = 1 | 就是原始主成分得分 | 载荷是单位特征向量 | 得分点距离有意义,变量方向标准化 |
| alpha = 0 | 得分是归一化得分 | 载荷是特征向量乘以奇异值 | 突出变量贡献 |
| alpha = 0.5 | 对称缩放 | 对称缩放 | 样本点和箭头量级更均衡,出图最美观 |
论文里最常用的是alpha = 0.5,因为视觉上最均衡。不过也有不少文献明确报告自己用的是alpha = 1或说采用 "column metric preserving",重点是你必须写明约定,不能画完图自己不解释。
另外还有一种做法是像 R 语言biplot()默认那样,给载荷额外乘一个缩放系数,比如sqrt(n)或根据得分范围自动计算的比例。后面绘图代码我会默认提供一个自动缩放比例,保证箭头在图上清晰可见。
4.2 论文级双标图的逐行绘图代码
下面这段代码把前面的计算结果变成一张可以直接放论文的图。
# ---------- 双标图坐标换算 ---------- alpha = 0.5 scores_plot = U * (S ** alpha) # 得分坐标 loadings_plot = loadings * (S ** (1 - alpha)) # 载荷坐标 # ---------- 绘图 ---------- fig, ax = plt.subplots(figsize=(8, 6)) # 1. 画样本散点。c 参数按类别着色,后面可以加图例。 sc = ax.scatter(scores_plot[:, 0], scores_plot[:, 1], c=y, cmap='viridis', s=40, edgecolor='white', linewidth=0.5) # 2. 自动调整载荷箭头的缩放比例。 # 得分和载荷经常不在一个量级,这里按箭头的最大长度 # 占得分范围的比例来缩放,保证箭头清晰可见。 scale = (np.abs(scores_plot[:, :2]).max() / np.abs(loadings_plot[:, :2]).max()) * 0.8 load_xy = loadings_plot[:, :2] * scale # 3. 画载荷箭头和变量标签。 for i in range(load_xy.shape[0]): ax.arrow(0, 0, load_xy[i, 0], load_xy[i, 1], color='#d62728', width=0.005, head_width=0.03, length_includes_head=True) ax.text(load_xy[i, 0] * 1.15, load_xy[i, 1] * 1.15, feature_names[i], color='#d62728', fontsize=11) # 4. 过原点的辅助线,方便观察象限分布。 ax.axhline(0, color='grey', lw=0.6, ls='--') ax.axvline(0, color='grey', lw=0.6, ls='--') # 5. 坐标轴带上方差解释率,这是论文配图的基本要求。 ax.set_xlabel(f'PC1 ({evr[0]:.2%})') ax.set_ylabel(f'PC2 ({evr[1]:.2%})') ax.set_title('PCA Biplot(手写 SVD)') ax.legend(*sc.legend_elements(), title='Species') plt.tight_layout() plt.savefig('pca_biplot.pdf', dpi=300, bbox_inches='tight') plt.show()几个需要重点解释的参数:
head_width和head_length如果设置太小,在 PDF 导出后箭头尖可能看不见;设置太大又会遮挡得分点。我一般先跑一次默认值,再根据得分范围微调。length_includes_head=True表示箭头总长度包含箭头的头部,这样箭头的实际指向不会因为头部大小而偏移,论文里坐标读起来才准确。scale这个量是自己加的额外缩放,不是 PCA 数学的一部分。它的作用是让箭头长度和得分点范围匹配。如果 R 里跑过biplot()你会发现它也有类似的内部缩放逻辑。- 保存图片时用
bbox_inches='tight'避免坐标轴标签被裁掉,投稿时输出 PDF 或 SVG 矢量图比 PNG 更好。
4.3 画完之后必须检查的四个点
图跑出来之后,先别急着写进论文。我一般会按以下顺序检查:
- 箭头长度是否合理:载荷箭头太长会覆盖所有样本点,太短则完全看不清。如果箭头和点严重不匹配,优先调
scale,而不是改alpha。 - 标签是否重叠:几个变量如果相关性高,载荷箭头会挤在同一个方向,标签文字叠在一起。这时候要么手动调整文字偏移,要么在图上只标注贡献最大的几个变量。
- 符号方向是否一致:把
PC1轴上的方向和变量含义对照一下。比如花瓣长度、花瓣宽度这类正相关变量,应该指向相同的大方向。如果出现矛盾,回头检查标准化和符号对齐。 - 方差解释率是否标注:坐标轴必须带上百分比。一个只有"PC1"、"PC2"没有解释率的图,在论文里是不过关的。
另外还要检查一个容易被忽略的点:得分点和箭头是否使用了同一个坐标系。有些教程先生成两个axes再叠加,一个画样本一个画箭头,如果没有把两者的xlim、ylim对齐,就会得到一张看起来正常、实际坐标完全错位的图。我的代码里只用一个ax画两层,就是为了避免这个问题。
5. 常见报错和排查链路
5.1 箭头太短、标签重叠、字体方块
这里集中说三个高频问题。
问题一:箭头太短或太长。先看loadings_plot和scores_plot的数值范围,打印出来对比一下。如果载荷坐标最大值只有 0.01,而得分坐标最大到 4,箭头当然看不见。解决办法就是调scale系数,把载荷坐标放大到得分范围的 60% 到 90% 左右。
问题二:标签文字重叠。变量多的时候几乎必然发生。有两个处理思路:一是只选取前 k 个对主成分贡献最大的变量画箭头,其它变量不上图,在正文里说明"为了清晰只展示主要变量";二是用简单的启发式偏移,比如根据箭头所在象限给文字设置不同的ha和va参数。如果项目里允许装扩展包,adjustText也可以做标签防重叠,但投稿场景我更喜欢手动控制排版,因为自动调整的结果有时反而更乱。
问题三:中文显示成方块。原因基本就是字体没有正确配置。先确认plt.rcParams['font.sans-serif']里设置的字体确实存在,然后确认系统里有对应中文字体。Linux 服务器上最容易踩坑,因为没有预装中文字体,需要安装fonts-wqy-zenhei之类的字体包,或者直接用英文标签绕过去。
5.2 为什么和 R 的 biplot() 长得不一样
这是知乎、Stack Overflow 上反复出现的问题。同样一套数据,R 里biplot(prcomp(...))画出来的箭头长度和 Python 手写图画出来的明显不一样,很多人以为自己写错了。
实际上两者都对,只是缩放约定不同。R 的默认biplot()会额外乘一个lam因子,并且对得分坐标做scale处理,最终效果是让两个坐标系的数值范围更接近。我们上面代码里的scale变量,做的就是类似的事情。
所以当你发现图和 R 不一样时,不要怀疑 SVD 算错了,先去核对:
- 用的数据集是否做了同样的标准化;
- 载荷是否经过了
S的幂次缩放; - 是否额外乘了自定义比例;
- 坐标轴范围是否一致。
只要得分矩阵、载荷矩阵、方差解释率能对拍上,双标图的差异就只来自可视化缩放,不影响统计结论。
5.3 数据有缺失、变量太多、聚类不明显怎么办
缺失值。SVD 不能直接处理包含NaN的矩阵。常见做法是先用均值填充、中位数填充或插值补全,再做 PCA。如果缺失比例很高,填充本身会引入偏差,这时可以考虑报告"基于完整样本"的分析。论文里必须在方法部分写清楚缺失值处理方式,这个细节很多学生容易漏。
变量太多。当变量数超过几十个时,双标图上的箭头会密集到无法辨认。我的建议是先用载荷矩阵的绝对值筛选,只保留前 k 个贡献最大的变量画箭头,其余变量不展示。筛选标准可以是前两个主成分载荷的平方和排名靠前,或者直接按某个主成分上的载荷绝对值阈值过滤。
聚类不明显。如果样本点混杂成一团,先别急着调绘图参数。回到数据本身想三件事:预处理有没有问题、前两个主成分的累计解释率是不是太低、数据是不是本身就没有明显分组结构。低解释率时可以考虑用前三个主成分画 3D 图或两两组合画多张双标图,而不是死磕一张二维图。
6. 从单张图到批量出图
6.1 封装成可复用的 biplot 函数
项目里如果要做多组数据、多个数据集的双标图,手写一份绘图代码显然不现实。我建议把前面的逻辑封装成一个函数,输入是数据矩阵和可选的分组标签,输出是双标图文件和统计结果。
def make_biplot(X, group=None, feature_names=None, alpha=0.5, out_path='biplot.pdf'): """ 标准化 -> 手写 SVD -> 双标图缩放 -> 绘图 -> 保存。 返回得分矩阵、载荷矩阵、方差解释率,方便后续继续使用。 """ # 标准化 from sklearn.preprocessing import StandardScaler X_scaled = StandardScaler().fit_transform(X) # 手写 SVD 做 PCA n, p = X_scaled.shape X_center = X_scaled - X_scaled.mean(axis=0) U, S, Vt = np.linalg.svd(X_center, full_matrices=False) scores = U * S loadings = Vt.T evr = (S ** 2 / (n - 1)) / (S ** 2 / (n - 1)).sum() # 双标图缩放 scores_plot = U * (S ** alpha) loadings_plot = loadings * (S ** (1 - alpha)) # 绘图 fig, ax = plt.subplots(figsize=(8, 6)) if group is not None: sc = ax.scatter(scores_plot[:, 0], scores_plot[:, 1], c=group, cmap='viridis', s=40, edgecolor='white', linewidth=0.5) else: ax.scatter(scores_plot[:, 0], scores_plot[:, 1], s=40, color='steelblue', edgecolor='white', linewidth=0.5) # 载荷箭头自动缩放 if feature_names is None: feature_names = [f'V{i}' for i in range(p)] scale = (np.abs(scores_plot[:, :2]).max() / np.abs(loadings_plot[:, :2]).max()) * 0.8 load_xy = loadings_plot[:, :2] * scale for i in range(p): ax.arrow(0, 0, load_xy[i, 0], load_xy[i, 1], color='#d62728', width=0.005, head_width=0.03, length_includes_head=True) ax.text(load_xy[i, 0] * 1.15, load_xy[i, 1] * 1.15, feature_names[i], color='#d62728', fontsize=11) ax.axhline(0, color='grey', lw=0.6, ls='--') ax.axvline(0, color='grey', lw=0.6, ls='--') ax.set_xlabel(f'PC1 ({evr[0]:.2%})') ax.set_ylabel(f'PC2 ({evr[1]:.2%})') ax.set_title('PCA Biplot') fig.tight_layout() fig.savefig(out_path, dpi=300, bbox_inches='tight') plt.close(fig) return scores, loadings, evr封装之后,批量出图就只剩一个循环:
# datasets 是一个字典,key 是数据集名称,value 是数据矩阵 for name, data in datasets.items(): make_biplot(data, group=labels[name], feature_names=cols[name], out_path=f'biplot_{name}.pdf')这里有一个工程化的提醒:批量跑的时候,别把所有图都输出成 PNG,尤其是论文要用的图,请输出 PDF 或 SVG 矢量图。矢量图放大不糊,投稿系统普遍支持。另外,输出文件名尽量用有意义的前缀,不要把结果全部塞进figure_1.png这种命名,不然十几张图跑完你就分不清谁是谁了。
6.2 分组着色和置信椭圆
如果样本本身有分组,比如不同处理、不同品种,可以在双标图里加上分组置信椭圆,让分组结构更直观。
from matplotlib.patches import Ellipse from scipy.stats import chi2 # 95% 置信椭圆的缩放半径 scale_ell = np.sqrt(chi2.ppf(0.95, df=2)) for g in np.unique(y): pts = scores_plot[y == g][:, :2] mean = pts.mean(axis=0) cov = np.cov(pts, rowvar=False) eigvals, eigvecs = np.linalg.eigh(cov) # 计算椭圆旋转角度 angle = np.degrees(np.arctan2(eigvecs[1, 0], eigvecs[0, 0])) # 椭圆的长短轴 width = 2 * scale_ell * np.sqrt(eigvals[1]) height = 2 * scale_ell * np.sqrt(eigvals[0]) ellipse = Ellipse(xy=mean, width=width, height=height, angle=angle, facecolor='none', edgecolor='grey', linestyle='--') ax.add_patch(ellipse)chi2.ppf(0.95, df=2)得到的是 2 维正态分布 95% 置信域的半径因子,大约是 2.448。直接写死这个数字也可以,但用scipy计算更清晰,别人读代码时一眼就明白来源。
注意一点:置信椭圆假设每个分组的数据近似服从二维正态分布。如果分组内部有明显多峰分布,画椭圆会误导读者。这种时候画凸包轮廓或者保持散点就够了,不要硬加椭圆。
6.3 论文投稿时对矢量图和字体的一点建议
最后说几个投稿相关的落地细节。
- 图片格式优先 PDF 或 SVG。如果期刊只收位图,再转成 600 dpi 的 TIFF 或 PNG。
- 字体大小要配合期刊要求。一般正文里嵌入的图,坐标轴标签和刻度字号不要小于 8pt,我通常用 10 到 12pt,保证缩印后仍可读。
- 颜色要照顾色盲读者。
viridis、cividis这类色觉友好型 colormap 是比默认jet更稳妥的选择。如果期刊是黑白印刷,还要考虑用不同符号区分分组。 - 双标图里如果样本量很大,比如几千个点,建议把点的 alpha 调低到 0.3 到 0.5,否则很多点叠在一起会掩盖密度信息。
- 我习惯把每次跑图的方差解释率、样本数、标准化方式记在附件或代码注释里。投稿后审稿人问起细节时,不用重新翻代码就能答上来。
手写 SVD 做 PCA 这件事,真正落地时最该盯住的不是代码能不能跑,而是每一步结果是否可解释。先跑通单张双标图,再考虑批量出图和封装函数;先和 sklearn 对拍确认数学正确,再去微调箭头长度和标签位置。踩过几次之后你会发现,很多问题不是 PCA 本身难,而是缩放约定、符号翻转、数据预处理这些前置细节没有处理干净。把这套流程完整走一遍,论文里的 PCA 双标图就不再是复制粘贴出来的装饰图,而是每一个坐标都有来源的实打实的结果。