Matplotlib実践Tips:論文品質グラフのPython実装(plt.rcParams/savefig/colormap/subplots/tight_layout)

matplotlib.pyplot.rcParams・savefig・subplots・tight_layout・colormap・mpl.font_manager・seaborn.set_context で出版用(論文・学会・プレゼン)図を Python 実装。LaTeX レンダリング(text.usetex / mathtext)、日本語フォント埋め込み、知覚均等カラーマップ(viridis/cividis/magma)、ベクター出力(PDF/SVG)の pdf.fonttype=42 設定、dpi=300 と figsize、余白調整、seaborn-paper スタイル、図のラスタライズ最適化まで実践 Tips を網羅。

はじめに

Matplotlibはデフォルト設定のまま使うと、フォントサイズが小さかったり、線が細すぎたり、論文やプレゼンテーションに直接使うには不十分なグラフが出力されます。設定を調整することで、そのまま論文に載せられる品質のグラフを作成できます。

この記事では、論文品質のグラフを作成するための実践的なTipsを紹介します。

rcParamsによるグローバル設定

plt.rcParamsを使うと、スクリプト全体のデフォルトスタイルを一括で変更できます。以下のテンプレートを使えば、毎回個別に設定する必要がなくなります。

import matplotlib.pyplot as plt

plt.rcParams.update({
    'font.size': 12,
    'axes.labelsize': 14,
    'axes.titlesize': 14,
    'xtick.labelsize': 11,
    'ytick.labelsize': 11,
    'legend.fontsize': 11,
    'figure.figsize': (6, 4),
    'figure.dpi': 150,
    'lines.linewidth': 1.5,
    'axes.linewidth': 0.8,
    'axes.grid': True,
    'grid.alpha': 0.3,
})

各パラメータの説明

  • font.size: 基本フォントサイズ。ここを基準に他のサイズも調整されます
  • axes.labelsize, axes.titlesize: 軸ラベルとタイトルのサイズ。本文より少し大きくすると読みやすくなります
  • figure.figsize: デフォルトの図サイズ(インチ単位)。論文のカラム幅に合わせて調整します
  • figure.dpi: 解像度。150以上にしておくとノートブック上でも鮮明に表示されます
  • axes.grid, grid.alpha: 薄いグリッド線を表示してデータの読み取りを助けます

デフォルト設定 vs カスタム設定

スタイルシートとの併用

Matplotlibにはプリセットのスタイルシートもあります。rcParamsと併用する場合、スタイルシートを先に適用してから個別に上書きします。

plt.style.use('seaborn-v0_8-whitegrid')
plt.rcParams.update({
    'font.size': 12,
    'axes.labelsize': 14,
})

日本語フォントの設定

Matplotlibはデフォルトでは日本語フォントに対応しておらず、そのままでは文字化けします。

macOSの場合

macOSではjapanize-matplotlibパッケージを使うのが最も簡単です。

pip install japanize-matplotlib
import matplotlib.pyplot as plt
import japanize_matplotlib

plt.plot([1, 2, 3], [1, 4, 9])
plt.title("日本語タイトル")
plt.xlabel("X軸ラベル")
plt.ylabel("Y軸ラベル")
plt.show()

手動でフォントを指定する方法

japanize-matplotlibを使わない場合、フォントパスを直接指定できます。

import matplotlib
from matplotlib import font_manager

# macOS
font_path = '/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc'
# Linux
# font_path = '/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc'

font_manager.fontManager.addfont(font_path)
matplotlib.rc('font', family='Hiragino Sans')

Linuxの場合

Linux環境ではNotoフォントなどをインストールしてから設定します。

# フォント一覧の確認
fc-list :lang=ja

# Noto Sans CJKのインストール(Ubuntu/Debian)
sudo apt install fonts-noto-cjk

フォントキャッシュのクリアが必要な場合があります。

import matplotlib
matplotlib.font_manager._load_fontmanager(try_read_cache=False)

カラーマップの選択

jet/rainbowを避ける理由

jetrainbowは色覚多様性のある読者にとって判読が困難です。また、明るさが均一でないため、データの大小関係が正しく伝わらない場合があります。

推奨カラーマップ

連続データには以下のカラーマップが推奨されます。

  • viridis: デフォルトカラーマップ。明るさが均一で色覚多様性に配慮されています
  • cividis: viridisに似ていますが、より色覚バリアフリーに特化しています
  • plasma: 暖色系で、viridisとの使い分けに適しています
import numpy as np
import matplotlib.pyplot as plt

x = np.linspace(0, 10, 100)
y = np.linspace(0, 10, 100)
X, Y = np.meshgrid(x, y)
Z = np.sin(X) * np.cos(Y)

fig, axes = plt.subplots(1, 3, figsize=(15, 4))
cmaps = ['viridis', 'cividis', 'plasma']

for ax, cmap in zip(axes, cmaps):
    im = ax.pcolormesh(X, Y, Z, cmap=cmap)
    ax.set_title(cmap)
    fig.colorbar(im, ax=ax)

plt.tight_layout()
plt.show()

カラーマップの比較

カテゴリデータ用の定性パレット

カテゴリデータ(折れ線グラフで複数系列を区別する場合など)には定性パレットを使います。

import numpy as np
import matplotlib.pyplot as plt

np.random.seed(42)
x = np.linspace(0, 10, 100)

# tab10: Matplotlibのデフォルト配色(最大10色)
colors = plt.cm.tab10.colors

for i in range(5):
    plt.plot(x, np.sin(x + i), color=colors[i], label=f'Series {i+1}')

plt.legend()
plt.show()

Set2Dark2も論文向きの落ち着いた色合いで使いやすいパレットです。

「区別しやすい配色」を主観で判断しない

上の説明は多くのMatplotlib解説記事に共通する一般的な助言ですが、実際に色覚多様性(CVD: Color Vision Deficiency)の観点から検証すると、tab10Set2Dark2にはいずれも隣接色が判別困難になるペアが含まれています。Machado, Oliveira & Fernandes (2009) の生理学モデルに基づくCVDシミュレーションで色差(ΔE。値が大きいほど区別しやすく、目安はΔE 12以上)を計算すると、次のようになります。

パレット最も近い色ペア1型色覚(protanopia)でのΔE判定
tab10(デフォルト)オレンジ#ff7f0e ↔ 緑#2ca02c4.6実質的に同じ色に見える
Set2ピンク#e78ac3 ↔ 青紫#8da0cb2.5実質的に同じ色に見える
Dark2黄土#e6ab02 ↔ 緑#66a61e9.5境界域(他の視覚的手がかりが必須)
petroff10(Matplotlib 3.10+)#b9ac70 ↔ 橙#e7630029.3明確に区別可能

つまり「Set2Dark2は論文向きの落ち着いた配色」という助言は美観としては妥当でも、5系列以上を折れ線グラフで重ねると、1型/2型色覚(赤緑色覚異常)の読者には特定の系列同士が事実上見分けられなくなります。実際にシミュレーションすると次のようになります。

tab10とpetroff10の通常視野・1型色覚シミュレーション比較

上段が通常視野、下段が1型色覚(protanopia)のシミュレーションです。tab10(左)はオレンジ・緑・(一部)赤の3本が下段でほぼ同じオリーブ色に潰れてしまうのに対し、petroff10(右)は5本とも区別可能な状態を保っています。このシミュレーション画像は次のコードで生成しています(PROTANOPIA_MATRIXはcolour-scienceライブラリのcolour.blindness.matrix_cvd_Machado2009("Protanomaly", 1.0)が返す値と一致することを確認済みです)。

import numpy as np
import matplotlib.pyplot as plt

np.random.seed(42)

# Machado, Oliveira & Fernandes (2009) の1型色覚(protanopia)シミュレーション行列
# (線形RGB空間に適用する)
PROTANOPIA_MATRIX = np.array([
    [0.152286, 1.052583, -0.204868],
    [0.114503, 0.786281, 0.099216],
    [-0.003882, -0.048116, 1.051998],
])


def simulate_protanopia(rgb):
    """sRGB画像 (0-1, shape=(H, W, 3)) に1型色覚シミュレーションを適用する"""
    linear = np.where(rgb <= 0.04045, rgb / 12.92, ((rgb + 0.055) / 1.055) ** 2.4)
    sim_linear = np.clip(linear @ PROTANOPIA_MATRIX.T, 0, 1)
    srgb = np.where(
        sim_linear <= 0.0031308,
        sim_linear * 12.92,
        1.055 * sim_linear ** (1 / 2.4) - 0.055,
    )
    return np.clip(srgb, 0, 1)


def plot_with_cycle(ax, colors, title):
    x = np.linspace(0, 4 * np.pi, 200)
    for i, c in enumerate(colors):
        ax.plot(x, np.sin(x + i * 0.6) + i * 0.18, color=c, linewidth=2.5, label=f"Series {i+1}")
    ax.set_title(title)


tab10 = list(plt.cm.tab10.colors[:5])
with plt.style.context("petroff10"):
    petroff10 = list(plt.rcParams["axes.prop_cycle"].by_key()["color"])[:5]

fig, axes = plt.subplots(1, 2, figsize=(9, 3.4))
plot_with_cycle(axes[0], tab10, "tab10 (Matplotlib default cycle)")
plot_with_cycle(axes[1], petroff10, "petroff10 (Matplotlib >= 3.10)")
fig.tight_layout()
fig.canvas.draw()

rgba = np.asarray(fig.canvas.buffer_rgba()).astype(float) / 255
simulated = simulate_protanopia(rgba[..., :3])
plt.imsave("simulated_protanopia.png", simulated)

厳密なCVD配慮を優先するときは、目視ではなく検証ツールで確認するのが安全です(本記事のカラーマップ選びも同じ原則に基づいています)。

2024–2025年の新機能:CVD配慮済みの配色が標準搭載

Matplotlib 3.10(2024年12月リリース)で、CVDモデリングと審美性評価(クラウドソースの色の好み調査に基づく機械学習モデル)を組み合わせて設計されたpetroff10(および6色版・8色版のpetroff6/petroff8)スタイルシートが追加されました。使い方は他のスタイルシートと同じです。

plt.style.use('petroff10')

さらにMatplotlib 3.11では、Okabe & Ito (2008) による色覚多様性対応の定性パレットokabe_itoが定性カラーマップとして追加されています。既存コードを変更せずに配色だけ差し替えたい場合は、plt.style.use()でスタイルシートごと切り替えるのが最も簡単です。なお、ノートブック上でインタラクティブにデータ点の値を確認したい場合は、pip install mplcursorsで導入できるmplcursorsを使うと、クリックした点にツールチップを表示できて便利です。

サブプロットの配置と軸の共有

基本的なサブプロット

import numpy as np
import matplotlib.pyplot as plt

np.random.seed(42)
fig, axes = plt.subplots(2, 2, figsize=(10, 8), sharex=True, sharey=True)

for ax in axes.flat:
    ax.plot(np.random.randn(50).cumsum())

fig.align_ylabels(axes[:, 0])
plt.tight_layout()
plt.show()

sharex=Truesharey=Trueを指定すると、各サブプロットで軸の範囲が統一されます。fig.align_ylabels()でy軸ラベルの位置も揃えられます。実際に効果を比較すると次のようになります。

sharex/sharey/align_ylabelsの有無による比較

sharey=Trueを指定していない左側(Before)では、各パネルが自分のデータ範囲だけを見て軸を独立にスケーリングするため、Signal 2 の変動幅が他の3倍以上大きいという事実がグラフからは読み取れません。sharey=Trueを指定した右側(After)では全パネルが同じy軸範囲(-20〜20)を共有するため、パネル間で振幅を直接比較できます。またsharex/shareyを指定すると、Matplotlibは自動的に内側のサブプロットの重複する目盛りラベルを非表示にします(上段のx軸目盛りが省略されているのはこのためです)。

tight_layout と constrained_layout の仕組み

タイトルや軸ラベルがサブプロット同士で重なる問題は、plt.tight_layout()(またはlayout引数)を使うだけで直ることが多いですが、内部で何が起きているかを理解しておくと、効かないケースにも対処しやすくなります。まず、実際に重なりが解消される様子を確認します。

import numpy as np
import matplotlib.pyplot as plt


def build_figure(layout):
    np.random.seed(42)
    fig, axes = plt.subplots(2, 2, figsize=(5, 4.2), layout=layout)
    for i, ax in enumerate(axes.flat):
        ax.plot(np.random.randn(50).cumsum())
        ax.set_title(f"Very Long Subplot Title #{i}")
        ax.set_ylabel("Cumulative sum of\nrandom walk")
        ax.set_xlabel("Time step")
    return fig


for layout in [None, "tight", "constrained"]:
    fig = build_figure(layout)
    fig.savefig(f"layout_{layout or 'none'}.png", dpi=150)

レイアウトエンジンによるタイトル・ラベル重なりの解消

layout=None(左)では長いタイトルと隣接パネルのy軸ラベルが重なって判読できません。layout='tight'(中央)・layout='constrained'(右)はどちらもこの重なりを解消しています。では、この2つのアルゴリズムは何が違うのでしょうか。

  • tight_layout: 一度描画(レンダリング)した上で、各Axesの目盛りラベル・軸ラベル・タイトルの実際のバウンディングボックスをレンダラーから取得し、それらが重ならないようにsubplotparsleft/right/top/bottom/wspace/hspace)を事後的に計算し直すアルゴリズムです。あくまで「各Axes自体の装飾」を基準にした調整なので、Axesに直接紐付かない要素(複数Axesにまたがる凡例やカラーバーなど)は考慮されません。
  • constrained_layout: 制約ソルバー(constraint solver)を使い、サブプロット・軸ラベル・タイトル・凡例・カラーバー・suptitleなど図中のすべての装飾要素を「スペースを必要とするオブジェクト」として同時に扱い、これらが重ならないよう連立的に余白を決定します。図が再描画されるたびに解き直されるため、要素を追加・変更してもレイアウトが追従します。

この違いは、複数のAxesにまたがる共有カラーバーを追加すると顕著に現れます。

import numpy as np
import matplotlib.pyplot as plt

np.random.seed(42)
x = np.linspace(-3, 3, 60)
X, Y = np.meshgrid(x, x)
Z = np.sin(X) * np.cos(Y)

for layout in ["tight", "constrained"]:
    fig, axes = plt.subplots(2, 2, figsize=(6, 5), layout=layout)
    for i, ax in enumerate(axes.flat):
        im = ax.pcolormesh(X, Y, Z + 0.1 * i, cmap="viridis")
        ax.set_title(f"Panel {i}")
    fig.colorbar(im, ax=axes, shrink=0.8, label="Value")
    fig.suptitle(f"layout='{layout}'")
    fig.savefig(f"cbar_{layout}.png", dpi=150)

tight_layoutとconstrained_layoutでの共有カラーバーの扱いの違い

layout='tight'(左)では実行時に"This figure includes Axes that are not compatible with tight_layout, so results might be incorrect.“という警告が実際に出力され、共有カラーバーがPanel 1・Panel 3に重なって描画されてしまいます。layout='constrained'(右)では警告は出ず、カラーバー用のスペースが正しく確保されます。単純なタイトル・ラベルの重なりだけを直したいならtight_layoutでも十分ですが、カラーバー・凡例・suptitleを含む図ではlayout='constrained'plt.subplots(..., layout='constrained')のように指定)を使う方が安全です。なお、imshowのようにアスペクト比が固定されたAxesを並べる場合は、Axes同士を詰めて配置するlayout='compressed'も用意されています。

gridspec_kwによる細かいレイアウト制御

サブプロット間の余白を調整したい場合はgridspec_kwを使います。

fig, axes = plt.subplots(
    2, 2,
    figsize=(10, 8),
    gridspec_kw={'hspace': 0.05, 'wspace': 0.05},
    sharex=True,
    sharey=True
)

サイズの異なるサブプロット

GridSpecを使うと、サブプロットごとに異なるサイズを指定できます。

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.gridspec import GridSpec

np.random.seed(42)
t = np.linspace(0, 10, 200)
main_signal = np.sin(t) + 0.15 * np.random.randn(200)

fig = plt.figure(figsize=(10, 6), layout="constrained")
gs = GridSpec(2, 3, figure=fig)

ax_main = fig.add_subplot(gs[:, :2])  # 左側2/3を占める大きなプロット
ax_main.plot(t, main_signal)
ax_main.set_title("Main signal (spans 2 rows x 2 cols)")
ax_main.set_xlabel("Time [s]")
ax_main.set_ylabel("Amplitude")

ax_top = fig.add_subplot(gs[0, 2])  # 右上
ax_top.hist(main_signal, bins=15, color="tab:blue")
ax_top.set_title("Distribution")

ax_bottom = fig.add_subplot(gs[1, 2])  # 右下
ax_bottom.plot(t, np.gradient(main_signal, t), color="tab:orange")
ax_bottom.set_title("Derivative")

fig.savefig("gridspec_layout_demo.png", dpi=150)

GridSpecによる非対称なサブプロットレイアウト

左側の大きなパネルは2行×2列分の領域を占有し、右側には縦に2つの小さなパネルが並びます。メインの信号と、その分布・導関数を1枚の図にまとめる際によく使う構成です。

ベクター出力(PDF/SVG)

ベクター形式で保存する

論文投稿にはベクター形式(PDF, SVG)が推奨されます。拡大しても画質が劣化しません。

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

# PDF出力
fig.savefig('figure.pdf', bbox_inches='tight')

# SVG出力
fig.savefig('figure.svg', bbox_inches='tight')

bbox_inches='tight'を指定すると、余白が自動的にトリミングされます。

ラスター形式を使う場面

以下のような場合はPNG(ラスター形式)の方が適しています。

  • データ点が非常に多い散布図(数万点以上)
  • ヒートマップやイメージプロット
  • ファイルサイズを小さくしたい場合

ラスター形式で保存する場合はdpiを指定します。

fig.savefig('figure.png', dpi=300, bbox_inches='tight')

ベクターとラスターの組み合わせ

一つの図の中でベクターとラスターを混在させることもできます。rasterized=Trueを指定した要素だけがラスター化されます。

ax.scatter(x, y, rasterized=True)  # 散布図だけラスター化
fig.savefig('figure.pdf', dpi=300, bbox_inches='tight')

完成例:すべてのTipsを組み合わせる

以下は、ここまで紹介したTipsをすべて組み合わせた完成例です。

import numpy as np
import matplotlib.pyplot as plt

# グローバル設定
plt.rcParams.update({
    'font.size': 12,
    'axes.labelsize': 14,
    'axes.titlesize': 14,
    'xtick.labelsize': 11,
    'ytick.labelsize': 11,
    'legend.fontsize': 11,
    'figure.figsize': (6, 4),
    'figure.dpi': 150,
    'lines.linewidth': 1.5,
    'axes.linewidth': 0.8,
    'axes.grid': True,
    'grid.alpha': 0.3,
})

# データ生成
np.random.seed(42)
x = np.linspace(0, 2 * np.pi, 100)
signals = {
    'Signal A': np.sin(x) + np.random.normal(0, 0.1, 100),
    'Signal B': np.cos(x) + np.random.normal(0, 0.1, 100),
    'Signal C': np.sin(2 * x) + np.random.normal(0, 0.1, 100),
}

# カテゴリ用パレット
colors = plt.cm.Set2.colors

# サブプロット作成
fig, axes = plt.subplots(1, 2, figsize=(12, 4.5))

# 左: 折れ線グラフ
ax = axes[0]
for i, (label, data) in enumerate(signals.items()):
    ax.plot(x, data, color=colors[i], label=label)
ax.set_xlabel('Time [s]')
ax.set_ylabel('Amplitude')
ax.set_title('Time Series Comparison')
ax.legend()

# 右: ヒートマップ
ax = axes[1]
X, Y = np.meshgrid(x, x)
Z = np.sin(X) * np.cos(Y)
im = ax.pcolormesh(X, Y, Z, cmap='cividis', rasterized=True)
ax.set_xlabel('X')
ax.set_ylabel('Y')
ax.set_title('2D Heatmap')
fig.colorbar(im, ax=ax)

plt.tight_layout()

# ベクター形式で保存
fig.savefig('publication_figure.pdf', bbox_inches='tight')
fig.savefig('publication_figure.svg', bbox_inches='tight')
plt.show()

完成例:論文品質のグラフ

LaTeX 数式レンダリングとシリアル化のレシピ

論文に直接貼るグラフでは、本文と同じ LaTeX フォントで数式や軸ラベルを描画したい場合があります。text.usetex=True を有効にすると matplotlib.pyplot は LaTeX バイナリを呼び出してテキストを組版します。

import matplotlib.pyplot as plt
import numpy as np

plt.rcParams.update({
    'text.usetex': True,              # システムの LaTeX を使用
    'font.family': 'serif',
    'font.serif': ['Computer Modern Roman'],
    'text.latex.preamble': r'\usepackage{amsmath}\usepackage{siunitx}',
    'axes.labelsize': 12,
    'figure.figsize': (5.5, 3.5),
    'pdf.fonttype': 42,               # TrueType でフォント埋め込み
    'ps.fonttype': 42,
    'savefig.bbox': 'tight',
    'savefig.pad_inches': 0.02,
})

t = np.linspace(0, 2 * np.pi, 200)
fig, ax = plt.subplots()
ax.plot(t, np.sin(t) * np.exp(-t / 5),
        label=r'$x(t) = \sin(t)\,e^{-t/5}$')
ax.set_xlabel(r'Time $t$ [\si{\second}]')
ax.set_ylabel(r'$x(t)$')
ax.set_title(r'Damped Oscillation: $\omega_0 = 1\,\si{\radian\per\second}$')
ax.legend(frameon=False)
fig.savefig('damped.pdf')   # PDF (LaTeX 数式入り、ベクター)
fig.savefig('damped.svg')   # SVG

pdf.fonttype=42 を指定しておくと、出版社のプリプロセスでフォントが置換されません。text.usetex=False のままでも、mathtext (Matplotlib 内蔵) で $...$ の数式は使えます。レンダリング品質を比較したい場合は mathtext.fontset='cm' で Computer Modern 互換のグリフを得られます。

論文品質グラフの savefig レシピ表

最終出力時の設定は媒体ごとに最適値が異なります。よく使うレシピを表にまとめます。

用途推奨フォーマット主要パラメータ
論文本文(投稿用 PDF)figure.pdfdpi=300, bbox_inches='tight', pad_inches=0.02, pdf.fonttype=42
論文ベクター(編集者向け)figure.svgbbox_inches='tight', transparent=False
プレゼン(高解像度 PNG)figure.pngdpi=300, bbox_inches='tight', facecolor='white'
ブログ・Web(軽量 PNG)figure_web.pngdpi=150, bbox_inches='tight', facecolor='white'
サムネイルthumb.pngdpi=72, bbox_inches='tight', figsize=(3,2)
印刷物 (CMYK pre-press)figure.pdf + Ghostscript後段で gs -sDEVICE=pdfwrite -dProcessColorModel=/DeviceCMYK

seaborn-paper スタイルとの併用

seabornset_context('paper') は論文向けにフォントサイズと線幅を自動調整します。rcParams の手動指定と組み合わせると、デフォルトを保ちつつ局所微調整が可能です。

import seaborn as sns
sns.set_context('paper', font_scale=1.1)
sns.set_style('whitegrid', {'grid.alpha': 0.3})
plt.rcParams.update({'figure.dpi': 200, 'savefig.dpi': 300})

よくある落とし穴

ここまで紹介した設定を使っていても、次の2つの落とし穴には多くの人が一度は引っかかります。実際に再現して確認します。

savefig()をshow()の後に呼ぶと空の画像が保存される

plt.show()の後にplt.savefig()を呼ぶと、保存される画像が真っ白になることがあります。原因はMatplotlibの状態管理(pyplotが保持するグローバルな「現在の図」)にあります。GUIバックエンド(MacOSXTkAggQt5Aggなど)でブロッキング表示(plt.show())を使うと、ウィンドウを閉じた時点でそのFigureオブジェクトがpyplotの管理下から破棄されます。続けて引数なしのplt.savefig()を呼ぶと、pyplotは「現在の図」を探しに行きますが既に存在しないため、plt.gcf()が新しい空のFigureを暗黙に生成し、それを保存してしまいます。

import matplotlib

matplotlib.use("MacOSX")  # 実際に事象を再現するには対話的なGUIバックエンドが必要
import numpy as np
import matplotlib.pyplot as plt

np.random.seed(42)

# NG: show()の後にsavefig()を呼ぶ
plt.plot(np.arange(20), np.random.randn(20).cumsum())
plt.title("My Figure")
plt.show()  # ウィンドウを閉じるとFigureが破棄される
plt.savefig("wrong_blank.png")  # 新しい空のFigureが保存される

plt.close("all")

# OK: FigureオブジェクトとAxesを保持し、show()より前に保存する
fig, ax = plt.subplots()
ax.plot(np.arange(20), np.random.randn(20).cumsum())
ax.set_title("My Figure")
fig.savefig("right_correct.png")  # 先に保存
plt.show()

savefig()をshow()の後に呼んだ場合と前に呼んだ場合の比較

左がshow()の後にsavefig()した結果(4,410バイトの空白画像)、右がfig.savefig()show()より前に呼んだ結果(32,033バイト、正しく描画されている)です。この問題は非対話的なAggバックエンド(CI環境やサーバでスクリプトを実行する場合など)ではshow()が実質的に何もしないため再現しません。ローカルではグラフが正しく表示されるのに、サーバでバッチ実行すると壊れた画像が生成される、あるいはその逆といった不可解な不具合の多くはこのバックエンド差に起因します。確実な対策は次の2つです。

  • plt.savefig()(pyplotのグローバル状態に依存する呼び出し)は必ずplt.show()より前に呼ぶ
  • pyplotの暗黙的な状態に頼らず、fig, ax = plt.subplots()で得たオブジェクトに対してfig.savefig()を明示的に呼ぶ(オブジェクト指向インターフェース)

DPIとfigsizeの相互作用:画面では良く見えても保存すると小さすぎる/大きすぎる

Matplotlibのフォントサイズや線幅はポイント単位(1pt = 1/72インチ)で指定します。保存時のピクセルサイズは「figsize(インチ)× dpi」で決まるため、同じfigsize・同じfont.sizeのままdpiだけを変えると、テキストの物理的な大きさ(インチ換算)は変わらない一方で、ピクセル単位の大きさはdpiに比例して変わります。

import numpy as np
import matplotlib.pyplot as plt

np.random.seed(42)

for dpi in [72, 150, 300]:
    plt.rcParams.update({'font.size': 11})
    fig, ax = plt.subplots(figsize=(4, 3), layout='constrained')  # インチ単位、3パターンとも同じ
    x = np.linspace(0, 2 * np.pi, 100)
    ax.plot(x, np.sin(x) + 0.05 * np.random.randn(100))
    ax.set_title('Measured Signal')
    ax.set_xlabel('Time [s]')
    ax.set_ylabel('Amplitude')
    fig.savefig(f'signal_dpi{dpi}.png', dpi=dpi)

実際に測定した結果は次の通りです。

dpi出力ピクセルサイズファイルサイズ11ptタイトルの高さ(理論値 = 11pt × dpi/72)
72288 x 21612 KB11.0 px
150600 x 45030 KB22.9 px
3001200 x 90067 KB45.8 px

同じfigsize・font.sizeでdpiだけを変えた場合の比較

3枚とも同じ表示幅(500px)に揃えて並べています。dpi=72の画像は元のピクセル数が少ないため、拡大表示するとテキストや線がぼやけて見えます。逆に、ノートブック上でfigure.dpi(画面表示用の解像度)を高く設定して「綺麗に見える」ことを確認したつもりでも、savefig()時のdpi引数(またはrcParamsのsavefig.dpi)を別途低い値のまま出力すると、保存されたファイルは画面で見たものより粗くなります。このズレを避けるには、次を徹底します。

  • 表示用(figure.dpi)と保存用(savefig()dpi引数、またはsavefig.dpi)は別の設定であることを意識する
  • ブログ・Web用途ならdpi=150前後、印刷・論文用途ならdpi=300以上を明示的に指定する(本記事内の「論文品質グラフの savefig レシピ表」を参照)
  • figsizeを変えずにdpiだけを上げる調整(絶対ピクセル数が増え、ファイルも重くなる)と、figsize自体を変える調整(レイアウトの相対比率が変わる)は別の操作であることを区別する

まとめ

論文品質のグラフを作成するためのポイントをまとめます。

  • rcParamsでグローバル設定を定義し、一貫したスタイルを維持する
  • 日本語フォントはjapanize-matplotlibで手軽に対応する
  • カラーマップは色覚多様性に配慮したviridis系を選ぶ。カテゴリ配色は目視ではなくCVDシミュレーションで検証する(petroff10はMatplotlib 3.10+で検証済みの選択肢)
  • サブプロットはsharex/shareyalign_ylabelsで整列させる。カラーバーや凡例を含む複雑なレイアウトにはconstrained_layoutを使う
  • savefig()show()より前に、fig.savefig()の形で明示的に呼ぶ。DPIは表示用と保存用を別に管理する
  • 最終出力はPDF/SVGのベクター形式で保存する

Matplotlibの基本的な使い方や3Dアニメーション(GIF)の作成方法については、 MatplotlibでGIFアニメーションを作成する方法 もご参照ください。

関連記事