☰
顶刊星座图复现指南:从PCA降维到可视化全流程
2026/10/15 5:09:52 网站建设 项目流程

聊到复现顶刊里的星座图,很多人第一反应是“星座图?那不是天文学吗?”其实在科研图表里,星座图和天上的星座没有直接关系,它更像是一类把样本、基因或变量排布成星空状的点阵图,点与点之间常常还连着线。近年在单细胞聚类、群体遗传结构、微生物组分型这些领域里,星座图出现频率很高,因为它能在很小的一块版面里,同时把“分离”“过渡”“聚集”三种信息讲清楚。这篇文章就从一张自带随机种子的模拟数据出发,把从数据处理到最终成图的完整流程拆开揉碎,讲清楚每一步为什么这么做、有哪些可以抄作业的细节。适合刚进实验室需要复现图表的研究生,也适合想做配图但又不想被各种参数劝退的独立分析者。

1. 先搞懂星座图在论文里到底承担什么职责

1.1 星座图不是一种固定的统计图,而是一类“点加线”的叙事方式

星座图并没有出现在标准的统计图形教科书里,它更像是一种约定俗成的叫法。你仔细看那些高影响力论文里的星座图,会发现它们大多不是同一种底层算法生成的。有的基于PCA降维,有的基于t-SNE,有的用了网络布局算法。它们的共同点是:先用某种方法把高维数据映射到二维平面,然后让点代表样本或基因,再用连线、颜色、椭圆或箭头来强调点与点之间的关系。

这种图的核心是“视觉距离代表关系远近”。点靠得近,说明样本性质接近;点被有线连接,说明它们之间存在某种可解释的关联或路径。这跟天文学星座图的直观逻辑很像:星星本身是无数光年外的大火球,但人们用连线把它划成了有意义的形状。科研星座图也一样,点本身是数据点,连线则是研究者在数据中识别出的结构。

我经常看到有人把星座图和普通的散点图混为一谈。严格来说,散点图只是把点画在坐标轴上,星座图则多了一个“结构化”的动作,比如加置信椭圆、组中心连线、轨迹箭头,或者手动用颜色圈出几个亚群。没有这些额外的视觉语义,那就只是一张散点图,谈不上星座图。

1.2 顶刊为什么偏爱这种表达方式:信息密度高,读者能一眼读出结构

顶刊的版面寸土寸金,审稿人没有耐心看复杂的多面板拆解。星座图恰好能在半张图的大小里同时完成几件任务:一是展示样本整体分布轮廓;二是让不同分组在视觉上天然分开;三是通过连线或轨迹暗示样本之间的连续变化,比如发育时序、地理渐变、剂量梯度。

举个例子,如果有一批来自不同地理区域的个体,你做完PCA之后,会发现它们在第一主成分轴上呈连续的梯度分布。这时候在散点图上把每个地理组的均值点连起来,就会得到一条弧线,读者马上能感受到“由西到东的渐变”。这就是星座图的表达效率。另一种常见场景是单细胞聚类,UMAP降维后,不同细胞亚群像一片星云里的几个亮团,再用颜色区分,配上几个关键marker的注释,一张图就能讲清楚“有哪些细胞类型、它们是否连续、哪里过渡最明显”。

说白了,星座图能火起来,是因为它把数据的“形状”和“关系”同时画出来了。普通表格给不了这种直觉,普通柱状图又太离散。所以,如果你的数据天然带有簇结构、梯度结构或轨迹结构,就不妨考虑用星座图来呈现。

2. 复现前需要准备的原料与方法选型

2.1 我们需要怎样的数据

在开始画图之前,先确认你的数据表是什么形态。星座图最常见的输入是“观测矩阵”:行是样本,列是变量。变量可以是基因表达量、菌群丰度、代谢物含量,也可以是连续型的临床指标。理论上,任何能够描述样本之间差异的数值型变量都可以。

如果你的数据里带有分类变量,比如性别、疾病状态、季节,需要先做编码(0/1或one-hot)。但编码后的变量在PCA里不太好解释,我建议优先对连续变量做分析,分类变量直接作为后续的颜色映射、形状映射来用。另一个要求是样本量,经验上至少要有几十个样本才能撑起星座图的“漂亮”。样本太少,比如每组就三五例,画出来的点稀稀拉拉,置信区间也估算不准,强行连线会显得很单薄。

模拟数据是最稳妥的练习方式。我会用三组高斯分布构造一批样本,一组偏移在变量1到10上,一组偏移在变量11到20上,第三组群稍微有重叠,这样既能看出分组,又不会分得太干净。实际操作里,真实数据往往没有这么干净,但流程是一样的。

2.2 工具选型:R还是Python,怎么选不后悔

我两个工具都在项目里用过,实话实说,没有绝对的优劣,只有顺手程度。R语言里,ggplot2 + ggforce + dplyr这套组合很舒服,尤其适合做“数据整理加绘图一体”的探索流程。它的分层语法天然适合“先画点、再画线、再加椭圆”的星座图逻辑,代码写起来也简洁。Python方面,matplotlib + seaborn + scikit-learn更适合接在已有的数据处理流程后面,尤其是你已经在用pandas做清洗、用sklearn做模型时,直接用Python画图可以少一道跨语言搬运的工序。

我个人的建议是:如果主要工作是生物信息、生态分析,优先选R;如果还要做机器学习建模、深度学习,直接用Python。两个版本我都会给出代码,你挑一个顺手的抄作业即可,没必要两个都学。

2.3 环境准备与包安装

R环境需要安装以下几个包:ggplot2负责绘图核心,dplyr负责分组统计,ggforce提供置信椭圆的geom,FactoMineR不是必须的,但如果你想要更完整的PCA结果输出,可以安上。安装命令如下:

install.packages("ggplot2") install.packages("dplyr") install.packages("ggforce")

Python环境建议用conda或venv创建独立环境,避免和其他项目冲突。需要的库包括numpy、pandas、scikit-learn、matplotlib。安装命令:

pip install numpy pandas scikit-learn matplotlib

我强烈建议把随机种子固定下来。R里用set.seed(2024),Python里用np.random.seed(2024)。这样任何人重跑都能得到一模一样的图,方便排查问题,也方便你后续改动参数时能对比差异。随机种子不设,每次画的点位置都不同,你以为自己换了参数,其实是换了一堆新数据,白白浪费时间。

3. 拆解星座图的三个关键设计决策

3.1 用哪张“画布”:PCA、t-SNE还是UMAP

这是复现星座图前必须想清楚的问题,直接决定图里点的排布。“画布”就是降维算法的二维坐标。我做了个对比,方便你根据数据类型选择。

算法保留的全局距离保留的局部结构计算速度可解释性
PCA强一般极快高,坐标轴有方差占比
t-SNE弱,只看局部邻居极强慢低,坐标轴没有物理意义
UMAP较好很强较快低,但布局稳定性比t-SNE好

实际项目里,我一般先用PCA试一遍。PCA有两个明显优点:它线性且确定性,不会因为随机初始化导致每次图都不一样;它还能输出每个主成分解释的方差比例,你可以在坐标轴标签上写清楚“PC1 (45.2%)”,有据可依。很多顶刊里的群体遗传星座图用的就是PCA,因为那份“连续渐变”的位置关系正是PCA保留的全局结构。

如果PCA前两轴只解释不到10%的方差,比如单细胞表达数据,那我会直接切换到UMAP或t-SNE。这时注意,坐标轴标签不要再写PC1/PC2了,而是写UMAP1/UMAP2或tSNE1/tSNE2。也不要试图从UMAP坐标轴读出数值含义,它只负责把我们带入“邻近关系的结构”。

3.2 点的摆放:先降维,还是先算关系再布局

有时候星座图里的点不一定来自降维,还可能来自网络布局算法。比如你要展示多个基因之间的共现关系,每个点是基因,点与点之间的连线表示它们在同一样本中出现频率显著偏高或偏低。这时如果强行用PCA,会出现一个问题:PCA只能处理“样本×变量”矩阵,不能直接把“基因×基因”的关系矩阵变成平面坐标。

解决方法是改用图布局,比如Force-directed算法、Fruchterman-Reingold算法。这些算法会尝试让有关联的节点靠得近、无关联的节点离得远,最终也会形成星座状的点云。区别在于,PCA坐标的每个轴有方差含义,而图布局的坐标纯粹是布局结果,不能单独解释。

所以在动手前,先问自己:我要画的是“样本关系星座图”还是“变量共现星座图”?前者用PCA/UMAP降维,后者用网络布局。这篇博文重点讲PCA路径,因为它最常见。网络布局路径我会在最后简略提一句,不会深入展开。

3.3 连线的语义:中心连线、样本间连线还是轨迹连线

连线是星座图的灵魂,也是最容易翻车的地方。三类连线各有用途,不要混用。

中心连线:先对每个组求质心,然后把各个质心用线段连起来。适合展示“组与组之间的总体关系”,比如三组样本形成一条V字形结构,说明组A与组B距离近,组C是离群方向。中心连线建议用灰色虚线或者细实线,因为它只是辅助视觉,不能抢点阵的风头。

样本间连线:每两点之间连一条线,表示它们在关系拓扑上直接连接。这种图如果样本量超过二三十个,就会变成一团乱麻。真要这样做时,通常只连接“显著相关的点对”或“预设路径上的相邻节点”,而不是所有点都连。

轨迹连线:在有时间序列或假时序的单细胞数据里很常见。比如细胞从状态1到状态2再到状态3,你会按状态顺序把中心点连起来,外加箭头,暗示动态方向。轨迹连线的核心是“顺序”,必须保证连线的端点按真实时间或伪时间排序,否则会出现“回形针”一样的折线。

一句话总结:直线不要多,宁可少一根也不能多一根。论文里的星座图看起来高级,不是因为线多,而是每一根线都有不可替代的信息量。

4. 手把手复现:从模拟数据到成图

4.1 构造一份可复现的模拟数据

我用三组高斯分布样本作为基础。每组35个样本,共105个样本,20个变量。为了让分组不那么完美,我会把偏移量分别放在不同的变量子集上:组A在变量1到10上整体加1.5,组B在变量1到10上整体减1.2,组C在变量11到20上整体加1.8。这样前两个主成分应该能清楚分出三个团,但组内仍然有自然的个体差异。

R语言造数据的代码如下:

set.seed(2024) n_per_group <- 35 group <- rep(c("Group A", "Group B", "Group C"), each = n_per_group) X <- matrix(rnorm(105 * 20, mean = 0, sd = 1), nrow = 105, ncol = 20) X[group == "Group A", 1:10] <- X[group == "Group A", 1:10] + 1.5 X[group == "Group B", 1:10] <- X[group == "Group B", 1:10] - 1.2 X[group == "Group C", 11:20] <- X[group == "Group C", 11:20] + 1.8

Python造数据的代码如下:

import numpy as np np.random.seed(2024) n_per_group = 35 group = np.concatenate([np.full(n_per_group, 'A'), np.full(n_per_group, 'B'), np.full(n_per_group, 'C')]) X = np.random.randn(105, 20) X[group == 'A', :10] += 1.5 X[group == 'B', :10] -= 1.2 X[group == 'C', 10:20] += 1.8

这里的偏差量不是随手拍的。1.5、1.2、1.8对应的是让组间差异约等于变量标准差的1.2到1.8倍。这样PCA之后的组间分离会比较明显,但又不至于挤成三个“实心球”,椭圆的形态也才好看。

4.2 R语言完整实现:从PCA到星座雏形

R版本的完整代码分为拉伸、降维、分组统计、画图四步:

library(ggplot2) library(dplyr) library(ggforce) # 标准化 X_scaled <- scale(X) # PCA pca <- prcomp(X_scaled, center = TRUE, scale. = TRUE) scores <- pca$x[, 1:2] df <- data.frame( PC1 = scores[, 1], PC2 = scores[, 2], group = group ) # 各组中心 centers <- df %>% group_by(group) %>% summarise(PC1 = mean(PC1), PC2 = mean(PC2)) # 中心连接数据(从C连接A和B,形成V型骨架) seg_df <- data.frame( xstart = c(centers$PC1[3], centers$PC1[3]), ystart = c(centers$PC2[3], centers$PC2[3]), xend = centers$PC1[1:2], yend = centers$PC2[1:2] ) p <- ggplot(df, aes(x = PC1, y = PC2, color = group)) + geom_point(size = 2, alpha = 0.7) + geom_point(data = centers, aes(x = PC1, y = PC2), size = 4, shape = 21, fill = "white", stroke = 1.2) + geom_segment(data = seg_df, aes(x = xstart, y = ystart, xend = xend, yend = yend), inherit.aes = FALSE, color = "grey60", linetype = "dashed", linewidth = 0.6) + stat_ellipse(level = 0.95, linetype = "dotted", linewidth = 0.5) + labs(x = sprintf("PC1 (%.1f%%)", summary(pca)$importance[2, 1] * 100), y = sprintf("PC2 (%.1f%%)", summary(pca)$importance[2, 2] * 100)) + theme_classic(base_size = 10, base_family = "Arial") + theme(legend.position = c(0.85, 0.2), legend.background = element_blank(), axis.line = element_line(color = "black", linewidth = 0.5)) + coord_equal() ggsave("constellation_R.pdf", p, width = 7.5, height = 7.5, units = "cm", dpi = 300)

这里有几个细节值得说明。scale()做完之后,PCA里再写center=TRUE, scale.=TRUE并不会重复标准化,这是为了保留prcomp的默认行为,确保结果稳健。用summary(pca)$importance[2,1]*100计算PC1解释方差,是为了给坐标轴加上可信的百分号标签,这是顶刊常见的做法。

中心连线我故意选择了组C作为“枢纽”,把C连到A和B。这不是随意选的,三种组的数据里,组C的偏移方向和A、B差异最大,所以从C出发画雷达式的两根线最能体现“星座骨架”。如果你的数据没有天然枢纽,可以考虑直接用质心之间的完整三角形或凸包,看哪种更清晰。

4.3 Python语言完整实现:matplotlib也能画得很干净

Python版本同样遵循标准化、PCA、绘图三段式。下面是完整可跑的代码:

import numpy as np import pandas as pd from sklearn.preprocessing import StandardScaler from sklearn.decomposition import PCA import matplotlib.pyplot as plt from matplotlib.patches import Ellipse np.random.seed(2024) n_per_group = 35 group = np.concatenate([np.full(n_per_group, 'A'), np.full(n_per_group, 'B'), np.full(n_per_group, 'C')]) X = np.random.randn(105, 20) X[group == 'A', :10] += 1.5 X[group == 'B', :10] -= 1.2 X[group == 'C', 10:20] += 1.8 X_scaled = StandardScaler().fit_transform(X) pca = PCA(n_components=2) scores = pca.fit_transform(X_scaled) df = pd.DataFrame({'PC1': scores[:, 0], 'PC2': scores[:, 1], 'group': group}) centers = df.groupby('group')[['PC1', 'PC2']].mean() colors = {'A': '#4DBBD5', 'B': '#E64B35', 'C': '#00A087'} def confidence_ellipse(ax, x, y, color, n_std=2.0): if len(x) < 3: return cov = np.cov(x, y) eigvals, eigvecs = np.linalg.eigh(cov) order = eigvals.argsort()[::-1] eigvals, eigvecs = eigvals[order], eigvecs[:, order] theta = np.degrees(np.arctan2(*eigvecs[:, 0][::-1])) w, h = 2 * n_std * np.sqrt(eigvals) ell = Ellipse(xy=(np.mean(x), np.mean(y)), width=w, height=h, angle=theta, facecolor='none', edgecolor=color, linestyle=':', linewidth=0.8) ax.add_patch(ell) fig, ax = plt.subplots(figsize=(2.95, 2.95), dpi=300) for g in ['A', 'B', 'C']: d = df[df['group'] == g] ax.scatter(d['PC1'], d['PC2'], s=10, alpha=0.7, color=colors[g], edgecolors='none', label=g) confidence_ellipse(ax, d['PC1'], d['PC2'], colors[g]) ax.scatter(centers['PC1'], centers['PC2'], s=50, facecolors='white', edgecolors='grey', linewidths=1.2, zorder=5) for other, c in [('A', centers.loc['A']), ('B', centers.loc['B'])]: ax.plot([centers.loc['C', 'PC1'], c['PC1']], [centers.loc['C', 'PC2'], c['PC2']], color='grey', linestyle='--', linewidth=0.8, zorder=4) explained = pca.explained_variance_ratio_ * 100 ax.set_xlabel(f'PC1 ({explained[0]:.1f}%)') ax.set_ylabel(f'PC2 ({explained[1]:.1f}%)') ax.set_aspect('equal') ax.legend(frameon=False, loc='upper right', fontsize=8) plt.tight_layout() plt.savefig('constellation_python.png', dpi=300, bbox_inches='tight') plt.show()

这段代码里有几个小技巧。第一,figsize=(2.95, 2.95)对应的是约7.5厘米,这是单栏配图的常见宽度,能保证放入论文排版时不会因为缩放而丢失细节。第二,ax.set_aspect('equal')和R里的coord_equal()效果一样,防止星座图被压扁。第三,matplotlib画椭圆需要手动计算协方差和特征向量,但核心逻辑只有十行,比想象中简单。第四,中心点用“空心圆点”的画法,边线灰色,内部白色,读者一眼就能和普通样本点区分开。

4.4 导出顶刊风格:尺寸、字体、分辨率

图做出来只是第一步,文件导出的参数决定它印在纸上的真实观感。顶刊风格不是玄学,就是几个固定参数叠加:尺寸要小到能放进单栏,字体要统一,分辨率要足够,颜色要克制。

R语言里我用ggsave(..., width = 7.5, height = 7.5, units = "cm", dpi = 300)。PDF格式用于投稿,PNG格式用于汇报。Python里我直接用dpi=300保存,同时用bbox_inches='tight'避免留白。字体方面,R里设置base_family = "Arial",Python里建议在全局设置里用plt.rcParams['font.sans-serif'] = ['Arial'],如果你用的是中文环境,还要加上plt.rcParams['axes.unicode_minus'] = False。

分辨率300dpi是印刷底线,低于这个数值缩印之后会发虚。有些投稿系统会要求600dpi,我通常一次性保存300dpi的PDF,PDF是矢量格式,没有像素问题。如果杂志要求位图,再300dpi导出PNG即可。颜色上,不要用彩虹色,尽量选三到四个饱和度不高的颜色,比如蓝色、红色、绿色系,并避免红绿同时出现,这是色盲友好的基本常识。

5. 实战中的坑:我踩过的和可能会踩的

5.1 点重叠成了一坨黑

样本量稍微大一点,星座图就容易出现几十个点叠在一个区域,打开图片一看,全部糊成一团黑。很多新手会以为是自己数据的问题,其实是绘图参数没调。解决办法有三个:一是降低透明度,alpha设置到0.5到0.8之间,重叠区域颜色自然变深,不再是一坨死黑;二是调小点的大小,从默认的6降到2或10;三是如果重叠还是严重,可以对数据做2D密度估计,用等高线表示密集区域,再叠加少数关键点作为标注。

我用过的一个改动是同时调小size并把alpha调到0.6,效果立竿见影。如果还不行,就考虑hexbin图或者分面小图。

5.2 连线顺序错乱出现“回形针”

这个坑在画轨迹型星座图时特别容易出现。数据如果按字母排序,而连线是按数据框的行顺序画的,就会导致线在点之间穿来穿去,像一团回形针。问题不是数据错了,而是连线的端点顺序没有按照真实逻辑排序。

解决办法是在生成线段数据之前,先根据你要展示的轨迹顺序排序,再创建一个segments数据框,每一行包含起点和终点坐标。千万不要直接在geom_point的整形数据里用geom_line,除非你明确知道行顺序。我通常会用arrange()或sort_values()把顺序整理好,再单独构建segment数据,这样画出来的线条一定是平滑的。

5.3 坐标轴纵横比不一致,星座被压扁

有一次我画完图没有设置coord_equal,结果在PDF里看起来星群是扁平的,点与点之间的“近邻”关系全部失真。原因很简单,PC1和PC2的取值范围不同,如果没有强制纵横比相等,绘图区会自动拉长或压扁。

解决办法在R里是coord_equal(),在Python里是ax.set_aspect('equal')。两者都是让x轴和y轴的单位长度一致。做星座图,这应该是默认设置,不能省。

5.4 图例位置和坐标轴信息不全

图例放哪里是个审美问题,也是空间问题。图例放在底部会占据一整行,缩小了中间数据区的面积。我一般放在右上角画布内的空白处,并使用空心背景,这样不遮挡点阵。

坐标轴信息方面,只写“PC1”是远远不够的,要写“PC1 (45.2%)”,让读者知道这个轴的贡献度。如果用的是t-SNE或UMAP,可以写“UMAP1”但不标百分比,因为UMAP的方差占比没有统计意义。这些细节看似小,但顶刊审稿人经常盯这些。

我顺手整理了一个速查表:

问题可能原因解决办法
点重叠严重样本量大、尺寸太大调小size、增加alpha、用hexbin
连线像回形针顺序未按轨迹排列先排序再建segments数据
图形压扁坐标轴比例不一致coord_equal / set_aspect(equal)
坐标轴无意义缺百分号或算法标注写PC1 (45.2%)或UMAP1
导出模糊DPI不足300dpi起步,优先PDF
颜色刺眼高饱和彩虹色用2-4个低饱和色,避免红绿

6. 进阶思路:让星座图会讲故事

6.1 用“原点”聚焦参照样本

如果你有一类样本是明确参考组,比如对照组、野生型、健康个体,可以把这类样本组的质心放到坐标系原点,然后把所有其他样本都相对这个质心平移。这相当于把“参考”设定为星座图中的北极星,其余点围绕它展开,能非常直观地表现相对差异。

操作上其实很简单:用参考组质心的PC1和PC2值,从每个样本的PC1和PC2中减去该值。代码上就是在降维之后多一步矩阵减法。这样图里的横纵坐标就不再是原始主成分,而是“相对于参考组的差异”。这种图在比较型研究中很有说服力,但前提是你要在轴标签或图注里解释清楚,否则读者会一头雾水。

6.2 用箭头表达“方向性变化”

如果数据带有明确的方向性,比如不同时间点的采样、不同浓度的处理,箭头能把“从哪儿到哪儿”说得一清二楚。我常用的是将每个时间组的质心按顺序连起来,并在最后一个质心处加上箭头。R里用ggplot2的geom_segment(arrow = arrow(length = unit(0.1, "inches"))),Python里用matplotlib的ax.annotate或者手动画箭头。

需要注意的是,箭头线要细,颜色要灰,否则会抢走点的注意力。箭头的意义在于强调趋势,而不是变成图的焦点。如果你的图有多个处理分支,也可以用不同颜色的箭头表示不同分组的发展方向,但通常不要超过三条主线。

6.3 多面板小图与分组注释

当一张图塞下太多信息时,星座图反而失去优势。这时不如拆分成多个小面板,每个面板只展示一个分组或亚群的星座,然后用标签标明组名和样本量。顶刊里常见这种做法:一个主图展示整体结构,旁边两三个子图分别展示某个重要亚群的结构。

拆分的代码并不复杂,R里用facet_wrap(~group, scales = "free"),Python里用matplotlib的subplots。每个子图可以用相同的坐标范围,也可以自由缩放。如果自由缩放,一定要在图例或注释中说明,否则读者会误以为各组之间差异很大。多面板的总体原则是:主图负责全貌,子图负责细节,主次分明。

最后分享一点个人心得。我做过很多版星座图,最后能拿出手的,往往是那些最“克制”的图。点做得小一点,线做得细一点,颜色尽量素一点,图例塞进左上角或右上角,不配花哨的背景。整体看上去要像夜空反射在数据上的影子,而不是一张彩色蛛网。另一个小技巧是,保存时把图例放在画布内侧空白处,不要放到下方,这样能省出一行正文的高度。这些细节不会出现在论文的方法部分,但它们直接影响审稿人审图时的第一感受。好的星座图不是信息越多越好,而是每一条线、每一个点都有它的位置和理由。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询