ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

手把手复现Nature风格小提琴图:从KDE到显著性标注

手把手复现Nature风格小提琴图:从KDE到显著性标注

第三篇终于来了。前两篇把柱状图和箱线图的复现流程钉死之后,不少读者留言说,最想看的还是这张小提琴图。原因不难猜:它能把数据的分布形态和组间差异同时画出来,信息密度比箱线图高一大截,美观程度也明显更能打。但市面上的教程要么停留在一行 seaborn 代码上,要么还原度差得远,和顶刊出版风格差了十万八千里。

这篇不打算说空话。我直接拿一份模拟的实验分组数据,把一张“能进 Nature 组图”的小提琴图完整复现出来:从 KDE 核密度计算、左右镜像填充,到箱线叠加、散点抖动、显著性标注,所有关键参数都写到位。想给自己论文配图、做数据分析后快速出图的研究者,或者被默认主题折磨过的 Python 绘图玩家,都能从这篇里找到能直接抄走的方案。

1. 动手设计:小提琴图的本质与方案取舍

1.1 小提琴图比箱线图多了什么

小提琴图说白了就是“箱线图 + 旋转 90 度的核密度曲线”。箱线图只给你五个数字:最小值、下四分位数、中位数、上四分位数、最大值,外加一堆离散的离群点。它方便,但信息损失严重——两组数据的五数概括可能完全一样,实际分布却一个单峰一个双峰,这个差异在箱线图里是看不出来的。

小提琴图用核密度估计(KDE)把每个分组的数据分布拟合成一条平滑曲线,曲线横过来放在分组轴上,左右镜像填充后就像一个“倒着的小提琴”。从这里你能直观看出分布是偏左还是偏右、是否有双峰,甚至能对比不同组的峰宽与拖尾。对于生物实验里常见的“对照组平缓、处理组出现双峰”这类情况,小提琴图几乎是标配。

不过要提醒一句:信息量提升是有代价的。箱线图哪怕是几十个样本也能稳得住,小提琴图在样本量不足时,那条曲线会被带宽参数左右,画出来的形状未必可靠。具体阈值后面我会专门说。

1.2 为什么不用 seaborn 默认图

很多人的第一反应是:seaborn.violinplot()一行代码不就完了?确实能出图,但离 Nature 风格差得远。我用 seaborn 默认参数画过一版,问题有三个。

第一,各组小提琴宽度不统一。seaborn 默认会把每条密度曲线按组内最大值归一化,这本身没问题,但分组之间的“胖瘦”会和样本量、离群值混在一起,组间视觉上的宽度对比容易误导读者。第二,默认配色太花哨,红黄蓝紫一股脑上,既不满足色盲友好,又和黑白印刷场景冲突。第三,也是最关键的:当你需要精确标注组间显著性、控制每一个字体字号、把图无缝嵌进分面组图时,默认 API 的调整空间不够。

所以这次我选择基于 matplotlib 底层手写。听起来麻烦,实际写完一个绘图函数之后,后面每组数据只是换参数调用的事,改起来反而比反复调 seaborn 的inner、linewidth更顺手。

1.3 成图规格与代码结构

先定一个最终目标,避免画到一半跑偏。

  • 输出格式:PDF 矢量文件,后续用软件拼组图也不会糊。
  • 物理尺寸:单栏图宽度 8.5 cm 左右,符合主流期刊排版需求。
  • 字体:Helvetica 或 Arial,全部字号 7 pt,坐标轴标题可稍大。
  • 配色:一套色盲友好的三色方案,填充透明度 0.6~0.7。
  • 结构:数据准备 → KDE 计算 → 主体绘制 → 细节定制 → 输出保存。

我在下文按照这个流程展开,每个环节都会贴代码和参数说明。

2. 数据准备与分组设计

2.1 数据结构与读取

先造一份模拟数据。假设某课题组比较三种条件下的细胞活性测量值,数据结构就是长表:一列是分组,一列是数值。

import numpy as np import pandas as pd rng = np.random.default_rng(42) group_a = rng.normal(50, 8, size=40) group_b = rng.normal(62, 12, size=40) group_c = np.concatenate([ rng.normal(45, 5, size=25), rng.normal(70, 6, size=15) ]) df = pd.DataFrame({ "group": ["Ctrl"] * 40 + ["Treat1"] * 40 + ["Treat2"] * 40, "value": np.concatenate([group_a, group_b, group_c]) })

注意第三组我故意做了双峰结构,这是小提琴图最能体现价值的地方:箱线图看到的是一个扁平的大盒子,而小提琴图能把两个峰值清清楚楚地展示出来。

数据可以从 CSV 读进来:

# df = pd.read_csv("your_data.csv")

只要保证group列是类别、value列是数值型,后面所有代码都不用改。

2.2 样本量:多少人、多少个点才适合

这是我在实际投稿过程中被审稿人问过的问题:你的样本量足够画小提琴图吗?

我的经验法则是:单组样本量低于 10 时,KDE 的误差会非常大,曲线会出现明显锯齿或者拖尾,这时画小提琴图不如直接画箱线图加散点。样本量在 10~30 之间,可以画,但需要手动选择较宽的带宽,并且要在图注里注明数据点数量。样本量在 30 以上,基本可以放心画,KDE 会稳定很多。

为什么会这样?因为核密度估计本质是用一组核函数去盖住每个数据点,然后求和。数据点太少,核函数叠不出来光滑的峰,就会变成一坨不规则的波浪线。你当然可以用更宽的带宽强行磨平,但那属于用图形参数掩盖数据缺陷,不太诚实。

所以拿到数据先看一眼df.groupby("group")["value"].count(),心里有数再决定要不要用这张图。

2.3 数据清洗与缺失值处理

小提琴图对离群值比对箱线图更敏感。一个极端离群值会把 KDE 曲线向右拖出长长的尾巴,导致整个小提琴看起来“飘”了。处理办法分两种:一是确认离群值来自测量错误或录入错误,可以直接删除;二是异常值确实是生物学差异,那就保留,同时把 y 轴范围裁到合理区域,并在作图时选择更稳健的带宽。

df = df.dropna(subset=["value"]) df = df[np.abs(df["value"] - df["value"].mean()) < 4 * df["value"].std()]

直接删掉 4 个标准差之外的点,是一种比较常见的做法。注意这里要用分组的统计量而不是全局统计量,否则不同组之间的基线差异会被误删。

还有一类数据不能直接画小提琴图:离散计数数据,比如每个细胞内的斑点数量,取值只有 0、1、2、3。KDE 会把离散值滑成平滑的连续曲线,出现大量“半整数”的假象。这种数据应该用蜂群图、条形比例图或者直接画原始散点,别硬上小提琴图。

3. 核心绘制:KDE 计算与小提琴主体渲染

3.1 理解 KDE 与带宽参数

KDE 计算其实不神秘。scipy.stats.gaussian_kde会以每个数据点为中心放一个高斯核,然后把这些核叠加起来做归一化。带宽参数bw_method控制核函数的宽度,它就像一把尺子:尺子短,曲线会细致但可能过拟合;尺子长,曲线光滑但可能把真实的双峰磨掉。

用个生活化的类比:想象你在沙滩上用脚把脚印抹平。带宽太小,脚印之间的边界还在,整片沙滩坑坑洼洼;带宽太大,一脚下去全部抹平,原来的起伏全没了。所以这个参数决定了你是否能看清数据里真实存在的“脚印”。

默认的bw_method="scott"是根据样本量自适应计算的,大多数时候能用。但我个人习惯在小提琴图上手动设bw_method=0.3左右,让曲线更平滑。如果数据是单峰且样本量中等,0.25~0.35 通常合适;如果样本量很大(n > 100),可以用 0.15~0.2 保留更多细节。建议多画几版对比,结合直方图确认自己没有把双峰抹平。

3.2 小提琴主体:镜像填充与归一化

先写一个灵活的小函数,负责画单个分组的小提琴。

import matplotlib.pyplot as plt from matplotlib.patches import Polygon from scipy import stats def violin_group(ax, data, pos, color, bw=0.3, width_scale=0.45): xs = np.linspace(data.min() - 0.1 * np.ptp(data), data.max() + 0.1 * np.ptp(data), 200) dens = stats.gaussian_kde(data, bw_method=bw)(xs) dens = np.clip(dens, 0, None) dens = dens / dens.max() * width_scale ax.fill_betweenx(xs, pos - dens, pos + dens, facecolor=color, alpha=0.6, lw=0.8, edgecolor=color, zorder=2)

重点是这两行:np.clip(dens, 0, None)把负密度裁掉,防止尾巴跑进负值区域;dens / dens.max() * width_scale是把每组密度归一化到相同的最大宽度。如果不做归一化,峰值高的组会看起来“胖”,峰值低的组“瘦”,组间无法公平比较形状。width_scale控制整张图中小提琴的最大宽度,一般 0.4~0.5 比较协调,太宽会互相挤压,太窄则看不清形状。

注意我用的是fill_betweenx,也就是把数据值放在 y 轴、分组位置放在 x 轴。这是大多数期刊图表的标准排版方向。

3.3 箱线叠加、散点抖动与统计标注

小提琴主体画完后,图还缺几个要素:内部的箱线统计量、原始数据散点、组间显著性标注。

箱线图可以直接用ax.boxplot,设置widths控制箱体宽度,showfliers=False关闭离群点,因为小提琴已经展示了分布,散点也会承担展示离群点的功能。

散点抖动需要注意:直接用原始数据点画,所有点会重叠成一条竖线,什么信息也看不到。所以要在 x 方向加一点随机偏移。

def jitter(values, center, seed=1): gen = np.random.default_rng(seed) return center + gen.normal(0, 0.04, size=len(values))

固定随机种子很重要。否则每次运行图都会变,后续投稿时图文对不上会很麻烦。散点的s=8左右比较合适,太小看不到,太大会盖住箱线。

绘制主流程:

fig, ax = plt.subplots(figsize=(3.2, 4.0)) groups = df["group"].unique() colors = ["#4C72B0", "#DD8452", "#55A868"] for i, (g, c) in enumerate(zip(groups, colors)): sub = df[df["group"] == g]["value"] violin_group(ax, sub, pos=i+1, color=c) ax.boxplot(sub, positions=[i+1], widths=0.08, showfliers=False, zorder=3, medianprops=dict(color="black", lw=1.2), patch_artist=False) ax.scatter(jitter(sub, i+1), sub, s=8, color="black", alpha=0.5, zorder=4)

zorder 必须安排好:小提琴在底层,箱线在中层,散点最上层。如果散点被密度填充盖住,检查 zorder。

显著性标注可以手动画一条横线加星号:

x1, x2 = 1, 2 y = max(np.max(df["value"]) * 1.05, 80) ax.plot([x1, x1, x2, x2], [y, y+2, y+2, y], color="black", lw=1) ax.text((x1+x2)/2, y+4, "**", ha="center", va="bottom", fontsize=9)

注意 P 值星号要放在方括号里说明检验方法:**p < 0.01, two-sided Wilcoxon rank-sum test。别只画星号不写检验过程,这是学术规范问题。

4. Nature 风格细节定制

4.1 字体、字号、线宽的整体预设

默认的 matplotlib 字体是 DejaVu Sans,很多期刊编辑一眼就能看出来。学术出版界最常见的两类字体是 Helvetica 和 Arial,如果你所在机构没有购买 Helvetica 版权,Arial 是更稳妥的替代。在代码开头统一设置:

plt.rcParams.update({ "font.family": "sans-serif", "font.sans-serif": ["Arial"], "font.size": 7, "axes.linewidth": 0.6, "xtick.major.width": 0.6, "ytick.major.width": 0.6, "xtick.direction": "out", "ytick.direction": "out", })

字号方面,正文文字 7 pt,坐标轴标题可以到 8 pt,图例 6.5 pt。不要小于 6 pt,否则印刷后没法辨识。物理尺寸上,单栏图宽度 8.5 cm 左右,双栏图 17 cm 左右,高度按比例调整即可。图小字小是顶刊的特点,不是越大的字越清晰,而是整体协调。

4.2 配色方案与色盲友好

关于配色,我强烈建议别再用默认的tab10。那套颜色对于红绿色盲完全不友好。这里给出一套我实测下来比较稳妥的预设:

COLOR_PALETTE = ["#4C72B0", "#DD8452", "#55A868"]

三色刚好对应三组,色相距离足够大,而且都是中等饱和度的“学术色”。如果需要四到六组,可以选Set2色板中挑,但要注意相邻颜色之间的区分度。对于黑白印刷场景,透明度和边框样式也能帮上忙:把其中一组改成低透明度、另一组改成实心,打印出来后依然能区分。

对小提琴主体,我习惯填充透明度 0.6,边缘线宽 0.8。这样组间重叠的区域会自然产生深色混合,不会让人误以为是另一个数据组。

4.3 坐标轴、刻度、图例和导出设置

Nature 风格的坐标轴是极简的:只保留左边和底下的边框,去掉上边和右边。顶部和右侧的边框线几乎不会出现在顶刊图里。

ax.spines["top"].set_visible(False) ax.spines["right"].set_visible(False)

刻度朝外,而不是朝内,目的是不干扰数据区域的阅读。tick 长度建议 2~3 pt。

图例如果必要,放在右上角或图外。默认的图例边框线非常粗,需要去掉:

ax.legend(frameon=False, loc="upper right", fontsize=6.5)

如果图里已经有分组标签作为 x 轴刻度,图例甚至可以不加,减少冗余信息。

导出时不要用默认的 PNG 就完了。投稿用的矢量图最佳格式是 PDF 或 EPS;如果期刊要求位图,选 TIF,600 dpi 以上,LZW 压缩。

fig.savefig("violin_nature.pdf", bbox_inches="tight")

保存 PDF 之后,建议用软件打开缩放看一遍,确认所有字体都是矢量线条,没有奇怪的锯齿。

5. 常见问题排查与避坑记录

5.1 六类高频问题速查表

我把自己和周围同事实际踩过的坑整理成一张表,几乎覆盖了画这张图会遇到的大部分问题。

现象可能原因解决办法
各组小提琴宽窄不一没有做密度归一化统一除以各组最大密度或全局最大密度
曲线出现负值区域KDE 拟合值未裁剪加np.clip(dens, 0, None)
左右镜像不对称填充时顺序写错检查左右边界点的排列顺序
散点被密度图完全盖住zorder 层级不对散点 zorder 设为 4,箱线设为 3
双峰被磨平带宽过大调小bw_method,比如 0.2
保存 PDF 后中文字体变方块字体没有嵌入指定font.sans-serif使用系统的中文字体

5.2 实战复盘:从“能出图”到“能投稿”

讲一个典型的修改案例。A 同学拿一段 RNA 表达数据来找我,说自己的小提琴图被导师批了。我一看,第一版用的是 seaborn 默认样式:六组数据每组宽度都不一样,颜色是默认的紫色,箱线内的小提琴有统一的白色边缘线,整体看起来像玩具图,明显达不到投稿标准。

我们做的修改有三步。第一步,把每组密度除以全局最大密度,确保六组宽度代表真实的相对密度,而不是被各组内部峰值带歪。第二步,把颜色全部换成统一灰色系,不同组之间用透明度区分。第三步,在每组下方标注 n 值,在图注里加上统计检验方法。改完之后的图虽然颜色朴素了,但信息传递效率反而更高,审稿人看不到花哨颜色,只能看到数据和统计结果。

这个小案例说明:学术图表的美感不等于色彩丰富,而是信息组织清晰、所有元素都服务于数据本身。每加一个视觉效果,都要问自己“这能让读者更懂数据吗”。

5.3 什么时候不要用小提琴图

不是所有分布比较都适合用小提琴图。如果你只有 5 个样本,KDE 形状基本不可信,这时候老老实实画箱线图加所有散点,大家都能看懂你的结论。如果你的数据是离散计数,小提琴图会把离散值磨成连续曲线,给人错误的连续印象。如果你关心的只是中位数差异,箱线图就够了,画小提琴图反而引入过多视觉噪声。

另一个常见误用是——把小提琴图画出来时只展示密度,却没有标注任何统计量。审稿人盯着图上那两条“腰”想知道中位数在哪,结果找不到。这时应该要么叠加箱线,要么在每组旁边用粗点标出中位数。

6. 进阶扩展:更复杂的分组与自动化标注

6.1 双因素分组的小提琴图

有时候实验设计是两个因素的组合,比如“时间点 × 处理方式”。这种情况可以把分组轴设置为"Ctrl-0h"、"Ctrl-24h"这样的字符串,简单直接,也是我在实际项目里最常用的方案。

更花哨的方式是嵌套小提琴:内圈画第一个因素,外圈画第二个因素。我建议新手先别追求这种效果,因为嵌套之后组间比较变得很困难,除非你有明确的视觉分层需求。嵌套关键代码是内外两层fill_betweenx,外层用浅色,内层用深色,但这种方式只适合两因素且每组样本量很大的场景。

6.2 自动把检验结果转成星号

手动画星号容易写错组间位置。自动化做法是解析检验的 P 值,然后生成字符串:

from scipy.stats import mannwhitneyu def star(p): if p < 0.001: return "***" if p < 0.01: return "**" if p < 0.05: return "*" return "ns" stat, p = mannwhitneyu(group_a, group_b) label = star(p) # 用于标注

这里有个容易踩的坑:mannwhitneyu默认返回单侧 P 值,比较两组时最好设置alternative="two-sided"。另外,如果组间方差不齐,用 Welch t 检验或 Mann-Whitney U 检验;如果数据符合正态性且方差齐性,直接用 t 检验更常见。选择错误的检验方法,虽然画出来的星号差不多,但审稿人一旦追问方法细节就会很被动。

6.3 动画或交互式小提琴图的备注

若你想做交互展示,plotly 虽然一行出图,但交互式的图形不适合期刊投稿,只适合内部探索数据。我个人建议:分析阶段可以用交互式快速看分布,发表阶段还是走 matplotlib 的静态矢量图。交互式图的鼠标悬浮并不能帮你提升论文的说服力,反而可能让合作者误以为某些特征可以在浏览器里验证。

结尾

最后再分享一个我的习惯:每次存图前,我都会把 KDE 曲线和原始数据的直方图叠在一起看一遍。如果直方图明明有明显双峰,而我的 KDE 参数却把所有峰磨掉了,那这张图就是视觉骗子。先确认形状可信,再考虑配色好不好看。做图这件事,数据诚实永远排在美观前面。

如果你也想做一张能投出去的图,不用急着把每个参数调到完美。先用默认参数跑通流程,再逐项调整带宽、归一化、字体、配色,最后导出 PDF 放大检查。这个顺序我反复用了很多年,过程最顺,返工最少。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进