做科研图表复现存续两年的习惯里,我收到最多的问题不是“怎么画线”,而是:为什么同样是散点图,别人的图放在期刊里毫无违和感,我自己的图一贴进 Word 就露馅?这期“Nature图表复现”系列,我们专门拆散点图。我会用一条完整案例,从视觉规范建立、数据清洗、坐标轴整形、拟合线、组合图到导出投稿,带你走完一张出版级散点图的全程。适合准备 SCI 论文配图的研究生、动手做性能分析报告的数据工程师,也包括所有想摆脱 matplotlib 默认样式的人。
散点图本身是最简单的二维图形,但这恰恰意味着细节决定成败。因为简单,任何审美缺陷都无法被复杂的表现手法掩盖。下面内容不涉及晦涩的理论,全部是可复制的参数和思路。
1. 先拆解目标:Nature散点图的“高级感”从哪里来
1.1 高级感来自“视觉规范”,不是某一笔好画
我拆过不少发表在 Nature 正刊和子刊上的散点图。放大看,每个元素都不复杂:一条细坐标轴线、四五个点群、少量误差线、一句标注。但组合起来就是干净。关键在两点:第一,全图用的字体、线宽、字号、颜色数量都严格控制,没有多余装饰;第二,所有子图共用同一套样式配置,而不是每张图临时调。这两点落到操作上,就是把你常用的绘图参数抽成一个全局样式文件,所有图统一调用。
对 matplotlib 来说,最直接的做法是修改 matplotlib.rcParams。下面这套参数是我日常做复现类图表时存档的,核心思路是“白底、无装饰、刻度朝外、线宽统一”。
python复制import matplotlib as mpl
mpl.rcParams.update({
'font.family': 'sans-serif',
'font.sans-serif': ['Arial', 'Helvetica', 'DejaVu Sans'],
'font.size': 8,
'axes.linewidth': 0.8,
'axes.edgecolor': 'black',
'axes.labelcolor': 'black',
'text.color': 'black',
'xtick.color': 'black',
'ytick.color': 'black',
'xtick.direction': 'out',
'ytick.direction': 'out',
'xtick.major.size': 4,
'ytick.major.size': 4,
'xtick.major.width': 0.8,
'ytick.major.width': 0.8,
'xtick.labelsize': 7,
'ytick.labelsize': 7,
'axes.grid': False,
'figure.dpi': 150,
'savefig.dpi': 600,
'savefig.bbox': 'tight',
})
这套配置单独看没什么,但它能避免我反复踩的坑:忘改字体导致中文字体乱入、刻度方向一会儿朝内一会儿朝外、导出分辨率不够导致线条发虚。你不需要完全照搬,重要的是先建立起“一套规范走到底”的意识。
1.2 默认样式是“探测工具”,复现样式是“出版工具”
很多人没意识到 matplotlib 默认风格的定位:灰色背景、白网格、四边都闭合的边框,这是为了方便快速发现数据问题,不是为了放到论文里给人读。印刷阅读场景要求相反:白底、无网格或极淡网格、只保留左和下两条轴线、刻度朝外。
我常用一个类比解释这两者的区别:默认样式像外科手术的无影灯,越亮越好,目的是看清所有细节;出版样式像博物馆的射灯,只照亮展品,背景尽量安静。理解了“探索”和“出版”这两套目标的分野,就不会再纠结为什么默认参数画出来“总觉得差点意思”。
| 维度 | matplotlib 默认样式 | 出版级复现样式 |
|---|---|---|
| 背景 | 浅灰色 | 纯白 |
| 网格 | 白色网格线 | 关闭或极淡虚线 |
| 刻度方向 | 朝内 | 朝外 |
| 坐标轴 | 四边闭合 | 仅保留左、下 |
| 字号 | 10~12 | 7~9 |
| 线宽 | 粗,约 1.5 | 0.8~1.0 |
表格里的每一项看起来都是小细节,但组合在一起就是“期刊感”和“代码跑通感”的分水岭。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据清洗先行:散点图的“好看”是清理出来的
2.1 不处理异常值,后面所有参数都白调
散点图和数据质量的关系,比很多人想象中紧密。如果数据里混进几个异常点,坐标轴范围会被拉得很大,主体数据全挤在图的一角,透明度再调也没用。这不是绘图问题,是数据问题。
我通常会在绘图前做三步:
- 删除缺失关键字段的行,比如 x 或 y 为 NaN。
- 用四分位距(IQR)定位异常值,但不一定直接删除,而是先看业务含义。
- 对重复点做去重处理,或者叠加抖动让分布可见。
python复制import pandas as pd
df = pd.read_csv('lock_throughput.csv')
df = df.dropna(subset=['lock_wait_ms', 'throughput_tps'])
q1 = df['lock_wait_ms'].quantile(0.25)
q3 = df['lock_wait_ms'].quantile(0.75)
iqr = q3 - q1
lower, upper = q1 - 1.5 * iqr, q3 + 1.5 * iqr
df['is_outlier'] = (df['lock_wait_ms'] < lower) | (df['lock_wait_ms'] > upper)
df_clean = df[~df['is_outlier']]
对科研绘图,我的建议是:异常值不要静默删除,最好单独标记,或者用截断显示的方式处理。所谓截断显示,就是设定坐标轴范围后,在范围外标注箭头,告诉读者“此处还有更极端的点”。很多期刊图里那条坐标轴上的截断符号,就是这个作用。
2.2 分类变量用离散色,连续变量用渐变色
散点图里除了 (x, y) 坐标,第三维信息通常来自颜色、大小或形状。这里有个经验法则:分类变量用离散色,连续变量用渐变色,不要混用。“viridis”这类感知均匀的色带更适合连续值,而点的大小通常用来表达第三维数量,不适合表达分类。
以热词里提到的“锁等待时长 系统吞吐量 关系散点图”为例,我习惯把锁等待时长映射为颜色,用颜色深浅暗示压力水平,这样一张图可以同时表达三个维度:横轴是响应延迟的伴随指标,纵轴是吞吐量,颜色是锁等待时长。比单纯一张黑白散点图信息量多一个层级,又不会像 3D 图那样难读。
这里有一个关键点:颜色映射最好做归一化,否则极端值会把颜色范围拽偏。matplotlib 里可以用 norm=plt.Normalize(vmin, vmax) 控制,或者直接给 c= 传归一化后的数值。很多人在这一步偷懒,结果就是图的颜色深一片浅一片,图例没法解释。
3. 从 plt.scatter 到 Nature 质感:核心参数逐个调校
3.1 透明度:散点密度控制的第一要素
热词里有人搜“matplotlib 散点图 透明度 网格”,说明大部分人都知道要调透明度,但不知道调到多少。透明度本质上是在模拟墨水的叠加:点多了,重叠区域颜色会加深。如果数据量几百个点,alpha 取 0.7 比较合适;几千个点时降到 0.3~0.5;上万个点时可能需要 0.05~0.2。
怎么判断是否合适?一个很土但有效的标准:把图缩到预览的 50% 大小,如果最密集的区域完全变成一团黑,说明 alpha 太大。真正到位的密度显示,最深色区域应该还能看出分布层次,而不是一个墨块。
python复制import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(3.5, 2.5))
scatter = ax.scatter(
df['lock_wait_ms'],
df['throughput_tps'],
s=18,
c=df['lock_wait_ms'],
cmap='viridis',
alpha=0.6,
edgecolors='none',
)
这里 s=18 不是半径,而是点的面积,单位是平方像素。很多教程不强调这一点,导致点大小总比想象中夸张。想表达半径比例关系的数据,记得先把半径平方再传入。
3.2 颜色、尺寸和边框的配合
散点图里点与点之间要有“呼吸感”,靠的是三个参数的协同:s 控制面积、alpha 控制透明度、edgecolors 控制描边。
当数据量中等(几百到一千)且重叠较多时,我会给点加一圈很细的白色描边,宽度 0.3 左右。这个白色描边能让重叠的点之间出现微弱的间隔,提升颗粒感。但点特别小(s 小于 15)时不要用描边,因为白边会喧宾夺主,让点看起来是空心圈。
python复制scatter = ax.scatter(
x, y,
s=24,
c=color,
alpha=0.65,
edgecolors='white',
linewidths=0.3,
)
如果是分类数据,同一类点共用一个颜色时,不同类别之间要保证色相有明显差异。我最常用的分类配色是 tab10 里挑了又调过的版本,避免默认的亮红色和亮蓝色直接怼在一起。出版级图表通常饱和度偏低,颜色像兑了一点白,看起来更温和。
3.3 点太多时怎么办:抽稀比调透明度更有效
前面说到上万点时 alpha 调到 0.1 以下,但更推荐的做法是抽稀(downsample)。随机抽取一部分点绘制,同时保留全样本绘制出的趋势,这在数据量极大时能大幅度提升渲染速度和文件体积,对视觉品质反而更友好。
抽稀不是在数据层面暴力取前 N 行,而是用随机种子保证可复现:
python复制sample = df.sample(n=8000, random_state=42)
注意,抽稀仅适用于探索性分析或示意图。如果是为期刊提供原始统计结论,还是要用全量数据计算相关系数和回归,绘图时再做抽样展示,并在方法部分说明抽样方式。
4. 坐标系的“整形手术”:刻度、网格与边框的取舍
4.1 刻度和字体:图表的“字形”
坐标轴的刻度是图表最容易被忽略的“字形”。Nature 类期刊普遍使用刻度朝外,刻度的短线长度在 3~4 pt,宽度与坐标轴线一致,刻度标签用 7~8 pt 无衬线字体。这样整体观感非常轻盈。
这些细节可以通过 ax.tick_params() 批量控制:
python复制ax.tick_params(
axis='both',
direction='out',
length=3.5,
width=0.8,
labelsize=7,
pad=3,
)
pad=3 控制刻度标签与刻度线的距离,默认值有时候会让标签贴到刻度线上,在印刷时显得局促。调完这个,整张图会立刻松弛下来。
还有一个很实用的习惯:坐标轴标签务必带上单位,比如“Lock wait time (ms)”“Throughput (tps)”。很多图被审稿人打回就是因为坐标轴只有变量名没有单位,读者要翻原文才知道量纲。
4.2 网格线要“存在但不出声”
散点图的网格历来有争议。Nature 正刊里的散点图大多没有网格,靠坐标轴数值本身定位就够。但如果数据分布没有明显规律、读者需要做粗略估读,极淡的网格反而有用。
我的方案是:需要网格时,用虚线、细线、低透明度,颜色用灰色而不是纯黑:
python复制ax.grid(True, linestyle='--', linewidth=0.5, alpha=0.3, color='gray')
判断标准很简单:网格线存在,但不应该抢在数据点之前进入视线。如果你在扫视图表时第一眼看的是网格而不是数据,说明网格太重了,继续降低 alpha 或者直接关掉。
4.3 坐标轴范围与极端值的截断策略
坐标轴范围设多少,直接决定散点图的信息量。matplotlib 默认会留出 5% 左右的边距,但出版图通常希望数据点尽量占满画布,减少浪费的空白。
我用一个函数做轴范围裁剪,简单实用:
python复制def set_axis_limits(ax, x, y, pad_ratio=0.03):
ax.set_xlim(x.min() - (x.max() - x.min()) * pad_ratio,
x.max() + (x.max() - x.min()) * pad_ratio)
ax.set_ylim(y.min() - (y.max() - y.min()) * pad_ratio,
y.max() + (y.max() - y.min()) * pad_ratio)
如果数据里存在少数极大值,不删数据但又要保住主体的分布形态,我会把坐标轴上限设为主体的 98 分位附近,然后用一个箭头标注“截断方向”,让读者知道图外还有数据。这种处理方式比直接删点更诚实,也更符合科研制图规范。
5. 信息密度提升:拟合线、置信带和柱点组合图
5.1 散点图加上拟合线和置信带,才有“结论感”
只有裸散点,读者看到的是“一堆点”;加上拟合线和置信带,读者看到的才是“趋势和置信程度”。这一步对数据分析报告尤其重要。
我一般用 SciPy 做线性回归,然后手算置信带,而不是直接调 seaborn 的 regplot。因为这样得到的回归系数、p 值、置信区间都可以写到图注里:
python复制import numpy as np
from scipy import stats
res = stats.linregress(x, y)
slope, intercept, rvalue, pvalue = res.slope, res.intercept, res.rvalue, res.pvalue
x_fit = np.linspace(x.min(), x.max(), 100)
y_fit = slope * x_fit + intercept
# 95% 置信带
n = len(x)
t_val = stats.t.ppf(0.975, n - 2)
y_pred = slope * x + intercept
residual_std = np.sqrt(np.sum((y - y_pred) ** 2) / (n - 2))
se_fit = residual_std * np.sqrt(1 / n + (x_fit - x.mean()) ** 2 / np.sum((x - x.mean()) ** 2))
upper = y_fit + t_val * se_fit
lower = y_fit - t_val * se_fit
绘制时,拟合线用实线,置信带用浅色填充:
python复制ax.plot(x_fit, y_fit, color='#d62728', linewidth=1.0, label='Linear fit')
ax.fill_between(x_fit, lower, upper, color='#d62728', alpha=0.15, edgecolor='none')
注意,置信带不是给你画着好看的,它直接说明趋势的稳定性。如果置信带非常宽,哪怕 p 值小于 0.05,也要谨慎解读。
5.2 柱形图与散点图“折叠”在同一张图里的正确姿势
热词里有一条“word 如何让柱形图和散点图折叠在一起”,这说明很多人在 Word 里折腾组合图失败过。先给个结论:Word 自带图表功能不适合做这类组合图,正确做法是先用 Python、Origin 或 GraphPad 生成好图,再插入 Word。
在 matplotlib 里,“柱形图 + 散点图”的组合通常用于展示“分组统计量 + 所有原始样本”。柱表达均数或中位数,散点表达每个样本的分布。这种图在论文里还有个专门名字:Dot plot with bar,或者 bar + strip plot。
python复制fig, ax = plt.subplots(figsize=(3.5, 2.5))
groups = ['A', 'B', 'C']
means = [12.3, 18.7, 15.2]
errors = [1.2, 1.5, 1.1]
# 柱子表示均值 + 误差线
bars = ax.bar(groups, means, yerr=errors,
color='#cccccc', edgecolor='black',
linewidth=0.8, width=0.5, capsize=3)
# 散点表示所有原始样本,加一点 x 方向的抖动
for i, (g, xvals) in enumerate(zip(groups, sample_groups)):
jittered = np.full_like(xvals, i) + np.random.uniform(-0.15, 0.15, size=len(xvals))
ax.scatter(jittered, xvals, s=10, alpha=0.6,
color='#1f77b4', edgecolors='white', linewidths=0.2)
这里有两个坑。第一,柱宽不要太大,0.5 左右留出空间给散点;第二,散点的 x 坐标必须做抖动,否则同组的点会全部叠在柱子中心线上,变成一根“火柴棍”。抖动幅度控制在柱宽的 30% 以内,太大会混到其他组里去。
如果你要加右侧次坐标轴,用 ax.twinx()。但有个原则:次坐标轴必须有明确的量纲和刻度,不要只是为了“多加一条折线”而滥用。一张图同时出现两根 y 轴时,读者的阅读成本会显著上升,能不用就不用。
6. 实际案例复盘:用散点图揭示锁等待时长与系统吞吐量的关系
6.1 数据场景与采集口径
这类散点图在数据库性能分析里非常常见,比如定位锁竞争对系统吞吐量的影响。数据来源通常是压测期间的监控日志:每次采样记录一个锁等待时长和一个吞吐量值。采集时要注意口径统一,比如都在同样的并发线程数下采样,否则不同并发背景的数据混在一起,散点图会呈现多条“趋势簇”,解释起来非常困难。
实际分析中,我先把数据按并发档位分组,先画总览图,再画分档图。如果总览图呈现明显负相关但分档图没有,说明相关关系很可能被并发参数混淆了。这是统计上的混淆变量问题,不是画图能解决的。
6.2 完整绘图代码与输出解读
下面是这个案例的一份完整绘图代码,组合了前面所有技术点:
python复制import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib as mpl
# 全局出版样式
mpl.rcParams.update({
'font.family': 'sans-serif',
'font.sans-serif': ['Arial', 'Helvetica', 'DejaVu Sans'],
'font.size': 8,
'axes.linewidth': 0.8,
'xtick.direction': 'out',
'ytick.direction': 'out',
'xtick.major.size': 3.5,
'ytick.major.size': 3.5,
'axes.grid': False,
})
df = pd.read_csv('lock_throughput.csv')
df = df.dropna(subset=['lock_wait_ms', 'throughput_tps'])
# 用颜色映射锁等待时长
fig, ax = plt.subplots(figsize=(3.5, 2.5))
sc = ax.scatter(
df['throughput_tps'],
df['lock_wait_ms'],
s=20,
c=df['lock_wait_ms'],
cmap='viridis',
alpha=0.55,
edgecolors='none',
)
# 拟合线
res = stats.linregress(df['throughput_tps'], df['lock_wait_ms'])
x_fit = np.linspace(df['throughput_tps'].min(), df['throughput_tps'].max(), 100)
y_fit = res.slope * x_fit + res.intercept
ax.plot(x_fit, y_fit, color='#d62728', linewidth=1.0)
ax.set_xlabel('Throughput (tps)')
ax.set_ylabel('Lock wait time (ms)')
ax.tick_params(labelsize=7)
cbar = fig.colorbar(sc, ax=ax, pad=0.02)
cbar.set_label('Lock wait time (ms)', fontsize=7)
cbar.ax.tick_params(labelsize=6)
plt.savefig('lock_throughput_scatter.pdf')
图绘出来后,我会先看三点:趋势方向是否符合业务预期、置信带宽度是否可接受、颜色映射是否有清晰的梯度。如果颜色梯度从深到浅在图上没有自然过渡,而是碎片化乱跳,通常说明锁等待时长和吞吐量之外,还有其他变量在起作用,值得进一步下钻分析。
6.3 投稿前的导出细节:尺寸、DPI 和字体
复现类文章最后一步是把图导出为期刊能用的文件。这步出错最多的原因是混淆“屏幕图”和“印刷图”。屏幕看图 72~150 DPI 够用,印刷至少 300 DPI,期刊图通常要求 600 DPI。
具体到尺寸,Nature 的栏宽要求是:单栏图 89 mm,双栏图 183 mm。换算成英寸分别是 3.5 和 7.2。所以我在 figsize 里习惯直接写英寸:
- 单栏散点图:
figsize=(3.5, 2.5)或(3.5, 3) - 双栏大图:
figsize=(7.2, 5)
导出格式优先 PDF 或 EPS 矢量格式,不要只导 PNG。矢量格式在期刊缩放时不会产生锯齿,审稿人放大看细节也更清晰。如果期刊必须收位图,再导出 600 DPI 的 TIFF。
字体嵌入是另一个容易被忽略的点。matplotlib 保存 PDF 时会嵌入字体,但有时会变成 Type 3 字体,个别期刊不接受。稳妥做法是把 pdf.fonttype 和 ps.fonttype 设为 42,这样输出 TrueType 字体,兼容性更好。
python复制mpl.rcParams['pdf.fonttype'] = 42
mpl.rcParams['ps.fonttype'] = 42
最后,给所有图做一个统一的“导出前检查列表”:坐标轴标签是否带单位、图例是否与数据点重叠、刻度字号是否小于可读下限、字体是否全部嵌入。检查列表可以帮你避免 90% 的投稿退修问题。
做图表复现这件事,我个人的体会是:技巧都是次要的,真正拉开差距的是“对细节的偏执”和“统一的规范”。今天这套散点图流程你可以直接抄去用,但最好还是根据你所在期刊的排版风格,把 rcParams 里的参数微调成自己的固定模板。下回再做同类图,十分钟就能出一张不露怯的出版级散点图。
