PCA双标图Biplot是论文里最常见的降维可视化图之一它能在同一张图上同时展示样本在主成分空间的分布以及原始变量对主成分的“贡献方向”。这篇教程用纯手写 SVD 的方式把 PCA 完整实现一遍不直接调 sklearn 的 PCA 封装再逐行注释画出一张可以放进论文的科研级双标图。适合正在写毕业论文、准备会议配图、或者想彻底搞清楚主成分分析内部结构的人。整篇文章最值得关注的地方不是那几行 SVD 调用而是双标图里得分坐标和载荷坐标之间的缩放关系这个关系搞不懂画出来的图看起来很像解释起来全是错的。1. 先搞明白双标图在表达什么再决定要不要手写 SVD1.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 运行环境与依赖清单先说明环境。我建议的版本组合是这样的依赖库版本建议作用Python3.9 及以上运行环境numpy1.23 及以上矩阵运算、SVD 分解matplotlib3.6 及以上绘图scikit-learn可选只用来加载示例数据集和对拍验证如果你的机器上还没有这些库可以用 pip 一次性装好pip install numpy matplotlib scikit-learn这里说明一下手写 SVD 本身只需要 numpyscikit-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 下通常用SimHeimacOS 下常碰到的是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(axis0) # 2. SVD 分解。full_matricesFalse 返回经济型分解省内存。 # X_center ≈ U diag(S) Vt U, S, Vt np.linalg.svd(X_center, full_matricesFalse) # 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_components4, svd_solverfull) 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], cy, cmapviridis, s40, edgecolorwhite, linewidth0.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, width0.005, head_width0.03, length_includes_headTrue) ax.text(load_xy[i, 0] * 1.15, load_xy[i, 1] * 1.15, feature_names[i], color#d62728, fontsize11) # 4. 过原点的辅助线方便观察象限分布。 ax.axhline(0, colorgrey, lw0.6, ls--) ax.axvline(0, colorgrey, lw0.6, ls--) # 5. 坐标轴带上方差解释率这是论文配图的基本要求。 ax.set_xlabel(fPC1 ({evr[0]:.2%})) ax.set_ylabel(fPC2 ({evr[1]:.2%})) ax.set_title(PCA Biplot手写 SVD) ax.legend(*sc.legend_elements(), titleSpecies) plt.tight_layout() plt.savefig(pca_biplot.pdf, dpi300, bbox_inchestight) plt.show()几个需要重点解释的参数head_width和head_length如果设置太小在 PDF 导出后箭头尖可能看不见设置太大又会遮挡得分点。我一般先跑一次默认值再根据得分范围微调。length_includes_headTrue表示箭头总长度包含箭头的头部这样箭头的实际指向不会因为头部大小而偏移论文里坐标读起来才准确。scale这个量是自己加的额外缩放不是 PCA 数学的一部分。它的作用是让箭头长度和得分点范围匹配。如果 R 里跑过biplot()你会发现它也有类似的内部缩放逻辑。保存图片时用bbox_inchestight避免坐标轴标签被裁掉投稿时输出 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, groupNone, feature_namesNone, alpha0.5, out_pathbiplot.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(axis0) U, S, Vt np.linalg.svd(X_center, full_matricesFalse) 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], cgroup, cmapviridis, s40, edgecolorwhite, linewidth0.5) else: ax.scatter(scores_plot[:, 0], scores_plot[:, 1], s40, colorsteelblue, edgecolorwhite, linewidth0.5) # 载荷箭头自动缩放 if feature_names is None: feature_names [fV{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, width0.005, head_width0.03, length_includes_headTrue) ax.text(load_xy[i, 0] * 1.15, load_xy[i, 1] * 1.15, feature_names[i], color#d62728, fontsize11) ax.axhline(0, colorgrey, lw0.6, ls--) ax.axvline(0, colorgrey, lw0.6, ls--) ax.set_xlabel(fPC1 ({evr[0]:.2%})) ax.set_ylabel(fPC2 ({evr[1]:.2%})) ax.set_title(PCA Biplot) fig.tight_layout() fig.savefig(out_path, dpi300, bbox_inchestight) plt.close(fig) return scores, loadings, evr封装之后批量出图就只剩一个循环# datasets 是一个字典key 是数据集名称value 是数据矩阵 for name, data in datasets.items(): make_biplot(data, grouplabels[name], feature_namescols[name], out_pathfbiplot_{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, df2)) for g in np.unique(y): pts scores_plot[y g][:, :2] mean pts.mean(axis0) cov np.cov(pts, rowvarFalse) 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(xymean, widthwidth, heightheight, angleangle, facecolornone, edgecolorgrey, linestyle--) ax.add_patch(ellipse)chi2.ppf(0.95, df2)得到的是 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 双标图就不再是复制粘贴出来的装饰图而是每一个坐标都有来源的实打实的结果。