【R论文复刻】Nature Communications 冲积图进阶:状态转移、流带排序与多面板排版(保姆级教程)

冲积图并不只是把几条彩带连起来。真正容易出错的地方,往往发生在绘图之前:同一个对象在各阶段是否只有一条记录?缺失状态应当删除,还是作为一种状态保留?流带宽度表示个体数、比例,还是其他权重?当流带交叉得很厉害时,我们还能不能相信图中呈现的转移关系?摘要 :本文以《Nature Communications》论文中的多阶段冲积图为复刻对象,使用 R 语言 ggalluvial 包自带的学生专业选择数据,系统演示了从数据清洗、完整性校验到冲积图绘制、转移矩阵核对及多面板排版的完整流程。重点解决了冲积图绘制中的常见问题:如何正确处理缺失值、确保数据完整性、选择适当的几何对象(geom_flow vs geom_alluvium)、解读流带交叉现象,以及通过转移矩阵验证图形准确性。最终生成包含轨迹主图、组成比例面板和转移矩阵面板的论文式组合图,并提供了出版级导出方案和常见错误修正指南。本文强调冲积图的可信度依赖于数据预处理、独立数值核对和清晰的解释框架,而非仅靠视觉优化。

本文参考一篇 2025 年发表在 Nature Communications 的论文图 1c,复刻其中"多阶段类别状态沿固定轴变化"的视觉结构。原图展示早期胚胎发育过程中染色质状态的变化;这里不下载、重算论文的生物学数据,而使用 ggalluvial 包自带的学生专业选择数据,完整演示数据清洗、长宽格式校验、冲积图绘制、转移矩阵核对和多面板排版。

这也是对旧文《R语言ggplot2 | R语言绘制物种组成冲积图(一)》的进阶补充。旧文处理的是分组后的物种丰度;本文追踪的是同一对象跨多个阶段的状态轨迹,两者的数据单位和解释方式并不相同。

1. 复刻对象与边界

参考论文:

Cardamone F, Piva A, Löser E, et al. Chromatin landscape at cis-regulatory elements orchestrates cell fate decisions in early embryogenesis. Nature Communications. 2025;16:3007. DOI: 10.1038/s41467-025-57719-4.

论文图 1c 被作者明确称为 alluvial plot,用于连接三个发育阶段的 H3K27me3、H3K27ac、双标记及未分配状态。Methods 还注明该图使用 easyalluvial 0.3.2 绘制。

本文复刻的是以下结构:

  • 横轴为有顺序的多个阶段;
  • 每个阶段包含若干互斥状态;
  • 同一观察单位可以在阶段之间改变状态;
  • 流带宽度表示观察单位数量;
  • 主图旁增加组成比例和转移矩阵,形成论文常见的组合图。

本文没有复刻论文数值,也不对原论文的生物学结论作二次分析。教学数据中的"学生---学期---专业"只承担结构演示作用。

2. 先把名称说准确:这是冲积图,不是普通桑基图

在中文资料中,alluvial plot 和 Sankey diagram 经常被混称。两者都使用带宽表达数量,但侧重点不同。

对比项 冲积图(alluvial plot) 桑基图(Sankey diagram)
节点位置 固定在预先定义的类别轴上 常由布局算法决定
横轴含义 通常是阶段、时间或多个类别维度 不一定存在统一的阶段轴
观察对象 强调同一批对象或队列跨轴的组合/变化 强调网络节点之间的流量
纵轴 各层高度与数量有直接关系 节点可自由排列
本文场景 同一学生在 8 个学期的专业状态 不采用自由网络布局

ggalluvial 文档把图形元素分为:

  • axis:类别所在的轴;
  • stratum:某一轴上的状态块;
  • lode:一条轨迹在状态块内部占据的部分;
  • flow:连接相邻两个轴的带状部分;
  • alluvium:同一对象跨全部轴形成的完整轨迹。

后文使用 geom_flow() 是因为颜色需要随着每个学期的当前状态改变;若使用宽格式并把颜色固定在整条 alluvium 上,就很难表达这种逐阶段变色。

3. 准备环境

首次运行时安装所需包:

r 复制代码
install.packages(c(
  "ggplot2", "dplyr", "tidyr", "tibble", "forcats",
  "ggalluvial", "patchwork", "scales"
))

加载包并建立输出目录:

r 复制代码
options(stringsAsFactors = FALSE)

suppressPackageStartupMessages({
  library(ggplot2)
  library(dplyr)
  library(tidyr)
  library(tibble)
  library(forcats)
  library(ggalluvial)
  library(patchwork)
  library(scales)
})

set.seed(20260802)

asset_dir <- file.path("outputs", "alluvial_replication_assets")
table_dir <- file.path(asset_dir, "tables")
publication_dir <- file.path(asset_dir, "publication")

dir.create(asset_dir, recursive = TRUE, showWarnings = FALSE)
dir.create(table_dir, recursive = TRUE, showWarnings = FALSE)
dir.create(publication_dir, recursive = TRUE, showWarnings = FALSE)

本文实际运行环境为 R 4.6.0、ggplot2 4.0.3、dplyr 1.2.1、tidyr 1.3.2、forcats 1.0.1、ggalluvial 0.12.6、patchwork 1.3.2 和 scales 1.4.0。版本不必完全一致;若使用较旧的 ggplot2linewidth 参数可能需要改为 size

4. 认识教学数据

读取 majors

r 复制代码
data("majors", package = "ggalluvial")

dim(majors)
head(majors, 10)

数据包含 10 名学生在 8 个学期的专业选择,共 80 行。它天然是"长格式":一行表示某个学生在某个学期的一个状态。

原始数据有两个需要主动处理的问题:

  1. SculpureSculpture 的拼写错误;
  2. 共有 6 个 NA,代表尚未申报专业,而不是该学生从样本中消失。

如果直接 drop_na(),对应流带会在中途断掉,每个学期的总人数也不再守恒。这里把它重编码为 Undeclared,让"未申报"作为可解释的状态保留。

r 复制代码
semester_levels <- paste0("CURR", seq(1, 15, by = 2))
semester_labels <- paste("Semester", seq(1, 15, by = 2))

state_levels <- c(
  "Painting", "Sculpture", "Digital Art", "Photography",
  "Ceramic", "Art History", "Undeclared"
)

majors_clean <- majors |>
  as_tibble() |>
  transmute(
    student = sprintf("S%02d", student),
    semester = factor(
      semester,
      levels = semester_levels,
      ordered = TRUE
    ),
    state = case_when(
      is.na(curriculum) ~ "Undeclared",
      curriculum == "Sculpure" ~ "Sculpture",
      TRUE ~ curriculum
    ),
    state = factor(state, levels = state_levels)
  ) |>
  arrange(student, semester)

清洗后的前几行是:

text 复制代码
student  semester  state
S01      CURR1     Painting
S01      CURR3     Painting
S01      CURR5     Painting
S01      CURR7     Painting
S01      CURR9     Painting
S01      CURR11    Painting
S01      CURR13    Painting
S01      CURR15    Painting

5. 作图前先做完整性检查

5.1 每个对象在每个阶段是否只有一条记录

r 复制代码
duplicate_check <- majors_clean |>
  count(student, semester) |>
  filter(n != 1)

duplicate_check

结果为 0 行,说明不存在重复记录或缺少"学生---学期"组合。

再检查每个学期的样本总数:

r 复制代码
state_counts <- majors_clean |>
  count(semester, state, .drop = FALSE) |>
  group_by(semester) |>
  mutate(
    semester_total = sum(n),
    proportion = n / semester_total
  ) |>
  ungroup()

state_counts |>
  distinct(semester, semester_total)

8 个学期的 semester_total 均为 10。主图每个轴的总高度因此都应当等于 10;如果绘图后某一轴忽然变矮,首先应回头检查数据,而不是调整坐标范围掩盖问题。

5.2 用专用函数校验长格式和宽格式

ggalluvial 接受两类数据:

  • 宽格式(alluvia form):一行是一条完整轨迹,各阶段分别占一列;
  • 长格式(lodes form):一行是某条轨迹在某个轴上的状态,另有 ID 列把同一对象连接起来。
r 复制代码
majors_wide <- majors_clean |>
  mutate(state = as.character(state)) |>
  pivot_wider(
    names_from = semester,
    values_from = state
  ) |>
  arrange(student)

long_form_valid <- is_lodes_form(
  majors_clean,
  key = semester,
  value = state,
  id = student,
  silent = TRUE
)

wide_form_valid <- is_alluvia_form(
  majors_wide,
  axes = semester_levels,
  silent = TRUE
)

c(long_form_valid, wide_form_valid)

实际输出:

text 复制代码
TRUE TRUE

这一步很值得保留。冲积图出现断带、错连或权重异常时,问题通常来自 ID、阶段顺序或一对多记录,而不是曲线参数。

5.3 把检查结果画出来

图 2 能同时回答两个问题:每个人的轨迹是否完整,以及每个阶段的总量是否守恒。对于单细胞亚群、临床分型和样本状态转移数据,这一步比直接画主图更重要。

6. 画出基础多阶段冲积图

先准备一套固定配色。状态颜色必须在所有面板中保持一致。

r 复制代码
state_palette <- c(
  "Painting" = "#D36F63",
  "Sculpture" = "#4F86B5",
  "Digital Art" = "#4FA888",
  "Photography" = "#D1A23F",
  "Ceramic" = "#9875B4",
  "Art History" = "#8A8A8A",
  "Undeclared" = "#D7D7D7"
)

核心映射关系如下:

r 复制代码
p_basic <- ggplot(
  majors_clean,
  aes(
    x = semester,
    stratum = state,
    alluvium = student,
    y = 1,
    fill = state,
    order = student
  )
) +
  geom_flow(
    width = 0.22,
    alpha = 0.60,
    colour = "white",
    linewidth = 0.18,
    curve_type = "xspline"
  ) +
  geom_stratum(
    width = 0.22,
    colour = "white",
    linewidth = 0.45
  ) +
  geom_text(
    stat = "stratum",
    aes(label = after_stat(stratum)),
    size = 2.65,
    check_overlap = TRUE
  ) +
  scale_fill_manual(values = state_palette, drop = FALSE) +
  scale_x_discrete(
    labels = semester_labels,
    expand = expansion(mult = c(0.025, 0.025))
  ) +
  scale_y_continuous(
    breaks = seq(0, 10, by = 2),
    expand = expansion(mult = c(0, 0.03))
  ) +
  labs(
    x = NULL,
    y = "Number of students",
    fill = "Curriculum",
    title = "Basic multi-stage alluvial plot",
    subtitle = "Ribbon width is a count; ribbon colour follows the state at each axis"
  ) +
  theme_classic(base_size = 11) +
  theme(
    axis.text.x = element_text(angle = 20, hjust = 1),
    legend.position = "bottom"
  )

p_basic

图 3 10 名学生在 8 个学期的专业状态轨迹。每名学生贡献 1 个单位的带宽。

这里有四个不能互换的映射:

  • x = semester 决定阶段轴;
  • stratum = state 决定每个轴上的状态块;
  • alluvium = student 决定哪些记录属于同一条完整轨迹;
  • y = 1 表示每名学生权重相同。

若数据是细胞数、病例数或峰数量汇总表,应把 y = 1 改为真实计数列。若各阶段总样本量不同,带宽直接画计数和画比例会回答不同的问题,不能只为了"看起来整齐"随意切换。

6.1 geom_flow()geom_alluvium() 怎么选

本文用 geom_flow(),因为流带颜色跟随每个轴上的当前状态。geom_alluvium() 更适合整条轨迹共用一种颜色,例如按基线分组、处理组或最终结局着色。

还要注意:单独使用 geom_flow() 时,统计变换关注相邻轴之间的流。若研究问题要求严格追踪完整个体,alluvium = student 必须是稳定且唯一的 ID,不能拿状态名称代替。

6.2 流带交叉能否"消除"

order = student 固定了同一状态块内部的个体顺序,使结果可重复。它不会改变转移计数,只影响视觉上的上下排列。

交叉并不一定是错误。如果真实数据中多个状态相互转入转出,就不可能同时让每一段都无交叉。可以通过以下方式降低杂乱:

  • 固定状态因子顺序;
  • 使用有意义且稳定的对象排序;
  • 减少不必要的边框和高饱和色;
  • 把精确计数放到辅助面板,而不是强迫读者从交叉带中估计。

不要为了减少交叉而重新编码状态或删除少数轨迹,那会改变数据含义。

7. 用转移矩阵核对冲积图

冲积图擅长展示整体路径,却不适合精确读取每一种转移的次数。把相邻阶段拆成转移记录,可以得到独立的数值核对表。

r 复制代码
transition_records <- majors_clean |>
  group_by(student) |>
  arrange(semester, .by_group = TRUE) |>
  mutate(
    next_semester = lead(semester),
    next_state = lead(state)
  ) |>
  filter(!is.na(next_semester)) |>
  ungroup() |>
  transmute(
    student,
    from_semester = semester,
    to_semester = next_semester,
    from_state = factor(as.character(state), levels = state_levels),
    to_state = factor(as.character(next_state), levels = state_levels),
    changed = from_state != to_state
  )

transition_counts <- transition_records |>
  count(from_state, to_state, name = "n") |>
  complete(
    from_state = factor(state_levels, levels = state_levels),
    to_state = factor(state_levels, levels = state_levels),
    fill = list(n = 0)
  )

10 名学生、8 个阶段,每名学生有 7 个相邻阶段,因此理论转移数是:

text 复制代码
10 × (8 - 1) = 70

实际得到 70 条,说明没有漏掉相邻阶段。进一步统计:

r 复制代码
table(transition_records$changed)
text 复制代码
FALSE  TRUE
   58    12

即 58 次保持原状态,12 次发生变化。这里的"12 次"是转移事件数,不是发生变化的学生数;同一学生可以变化多次。

再计算每人的变化次数:

r 复制代码
student_summary <- majors_clean |>
  group_by(student) |>
  arrange(semester, .by_group = TRUE) |>
  summarise(
    first_state = as.character(first(state)),
    last_state = as.character(last(state)),
    n_changes = sum(state != lag(state), na.rm = TRUE),
    n_undeclared = sum(state == "Undeclared"),
    .groups = "drop"
  ) |>
  arrange(desc(n_changes), student)

结果显示 S08、S09 和 S15 各变化 2 次,S01 全程保持 Painting,其余学生变化 1 次。

图 4 左图汇总 70 次相邻阶段转移;对角线表示状态保持。右图显示每名学生完整轨迹中的变化次数。

转移矩阵把所有相邻学期合并后计数,因此不能回答"变化具体发生在哪一学期"。如果研究问题关注时间特异性,应把 from_semester 加入分组,分别绘制每一对相邻阶段的矩阵。

8. 组合成论文式多面板图

单张冲积图能够讲"路径",但很难同时精确表达阶段组成和转移次数。终稿采用三个互补面板:

  • A:完整轨迹,展示主要状态及变化方向;
  • B:每阶段组成比例,回答结构如何变化;
  • C:转移矩阵,提供精确次数核对。

8.1 比例组成面板

r 复制代码
p_state_share <- ggplot(
  state_counts,
  aes(x = semester, y = proportion, fill = state)
) +
  geom_col(width = 0.74, colour = "white", linewidth = 0.22) +
  scale_fill_manual(values = state_palette, drop = FALSE) +
  scale_x_discrete(labels = paste0("S", seq(1, 15, by = 2))) +
  scale_y_continuous(labels = percent_format(accuracy = 1)) +
  labs(
    x = "Semester",
    y = "Share of students",
    title = "State composition"
  ) +
  theme_classic(base_size = 10) +
  theme(legend.position = "none")

8.2 转移矩阵面板

r 复制代码
p_transition_final <- ggplot(
  transition_counts,
  aes(
    x = to_state,
    y = forcats::fct_rev(from_state),
    fill = n
  )
) +
  geom_tile(colour = "white", linewidth = 0.55) +
  geom_text(
    aes(label = if_else(n == 0L, "", as.character(n))),
    size = 2.7
  ) +
  scale_fill_gradient(low = "#F3F5F6", high = "#315B79") +
  coord_fixed(ratio = 0.66) +
  labs(
    x = "Next state",
    y = "Current state",
    title = "Transition counts"
  ) +
  theme_classic(base_size = 10) +
  theme(
    axis.text.x = element_text(angle = 40, hjust = 1),
    axis.ticks = element_blank(),
    axis.line = element_blank(),
    legend.position = "none"
  )

8.3 用 patchwork 拼图

先在基础图上调整标题、图例和边距,得到组合图的 A 面板:

r 复制代码
p_alluvial_final <- p_basic +
  labs(
    x = NULL,
    y = "Students",
    fill = "Curriculum",
    title = "State trajectories",
    subtitle = "Each student contributes one unit of width at every semester"
  ) +
  theme(
    axis.text.x = element_text(angle = 15, hjust = 1),
    legend.position = "bottom",
    plot.margin = margin(8, 8, 6, 8)
  )

再完成拼接:

r 复制代码
p_final <- p_alluvial_final /
  (
    p_state_share + p_transition_final +
      plot_layout(widths = c(1.08, 0.92))
  ) +
  plot_layout(heights = c(1.35, 1)) +
  plot_annotation(
    title = "Multi-stage categorical state transitions",
    subtitle = paste(
      "Structure-driven reconstruction inspired by",
      "Nature Communications Fig. 1c"
    ),
    caption = paste0(
      "Teaching data: ggalluvial::majors. ",
      "The source paper's biological data and conclusions are not reanalysed. ",
      "Ribbon widths and matrix entries are descriptive counts."
    ),
    tag_levels = "A"
  )

p_final

图 5 Nature 风格多面板组合终稿。A 为个体状态轨迹,B 为阶段组成比例,C 为相邻阶段转移次数。

这类拼图不是把三张图简单堆在一起。三个面板分别处理"路径、组成、精确计数",信息互补且不重复。若面板 B 和 C 只是再次换一种方式表现主图中同一个总量,拼图再复杂也不会增加解释力。

9. 如何解读这组结果

从教学数据可读出:

  1. Painting 从第 1 学期的 40% 增加到第 11 学期的 100%,随后部分学生转为 Undeclared;
  2. 70 次相邻转移中,58 次位于转移矩阵对角线,说明多数阶段保持原状态;
  3. Painting → Painting 为最常见路径,共 44 次;
  4. S08、S09 和 S15 各发生 2 次状态变化,但变化方向并不相同;
  5. 末期的灰色流带是被显式保留的 Undeclared,不能解释为数据丢失。

这些都是描述性结果。冲积图能显示"发生了什么",不能单独证明状态变化由时间、处理或其他变量导致。若要做推断,应根据研究设计另建重复测量模型、多状态模型或生存模型。

10. 出版级导出

CSDN 插图建议使用 300--320 dpi 的 PNG;投稿或后期排版同时保存 PDF 和 TIFF。

r 复制代码
ggsave(
  file.path(asset_dir, "05_nature_style_composite.png"),
  p_final,
  width = 13.4,
  height = 7.2,
  dpi = 320,
  bg = "white"
)

ggsave(
  file.path(publication_dir, "Nature_alluvial_composite.pdf"),
  p_final,
  width = 13.4,
  height = 7.2,
  device = cairo_pdf,
  bg = "white"
)

ggsave(
  file.path(publication_dir, "Nature_alluvial_composite.tiff"),
  p_final,
  width = 13.4,
  height = 7.2,
  dpi = 600,
  compression = "lzw",
  bg = "white"
)

本文实际生成的终稿 PNG 为 4288 × 2304 像素;PDF 为矢量文件;TIFF 使用 600 dpi 和 LZW 无损压缩。

11. 常见错误及修正

错误 1:把 NA 全部删掉

NA 表示"未知""未分配"或"未申报",删除会改变分母并切断轨迹。应先确认它是缺测记录,还是有业务含义的状态。

错误 2:用状态变量充当个体 ID

r 复制代码
# 错误示意
aes(alluvium = state)

这样会把所有同状态对象揉成一条轨迹,无法追踪个体。alluvium 应指向稳定的个体、峰、克隆或队列 ID。

错误 3:阶段顺序按字符排序

字符排序可能把 T10 放在 T2 前。应显式设置 factor levels。

错误 4:把条带高度称为概率

本文 y = 1,所以高度是人数;按阶段归一化后才是比例。若输入为丰度、权重或表达量,图中高度也随之改变。

错误 5:仅凭颜色估计精确转移数

复杂流带适合识别主路径,不适合精确读数。加入转移矩阵或导出转移表,能够避免视觉判断代替统计汇总。

错误 6:把冲积图当作因果证据

状态随时间共同变化不等于时间造成变化。图中没有控制混杂因素,也没有给出不确定性区间或显著性检验。

12. 换成生信数据时需要哪些列

最通用的长格式至少包含四列:

text 复制代码
object_id    stage    state    weight
peak_001     T1       Open     1
peak_001     T2       Poised   1
peak_001     T3       Active   1

对应关系为:

  • object_idalluvium:细胞、克隆、基因组区域、患者或菌群特征;
  • stagex:时间点、处理阶段、发育时期或分析层级;
  • statestratumfill:细胞类型、染色质状态、分型或功能类别;
  • weighty:对象计数、丰度或其他明确定义的权重。

适合扩展的生信场景包括:

  • 单细胞聚类在不同分辨率或注释版本间的对应关系;
  • 细胞命运或克隆谱系状态变化;
  • 染色质区域在多个时期的活性状态;
  • 患者分型从基线到治疗后的变化;
  • 微生物分类单元从门、纲、目到属的层级组成。

若不同阶段并非同一批对象,不能伪造 object_id 把独立横断面样本连成个体轨迹。此时可以绘制聚合流量,但文中必须明确它表示组间组成映射,而非真实纵向追踪。

13. 可复现性校验

完整脚本在结束前执行以下检查:

r 复制代码
stopifnot(
  nrow(majors_clean) == 80,
  n_distinct(majors_clean$student) == 10,
  n_distinct(majors_clean$semester) == 8,
  sum(majors_clean$state == "Undeclared") == 6,
  nrow(transition_records) == 70,
  sum(transition_counts$n) == 70,
  long_form_valid,
  wide_form_valid,
  all(state_counts$semester_total == 10)
)

实际运行结果:

text 复制代码
Rows in cleaned long data: 80
Students: 10
Semesters: 8
States including Undeclared: 7
Recoded Undeclared records: 6
Adjacent-semester transitions: 70
Stable transitions: 58
Changed transitions: 12
Long-format alluvial validation: TRUE
Wide-format alluvial validation: TRUE
All validation checks passed: TRUE

14. 小结

一张可信的多阶段冲积图,至少要同时满足三件事:对象 ID 连得对、每个阶段的分母说得清、图中的流量能由独立汇总表复核。视觉优化应放在这些检查之后。

本文最终保留了完整轨迹、组成比例和转移矩阵三个层次。主图负责看路径,辅助图负责看结构和读数。将来替换成自己的单细胞、表观组、临床随访或微生物数据时,首先替换的是 object_id---stage---state---weight 四列,而不是先改颜色。

参考资料

  1. Cardamone F, Piva A, Löser E, et al. Nature Communications 16, 3007 (2025). https://www.nature.com/articles/s41467-025-57719-4
  2. ggalluvial package documentation. https://search.r-project.org/CRAN/refmans/ggalluvial/html/00Index.html
  3. ggalluvial vignette: Alluvial Plots in ggplot2. https://corybrunson.github.io/ggalluvial/articles/ggalluvial.html
相关推荐
减瓦1 小时前
用 Git 为本地文件打造一台时光机
开发语言·git·编辑器
冻柠檬飞冰走茶2 小时前
PTA基础编程题目集 7-37 整数分解为若干项之和(C语言实现)
c语言·开发语言·数据结构·算法
末代iOS程序员华仔2 小时前
OPC + Flutter + Swift:跨平台应用上架与专业知识点获客实战指南
开发语言·flutter·swift
脚踏实地皮皮晨2 小时前
003004002_UniformGrid控件的使用方法
开发语言·c#·.net·wpf·visual studio
j7~3 小时前
【Linux】二十六.线程篇三《Linux多线程编程:线程控制、线程ID以及进程地址空间分布、线程局部存储__thread以及clone系统调用》---详解
linux·运维·服务器·开发语言·c++·多线程编辑·线程的控制
维吉斯蔡11 小时前
【VS Code / Cursor】文件夹右键快捷打开与文件类型自动关联
开发语言·程序人生·学习方法
luj_176813 小时前
星火科技助力边远地区防病攻坚
c语言·开发语言·c++·经验分享·算法
always_TT13 小时前
【Python 日志记录:logging 模块入门】
开发语言·python·php
xcLeigh13 小时前
Go入门:变量声明的五种方式详解
java·开发语言·golang