Python 科研可视化从入门到精通

写给科研工作者、研究生和数据分析从业者的"够用且不啰嗦"的可视化教程


📖 关于本教程

本教程把 Python 科研可视化的核心知识凝练成 12 章、60 节——每节学习时间 2-3 分钟,全书 2.5 小时学完,相当于一本紧凑的"口袋指南"。

和市面上同类教程的区别: - 不堆砌 API 列表,每节只讲"为什么这么做"的核心点 - 不画"玩具数据",所有代码示例都直接服务于科研场景 - 不回避"坑"——每节都标出一个真实会绊倒人的细节 - 不假装"简单"——审稿人会黑白打印你的图,本书从一开始就提醒你

学完你能做什么

  • 用 Matplotlib 画出任何论文需要的静态图
  • 用 Seaborn 一行代码画出复杂的统计图
  • 用 Plotly 做交互式可视化和网页仪表板
  • 处理地理空间数据和机器学习评估图
  • 输出符合 SCI 期刊规范的论文级图表

适合人群

  • 有 Python 基础,但可视化只会照抄博客的研究生
  • 用 Excel 画图被审稿人退回的科研工作者
  • 想系统了解 Python 可视化生态的数据分析师

📋 目录

  • 第 1 章 基础准备 — NumPy、Pandas、环境搭建
  • 第 2 章 Matplotlib 绘图基础 — 折线、散点、柱状、直方、箱线
  • 第 3 章 图形定制与美化 — 配色、字体、布局、样式
  • 第 4 章 进阶图表 — 误差棒、等高线、伪彩色、时间序列
  • 第 5 章 Seaborn 统计可视化 — 分布、回归、热力图
  • 第 6 章 其他可视化库 — scienceplots、missingno
  • 第 7 章 地理空间可视化 — Cartopy 与 Geopandas
  • 第 8 章 交互式可视化 — Plotly Express 与 graph_objects
  • 第 9 章 专业场景可视化 — 网络图、桑基图、降维、火山图、曼哈顿图
  • 第 10 章 机器学习可视化 — 混淆矩阵、ROC、特征重要性、训练曲线
  • 第 11 章 论文级输出 — 格式、尺寸、字号、批量化
  • 第 12 章 综合实战 — 跨领域 5 个端到端案例

🛠 如何使用本教程

  1. 顺序阅读 — 章节有依赖关系,前 3 章是基础,建议按顺序读
  2. 跑代码 — 每节代码都可以直接复制到 Jupyter 里跑,不跑等于没学
  3. 关注"一个坑" — 这是每节最值钱的部分,能让你少踩 5-10 个坑
  4. 遇到 💡 深入 标记 — 表示这块还有更深的内容,需要时再回看

约定:所有代码假设你已经做过 import matplotlib.pyplot as pltimport numpy as np,节内代码块为了简洁可能省略 import。


第 1 章 基础准备

这一章是准备工作。环境搭好、NumPy 和 Pandas 会用,后面才能专注画图。


§ 1.1 Python 科研可视化全景:核心库与如何选

⏱️ 3 分钟

这节学啥:Python 科研可视化常用哪些库,何时用哪个。

为什么:Python 可视化库有十几个,但科研里 95% 的图只需要三个——Matplotlib(论文出版)Seaborn(统计图)Plotly(交互报告)。其他库要么是小众替代,要么是专门方向。先记住这三个的分工,比记 10 个名字有用。

选库口诀: - 论文里的静态图 → Matplotlib(什么都能画,是基础) - 复杂统计图(箱线、小提琴、热力图)→ Seaborn(基于 Matplotlib,更简洁) - 网页报告、要能缩放点击 → Plotly(交互式,输出 HTML)

一个坑:很多人一上来想找"最强大"的库一劳永逸。但科研图的好坏取决于审稿人能不能看清,不取决于工具多花哨。Matplotlib 永远是基本盘,先学好它。

💡 深入:5 个主流可视化库画同一张图的代码长度对比 → 见附录 A(待补)


§ 1.2 环境一次搭好:Miniconda + 核心库 + Jupyter

⏱️ 3 分钟

这节学啥:一次性把本书所有依赖装齐。

为什么:科研代码最怕"环境问题"。用 conda 建一个独立环境,今天装的东西明年还能跑;不用环境直接装全局,半年后必出冲突。每个项目一个环境,是科研代码的基本卫生

代码(在命令行执行):

# 1. 装 Miniconda(官网下载安装包,按提示装即可)

# 2. 建独立环境
conda create -n sciviz python=3.11 -y
conda activate sciviz

# 3. 一次装齐
conda install numpy pandas scipy matplotlib seaborn scikit-learn jupyter -y
pip install plotly missingno scienceplots

# 4. 验证(在 Jupyter 里跑)
import matplotlib.pyplot as plt
plt.plot([1, 2, 3], [1, 4, 9])
plt.show()

一个坑:装 cartopy(第 7 章用)必须加 -c conda-forge,pip 装常报错。命令:conda install -c conda-forge cartopy -y

💡 深入:环境导出/复现、镜像源配置 → 见附录 A


§ 1.3 NumPy 速成:数组创建、索引与广播

⏱️ 3 分钟

这节学啥:用 NumPy 创建数组、取数据、做向量化运算。

为什么:画图的数据 99% 是 NumPy 数组。它和 Python 列表的关键区别是向量化——对整个数组做运算不写循环,底层一次性算完,比 list 快 10-100 倍。

代码

import numpy as np

# 创建:4 种主要方式
a = np.array([1, 2, 3])              # 从 list
b = np.zeros((3, 4))                  # 全零,3×4
c = np.linspace(0, 2*np.pi, 100)      # 0 到 2π 之间 100 个点(画图最常用)
d = np.arange(0, 10, 2)               # 等差:[0,2,4,6,8]

# 索引:取行 / 取列 / 按条件
m = np.arange(20).reshape(4, 5)
m[1, 2]         # 单元素
m[:, 2]         # 第 2 列
m[m > 10]       # 按条件筛选

# 广播:不同形状自动对齐运算
m + np.array([1, 2, 3, 4, 5])   # (4,5) + (5,) → 每行都加这个向量

一个坑shape=(3,)shape=(3,1) 不一样——前者一维,后者二维。画图时维度不对会直接报错。


§ 1.4 NumPy 速成:聚合统计与随机数

⏱️ 3 分钟

这节学啥:用 NumPy 算均值/方差等统计量,以及生成可复现的随机数。

为什么:聚合函数(mean/std/max)几乎每张图都要用。axis 参数是最大的坑——记住 "axis=X 的意思是消掉第 X 维",永远不会错。

代码

import numpy as np

a = np.array([[1, 2, 3],
              [4, 5, 6]])    # shape: (2, 3)

# 全局聚合
a.mean()           # 3.5
a.std()            # 1.71

# 沿轴聚合(重点)
a.mean(axis=0)     # [2.5, 3.5, 4.5]  消掉第 0 维 → 每列均值
a.mean(axis=1)     # [2.0, 5.0]       消掉第 1 维 → 每行均值

# NaN 处理:用 nan 系列函数
b = np.array([1, 2, np.nan, 4])
np.nanmean(b)      # 2.33(忽略 NaN)

# 可复现的随机数(新 API)
rng = np.random.default_rng(seed=42)
rng.normal(0, 1, size=100)      # 100 个标准正态
rng.uniform(0, 10, size=(3, 4)) # 3×4 均匀分布

一个坑:论文图必须能复现——永远设 seed。不设 seed 跑出来的随机图,明天再跑就变了。


§ 1.5 Pandas 速成:DataFrame、读取、清洗、聚合

⏱️ 5 分钟

这节学啥:读 CSV → 看数据 → 清洗 → 分组聚合,一条龙。

为什么:Pandas 把"Excel + SQL"的能力塞进了 Python。真实数据从来不干净——读完先看缺失值、再清洗、再聚合,这套流程跑一遍后才能进入画图。

代码

import pandas as pd

# 1. 读取
df = pd.read_csv('data.csv', parse_dates=['date'], encoding='utf-8')

# 2. 看数据(三句魔咒)
df.head()        # 前 5 行
df.info()        # 列类型、非空数
df.describe()    # 数值列的统计摘要

# 3. 清洗
df.isna().sum()                              # 每列缺失数
df = df.dropna(subset=['important_col'])     # 删关键列缺失
df['x'] = df['x'].fillna(df['x'].median())   # 中位数填充

# 4. 选行选列
df[df['age'] > 25]                           # 条件筛选
df.loc[df['age'] > 25, ['name', 'income']]   # 同时筛行选列

# 5. 分组聚合
df.groupby('city')['income'].mean()                              # 按城市算均收入
df.groupby('city').agg({'income': 'mean', 'age': 'median'})      # 多列多函数

# 6. 时间序列
df = df.set_index('date')
df.resample('M').mean()                       # 月均
df['ma7'] = df['value'].rolling(7).mean()     # 7 日滚动均

一个坑:读中文 CSV 报 UnicodeDecodeError 是高频问题,加 encoding='gbk''utf-8-sig' 解决。

💡 深入:透视表、合并 join、多级索引 → 见附录 A


第 2 章 Matplotlib 绘图基础

Matplotlib 是 Python 科研绘图的事实标准。本章教完,你能独立画出论文里最常见的 5 类图。


§ 2.1 pyplot vs 面向对象 API

⏱️ 2 分钟

这节学啥:Matplotlib 有两种写法,知道用哪种。

为什么:网上教程混用两种 API 导致很多人困惑。规则其实简单——画一张简单图用 plt.xxx,画复杂图(多子图、精细控制)用 fig, ax = plt.subplots()。本书统一用后者,因为科研图都不"简单"。

代码

import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 10, 50)

# 方式 1:pyplot 风格(脚本式,省事)
plt.plot(x, np.sin(x))
plt.title("Sin")
plt.show()

# 方式 2:面向对象(推荐)
fig, ax = plt.subplots(figsize=(7, 4))
ax.plot(x, np.sin(x))
ax.set_title("Sin")
plt.show()

一个坑:两种 API 的方法名略有差异——pyplot 是 plt.title(),面向对象是 ax.set_title()(多了 set_)。混用时经常报错"AttributeError"。


§ 2.2 第一张图:figure 和 axes 的关系

⏱️ 2 分钟

这节学啥:理解 Matplotlib 的两层结构。

为什么figure 是一整张画布,axes 是画布上的一个坐标系。一张 figure 可以有多个 axes——这就是多子图的基础。记住这个关系,后面所有"子图怎么排"都迎刃而解。

代码

import matplotlib.pyplot as plt

# 一张图一个坐标系
fig, ax = plt.subplots(figsize=(7, 4))

# 一张图四个坐标系(2 行 2 列)
fig, axes = plt.subplots(2, 2, figsize=(10, 8))
axes[0, 0].plot([1, 2, 3])        # 左上
axes[0, 1].scatter([1, 2], [3, 4]) # 右上
axes[1, 0].bar(['A', 'B'], [3, 5]) # 左下
axes[1, 1].hist([1, 2, 2, 3, 3])   # 右下

plt.tight_layout()  # 自动调整子图间距
plt.show()

一个坑:单个 ax 时 axes 不是数组,是单个对象——不能用 axes[0]。多子图时 axes 才是数组。


§ 2.3 保存图形:DPI、格式、透明度

⏱️ 2 分钟

这节学啥:画完图怎么保存成论文能用的格式。

为什么:论文投稿对图片有硬性要求。矢量格式 (PDF/SVG) 投稿时永远首选——放大不糊。位图 (PNG) 要至少 300 DPI 才合格。会议海报或网页用 PNG 就行,但期刊一律 PDF/SVG

代码

fig, ax = plt.subplots(figsize=(6, 4))
ax.plot([1, 2, 3], [1, 4, 9])

# 推荐:双格式同时保存
fig.savefig('out.pdf', bbox_inches='tight')             # 矢量,投稿用
fig.savefig('out.png', dpi=300, bbox_inches='tight')    # 位图,预览/网页用

# 透明背景(嵌入 PPT 时用)
fig.savefig('out.png', dpi=300, transparent=True, bbox_inches='tight')

一个坑:忘加 bbox_inches='tight',保存的图四周会有大片空白边距。这个参数应该是默认习惯。


§ 2.4 折线图 plot():单线与多线

⏱️ 3 分钟

这节学啥:用 plot() 画一条或多条折线。

为什么:科研里最常见的坑不是"画不出来",而是"几条线挤在一起分不清"。审稿人会黑白打印你的图——所以多线必须用 颜色 + 线型 + marker 三重区分,缺一不可。

代码

import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 2*np.pi, 50)

fig, ax = plt.subplots(figsize=(7, 4))
ax.plot(x, np.sin(x),  label='sin',  color='#2E86AB', linestyle='-',  marker='o', markevery=5)
ax.plot(x, np.cos(x),  label='cos',  color='#E63946', linestyle='--', marker='s', markevery=5)
ax.plot(x, np.sin(2*x), label='sin2x', color='#06A77D', linestyle=':',  marker='^', markevery=5)

ax.set_xlabel('x'); ax.set_ylabel('y'); ax.legend()
plt.show()

一个坑:marker 太密会糊成一片。50 个点画 50 个 marker 难看,加 markevery=5 每隔 5 个画一个最美观。

💡 深入:好图 vs 坏图、决策流程、黑白打印测试 → 见附录 A


§ 2.5 散点图 scatter() 与气泡图

⏱️ 3 分钟

这节学啥:画散点,并用大小/颜色编码额外维度。

为什么:散点图是探索两个变量关系的首选。升级版"气泡图"用点的大小或颜色表达第三维——一张图传达三个变量,是科研图常见的"高密度信息"写法。

代码

import matplotlib.pyplot as plt
import numpy as np

rng = np.random.default_rng(42)
x = rng.normal(0, 1, 100)
y = x * 0.5 + rng.normal(0, 0.5, 100)
sizes = rng.uniform(20, 200, 100)
colors = rng.uniform(0, 1, 100)

fig, ax = plt.subplots(figsize=(7, 5))
sc = ax.scatter(x, y, s=sizes, c=colors, cmap='viridis',
                alpha=0.6, edgecolors='white', linewidth=0.5)
fig.colorbar(sc, ax=ax, label='Third Variable')
ax.set_xlabel('X'); ax.set_ylabel('Y')
plt.show()

一个坑:点重叠时看不出密度。永远加 alpha=0.5~0.7——半透明叠加能直观显示密集区域。


§ 2.6 柱状图 bar() 与分组柱状图

⏱️ 3 分钟

这节学啥:画单组柱状图和多组对比柱状图。

为什么:柱状图用来比较类别之间的数值。分组柱状图(每个类别旁边并排画几个柱)能同时比较"类别 × 组别"两个维度,是科研里"实验组 vs 对照组"的标配画法。

代码

import matplotlib.pyplot as plt
import numpy as np

categories = ['A', 'B', 'C', 'D']
control = [23, 45, 56, 78]
treatment = [25, 50, 60, 85]

x = np.arange(len(categories))
width = 0.35

fig, ax = plt.subplots(figsize=(7, 4))
ax.bar(x - width/2, control,   width, label='Control',   color='#2E86AB')
ax.bar(x + width/2, treatment, width, label='Treatment', color='#E63946')

ax.set_xticks(x)
ax.set_xticklabels(categories)
ax.set_ylabel('Value'); ax.legend()
plt.show()

一个坑:分组柱状图要算 x - width/2x + width/2 才能并排放——这是新手最常翻车的几行。组数变多时改用公式 x + i*width - (n-1)*width/2


§ 2.7 直方图 hist()

⏱️ 2 分钟

这节学啥:用直方图看一组数据的分布。

为什么:直方图是探索"这组数据长什么样"的第一招——是正态分布?还是双峰?有没有离群值?bins 数量是关键:太少看不出形状,太多噪音过大。经验值 bins=30,或用 'auto'

代码

import matplotlib.pyplot as plt
import numpy as np

rng = np.random.default_rng(42)
data = np.concatenate([rng.normal(0, 1, 1000),
                        rng.normal(5, 1.5, 500)])  # 双峰分布

fig, ax = plt.subplots(figsize=(7, 4))
ax.hist(data, bins=40, color='#2E86AB', edgecolor='white', alpha=0.8)
ax.set_xlabel('Value'); ax.set_ylabel('Count')
plt.show()

一个坑:直方图柱子之间没有 edgecolor 就会糊成一片黑色矩形。永远加 edgecolor='white' 让每个柱子有边界。


§ 2.8 箱线图 boxplot()

⏱️ 3 分钟

这节学啥:画箱线图比较多组数据的分布。

为什么:箱线图一张图同时传达中位数、四分位数、离群值四个信息——比直方图更紧凑,适合"多组数据并排比较"。科研里"几个实验条件下结果分布"的标准画法。

代码

import matplotlib.pyplot as plt
import numpy as np

rng = np.random.default_rng(42)
data = [rng.normal(loc=m, scale=1, size=100) for m in [0, 1, 2, 3]]

fig, ax = plt.subplots(figsize=(7, 4))
bp = ax.boxplot(data, labels=['A', 'B', 'C', 'D'],
                patch_artist=True,           # 允许填色
                boxprops=dict(facecolor='#2E86AB', alpha=0.7),
                medianprops=dict(color='red', linewidth=2))
ax.set_ylabel('Value')
plt.show()

一个坑:箱线图默认是白底黑框,配色单调。patch_artist=True + boxprops 才能填色。中位数线要加粗加红——这是观察的重点。


§ 2.9 标题、轴标签、图例、刻度

⏱️ 3 分钟

这节学啥:把"画出来的图"变成"能看懂的图"。

为什么:没有标签的图等于没画。论文图的最低要求:x/y 轴名 + 单位、图例(多线时)、字号够大(≥10pt)。LaTeX 数学公式用 $...$ 包起来就能渲染。

代码

import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 2*np.pi, 100)

fig, ax = plt.subplots(figsize=(7, 4))
ax.plot(x, np.sin(x), label=r'$y = \sin(x)$')
ax.plot(x, np.cos(x), label=r'$y = \cos(x)$')

# 标签与标题
ax.set_xlabel('Angle (rad)', fontsize=12)
ax.set_ylabel('Amplitude',   fontsize=12)
ax.set_title(r'Trigonometric Functions: $\sin$ and $\cos$', fontsize=13)

# 坐标轴范围与刻度
ax.set_xlim(0, 2*np.pi)
ax.set_xticks([0, np.pi/2, np.pi, 3*np.pi/2, 2*np.pi])
ax.set_xticklabels(['0', r'$\pi/2$', r'$\pi$', r'$3\pi/2$', r'$2\pi$'])

# 图例与网格
ax.legend(loc='upper right', fontsize=10)
ax.grid(True, alpha=0.3)

plt.show()

一个坑:图例 loc='upper right' 经常挡住数据。用 loc='best' 让 matplotlib 自动找空位,或者 bbox_to_anchor=(1.02, 1), loc='upper left' 放到图外。


第 3 章 图形定制与美化

第 2 章你能画出图了。这一章让图从"能看"变成"好看"——而"好看"在科研里有明确标准。


§ 3.1 颜色与 Colormap:如何选

⏱️ 3 分钟

这节学啥:Matplotlib 三种指定颜色的方式,以及 Colormap 的分类。

为什么:颜色不是随便选的。Colormap 分三类:顺序型(如 viridis,表示数值大小)、发散型(如 RdBu,表示正负偏离)、定性型(如 tab10,表示离散类别)。选错类型会误导读者——用发散型 colormap 表示纯正数会让读者错觉"有个零基线"。

代码

import matplotlib.pyplot as plt
import numpy as np

# 指定颜色的 3 种方式
ax.plot(x, y, color='red')         # 命名
ax.plot(x, y, color='#2E86AB')     # HEX
ax.plot(x, y, color=(0.2, 0.5, 0.8)) # RGB 元组(0-1)

# 三类 Colormap
data = np.random.rand(10, 10)
fig, axes = plt.subplots(1, 3, figsize=(12, 3.5))
axes[0].imshow(data, cmap='viridis')      # 顺序型:从小到大
axes[1].imshow(data*2-1, cmap='RdBu_r')   # 发散型:以 0 为中心
axes[2].imshow(np.random.randint(0, 5, (10,10)), cmap='tab10')  # 定性型:离散类别
plt.show()

一个坑永远不要用 jet 这个 colormap。它色彩失真、不色盲友好、亮度不均匀——尽管 Matplotlib 老版本默认就是它。改用 viridis


§ 3.2 科研配色原则:色盲友好与黑白可读

⏱️ 3 分钟

这节学啥:科研图配色的两条铁律。

为什么:约 8% 的男性是色盲。同时,审稿人和编辑会黑白打印你的图。所以——红绿撞色是大忌(色盲看不清),仅靠颜色区分多线也是大忌(黑白打印混成一团)。解法:用色盲友好色板 + 三重区分(颜色 + 线型 + marker)

代码(直接复制可用的科研色板):

# 色盲友好色板(5 色版)
colors = ['#2E86AB',   # 蓝
          '#E63946',   # 红
          '#06A77D',   # 绿
          '#F4A261',   # 橙
          '#6C757D']   # 灰

# 用 seaborn 现成的色盲友好色板
import seaborn as sns
colors = sns.color_palette('colorblind')   # 10 色色盲友好

# 检查黑白可读:保存灰度图看一眼
import matplotlib.pyplot as plt
fig.savefig('test_gray.png', dpi=150)
# 用图像查看器看 → 几条线能不能区分?

一个坑:以为"颜色饱和度高 = 醒目 = 好"。高饱和色看屏幕舒服,打印出来反而刺眼且色盲不友好。冷静的蓝/红/绿/橙四色组合永远不会错。

💡 深入:色板设计原理、Nature/Science 期刊的官方配色 → 见附录 A


§ 3.3 线型、标记、线宽与区域填充

⏱️ 3 分钟

这节学啥:折线图四种关键视觉参数的选择。

为什么:第 2.4 节说过"三重区分",这节给具体规格。论文图线宽 1.5~2.5 最佳,太细黑白打印看不清,太粗显得业余。marker 优选 o/s/^/D 四种——区分度最高。填充区域 fill_between 是画置信区间/误差带的标配。

代码

import matplotlib.pyplot as plt
import numpy as np

x = np.linspace(0, 10, 50)
mean = np.sin(x)
std = 0.2

fig, ax = plt.subplots(figsize=(7, 4))

# 主曲线
ax.plot(x, mean, color='#2E86AB', linewidth=2.0,
        linestyle='-', marker='o', markevery=5, markersize=6,
        label='Mean')

# 置信区间(最常用的 fill_between)
ax.fill_between(x, mean - std, mean + std,
                color='#2E86AB', alpha=0.2, label='±1 std')

ax.legend(); plt.show()

一个坑linewidth < 1.0 黑白打印就消失了,论文里最低也要 1.2


§ 3.4 中文显示与字体配置

⏱️ 3 分钟

这节学啥:解决"中文标题变方框 ☐☐☐" 的问题。

为什么:Matplotlib 默认字体不含中文。这是中文用户的头号坑,几乎每个新人都中招。解法是显式指定中文字体,并关掉 minus 符号的特殊处理(否则负号会变方框)。

代码(放在脚本最前面,全局生效):

import matplotlib.pyplot as plt

# Windows
plt.rcParams['font.sans-serif'] = ['SimHei']        # 黑体

# Mac
plt.rcParams['font.sans-serif'] = ['Arial Unicode MS']

# Linux(先安装:sudo apt install fonts-wqy-zenhei)
plt.rcParams['font.sans-serif'] = ['WenQuanYi Zen Hei']

# 通用:让 minus 正常显示
plt.rcParams['axes.unicode_minus'] = False

# 验证
fig, ax = plt.subplots()
ax.plot([1, 2, 3], [-1, -2, -3])
ax.set_title('中文标题 测试 -1')
plt.show()

一个坑:改了 rcParams 后当前已有的 figure 不会自动更新。要重新执行画图代码才生效。


§ 3.5 rcParams 与样式表

⏱️ 2 分钟

这节学啥:一次性设置全局画图风格。

为什么:每次画图都写一堆 set_xlabel set_fontsize 重复又啰嗦。用 rcParams 设全局默认值,整本论文风格统一。Matplotlib 自带样式表(如 seaborn-v0_8ggplot),一行切换风格。

代码

import matplotlib.pyplot as plt

# 方式 1:手动设全局
plt.rcParams['figure.figsize'] = (7, 4.5)
plt.rcParams['axes.labelsize'] = 12
plt.rcParams['axes.titlesize'] = 13
plt.rcParams['lines.linewidth'] = 2.0
plt.rcParams['axes.grid'] = True
plt.rcParams['grid.alpha'] = 0.3

# 方式 2:用预设样式(瞬间切换风格)
plt.style.use('seaborn-v0_8-whitegrid')   # 推荐:清爽
# plt.style.use('ggplot')                   # 备选:R 风格
# plt.style.use('default')                  # 还原默认

# 查看所有可用样式
print(plt.style.available)

一个坑:在 Jupyter 里一旦 plt.style.use() 切换,对后续所有 cell 生效,不是只对当前 cell。要还原用 plt.style.use('default')


§ 3.6 子图布局:subplots 与 GridSpec

⏱️ 4 分钟

这节学啥:画多子图,并能精细控制每个子图的位置和大小。

为什么:论文图常是"一张大图 + 多个子图 (a)(b)(c)(d)"。简单网格用 plt.subplots()子图大小不一时用 GridSpec——可以让某个子图跨多行多列,做出 Nature 风格的精致排版。

代码

import matplotlib.pyplot as plt

# 简单:2×2 等大网格
fig, axes = plt.subplots(2, 2, figsize=(10, 7))
axes[0, 0].plot([1, 2, 3])
axes[1, 1].scatter([1, 2], [3, 4])
plt.tight_layout()

# 进阶:GridSpec 不等大网格
from matplotlib.gridspec import GridSpec
fig = plt.figure(figsize=(10, 6))
gs = GridSpec(2, 3, figure=fig)

ax1 = fig.add_subplot(gs[0, :])      # 第 0 行跨 3 列(大图)
ax2 = fig.add_subplot(gs[1, 0])      # 左下
ax3 = fig.add_subplot(gs[1, 1])      # 中下
ax4 = fig.add_subplot(gs[1, 2])      # 右下

ax1.set_title('a) Big plot on top')
ax2.set_title('b)')
ax3.set_title('c)')
ax4.set_title('d)')
plt.tight_layout()
plt.show()

一个坑:子图标题/标签互相重叠是高频问题。永远在最后加 plt.tight_layout()fig.tight_layout() 让 Matplotlib 自动调整间距。


第 4 章 进阶图表

第 2 章学了基础 5 种图。这一章加 5 种科研里常用但稍进阶的图:误差棒、等高线、伪彩色、时间序列、双 y 轴。


§ 4.1 误差棒图 errorbar()

⏱️ 3 分钟

这节学啥:在数据点上加误差棒,表示不确定度。

为什么带误差棒的图才有科研感。实验有重复就有标准差,理论拟合有置信区间——这些不画出来,审稿人第一个问题就是"误差呢?"。errorbar() 是 plot 的升级版,多一个 yerr 参数。

代码

import matplotlib.pyplot as plt
import numpy as np

rng = np.random.default_rng(42)
x = np.arange(1, 11)
y = np.log(x)
yerr = 0.1 + 0.05 * x   # 误差随 x 增大

fig, ax = plt.subplots(figsize=(7, 4))
ax.errorbar(x, y, yerr=yerr,
            fmt='o-',                # 数据点 + 连线
            color='#2E86AB',
            ecolor='gray',           # 误差棒颜色
            elinewidth=1.5,
            capsize=4,               # 误差棒"帽子"长度
            capthick=1.5,
            label='Experiment')
ax.set_xlabel('x'); ax.set_ylabel('y'); ax.legend()
plt.show()

一个坑yerr 也可以是不对称误差——传 yerr=[lower_err, upper_err](两个等长数组)。这对置信区间不对称的数据(如计数数据)必须这么做。


§ 4.2 等高线图 contour 与 contourf

⏱️ 3 分钟

这节学啥:把二维函数 z = f(x, y) 画成等高线或色块。

为什么:等高线图是表达二维标量场的标配——气象的气压场、材料的能量面、机器学习的损失函数地形,都用它。contour 画线,contourf 填色,两者叠加(先 contourf 再 contour)效果最好。

代码

import matplotlib.pyplot as plt
import numpy as np

# 准备 2D 网格数据
x = np.linspace(-3, 3, 100)
y = np.linspace(-3, 3, 100)
X, Y = np.meshgrid(x, y)
Z = np.exp(-(X**2 + Y**2) / 4) * np.cos(2*X) * np.sin(2*Y)

fig, ax = plt.subplots(figsize=(6, 5))

# 填色 + 等高线叠加(最常用的科研画法)
cf = ax.contourf(X, Y, Z, levels=20, cmap='RdBu_r')
ax.contour(X, Y, Z, levels=10, colors='black', linewidths=0.5, alpha=0.5)

fig.colorbar(cf, ax=ax, label='Z value')
ax.set_xlabel('X'); ax.set_ylabel('Y')
plt.show()

一个坑:忘记用 np.meshgrid 生成二维网格。直接传 1D 的 x、y 会报错——contour 要的是矩阵形式的 X、Y、Z。


§ 4.3 伪彩色图 pcolormesh / imshow

⏱️ 3 分钟

这节学啥:把矩阵数据画成颜色矩阵(热力图)。

为什么imshow 适合图像、pcolormesh 适合不规则网格数据。它们和 contour 的区别:等高线是"光滑插值",伪彩色是"忠实显示每个格子"。展示原始数据用伪彩色,展示连续场用等高线。

代码

import matplotlib.pyplot as plt
import numpy as np

data = np.random.randn(20, 30)

fig, axes = plt.subplots(1, 2, figsize=(11, 4))

# imshow:默认 y 轴反向(图像坐标系),用 origin='lower' 改回数学坐标系
im1 = axes[0].imshow(data, cmap='viridis', origin='lower', aspect='auto')
axes[0].set_title('imshow')
fig.colorbar(im1, ax=axes[0])

# pcolormesh:自动配合坐标值
x = np.arange(31); y = np.arange(21)
im2 = axes[1].pcolormesh(x, y, data, cmap='viridis', shading='auto')
axes[1].set_title('pcolormesh')
fig.colorbar(im2, ax=axes[1])

plt.show()

一个坑imshow 默认 origin='upper'——y 轴朝下(图像惯例)。画数据矩阵时必须加 origin='lower',否则上下颠倒。


§ 4.4 时间序列绘图与日期刻度

⏱️ 3 分钟

这节学啥:x 轴是日期时如何正确绘图与格式化。

为什么:日期 x 轴有自己的坑——刻度密密麻麻、文字重叠、格式不统一。Matplotlib 自带日期定位器和格式化器,搞清楚 3 个关键 API(mdates.MonthLocatormdates.DateFormatterfig.autofmt_xdate)就能搞定 90% 场景。

代码

import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import pandas as pd
import numpy as np

# 一年的日数据
dates = pd.date_range('2024-01-01', periods=365)
values = np.cumsum(np.random.randn(365))

fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(dates, values, color='#2E86AB', linewidth=1.5)

# 关键 3 行:定位器 + 格式化器 + 旋转标签
ax.xaxis.set_major_locator(mdates.MonthLocator())          # 每月一个刻度
ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m')) # 显示成 YYYY-MM
fig.autofmt_xdate()                                          # 自动旋转防重叠

ax.set_xlabel('Date'); ax.set_ylabel('Value')
plt.show()

一个坑:直接 df.plot() 时 Pandas 会自动处理日期格式,但用 ax.plot() 不会。日期轴出问题时第一反应:显式加 locator 和 formatter


§ 4.5 双 y 轴与共享坐标

⏱️ 3 分钟

这节学啥:两条数据量级差很大时,用双 y 轴画到一张图。

为什么:温度和降水量、价格和成交量——量级差 100 倍的两组数据画一张图,普通画法会让小的那条变直线。ax.twinx() 在同一 figure 上叠加第二个 y 轴。但慎用:双 y 轴容易误导读者,能用两个子图就用两个子图。

代码

import matplotlib.pyplot as plt
import numpy as np

x = np.arange(1, 13)
temp = 15 + 10 * np.sin((x - 4) * np.pi / 6)  # 温度 5~25 °C
rain = np.array([50, 60, 80, 100, 150, 200, 250, 220, 150, 100, 70, 50])  # 降水 mm

fig, ax1 = plt.subplots(figsize=(8, 4.5))

# 左 y 轴:温度
l1 = ax1.plot(x, temp, 'o-', color='#E63946', linewidth=2, label='Temperature')
ax1.set_xlabel('Month')
ax1.set_ylabel('Temperature (°C)', color='#E63946')
ax1.tick_params(axis='y', labelcolor='#E63946')

# 右 y 轴:降水
ax2 = ax1.twinx()
l2 = ax2.bar(x, rain, color='#2E86AB', alpha=0.4, label='Rainfall')
ax2.set_ylabel('Rainfall (mm)', color='#2E86AB')
ax2.tick_params(axis='y', labelcolor='#2E86AB')

# 合并图例
ax1.legend(handles=[l1[0], l2], loc='upper left')
plt.show()

一个坑:双 y 轴的图例默认只显示一个轴的。要合并图例,需要手动收集 handles 再统一 legend()


第 5 章 Seaborn 统计可视化

Seaborn 建立在 Matplotlib 之上,专门简化统计图。学完本章,常见统计图基本一行代码搞定。


§ 5.1 Seaborn 设计哲学与主题

⏱️ 3 分钟

这节学啥:Seaborn 是什么、和 Matplotlib 关系、怎么设全局主题。

为什么:Seaborn 不是替代 Matplotlib,而是给 Matplotlib 加了一层"统计图快捷方式"。它的优势:(1) 一行画出复杂统计图;(2) 自带漂亮主题;(3) 直接接受 DataFrame 用列名指定。

代码

import seaborn as sns
import matplotlib.pyplot as plt

# 一句话设主题(影响全局)
sns.set_theme(
    style='whitegrid',          # 'white' / 'darkgrid' / 'ticks' 等
    palette='colorblind',       # 色盲友好色板
    font_scale=1.1              # 全局字号放大 1.1 倍
)

# 加载内置数据集(练手用)
tips = sns.load_dataset('tips')
print(tips.head())

一个坑:调用 sns.set_theme()修改 Matplotlib 全局 rcParams。如果同一个 Notebook 后面又用纯 Matplotlib 画图,风格会被 Seaborn 主题污染。要还原用 sns.reset_defaults()


§ 5.2 分布图:histplot / kdeplot

⏱️ 3 分钟

这节学啥:用 Seaborn 画直方图和核密度估计图。

为什么:Matplotlib 的 hist 比较糙,Seaborn 的 histplot 自带 KDE 叠加、按类别分组着色等高级功能——一行代码画出"按性别分组的小费分布" 这种探索性图。

代码

import seaborn as sns
import matplotlib.pyplot as plt

tips = sns.load_dataset('tips')

fig, axes = plt.subplots(1, 2, figsize=(11, 4))

# histplot:直方图 + KDE 叠加
sns.histplot(data=tips, x='total_bill', kde=True,
             color='#2E86AB', ax=axes[0])
axes[0].set_title('Histogram with KDE')

# kdeplot:按类别分组的密度估计
sns.kdeplot(data=tips, x='total_bill', hue='sex',
            fill=True, alpha=0.4, ax=axes[1])
axes[1].set_title('KDE grouped by sex')

plt.tight_layout()
plt.show()

一个坑hue='sex' 自动按性别分组着色——这是 Seaborn 最香的特性。但用 hue 前必须确保该列是分类型,连续值传给 hue 会生成几十种颜色,乱成一团。


§ 5.3 多变量探索:jointplot 与 pairplot

⏱️ 3 分钟

这节学啥:一行画出两变量联合分布或多变量两两关系。

为什么:拿到新数据集第一件事是看变量两两之间的关系pairplot 一行画出"所有特征 vs 所有特征"的矩阵,对角线是分布,非对角是散点——探索性数据分析的核武器

代码

import seaborn as sns

iris = sns.load_dataset('iris')

# jointplot:两变量联合分布(散点 + 边缘直方图)
sns.jointplot(data=iris, x='sepal_length', y='petal_length',
              hue='species', kind='scatter', height=5)

# pairplot:所有数值列两两关系(对角线是分布)
sns.pairplot(iris, hue='species', diag_kind='kde',
             palette='colorblind', height=2.0)

一个坑pairplot 对 20+ 列的数据会爆炸(20×20=400 个子图)。先用 df.select_dtypes()vars=['col1','col2',...] 参数挑出最关心的几列再画。


§ 5.4 分类分布:boxplot / violinplot

⏱️ 3 分钟

这节学啥:Seaborn 版的箱线图和小提琴图,比 Matplotlib 简洁太多。

为什么:箱线图只显示分位数信息——但分布形状(双峰、长尾)就丢失了。小提琴图把箱线图和 KDE 合二为一,既看四分位又看形状。多组对比时优先用小提琴图。

代码

import seaborn as sns
import matplotlib.pyplot as plt

tips = sns.load_dataset('tips')

fig, axes = plt.subplots(1, 2, figsize=(11, 4))

# boxplot
sns.boxplot(data=tips, x='day', y='total_bill', hue='sex',
            palette='colorblind', ax=axes[0])
axes[0].set_title('Boxplot')

# violinplot:split=True 让两组共享中线(节省空间)
sns.violinplot(data=tips, x='day', y='total_bill', hue='sex',
               split=True, inner='quartile',
               palette='colorblind', ax=axes[1])
axes[1].set_title('Violinplot (split)')

plt.tight_layout()
plt.show()

一个坑:小提琴图在样本量小(< 30)时不可靠——KDE 在数据稀疏时会"幻想"出根本不存在的分布形状。小样本老老实实用箱线图或散点。


§ 5.5 回归图:lmplot / regplot

⏱️ 3 分钟

这节学啥:散点图 + 线性回归拟合 + 置信区间,一行搞定。

为什么:科研里画完散点经常要"加一条拟合线"。Matplotlib 要自己算回归再画——Seaborn 的 regplot 一行包办:拟合 + 拟合线 + 95% 置信带。lmplotregplot 的分面版,可以按类别画多个面板。

代码

import seaborn as sns
import matplotlib.pyplot as plt

tips = sns.load_dataset('tips')

fig, axes = plt.subplots(1, 2, figsize=(11, 4))

# regplot:单图回归
sns.regplot(data=tips, x='total_bill', y='tip',
            scatter_kws={'alpha': 0.5, 's': 30},
            line_kws={'color': '#E63946'},
            ax=axes[0])
axes[0].set_title('Linear regression')

# 二次拟合(order=2)
sns.regplot(data=tips, x='total_bill', y='tip', order=2,
            scatter_kws={'alpha': 0.5},
            line_kws={'color': '#06A77D'},
            ax=axes[1])
axes[1].set_title('Quadratic fit')

plt.tight_layout()
plt.show()

一个坑lmplot 是 figure-level 函数(创建自己的 figure),不能传 ax=。要嵌入子图必须用 regplot


§ 5.6 热力图 heatmap 与聚类热图

⏱️ 3 分钟

这节学啥:画相关矩阵热力图,以及带聚类树的高级热图。

为什么heatmap 是科研里展示相关矩阵、混淆矩阵、基因表达最常用的图。clustermap 升级版:自动行列聚类并画树状图——一张图同时展示数据 + 分组结构,生物信息学高频用。

代码

import seaborn as sns
import matplotlib.pyplot as plt

iris = sns.load_dataset('iris').select_dtypes('number')
corr = iris.corr()

# 1. 基础热力图:相关矩阵
fig, ax = plt.subplots(figsize=(6, 5))
sns.heatmap(corr, annot=True, fmt='.2f',
            cmap='RdBu_r', center=0,
            vmin=-1, vmax=1, square=True,
            cbar_kws={'shrink': 0.8}, ax=ax)
ax.set_title('Correlation Matrix')
plt.show()

# 2. 聚类热图:自动行列聚类
sns.clustermap(iris, cmap='viridis',
               standard_scale=1,    # 按列标准化
               figsize=(8, 6))

一个坑:相关矩阵热图忘记center=0cmap='RdBu_r'——结果颜色不对称,正负相关看起来差不多。相关矩阵永远用发散型 colormap + center=0。


第 6 章 其他可视化库

Matplotlib 和 Seaborn 是主力。这一章介绍两个"小而精"的辅助库,特定场景下能省大量功夫。


§ 6.1 scienceplots:期刊论文样式

⏱️ 3 分钟

这节学啥:一行代码切换成 Nature / IEEE / Science 期刊风格。

为什么:每个期刊对图有自己的规范——字号、线宽、配色、单双栏宽度。scienceplots 把这些预设成样式表plt.style.use(['science', 'nature']) 一行直接套用。投稿前把样式从 'science' 改成对应期刊就行。

代码

# 安装:pip install scienceplots
import scienceplots
import matplotlib.pyplot as plt
import numpy as np

# 切换到 science 样式(Nature 风格 + 黑白可读)
plt.style.use(['science', 'nature', 'no-latex'])
# 可选样式叠加:'ieee', 'grid', 'high-vis', 'bright', 'dark_background'

x = np.linspace(0, 10, 100)

fig, ax = plt.subplots(figsize=(3.5, 2.5))  # Nature 单栏宽度
ax.plot(x, np.sin(x), label=r'$\sin(x)$')
ax.plot(x, np.cos(x), label=r'$\cos(x)$')
ax.set_xlabel('x'); ax.set_ylabel('y'); ax.legend()
plt.show()

一个坑:完整 scienceplots 要装 LaTeX(如 MiKTeX),否则报错。临时方案:加 'no-latex' 样式,禁用 LaTeX 渲染但保留风格。投稿前再装 LaTeX 渲染数学公式。


§ 6.2 missingno:缺失值可视化

⏱️ 3 分钟

这节学啥:用图形看 DataFrame 的缺失值模式。

为什么:拿到新数据集,第一件事是了解缺失情况。df.isna().sum() 给数字,但看不出"哪些列同时缺失"这种模式。missingno 用三种图(matrix / bar / heatmap)把缺失模式可视化,一眼看出问题。

代码

# 安装:pip install missingno
import missingno as msno
import pandas as pd
import numpy as np

# 构造一个有缺失的数据集
rng = np.random.default_rng(42)
df = pd.DataFrame({
    'A': rng.normal(0, 1, 100),
    'B': rng.normal(0, 1, 100),
    'C': rng.normal(0, 1, 100),
    'D': rng.normal(0, 1, 100),
})
# 人为制造缺失
df.loc[rng.choice(100, 20), 'A'] = np.nan
df.loc[rng.choice(100, 30), 'B'] = np.nan
df.loc[rng.choice(100, 10), 'C'] = np.nan

# 三种视角
msno.matrix(df)      # 矩阵图:每列每行哪里缺
msno.bar(df)         # 条形图:每列缺多少
msno.heatmap(df)     # 热力图:列之间的缺失相关性

一个坑msno.heatmap 在所有列完全无缺失完全缺失时无法计算(除数为 0),会显示一片灰白。先用 bar 看完缺失情况再决定是否需要 heatmap。


第 7 章 地理空间可视化

地理数据有自己的"语言"——投影、坐标系、shapefile。本章教 Cartopy 和 Geopandas 两个主流库的基础用法。


§ 7.1 Cartopy 基础:地图投影与海岸线

⏱️ 4 分钟

这节学啥:用 Cartopy 画一张带海岸线的世界地图。

为什么:地球是球,纸是平的——投影 (projection) 决定地图怎么画。不同投影各有取舍:等距圆柱(PlateCarree)保留纬度但极地变形;墨卡托保形但极地面积爆炸;Robinson 美观平衡。做全球图首选 Robinson 或 PlateCarree

代码

# 安装:conda install -c conda-forge cartopy
import cartopy.crs as ccrs
import cartopy.feature as cfeature
import matplotlib.pyplot as plt

fig = plt.figure(figsize=(10, 5))

# 关键:subplot 时指定 projection
ax = fig.add_subplot(1, 1, 1, projection=ccrs.Robinson())

# 加地图要素
ax.add_feature(cfeature.COASTLINE, linewidth=0.5)
ax.add_feature(cfeature.BORDERS,   linewidth=0.3, linestyle=':')
ax.add_feature(cfeature.OCEAN,     facecolor='#A8D8EA')
ax.add_feature(cfeature.LAND,      facecolor='#F4F1DE')

ax.set_global()  # 显示全球
ax.gridlines(draw_labels=True, alpha=0.3)
plt.show()

一个坑:在 Cartopy 上 plot 数据点时必须指定 transform=ccrs.PlateCarree()——告诉 Cartopy "我的经纬度数据是这种坐标系",然后它再投影到目标投影。忘加 transform 点会画错位置。


§ 7.2 等值线与散点在地图上的绘制

⏱️ 4 分钟

这节学啥:把数据(散点或网格)叠加到地图上。

为什么:地理可视化的核心需求——"这个数据在哪里,多大"。气象数据是 2D 网格,画等值线;观测站点是离散点,画散点。两者的关键是同一句魔咒:transform=ccrs.PlateCarree()

代码

import cartopy.crs as ccrs
import cartopy.feature as cfeature
import matplotlib.pyplot as plt
import numpy as np

# 模拟全球温度场
lon = np.linspace(-180, 180, 73)
lat = np.linspace(-90, 90, 37)
LON, LAT = np.meshgrid(lon, lat)
T = 30 * np.cos(np.deg2rad(LAT)) - 10  # 简化的温度模型

fig = plt.figure(figsize=(11, 5))
ax = fig.add_subplot(1, 1, 1, projection=ccrs.Robinson())

# 等值线填色(注意 transform)
cf = ax.contourf(LON, LAT, T, levels=15, cmap='RdBu_r',
                  transform=ccrs.PlateCarree())

# 散点:几个城市
cities = {'Beijing': (116, 40), 'NY': (-74, 41), 'Sydney': (151, -34)}
for name, (x, y) in cities.items():
    ax.plot(x, y, 'o', color='black', markersize=6,
            transform=ccrs.PlateCarree())
    ax.text(x+3, y+3, name, transform=ccrs.PlateCarree(), fontsize=9)

ax.add_feature(cfeature.COASTLINE, linewidth=0.5)
fig.colorbar(cf, ax=ax, orientation='horizontal',
             label='Temperature (°C)', shrink=0.6, pad=0.05)
plt.show()

一个坑:colorbar 在 Cartopy 地图上常常位置/大小不对。手动加 shrinkpadorientation 调;或者用 cax 显式指定 colorbar 的轴位置。


§ 7.3 Geopandas:矢量数据与行政区划

⏱️ 3 分钟

这节学啥:用 Geopandas 读 shapefile 并画行政区划地图。

为什么:行政区划数据是矢量 (shapefile),不是栅格。Cartopy 主要处理栅格,Geopandas 专攻矢量。Geopandas 的 GeoDataFrame 是 Pandas DataFrame 加一列 geometry——可以用 Pandas 所有操作,加一行 .plot() 就出地图。

代码

# 安装:conda install -c conda-forge geopandas
import geopandas as gpd
import matplotlib.pyplot as plt

# 读 shapefile(这里用 Geopandas 自带的世界国家数据)
world = gpd.read_file(gpd.datasets.get_path('naturalearth_lowres'))
print(world.head())
print(world.columns)  # geometry 列存几何形状

fig, ax = plt.subplots(figsize=(11, 6))

# 按 GDP per capita 着色(choropleth 地图)
world['gdp_per_capita'] = world['gdp_md_est'] / world['pop_est']
world.plot(column='gdp_per_capita', ax=ax,
           cmap='viridis', legend=True,
           legend_kwds={'label': 'GDP per capita',
                        'shrink': 0.6},
           missing_kwds={'color': 'lightgray'})  # 缺失填灰
ax.set_xlabel('Longitude'); ax.set_ylabel('Latitude')
plt.show()

一个坑:Geopandas 新版本(>= 0.13)移除了内置数据集。如果 gpd.datasets.get_path 报错,需要自己下载 Natural Earth shapefile。


§ 7.4 地图装饰:比例尺、指北针、网格

⏱️ 3 分钟

这节学啥:给地图加比例尺、指北针、经纬度网格,达到出版级。

为什么论文里的地图必须有这三件套(至少经纬度网格 + 比例尺)。Cartopy 自带网格 (gridlines),比例尺要装额外包(cartopy-scalebar 或自己画),指北针通常用文字 + 箭头自己画。

代码

import cartopy.crs as ccrs
import cartopy.feature as cfeature
import matplotlib.pyplot as plt
from matplotlib.patches import FancyArrowPatch

fig = plt.figure(figsize=(8, 8))
ax = fig.add_subplot(1, 1, 1, projection=ccrs.PlateCarree())

# 显示中国范围
ax.set_extent([70, 140, 15, 55], crs=ccrs.PlateCarree())

ax.add_feature(cfeature.COASTLINE, linewidth=0.5)
ax.add_feature(cfeature.BORDERS,   linewidth=0.5)

# 1. 经纬度网格(自带标签)
gl = ax.gridlines(draw_labels=True, alpha=0.3,
                  linewidth=0.5, linestyle='--')
gl.top_labels = False; gl.right_labels = False

# 2. 指北针(简化版)
ax.annotate('N', xy=(0.95, 0.95), xytext=(0.95, 0.88),
            xycoords='axes fraction', ha='center', fontsize=14,
            fontweight='bold',
            arrowprops=dict(arrowstyle='-|>', color='black'))

# 3. 比例尺(手画一条横线 + 文字)
ax.plot([75, 85], [18, 18], 'k-', linewidth=2,
        transform=ccrs.PlateCarree())
ax.text(80, 19, '~1000 km', ha='center', fontsize=9,
        transform=ccrs.PlateCarree())

plt.show()

一个坑ax.set_extent必须传 crs=ccrs.PlateCarree()——否则 Cartopy 不知道你给的经纬度是哪种坐标系,可能把中国画到太平洋去。


第 8 章 交互式可视化

论文用静态图,但报告、网页、dashboard 需要交互。Plotly 是最主流的选择。


§ 8.1 plotly.express 快速上手

⏱️ 3 分钟

这节学啥:用 plotly.express(简称 px)一行画出交互图。

为什么:Plotly 有两层 API。plotly.express 是高层封装——一行代码、自带交互(悬停、缩放、筛选)。和 Seaborn 类似但输出是交互式 HTML。90% 的快速可视化需求用 px 就够

代码

import plotly.express as px

# 用内置数据集
df = px.data.iris()

# 散点图(一行)
fig = px.scatter(df, x='sepal_width', y='sepal_length',
                  color='species', size='petal_length',
                  hover_data=['petal_width'],   # 悬停显示额外列
                  title='Iris Dataset')
fig.show()

# 箱线图按类别分组
fig = px.box(df, x='species', y='sepal_length', color='species')
fig.show()

# 直方图带分组
fig = px.histogram(df, x='sepal_length', color='species',
                    barmode='overlay', opacity=0.7)
fig.show()

一个坑:在 Jupyter 里 fig.show() 默认在 Notebook 里渲染。但在纯 .py 脚本里运行会弹出浏览器——这是 Plotly 的特性,不是 bug。脚本里要保存图就用 fig.write_html('out.html')


§ 8.2 plotly.graph_objects 精细控制

⏱️ 4 分钟

这节学啥:用 plotly.graph_objects(简称 go)做需要精细定制的图。

为什么px 简洁但不够灵活。多 y 轴、复杂自定义样式、组合不同类型图,必须用 go。两者关系:px 内部就是基于 go 的封装——px 不够用时降一层到 go

代码

import plotly.graph_objects as go
import numpy as np

x = np.linspace(0, 10, 100)

fig = go.Figure()

# 加迹线
fig.add_trace(go.Scatter(x=x, y=np.sin(x), mode='lines',
                          name='sin', line=dict(color='#2E86AB', width=2)))
fig.add_trace(go.Scatter(x=x, y=np.cos(x), mode='lines+markers',
                          name='cos', line=dict(color='#E63946', width=2, dash='dash'),
                          marker=dict(size=4)))

# 布局
fig.update_layout(
    title='Sin and Cos',
    xaxis_title='x',
    yaxis_title='y',
    template='plotly_white',     # 主题:'plotly' / 'plotly_white' / 'plotly_dark'
    hovermode='x unified',       # 悬停时横向对齐显示所有迹线
    width=800, height=450
)

fig.show()

一个坑go.Figure 的层级是 data (一堆 trace) + layout (一堆设置)。改样式要分清是改 trace 还是改 layout——fig.update_traces(line=dict(width=3)) 改所有迹线,fig.update_layout(title='...') 改整体。两个 update 混用是新手最常翻车的地方。


§ 8.3 多子图与导出 HTML

⏱️ 4 分钟

这节学啥:Plotly 画多子图,以及把交互图保存成 HTML 嵌入网页。

为什么:仪表盘需要"一页多图"——Plotly 用 make_subplots 实现。保存成 HTML 后可以直接发邮件、嵌入网页、放进文档——接收方不用装任何东西,浏览器打开就能交互。这是 Plotly 相比 Matplotlib 最大的优势之一。

代码

import plotly.graph_objects as go
from plotly.subplots import make_subplots
import numpy as np

x = np.linspace(0, 10, 100)

# 2×2 子图布局
fig = make_subplots(rows=2, cols=2,
                     subplot_titles=('Sin', 'Cos', 'Tan', 'Random'),
                     vertical_spacing=0.15)

fig.add_trace(go.Scatter(x=x, y=np.sin(x), name='sin'), row=1, col=1)
fig.add_trace(go.Scatter(x=x, y=np.cos(x), name='cos'), row=1, col=2)
fig.add_trace(go.Scatter(x=x, y=np.tan(x), name='tan'), row=2, col=1)
fig.add_trace(go.Scatter(x=x, y=np.random.randn(100).cumsum(),
                          name='walk'), row=2, col=2)

fig.update_layout(height=600, width=900,
                   title_text='Subplots Demo',
                   template='plotly_white')

# 导出
fig.write_html('dashboard.html')               # 完整 HTML(含 Plotly.js,体积大)
fig.write_html('dashboard.html', include_plotlyjs='cdn')  # 用 CDN(体积小)

fig.show()

一个坑:默认 write_html 输出文件 3MB+(包含整个 Plotly.js 库)。邮件附件或嵌入网页时加 include_plotlyjs='cdn',文件秒变 50KB——但接收方需要联网。


第 9 章 专业场景可视化

这一章介绍 5 类"特殊但常用"的图:网络图、桑基图、降维可视化、火山图、曼哈顿图。


§ 9.1 网络图 NetworkX 基础

⏱️ 4 分钟

这节学啥:用 NetworkX 创建网络(图)数据结构并可视化。

为什么:社交网络、引文网络、知识图谱、蛋白质相互作用——本质都是"节点 + 边"。NetworkX 是 Python 处理网络数据的标配库,nx.draw() 一行画出网络,更多控制用底层 API。

代码

# 安装:conda install networkx
import networkx as nx
import matplotlib.pyplot as plt

# 创建图
G = nx.Graph()
G.add_edges_from([
    ('A', 'B'), ('A', 'C'), ('B', 'C'), ('B', 'D'),
    ('C', 'D'), ('D', 'E'), ('E', 'F'), ('F', 'A')
])

# 节点位置(布局算法决定美观度)
pos = nx.spring_layout(G, seed=42)   # 弹簧布局,最常用

fig, ax = plt.subplots(figsize=(7, 5))

# 分层画:边 → 节点 → 标签
nx.draw_networkx_edges(G, pos, alpha=0.5, width=1.5)
nx.draw_networkx_nodes(G, pos, node_size=800,
                        node_color='#2E86AB', alpha=0.9)
nx.draw_networkx_labels(G, pos, font_color='white',
                         font_weight='bold')

ax.set_axis_off()
plt.show()

一个坑:节点超过 100 个时 spring_layout 极慢——是 O(n²) 算法。大网络用 nx.kamada_kawai_layout 或先降到子图。


§ 9.2 桑基图 Sankey

⏱️ 3 分钟

这节学啥:用桑基图表达"流动"或"分配"关系。

为什么:桑基图最擅长表达"A 类的 60% 流向 B、40% 流向 C" 这种分配关系——能量流、人口流、预算分配都用它。Plotly 的 Sankey 比 Matplotlib 自带的好用太多,首选 Plotly 实现

代码

import plotly.graph_objects as go

fig = go.Figure(data=[go.Sankey(
    node = dict(
        pad=15, thickness=20,
        line=dict(color='black', width=0.5),
        label=['Energy', 'Coal', 'Gas', 'Solar',
               'Electricity', 'Loss'],
        color='#2E86AB'
    ),
    link = dict(
        # 第 0 个节点 → 第 1/2/3 个节点
        source = [0, 0, 0, 1, 2, 3],
        target = [1, 2, 3, 4, 4, 4],
        value  = [40, 30, 30, 30, 25, 28],   # 流量大小
    )
)])

fig.update_layout(title='Energy Flow', font_size=12)
fig.show()

一个坑:source/target 用的是节点的整数索引(按 label 列表顺序),不是名字字符串。新手最常错——直接传名字会报错或画错。


§ 9.3 降维可视化:PCA / t-SNE / UMAP

⏱️ 4 分钟

这节学啥:把高维数据降到 2D 后用散点图可视化。

为什么:有 50 维的样本想看"是否分簇"——必须降维。三种方法的取舍:PCA 快、线性、保留全局结构;t-SNE 慢、非线性、擅长局部分簇;UMAP 平衡了速度和质量,现在多数人首选 UMAP

代码

from sklearn.datasets import load_digits
from sklearn.decomposition import PCA
from sklearn.manifold import TSNE
import matplotlib.pyplot as plt
import numpy as np

# 加载手写数字(64 维)
digits = load_digits()
X, y = digits.data, digits.target

# 三种降维
X_pca  = PCA(n_components=2).fit_transform(X)
X_tsne = TSNE(n_components=2, random_state=42).fit_transform(X)

# 可视化
fig, axes = plt.subplots(1, 2, figsize=(13, 5))

for ax, X_emb, title in zip(axes, [X_pca, X_tsne], ['PCA', 't-SNE']):
    sc = ax.scatter(X_emb[:, 0], X_emb[:, 1], c=y,
                    cmap='tab10', s=10, alpha=0.7)
    ax.set_title(title)
    ax.set_xlabel('Dim 1'); ax.set_ylabel('Dim 2')
fig.colorbar(sc, ax=axes, label='Digit', ticks=range(10))
plt.show()

# UMAP(pip install umap-learn 后)
# import umap
# X_umap = umap.UMAP(random_state=42).fit_transform(X)

一个坑t-SNE 的距离没有物理意义——两簇之间挨得近 ≠ 真的相似。t-SNE 只保证"局部邻居"对,"簇间距离"完全不可信。这是审稿人最常质疑的点。


§ 9.4 火山图(差异表达)

⏱️ 3 分钟

这节学啥:画基因差异表达的火山图。

为什么:生物信息学差异分析的标准图——x 轴是 log2 倍数变化,y 轴是 -log10(p 值)。形状像火山:左右"喷发"的点 = 显著变化的基因。颜色编码:上调红、下调蓝、不显著灰。

代码

import matplotlib.pyplot as plt
import numpy as np

rng = np.random.default_rng(42)
n = 2000

# 模拟差异表达数据
log2fc = rng.normal(0, 1.5, n)
pval = 10 ** -np.abs(rng.normal(0, 1.5, n))

# 阈值
fc_thresh, p_thresh = 1, 0.05

# 分组
neg_log_p = -np.log10(pval)
up    = (log2fc >  fc_thresh) & (pval < p_thresh)
down  = (log2fc < -fc_thresh) & (pval < p_thresh)
notsig = ~(up | down)

fig, ax = plt.subplots(figsize=(7, 6))
ax.scatter(log2fc[notsig], neg_log_p[notsig], c='lightgray', s=8, alpha=0.5, label='NS')
ax.scatter(log2fc[up],     neg_log_p[up],     c='#E63946', s=12, alpha=0.7, label='Up')
ax.scatter(log2fc[down],   neg_log_p[down],   c='#2E86AB', s=12, alpha=0.7, label='Down')

# 阈值参考线
ax.axhline(-np.log10(p_thresh), color='gray', linestyle='--', linewidth=0.8)
ax.axvline( fc_thresh, color='gray', linestyle='--', linewidth=0.8)
ax.axvline(-fc_thresh, color='gray', linestyle='--', linewidth=0.8)

ax.set_xlabel(r'$\log_2$(Fold Change)')
ax.set_ylabel(r'$-\log_{10}$(p-value)')
ax.legend()
plt.show()

一个坑:p 值非常小时(如 1e-300)会变成 0,-np.log10(0) = inf 把图撑爆。给 p 值设最小下限pval = np.maximum(pval, 1e-300)


§ 9.5 曼哈顿图(GWAS)

⏱️ 3 分钟

这节学啥:画全基因组关联分析(GWAS)的曼哈顿图。

为什么:GWAS 的核心结果——横轴沿染色体位置排列所有 SNP,纵轴是 -log10(p)。看起来像曼哈顿天际线,因此得名。关键技巧:相邻染色体交替颜色(蓝-灰-蓝-灰)便于区分。

代码

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd

rng = np.random.default_rng(42)

# 模拟 GWAS 数据:22 条染色体,每条 1000 SNP
chroms = np.repeat(range(1, 23), 1000)
positions = np.tile(np.arange(1000), 22)
pvals = rng.uniform(1e-8, 1, 22000)

df = pd.DataFrame({'chr': chroms, 'pos': positions, 'pval': pvals})

# 累计位置(让 x 轴连续)
df['cum_pos'] = 0
offset = 0
chr_centers = []   # 每条染色体中心点(放标签用)
for c in range(1, 23):
    mask = df['chr'] == c
    df.loc[mask, 'cum_pos'] = df.loc[mask, 'pos'] + offset
    chr_centers.append(offset + 500)
    offset += 1000

# 画图
fig, ax = plt.subplots(figsize=(12, 4))
colors = ['#2E86AB', '#888888']
for c in range(1, 23):
    sub = df[df['chr'] == c]
    ax.scatter(sub['cum_pos'], -np.log10(sub['pval']),
               c=colors[c % 2], s=4, alpha=0.7)

# 显著性参考线
ax.axhline(-np.log10(5e-8), color='red', linestyle='--', linewidth=1)

ax.set_xticks(chr_centers)
ax.set_xticklabels(range(1, 23), fontsize=8)
ax.set_xlabel('Chromosome'); ax.set_ylabel(r'$-\log_{10}$(p)')
plt.show()

一个坑:GWAS 显著性阈值是 5×10⁻⁸(全基因组校正后)不是 0.05。论文里画的水平线必须用这个值,否则审稿人质疑。


第 10 章 机器学习可视化

训练完模型不画图等于白训。本章 6 节覆盖机器学习项目从评估到诊断的所有可视化需求。


§ 10.1 混淆矩阵

⏱️ 3 分钟

这节学啥:画分类模型的混淆矩阵。

为什么:混淆矩阵是分类任务第一个该看的图——比 accuracy 信息量大得多。它告诉你"模型把 A 错认成 B 多少次",比单一指标更能定位问题。sklearn + Seaborn 两行搞定。

代码

from sklearn.metrics import confusion_matrix
import seaborn as sns
import matplotlib.pyplot as plt
import numpy as np

# 模拟预测结果
rng = np.random.default_rng(42)
y_true = rng.integers(0, 3, 500)
y_pred = y_true.copy()
flip = rng.choice(500, 80, replace=False)
y_pred[flip] = rng.integers(0, 3, 80)

cm = confusion_matrix(y_true, y_pred)

fig, ax = plt.subplots(figsize=(5.5, 4.5))
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues',
            xticklabels=['Class A', 'Class B', 'Class C'],
            yticklabels=['Class A', 'Class B', 'Class C'],
            cbar=False, ax=ax)
ax.set_xlabel('Predicted'); ax.set_ylabel('Actual')
plt.show()

一个坑:当类别不平衡时,绝对数量的混淆矩阵会误导——大类的对角线数字很大但错误率可能很高。归一化版本更公平:confusion_matrix(y_true, y_pred, normalize='true'),annot 改 fmt='.2%'


§ 10.2 ROC / PR 曲线

⏱️ 3 分钟

这节学啥:画 ROC 曲线和 PR 曲线评估二分类模型。

为什么:单一阈值的指标(如 accuracy)只反映一个点。ROC/PR 曲线扫描所有阈值,展示模型完整能力。AUC 是曲线下面积,是单一总结数字。类别不平衡时优先看 PR 曲线——ROC 在极不平衡下会过度乐观。

代码

from sklearn.metrics import roc_curve, auc, precision_recall_curve
import matplotlib.pyplot as plt
import numpy as np

rng = np.random.default_rng(42)
y_true = rng.integers(0, 2, 500)
y_score = y_true + rng.normal(0, 0.5, 500)  # 模拟模型分数

# 计算
fpr, tpr, _ = roc_curve(y_true, y_score)
precision, recall, _ = precision_recall_curve(y_true, y_score)
roc_auc = auc(fpr, tpr)

fig, axes = plt.subplots(1, 2, figsize=(11, 4.5))

# ROC
axes[0].plot(fpr, tpr, color='#2E86AB', linewidth=2,
              label=f'AUC = {roc_auc:.3f}')
axes[0].plot([0, 1], [0, 1], 'k--', alpha=0.5, label='Random')
axes[0].set_xlabel('False Positive Rate'); axes[0].set_ylabel('True Positive Rate')
axes[0].set_title('ROC Curve'); axes[0].legend()

# PR
axes[1].plot(recall, precision, color='#E63946', linewidth=2)
axes[1].set_xlabel('Recall'); axes[1].set_ylabel('Precision')
axes[1].set_title('Precision-Recall Curve')

plt.tight_layout()
plt.show()

一个坑roc_curve 接受的是概率分数(如 predict_proba[:, 1])不是 0/1 预测标签。直接传 y_pred 会画出一条 3 个点的折线——这是新手最常翻车的地方。


§ 10.3 学习曲线与验证曲线

⏱️ 3 分钟

这节学啥:画训练集与验证集分数随样本量或超参变化的曲线。

为什么学习曲线诊断"数据够不够"——训练验证都低 = 欠拟合;训练高验证低 = 过拟合。验证曲线诊断"超参选哪个"——找验证分数最高的那个。

代码

from sklearn.model_selection import learning_curve
from sklearn.ensemble import RandomForestClassifier
from sklearn.datasets import make_classification
import numpy as np
import matplotlib.pyplot as plt

X, y = make_classification(n_samples=1000, random_state=42)

train_sizes, train_scores, val_scores = learning_curve(
    RandomForestClassifier(n_estimators=50, random_state=42),
    X, y, cv=5,
    train_sizes=np.linspace(0.1, 1.0, 10)
)

# 计算均值与标准差(用于误差带)
train_mean = train_scores.mean(axis=1); train_std = train_scores.std(axis=1)
val_mean   = val_scores.mean(axis=1);   val_std   = val_scores.std(axis=1)

fig, ax = plt.subplots(figsize=(7, 4.5))
ax.plot(train_sizes, train_mean, 'o-', color='#2E86AB', label='Train')
ax.fill_between(train_sizes, train_mean - train_std, train_mean + train_std,
                 alpha=0.2, color='#2E86AB')
ax.plot(train_sizes, val_mean, 's-', color='#E63946', label='Validation')
ax.fill_between(train_sizes, val_mean - val_std, val_mean + val_std,
                 alpha=0.2, color='#E63946')
ax.set_xlabel('Training samples'); ax.set_ylabel('Accuracy')
ax.legend(); plt.show()

一个坑:学习曲线两条线如果一直平行(差距大且都不动),说明加数据没用,要换模型或加特征。如果两条线还在分离,加数据有救。


§ 10.4 特征重要性

⏱️ 3 分钟

这节学啥:画特征重要性条形图,理解模型决策。

为什么:树类模型(Random Forest、XGBoost)训练后会输出每个特征的重要性。画成水平条形图(按重要性降序)是黄金画法——比饼图清晰,比表格直观。论文里"模型在意什么"的标准展示方式。

代码

from sklearn.ensemble import RandomForestClassifier
from sklearn.datasets import make_classification
import matplotlib.pyplot as plt
import numpy as np

X, y = make_classification(n_samples=500, n_features=10, random_state=42)
feature_names = [f'feat_{i}' for i in range(10)]

model = RandomForestClassifier(n_estimators=100, random_state=42).fit(X, y)

# 按重要性排序
importances = model.feature_importances_
idx = np.argsort(importances)   # 升序,从小到大

fig, ax = plt.subplots(figsize=(7, 5))
ax.barh(range(len(idx)), importances[idx], color='#2E86AB')
ax.set_yticks(range(len(idx)))
ax.set_yticklabels([feature_names[i] for i in idx])
ax.set_xlabel('Importance')
ax.set_title('Feature Importance')
plt.tight_layout(); plt.show()

一个坑feature_importances_特征间相关性高时不可靠——重要性会被随机分给相关特征。这时改用 permutation importance(sklearn.inspection.permutation_importance)。


§ 10.5 回归诊断:残差图与 Q-Q 图

⏱️ 3 分钟

这节学啥:用残差图和 Q-Q 图诊断回归模型。

为什么:回归模型 R² 高 ≠ 模型合理。残差图要随机散布(不能有趋势);Q-Q 图要接近对角线(残差正态分布)。任何模式(弯曲、扇形)都暗示模型有问题。

代码

from sklearn.linear_model import LinearRegression
from scipy import stats
import matplotlib.pyplot as plt
import numpy as np

rng = np.random.default_rng(42)
X = rng.uniform(0, 10, 200).reshape(-1, 1)
y = 2 * X.flatten() + 1 + rng.normal(0, 2, 200)

model = LinearRegression().fit(X, y)
y_pred = model.predict(X)
residuals = y - y_pred

fig, axes = plt.subplots(1, 2, figsize=(11, 4.5))

# 残差图:检查是否随机
axes[0].scatter(y_pred, residuals, alpha=0.6, color='#2E86AB')
axes[0].axhline(0, color='red', linestyle='--')
axes[0].set_xlabel('Predicted'); axes[0].set_ylabel('Residual')
axes[0].set_title('Residual Plot')

# Q-Q 图:检查残差是否正态
stats.probplot(residuals, dist='norm', plot=axes[1])
axes[1].set_title('Q-Q Plot')

plt.tight_layout(); plt.show()

一个坑残差图出现"喇叭口"(散点越右越大)= 异方差,普通线性回归不适用,要做对数变换或用 WLS。这是审稿人爱挑的毛病。


§ 10.6 训练历史曲线 Loss / Accuracy

⏱️ 3 分钟

这节学啥:画深度学习训练曲线,监控训练健康度。

为什么:训练完模型必画——Loss 必须下降(不下降说明学习率/优化器问题),train/val 不能差太多(差太多是过拟合)。一张图同时画 train+val 的 loss 和 accuracy 是标准画法。

代码

import matplotlib.pyplot as plt
import numpy as np

# 模拟训练历史(实际项目用 history.history)
epochs = np.arange(1, 51)
train_loss = 2.5 * np.exp(-epochs/15) + 0.15 + np.random.randn(50)*0.05
val_loss   = 2.5 * np.exp(-epochs/15) + 0.30 + np.random.randn(50)*0.08
train_acc  = 1 - train_loss/3
val_acc    = 1 - val_loss/3

fig, axes = plt.subplots(1, 2, figsize=(11, 4))

# Loss
axes[0].plot(epochs, train_loss, '-',  color='#2E86AB', linewidth=2, label='Train')
axes[0].plot(epochs, val_loss,   '--', color='#E63946', linewidth=2, label='Validation')
axes[0].set_xlabel('Epoch'); axes[0].set_ylabel('Loss')
axes[0].legend()

# Accuracy
axes[1].plot(epochs, train_acc, '-',  color='#2E86AB', linewidth=2, label='Train')
axes[1].plot(epochs, val_acc,   '--', color='#E63946', linewidth=2, label='Validation')
axes[1].set_xlabel('Epoch'); axes[1].set_ylabel('Accuracy')
axes[1].legend()

plt.tight_layout(); plt.show()

一个坑:训练曲线在 epoch 1 时常常y 轴被极大值撑爆导致后面看不清。可加 axes[0].set_yscale('log') 用对数 y 轴,或从 epoch 2 开始画。


第 11 章 论文级输出

前面学会"画图",这一章学"画论文图"——格式、尺寸、字号、批量化,都有讲究。


§ 11.1 DPI 与矢量格式 PDF / SVG

⏱️ 3 分钟

这节学啥:知道不同输出格式的取舍,论文该用哪种。

为什么矢量格式(PDF/SVG/EPS)放大不糊——文字和线条都是数学描述。位图格式(PNG/JPG/TIFF)放大就糊——但适合包含照片的复合图。期刊投稿的铁律:有条件用矢量、必须用位图至少 300 DPI

代码

import matplotlib.pyplot as plt
import numpy as np

x = np.linspace(0, 10, 100)
fig, ax = plt.subplots(figsize=(6, 4))
ax.plot(x, np.sin(x))

# 四种主流格式
fig.savefig('out.pdf',  bbox_inches='tight')                    # 矢量,投稿首选
fig.savefig('out.svg',  bbox_inches='tight')                    # 矢量,可编辑
fig.savefig('out.eps',  bbox_inches='tight')                    # 矢量,老期刊要这个
fig.savefig('out.png',  dpi=300, bbox_inches='tight')           # 位图 300 DPI(合格线)
fig.savefig('out.tiff', dpi=600, bbox_inches='tight',
            pil_kwargs={'compression': 'tiff_lzw'})              # 高 DPI TIFF(部分期刊要求)

一个坑:PDF 矢量图里如果用了过多透明色或太多点(>10万),文件会变成 10MB+ 把投稿系统卡死。解决:用 rasterized=True 只把散点栅格化、其他保持矢量:

ax.scatter(x, y, rasterized=True)

§ 11.2 期刊要求:尺寸、字号、线宽

⏱️ 3 分钟

这节学啥:把图调整到符合具体期刊的硬性要求。

为什么:每个期刊有自己的"图规范"。常见单栏宽 8.5cm(约 3.3 inch),双栏 17.5cm(约 6.9 inch)。投稿前必须读"Author Guidelines"——尺寸字号不对会直接被打回。

代码

import matplotlib.pyplot as plt

# Nature 单栏图典型设置
plt.rcParams.update({
    'figure.figsize': (3.3, 2.5),     # 英寸,单栏宽
    'font.family':    'sans-serif',
    'font.sans-serif': ['Arial'],
    'font.size':       8,              # Nature 推荐 7-9pt
    'axes.labelsize':  8,
    'axes.titlesize':  9,
    'xtick.labelsize': 7,
    'ytick.labelsize': 7,
    'legend.fontsize': 7,
    'lines.linewidth': 1.2,            # 论文图线宽 1.0~1.5
    'axes.linewidth':  0.8,            # 坐标轴线略细
    'xtick.major.width': 0.8,
    'ytick.major.width': 0.8,
})

# 之后画的所有图自动符合 Nature 单栏规范

一个坑字号要按"图打印后大小"算,不是"屏幕上看着大小"。如果 figsize=(3.3, 2.5) 但你在屏幕上把它放大到 10 寸看,字号是合规的——只是预览看起来小。导出后用真实尺寸看才准。


§ 11.3 多子图编号与排版

⏱️ 3 分钟

这节学啥:给多子图加 (a)(b)(c) 标号,并保持整体一致。

为什么:Nature/Science 风格的图都是 (a)(b)(c)(d) 子图组合。手动加标号繁琐易错,ax.text(0, 1.05, '(a)', transform=ax.transAxes) 是标准做法——transAxes 坐标系下 (0, 1) 是左上角,不管子图大小都一致。

代码

import matplotlib.pyplot as plt
import numpy as np
import string

fig, axes = plt.subplots(2, 2, figsize=(9, 7))

# 画 4 个不同图
axes[0, 0].plot(np.linspace(0, 10), np.sin(np.linspace(0, 10)))
axes[0, 1].scatter(np.random.randn(50), np.random.randn(50))
axes[1, 0].bar(['A','B','C','D'], [3,5,2,7])
axes[1, 1].hist(np.random.randn(500), bins=20)

# 统一加标号 (a) (b) (c) (d)
for ax, letter in zip(axes.flat, string.ascii_lowercase):
    ax.text(-0.1, 1.05, f'({letter})',
            transform=ax.transAxes,
            fontsize=12, fontweight='bold')

plt.tight_layout()
plt.savefig('figure.pdf', bbox_inches='tight')
plt.show()

一个坑:标号 (a) 的位置(如 -0.1, 1.05)需要根据子图大小微调——左侧有 y 轴标签时用 -0.15,没有时用 -0.05。试一两次就有感觉了。


§ 11.4 批量生成图表的自动化

⏱️ 3 分钟

这节学啥:用循环批量生成大量图(如每个国家一张图)。

为什么:论文 supplementary materials 经常要"为每个亚组画一张图"。手动画 30 张图既费时又易错。for 循环 + 字典传参 + savefig 批量产出,几行代码搞定。plt.close() 释放内存这步关键且常被遗忘

代码

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from pathlib import Path

# 模拟数据:每个国家一组时间序列
rng = np.random.default_rng(42)
countries = ['China', 'USA', 'Japan', 'Germany', 'India']
data = {c: np.cumsum(rng.randn(50)) + rng.uniform(0, 100)
        for c in countries}

# 输出目录
out_dir = Path('figures')
out_dir.mkdir(exist_ok=True)

for country, series in data.items():
    fig, ax = plt.subplots(figsize=(6, 4))
    ax.plot(series, color='#2E86AB', linewidth=2)
    ax.set_title(f'Trend: {country}')
    ax.set_xlabel('Time'); ax.set_ylabel('Value')

    # 保存:文件名用国家名
    fig.savefig(out_dir / f'trend_{country}.pdf', bbox_inches='tight')
    plt.close(fig)   # ★ 关键:释放内存

print(f'生成了 {len(data)} 张图到 {out_dir}/')

一个坑:批量画图plt.close() 会内存爆炸——画 100 张图后 Jupyter 内核可能崩。每张画完一定要 plt.close(fig)


第 12 章 综合实战

前 11 章学了"工具"。这一章用 5 个跨领域实战案例,串起所学,每个案例都是端到端的"数据 → 图"。


§ 12.1 实战:训练曲线对比图(机器学习)

⏱️ 4 分钟

这节学啥:复现 ML 论文里"多个模型训练曲线对比 + 最优点标注"的图。

为什么:这是 ML 论文 Figure 1 最常见的样式。综合用到:多线绘制 + 三重区分 + 误差带 + 关键点标注——前面所有章节的整合。

代码

import matplotlib.pyplot as plt
import numpy as np

rng = np.random.default_rng(42)
epochs = np.arange(1, 51)

# 模拟 3 个模型 × 5 次重复实验的 val loss
def gen_curves(base, decay, noise, runs=5):
    return np.array([base * np.exp(-epochs/decay) + 0.15 + rng.normal(0, noise, 50)
                     for _ in range(runs)])

models = {
    'ResNet-50':  (gen_curves(1.0, 10, 0.04), '#2E86AB', '-',  'o'),
    'ViT-Base':   (gen_curves(1.1, 12, 0.05), '#E63946', '--', 's'),
    'ConvNeXt-T': (gen_curves(1.0, 15, 0.04), '#06A77D', '-.', '^'),
}

fig, ax = plt.subplots(figsize=(8, 5))

for name, (runs, color, ls, mk) in models.items():
    mean, std = runs.mean(0), runs.std(0)
    ax.plot(epochs, mean, color=color, linestyle=ls,
            linewidth=2, marker=mk, markevery=5, markersize=6, label=name)
    ax.fill_between(epochs, mean-std, mean+std, color=color, alpha=0.15)

# 标注最优点
best_mean = models['ResNet-50'][0].mean(0)
best_idx = np.argmin(best_mean)
ax.annotate(f'Best: {best_mean[best_idx]:.3f}',
             xy=(epochs[best_idx], best_mean[best_idx]),
             xytext=(epochs[best_idx]-15, best_mean[best_idx]+0.15),
             arrowprops=dict(arrowstyle='->', color='#2E86AB'),
             fontsize=10, color='#2E86AB', fontweight='bold')

ax.set_xlabel('Epoch'); ax.set_ylabel('Validation Loss')
ax.set_title('Training Curves on ImageNet-1K (mean ± std, 5 runs)')
ax.legend()
plt.savefig('training_curves.pdf', bbox_inches='tight')
plt.show()

一个坑:误差带 fill_betweenalpha 别超过 0.3——多组数据叠加时,重叠区域的透明效果会让颜色变脏。0.15~0.2 最干净。


§ 12.2 实战:基因表达热力图(生物)

⏱️ 4 分钟

这节学啥:画带聚类树的基因表达热力图,生物论文里最常见的图之一。

为什么:综合用到:数据标准化 + clustermap + 颜色映射 + 分组着色。一张图同时展示"基因表达水平 + 样本聚类 + 基因聚类",信息密度极高。

代码

import seaborn as sns
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

rng = np.random.default_rng(42)

# 模拟 50 基因 × 20 样本的表达矩阵
n_genes, n_samples = 50, 20
data = rng.normal(0, 1, (n_genes, n_samples))
# 让前 25 基因在前 10 样本上调
data[:25, :10] += 2

df = pd.DataFrame(data,
                  index=[f'Gene_{i}' for i in range(n_genes)],
                  columns=[f'S{i}' for i in range(n_samples)])

# 样本组别(用于列颜色条)
sample_groups = ['Tumor']*10 + ['Normal']*10
col_colors = ['#E63946' if g == 'Tumor' else '#2E86AB'
              for g in sample_groups]

# clustermap:自动聚类 + 标准化
g = sns.clustermap(df,
                   cmap='RdBu_r', center=0,
                   standard_scale=0,         # 按行 z-score 标准化
                   col_colors=col_colors,    # 顶部组别色条
                   figsize=(10, 12),
                   cbar_kws={'label': 'Z-score'},
                   yticklabels=False)        # 50 基因名太多,隐藏
g.ax_col_dendrogram.set_title('Gene Expression Clustering', fontsize=14)
plt.savefig('heatmap.pdf', bbox_inches='tight')
plt.show()

一个坑:基因数 > 50 时 yticklabels=True 会让标签糊成一团。要么隐藏(False),要么只标关键基因——后者要手动设 yticklabels。


§ 12.3 实战:XRD 谱图(材料)

⏱️ 4 分钟

这节学啥:画 X 射线衍射 (XRD) 谱图,材料学论文标配。

为什么:综合用到:多曲线纵向偏移 + 峰位标注 + 双 x 轴(角度与晶面距)。XRD 图的灵魂是"多个样品垂直叠放对比 + 峰位标注晶面",几乎所有材料论文都这么画。

代码

import matplotlib.pyplot as plt
import numpy as np

rng = np.random.default_rng(42)
two_theta = np.linspace(10, 80, 1000)

# 模拟 3 个样品的 XRD 谱
def gen_xrd(peak_positions, intensities, width=0.3):
    pattern = np.zeros_like(two_theta)
    for p, i in zip(peak_positions, intensities):
        pattern += i * np.exp(-((two_theta - p) / width)**2)
    return pattern + rng.normal(0, 0.5, len(two_theta))

samples = {
    'Sample A': gen_xrd([28, 33, 47, 56, 76], [100, 80, 60, 45, 30]),
    'Sample B': gen_xrd([28, 33, 47, 56, 76], [90, 75, 55, 50, 35]),
    'Sample C': gen_xrd([29, 34, 48, 57, 77], [85, 70, 50, 40, 25]),
}

fig, ax = plt.subplots(figsize=(8, 6))

offset = 0
colors = ['#2E86AB', '#E63946', '#06A77D']
for (name, y), c in zip(samples.items(), colors):
    ax.plot(two_theta, y + offset, color=c, linewidth=1.2, label=name)
    offset += 130   # 垂直偏移

# 标注主要晶面
for pos, label in zip([28, 33, 47, 56, 76], ['(111)', '(200)', '(220)', '(311)', '(331)']):
    ax.axvline(pos, color='gray', linestyle=':', alpha=0.5, linewidth=0.6)
    ax.text(pos, ax.get_ylim()[1]*0.95, label, fontsize=9,
            ha='center', rotation=90, color='gray')

ax.set_xlabel(r'2$\theta$ (degrees)')
ax.set_ylabel('Intensity (a.u.)')
ax.set_yticks([])  # 强度任意单位,刻度无意义
ax.legend(loc='upper right')
plt.savefig('xrd.pdf', bbox_inches='tight')
plt.show()

一个坑:XRD 强度是 "arbitrary unit (a.u.)" ——不要画 y 轴刻度数字,反而专业。ax.set_yticks([]) 一行去掉。


§ 12.4 实战:时间序列趋势图(经济 / 气候通用)

⏱️ 4 分钟

这节学啥:画一张"时间序列 + 平滑趋势 + 关键事件标注"的图。

为什么:经济、气候、流行病学——所有"随时间变化"的研究都用这套画法。综合用到:原始数据 + 滚动均线 + 事件标注(垂直线 + 文字)+ 区间高亮

代码

import matplotlib.pyplot as plt
import pandas as pd
import numpy as np

rng = np.random.default_rng(42)
dates = pd.date_range('2020-01-01', '2024-12-31', freq='W')
n = len(dates)

# 模拟带趋势 + 周期 + 噪声的指标
trend = np.linspace(100, 130, n)
season = 5 * np.sin(np.arange(n) * 2 * np.pi / 52)
noise = rng.normal(0, 3, n)
values = trend + season + noise
values[60:80] += 25   # 模拟某事件冲击

df = pd.DataFrame({'date': dates, 'value': values}).set_index('date')
df['ma8'] = df['value'].rolling(8).mean()   # 8 周滚动均

fig, ax = plt.subplots(figsize=(11, 5))

# 原始数据 + 平滑线
ax.plot(df.index, df['value'], color='lightgray', linewidth=0.8, label='Weekly data')
ax.plot(df.index, df['ma8'],   color='#2E86AB',   linewidth=2,    label='8-week MA')

# 高亮事件区间
event_start = pd.Timestamp('2021-03-01')
event_end   = pd.Timestamp('2021-07-01')
ax.axvspan(event_start, event_end, alpha=0.2, color='#E63946', label='Event period')

# 关键点标注
peak_idx = df['value'].idxmax()
peak_val = df['value'].max()
ax.annotate(f'Peak: {peak_val:.0f}',
             xy=(peak_idx, peak_val),
             xytext=(peak_idx + pd.Timedelta(days=180), peak_val + 5),
             arrowprops=dict(arrowstyle='->'),
             fontsize=10, fontweight='bold')

ax.set_xlabel('Date'); ax.set_ylabel('Indicator Value')
ax.legend(loc='upper left')
plt.savefig('timeseries.pdf', bbox_inches='tight')
plt.show()

一个坑:原始日/周数据直接画会非常乱(噪声盖过信号)。永远叠加一条平滑线(滚动均线或 LOESS)——读者能看到趋势又能看到波动。


§ 12.5 实战:FFT 频谱图(物理 / 工程)

⏱️ 4 分钟

这节学啥:把时域信号做 FFT 后画频谱图。

为什么:信号处理的"时域 + 频域"组合图是物理/工程论文的常见画法。综合用到:numpy.fft 计算 + 双子图布局 + 频率轴正确单位

代码

import numpy as np
import matplotlib.pyplot as plt

# 合成信号:50 Hz + 120 Hz + 噪声
fs = 1000           # 采样率 Hz
t = np.arange(0, 1, 1/fs)
signal = (np.sin(2*np.pi*50*t)
          + 0.5 * np.sin(2*np.pi*120*t)
          + np.random.randn(len(t)) * 0.5)

# FFT
yf = np.fft.fft(signal)
freqs = np.fft.fftfreq(len(t), d=1/fs)

# 只取正频率部分
mask = freqs >= 0
freqs_pos = freqs[mask]
amp_pos = 2/len(t) * np.abs(yf[mask])

# 双子图:时域 + 频域
fig, axes = plt.subplots(2, 1, figsize=(9, 6))

axes[0].plot(t, signal, color='#2E86AB', linewidth=0.6)
axes[0].set_xlabel('Time (s)'); axes[0].set_ylabel('Amplitude')
axes[0].set_title('Time Domain Signal')
axes[0].set_xlim(0, 0.3)   # 只展示前 0.3 秒,看得清

axes[1].plot(freqs_pos, amp_pos, color='#E63946', linewidth=1)
axes[1].set_xlabel('Frequency (Hz)'); axes[1].set_ylabel('Amplitude')
axes[1].set_title('Frequency Spectrum (FFT)')
axes[1].set_xlim(0, 200)
# 标注主频
for f in [50, 120]:
    axes[1].axvline(f, color='gray', linestyle=':', alpha=0.5)
    axes[1].text(f, axes[1].get_ylim()[1]*0.9, f'{f} Hz',
                 ha='center', fontsize=9)

plt.tight_layout()
plt.savefig('fft.pdf', bbox_inches='tight')
plt.show()

一个坑:FFT 频谱幅值要乘 2/N 归一化(除去 DC 频率),否则幅值无物理意义。amp = 2/N * np.abs(fft_result) 是标准做法。


🎉 全书完结

学到这里,你应该能:

  • 用 Matplotlib 画出任何论文需要的静态图(第 2-4 章)
  • 用 Seaborn 一行画出复杂统计图(第 5 章)
  • 用 Plotly 做交互式可视化和仪表盘(第 8 章)
  • 处理地理空间数据(第 7 章)和机器学习评估图(第 10 章)
  • 输出符合期刊规范的论文级图表(第 11 章)
  • 应对生物、材料、物理、经济等领域的常见可视化任务(第 12 章)

接下来怎么进阶

  1. 找一篇你领域顶刊的论文,复现里面的所有图
  2. 把自己课题的数据用本书教的画法画一遍
  3. 遇到本书没覆盖的图,先查 Matplotlib gallery(matplotlib.org/stable/gallery)

可视化是科研的"语言"——画图水平直接影响论文录用率。画图无止境,但本书覆盖的内容已经足以让 90% 的科研人画出 90% 的图。


📚 附录 A:常用资源

  • Matplotlib 官方 Gallery:matplotlib.org/stable/gallery — 找灵感的圣地
  • Seaborn 官方示例:seaborn.pydata.org/examples — 一行代码画复杂图
  • Plotly 文档:plotly.com/python — 交互图首选
  • 色盲友好色板生成器:colorbrewer2.org — 期刊配色标准

📝 附录 B:科研配色速查(直接复制可用)

# 5 色色盲友好(本书统一色板)
colors_5 = ['#2E86AB', '#E63946', '#06A77D', '#F4A261', '#6C757D']

# Seaborn colorblind 10 色
import seaborn as sns
colors_10 = sns.color_palette('colorblind')

# Nature 风格(蓝/红/绿/橙/紫)
colors_nature = ['#0C5DA5', '#FF2C00', '#00B945', '#FF9500', '#845B97']

# 灰度科研色板(黑白可读)
colors_gray = ['#000000', '#525252', '#737373', '#969696', '#BDBDBD']

🎯 教程到此结束

可视化是科研的"语言"——画图水平直接影响论文录用率。

这本书覆盖的内容已经足以让 90% 的科研人画出 90% 的图。

剩下的 10%,需要时按图索骥即可:

进阶需求 推荐资源
3D 图形 Matplotlib 的 mplot3d 模块官方文档
动画 Matplotlib animation 模块 + ffmpeg
复杂网络图 NetworkX 文档 + Gephi 软件
Dashboard 应用 Plotly Dash 或 Streamlit
LaTeX 集成 tikzplotlib + scienceplots

版本:v1.0 骨架版 总字数:约 8 万字 预计学习时长:2.5 小时

祝你的论文图越画越漂亮 📈