LoginSignup
484
466

More than 5 years have passed since last update.

seabornの細かい見た目調整をあきらめない

Last updated at Posted at 2019-01-04

はじめに

seabornの洗練されたスタイルで作ったグラフはとてもきれいです。見た目だけでなく、列の多いデータの全体像を把握するのにも威力を発揮します1。特に適切に整形されたデータフレームを渡せばカテゴリの比較や全パラメータの相関を一瞥できる図が一瞬で作れる機能は、同等の図をmatplotlibで一から作る苦労を考えると驚愕に値します。データサイエンティストやkagglerに人気があるのも納得です。また、複雑なデータを扱っていないけど単に見た目の良いグラフを作りたいという人の要望にも簡単に答えてくれます。可視化のお作法的にも見た目的にもだいたい勝手にいい感じにしてくれる手軽さが売りのseabornですが、ときには自分で調整したくなるときもあります。matplotlibだと面倒な調整を手軽にやってくれるseabornらしいメソッドで解決できるならいいのですが、たまにseabornのベースであるmatplotlibの機能に直接アクセスする必要が生じます2。「手軽に複雑できれいなグラフを作れる」という特徴がseabornの最大の魅力であることを考えると、多くのseabornユーザーにとってはmatplotlibのオブジェクトを直接触らないといけないタイプの調整は技術的にも心理的にもハードルが高いものでしょう。そこで本稿では、seabornでできる調整を公式ドキュメントより詳しく確認するとともに、いくつかの具体例を通してmatplotlibに直接触る必要がある細かい見た目調整のやり方を解説します。

目的と内容

この記事ではseabornできれいに作れる様々なプロットの紹介はしません。その代わりに、「手軽に複雑できれいなグラフを作れる」というseabornの特徴を無駄にしないためにmatplotlibに深入りするのを避けている人たち向けに、seabornに用意されているキーワードやメソッドでは調整できない部分のいじり方を紹介します。結局のところどの例もmatplotlibの見た目調整機能に行き着くのですが、"生"のmatplotlibに詳しくないseabornユーザーがああでもないこうでもないと迷うことなく最短経路で目的の機能にたどりつけるようになる内容です。また、seabornの機能で対応できる調整もまとめます。公式ドキュメントでもきちんと説明されていないものがあるのでこれだけでも有益に感じる方がいるかもしれません。

こんな人向け

  • seabornは常用してるけどたまに最後の調整がつらいと感じている。
  • ドキュメントを精読したけど調整したい部分に関連する項目がなくてあきらめたことがある。
  • ***Grid系のメソッドの使い方がよくわからず使うのをあきらめたことがある。
  • seabornに魅力を感じているけどお膳立てされたグラフの細かい調整に自信がないから妥協してmatplotlibを使い続けている。

環境

Python 3.6とJupyterを使っています。

%matplotlib inline
import seaborn as sns
import matplotlib.pyplot as plt

print(sns.__version__)
# 0.9.0

このあとの例ではJupyter notebookにinline表示されたpng画像を貼っています。Jupyter notebookのinline画像はfig.savefigメソッドでbbox_inches='tight'を指定した際のものに相当します。つまりbbox_inchesを指定せずにファイルに出力すると端が欠けている可能性があるので注意してください。

matplotlibに関する前提知識

matplotlibのオブジェクト指向インターフェースとArtistオブジェクトについての理解が必須です。必ずこの記事に一通り目を通してください。特にPyplotインターフェースとオブジェクト指向インターフェースの区別は必須です3。具体例を色々挙げていますが、結局のところ全てに共通するのは「使っているseaborn APIに応じた方法で変えたい部分のmatplotlibオブジェクトにアクセスし、当該オブジェクトのメソッドを実行する」という点です。Pyplotインターフェースを使った方法でも対応できるものはありますが、複数のグラフをちゃちゃっと作ってしまうseabornの特性上、matplotlibのオブジェクトに関する理解がないと「今やりたい変更はPyplotインターフェースで大丈夫か?」という判断を下せないので、横着せずにオブジェクト指向インターフェースを使った方が良いです。

seabornの便利プロット機能は何をしているのか

seabornの便利プロット関数たちは"figureレベル"と"axesレベル"の2種類に分けられます。
Figure-level and axes-level functions
これらはそれぞれmatplotlibのFigureオブジェクト全体を管理しているか、Axesオブジェクトのみを扱っているかの違いです4。見分け方は案外簡単で、matplotlibのAxesオブジェクトを指定するaxキーワードをとるかどうかで判断できます。seabornの関数(ドキュメントではAPIと呼ばれる)のほとんどがaxesレベルです。数の少ないfigureレベル関数とその返り値のseabornオブジェクトを以下に列挙します。灰色がかかって見にくいですが、関数名と返り値はそれぞれのドキュメントにリンクしています。

公式ギャラリーをみるとわかりますが、複数のグラフを一気に作ってくれるものはfigureレベル関数です。ここに挙げられていないものは全てaxesレベル関数です。

ちなみに、seabornの基本となるこれら二つのカテゴリについて言及している日本語の記事は以下の二つしか見当たりませんでした。ドキュメントを読んでる人もいると思うのですが、あまり日本語で情報発信する方は少ないようです5

seabornの***Grid系オブジェクト

matplotlibのラッパーであるseabornが扱うオブジェクトのほとんどはmatplotlibのArtistオブジェクトです。しかし、seabornの目玉機能とも言える複雑なプロットを作ってくれるfigureレベル関数向けにだけseaborn独自の***Grid系オブジェクトが用意されています。***Grid系オブジェクトはJointGridとそれ以外(FacetGridPairGridClusterGrid)の2種類に分けられます。これはそれぞれの見た目からなんとなく想像できます。クリックすると大きい画像が表示されます。

JointGrid FacetGrid PairGrid ClusterGrid

JointGridだけグリッド(碁盤の目)っぽくないですね。ソースコードを見ると、グリッドっぽい見た目の後者三つはまさにGridクラスを継承していますが、JointGridだけGridを継承していません6。この分類は後ほど説明するseabornでできる調整を知る際に必要な知識です。

描画に使われたmatplotlibオブジェクトにアクセスする

seabornがちゃちゃっと描いてくれたかっこいいグラフは(驚くことに)全てmatplotlibがベースになっています。では、seabornというラッパー(wrapper=包み紙)をはぎとって中にあるmatplotlibに触れるにはどうすればいいのでしょうか。

figureレベル関数

複数のグラフをだーっと並べてくれるfigureレベル関数はmatplotlibのFigureオブジェクトとAxesオブジェクトを関数内で自分で用意しているため7、ユーザーが用意したFigureAxesを指定することはできません。関数内で作られたFigureAxesは、figureレベル関数の戻り値である各***Gridオブジェクトのfigおよびaxes属性に保持されています。

# https://seaborn.pydata.org/examples/anscombes_quartet.html より
sns.set(style="ticks")
df = sns.load_dataset("anscombe") # seabornに含まれるサンプルデータの一つを読み込み
grid = sns.lmplot(x="x", y="y", col="dataset", hue="dataset", data=df,
                  col_wrap=2, ci=None, palette="muted", height=2,
                  scatter_kws={"s": 50, "alpha": 1})

print(type(grid))
print(type(grid.fig))
# <class 'seaborn.axisgrid.FacetGrid'>
# <class 'matplotlib.figure.Figure'>

# FacetGridは1D numpy arrayとして、FigureはリストとしてAxesを保持
print(type(grid.axes))
print(type(grid.fig.axes))
# <class 'numpy.ndarray'>
# <class 'list'>

# 中身は同じ
print(id(grid.fig.axes[0]) == id(grid.axes[0]))
# True

download.png

axesレベル関数

figureレベル関数とは異なり、axesレベル関数にはAxesオブジェクトを指定できるaxキーワードがあります。axの指定の有無に関わらず、描画に使ったAxesが戻り値です。axに何も指定しなかった場合はFigureAxesはそれぞれPyplotインターフェースと同様にcurrent figureとcurrent axesが設定されます8

# https://seaborn.pydata.org/examples/errorband_lineplots.html より
sns.set(style="darkgrid")
plt.figure(figsize=(4, 3)) # デフォルトだと大きいので小さめに
fmri = sns.load_dataset("fmri")
ax = sns.lineplot(x="timepoint", y="signal",
                  hue="region", style="event",
                  data=fmri)
print(type(ax))
# <class 'matplotlib.axes._subplots.AxesSubplot'>
print(id(ax) == id(plt.gca()))
# True
print(id(ax.figure) == id(plt.gcf()))
# True

download.png

current figure/axesを使っているということは、Axesを指定せずにaxesレベル関数で作ったグラフはPyplotインターフェースのplt.figure(figsize=(4, 3))plt.xlabelなどが使えます。Axesを指定した場合は当然Pyplotインターフェースの代わりにオブジェクト指向インターフェースのax.set_titleax.set_xlabelなどが使えます。

seaborn APIでは手の届かない見た目調整のエッセンスは実はこの部分で、あとは個々のmatplotlibオブジェクトに対して適切なメソッドを使うだけです。

seaborn APIで設定できる項目

seaborn APIではできないことの話に入る前にできることを確認します。まずaxesレベル関数にはマーカーや線のサイズなどプロット本体の設定項目、より具体的に言うとax.plotax.scatterに渡せる項目しかありません。これは「〜プロットと呼ばれる図を作る」というaxesレベル関数の役割を考えると理解できて、付属部品にすぎない軸ラベルやタイトルに関する設定はドキュメントを見てもありません。つまり、axesレベル関数の場合はマーカーや線などのプロット本体以外の調整は関数の戻り値であるmatplotlibのAxesを受け取ってそのメソッドを使えということです。

一方、figureレベル関数の場合は戻り値の***Grid系オブジェクトのメソッドを使うと以下のようにいろいろな設定ができます。

FacetGridPairGridClusterGridで使えるsetメソッド

これは三つのオブジェクトが継承している大元のGridで定義されているメソッドですが、実際は全てのAxesに対してmatplotlibのAxes.setメソッドにパラメータをそのまま渡して実行しているだけです9Axes.set自体がドキュメントの説明がいい加減なせいかあまり知られていないようですが、Axesドキュメントに登場するすべてのset_***系メソッドの***部分をパラメータとして一括設定できるそこそこ便利なメソッドです。よく使われる項目を例にすると以下のような感じで使えます。titleやxscaleなどにも使えます。

ax = plt.gca()
ax.scatter(3*np.random.rand(30), 3*np.random.rand(30))
ax.set(ylim=(0,3), ylabel='y position', xlim=(0,3), xlabel='x position', aspect='equal')

# 以下と同じ
# ax.set_ylim((0,3))
# ax.set_ylabel('y position')
# ax.set_xlim((0,3))
# ax.set_xlabel('x position')
# ax.set_aspect('equal')

download-4.png

一見便利そうに見えますが、Grid.setは全てのAxesに対して適用されてしまうので、例えばxlabelylabelを設定するとseabornがせっかくいい感じに消してくれた軸ラベルも表示されてしまいます。

sns.set(style="ticks")
df = sns.load_dataset("anscombe")
grid = sns.lmplot(x="x", y="y", col="dataset", hue="dataset", data=df,
                  col_wrap=2, ci=None, palette="muted", height=2,
                  scatter_kws={"s": 50, "alpha": 1})
grid.set(xlabel='xx', ylabel='yy')

download.png

これをmatplotlibのメソッドで修正するには、左端と下端のAxesを区別する条件をつけてラベルを消していく必要があり「せっかくseabornを使っているのに何をやってるんだろうか」とむなしくなります。Grid.setメソッドが適しているのはこのような影響を受けないx/ylimx/yscaleaspectの設定でしょう。

FacetGrid独自のメソッド

FacetGridを返すrelplotcatplotlmplotの場合、Grid.set向きではないラベルやタイトルの設定には以下に挙げるFacetGrid独自のメソッドが便利です。

メソッド 説明
set_axis_labels([x_var, y_var]) 左端と下端のラベルを設定
set_xlabels([label]) 下端のラベルのみ設定
set_ylabels([label]) 左端のラベルのみ設定
set_titles([template, row_template, …]) 全てのAxesの上部またはグリッド上部や右側に列や行のカテゴリや値を示すタイトルを設定
set_xticklabels([labels, step]) 下端の数字ラベルを設定
set_yticklabels([labels]) 左端の数字ラベルを設定

一気に複数の部品を変更するため、Axesの似たようなメソッド名とは異なりどれもset_複数形になっていることに注意してください。しかし、実は注意が必要なのはメソッド名だけではありません。まずは特に注意の必要ない軸ラベルの変更をみてみましょう。

set_axis_labelsの例

軸ラベルの変更はメソッド名さえ知っていれば簡単です。

df = sns.load_dataset("anscombe")
grid = sns.lmplot(x="x", y="y", col="dataset", hue="dataset", data=df,
                  col_wrap=2, ci=None, palette="muted", height=2,
                  scatter_kws={"s": 50, "alpha": 1})
grid.set_axis_labels('x (sec)', 'y (m)') # x_var='x (sec)'などとしてもよい
# 以下も同じになる
# grid.set_xlabels('x (sec)').set_ylabels('y (m)')
# selfが返ってくるのでこのようなメソッドチェーンにもできる

download-1.png

set_titlesの例

FacetGridにおける各Axesのタイトルは元データのDataFrameの構造に忠実に従うべきという設計思想になっているようで、seabornで用意されているFacetGridl.set_titlesメソッドはあまり融通が利きません10ドキュメントには使い方が書いていないのでdocstringを見ると{col_var}{col_name}といった指定キーを使ってフォーマットしろとありますが、いまいちなんのことかわかりません。

set_titlesのdocstring
Signature: g.set_titles(template=None, row_template=None, col_template=None, **kwargs)
Docstring:
Draw titles either above each facet or on the grid margins.

Parameters
template : string
    Template for all titles with the formatting keys {col_var} and
    {col_name} (if using a `col` faceting variable) and/or {row_var}
    and {row_name} (if using a `row` faceting variable).
row_template:
    Template for the row variable when titles are drawn on the grid
    margins. Must have {row_var} and {row_name} formatting keys.
col_template:
    Template for the row variable when titles are drawn on the grid
    margins. Must have {col_var} and {col_name} formatting keys.

実際に使って見ましょう。

# https://seaborn.pydata.org/generated/seaborn.FacetGrid.html より
sns.set(style="ticks", color_codes=True)
tips = sns.load_dataset("tips")
g = sns.FacetGrid(tips, col="size", col_wrap=3)
g = g.map(plt.hist, "tip", bins=np.arange(0, 13), color="c"
g.set_titles("{col_var}={col_name} diners"))
g.fig.set_size_inches((6, 4))

download-2.png

tipsというDataFrameの中身は以下のようになっています。
スクリーンショット 2018-12-18 1.27.58.png
上の例のg.set_titles("{col_var}={col_name} diners"))では、size列の値ごとに作ったtipのヒストグラムに列の名前col_varと値col_nameを使ったタイトルを付けています11

colrowの両方を指定した場合のデフォルトタイトルは「row名 = row値 | col名 = col値」です。

kws = dict(s=50, linewidth=.5, edgecolor="w")
g = sns.FacetGrid(tips, col="smoker", row="sex")
g = g.map(plt.scatter, "total_bill", "tip", color="m", **kws)
g.set(xlim=(0, 60), ylim=(0, 12), xticks=[10, 30, 50], yticks=[2, 6, 10])

download-3.png

このときset_titlesは以下のように使えます。

g.set_titles(template='{row_var}: {row_name}\n{col_var}: {col_name}')
g.fig

download-4.png

FacetGridを作る際にmargin_titlesキーワードを使った場合は以下のようなことができます。

kws = dict(s=50, linewidth=.5, edgecolor="w")
g = sns.FacetGrid(tips, col="smoker", row="sex", margin_titles=True)
g = g.map(plt.scatter, "total_bill", "tip", color="m", **kws)
g.set(xlim=(0, 60), ylim=(0, 12), xticks=[10, 30, 50], yticks=[2, 6, 10])

download-5.png

g.set_titles(row_template='{row_var}: {row_name}', col_template='{col_var}: {col_name}')
g.fig

download-6.png

set_xticklabelsset_yticklabelsの例

set_titlesではseabornの設計思想に注意する必要がありましたが、set_xticklabels/set_yticklabelsはmatplotlibのTickの仕様に注意する必要があります。

tips = sns.load_dataset("tips")
g = sns.FacetGrid(tips, col="size", col_wrap=3)
g = g.map(plt.hist, "tip", bins=np.arange(0, 13), color="c")
g.set_titles("{col_var}={col_name} diners")
g.fig.set_size_inches((6, 4))
g.set_xticklabels(['', 'zero', 'five', 'ten', ''])

download-7.png

set_xticklabelsに渡しているリストの最初と最後に謎の空string要素があります。これは描画範囲の両端の外側に一つずつ見えないTickが設定されるmatplotlibの謎仕様に対応するためです。これは以下のようにするとわかります。

set_xticklabels適用前
[l for l in g.axes[0].get_xticklabels()]
# [Text(-5.0, 0, '−5'), # 謎tick
#  Text(0.0, 0, '0'),
#  Text(5.0, 0, '5'),
#  Text(10.0, 0, '10'),
#  Text(15.0, 0, '15')] # 謎tick
set_xticklabels適用後
[l for l in g.axes[0].get_xticklabels()]
# [Text(-5.0, 0, ''),  # 謎tick
#  Text(0.0, 0, 'zero'),
#  Text(5.0, 0, 'five'),
#  Text(10.0, 0, 'ten'),
#  Text(15.0, 0, '')] # 謎tick

JointGrid独自メソッド

ドキュメントをみるとJointGridにもset_axis_labelsメソッドがあります。メインプロットの軸ラベルを変えるだけのメソッドなので特に便利というわけでもありません。

# https://seaborn.pydata.org/generated/seaborn.JointGrid.html より
g = sns.JointGrid(x="total_bill", y="tip", data=tips, space=0)
g.plot_joint(sns.kdeplot, cmap="Blues_d")
g.plot_marginals(sns.kdeplot, shade=True)
g.fig.set_size_inches((4,4))
g.set_axis_labels('total bill (USD)', 'tip (USD)')

download-8.png

seaborn APIで設定できない項目の具体例

画像サイズ

これはすでに今までの例でもしれっと使っていました。axesレベル関数とfigureレベル関数で少し違います。axesレベル関数の場合はプロット関数実行前にあらかじめ画像サイズを定義できます。

# axを渡す場合
fig, ax = plt.subplots(figsize=(8, 3))
sns.lineplot(x="timepoint", y="signal",
             hue="region", style="event",
             data=fmri, ax=ax)
# axを渡さない場合はpyplotインターフェースでもよい
# 出力は上の例と同じ
plt.figure(figsize=(8, 3))
# plt.rcParams['figure.figsize']=(8, 3) # matplotlibのデフォルト設定を変えても同じ結果
ax = sns.lineplot(x="timepoint", y="signal",
                  hue="region", style="event",
                  data=fmri)

download-3.png

axキーワードでAxesオブジェクトを指定しない場合は、matplotlibのデフォルト設定に従うのでplt.rcParams['figure.figsize']=(8, 3)としても上と同じ結果が得られます。ただし、当然ながらデフォルト設定を変えるとこの後に作った図のサイズも影響を受けます。

figureレベル関数の画像サイズにはplt.rcParams['figure.figsize']は使われずseaborn関数のheightaspectから計算された設定された高さと幅が設定されます12。従ってfigureレベル関数の画像サイズは描画時に指定するか、描画後に変更することができます。ただし、seabornの関数で指定するのはプロット一つずつのサイズなので実際の画像のインチ幅はheight*aspect*プロット列数、高さはheight*プロット行数になります。その他にも以下に示すようにサイズ指定タイミングが描画時と描画後かによって、プロット間の余白やtickラベルなど細かいパーツの扱いが変わってくるので少し試行錯誤する必要はあるかもしれません。

grid = sns.lmplot(x="total_bill", y="tip", col="time", hue="smoker",
                  data=tips, legend_out=False)
grid.fig.set_size_inches((4, 3))

download-4.png

# 細かい部分を気にせずに4インチx3インチになりそうな単一プロットのアスペクト比を設定
grid = sns.lmplot(x="total_bill", y="tip", col="time", hue="smoker",
                  data=tips, legend_out=False, height=3, aspect=4/3/2)

download-5.png

このほかにもポスター向けやスライド向けに画像サイズとフォントサイズを同時に調整してくれるset_contextというseabornらしいメソッドもあります。詳細は以下をご覧ください。
seabornでMatplotlibの見た目を良くする | note.nkmk.me

上の例では問題はありませんでしたが、grid.fig.set_size_inchesで画像サイズを変えると凡例が微妙な位置にくることもあります。その際は後述する方法で凡例をいい位置に動かせば解決します。

タイトルと軸ラベル

FacetGridのタイトルは元データのカラム名と値をベースにしたものしかつけられないという制限がありました。また、axesレベル関数にはそもそもタイトルがつきません。一般に我々ユーザーはわがままですから、制限なく自由に設定できる方法は知っておくと良いでしょう。タイトルや軸ラベルはmatplotlibのFigureAxesにアクセスすることで自由に修正可能です。

# figureレベル関数の例
sns.set(style="ticks")
grid = sns.lmplot(x="x", y="y", col="dataset", hue="dataset", data=df,
                  col_wrap=2, ci=None, palette="muted", height=2,
                  scatter_kws={"s": 50, "alpha": 1})

grid.fig.suptitle('suptitle via grid.fig', y=1.02)

# grid.axesはnumpy arrayなのでravelかflatで一次元化します
for ax, title in zip(grid.axes.ravel(), ['a', 'b', 'c', 'd']):
    ax.set_title(f'changed title {title}')

# for ax in grid.axes[2:]: # 自分で変えるAxesを指定する場合
for ax in grid._bottom_axes: # 下端のAxesだけにアクセスできるプライベート変数を使う場合
    ax.set_xlabel('x (sec)')

download-1.png

Figureオブジェクトはplt.gcf()でも取得可能ですが、figureレベル関数で描画した後でないと意図通りに動きません。

fig = plt.gcf() # この段階のcurrent figureはlmplotには使われない
grid = sns.lmplot(x="x", y="y", col="dataset", hue="dataset", data=df,
                  col_wrap=2, ci=None, palette="muted", height=2,
                  scatter_kws={"s": 50, "alpha": 1})

# plt.gcf before lmplot
print(id(fig) == id(grid.fig))
# False

# plt.gcf after lmplot
fig = plt.gcf()
print(id(fig) == id(grid.fig))
# True

fig.suptitle('suptitle via gcf', y=1.02)

download-2.png

axesレベル関数の場合はPyplotインターフェースでもオブジェクト指向インターフェースでも同じことができます。

# axesレベル関数の例
sns.set(style="darkgrid")
fmri = sns.load_dataset("fmri")
plt.figure(figsize=(4, 3)) # デフォルトだと大きいので小さめに
ax = sns.lineplot(x="timepoint", y="signal",
                  hue="region", style="event",
                  data=fmri)
ax.set_title('title test')
ax.set_xlabel('time (msec)')

# lineplot実行後ならax = plt.gca()でも同じAxesを取得できる
print(id(ax) == id(plt.gca()))
# True
# 出力される図は上と同じ
sns.lineplot(x="timepoint", y="signal",
             hue="region", style="event",
             data=fmri)
# current axesに対する設定
plt.title('title test')
plt.xlabel('time (msec)')

download-3.png

凡例

調整したくなるグラフのパーツランキングがあれば間違いなく上位に入るであろう凡例は、いろいろといい感じにしてくれるseabornでも(いろいろと勝手にやってしまうが故に)手を加えたくなるもののようです。Stack Overflowにも位置を変えたいダブりを消したい凡例自体を消したいテキストを変えたいタイトル(もどき)を消したいなど、凡例に関して思い浮かぶ調整例がだいたい出てきます。これらの質問に対する解決方法は以下の二つの戦略のどちらかに従うか両方のいいとこ取りをしています13

  1. matplotlibのlegendメソッドを直接使って一から自分で作る(seaborn内で凡例を作らないようにするか作った後に上書き)
  2. seabornがだいたいいい感じに作ってくれたmatplotlibのLegendオブジェクトにアクセスして修正する

axesレベル関数で作った単一のプロットの場合は1で簡単に済むこともありますが、例えば「タイトルと軸ラベル」の最後に示したhuestyleを使ったlineplotの凡例を全て自分で作るのはかなり難しいです。seabornが自動的に作った凡例は、たとえ修正が必要だろうが複雑なプロットであるほどありがたい存在なので、できれば2で解決していきたいものです。ただ、例えば凡例の位置の調整は2だけでは非常に厄介なので、seabornの作った凡例の内容をコピーして作り直すという1と2の合わせ技が最適な場合もあります。seabornの凡例関連の挙動は少し癖があるので、具体例を紹介する前に知っておくべき注意事項を挙げておきます。

seabornの凡例における注意点

Legendオブジェクトの場所 Gridクラスを継承した***Gridオブジェクトを返すrelplot, catplot, pairplot, lmplotはデフォルトではmatplotlibのfig.legendメソッドを使って凡例を作っています。このとき作られたLegendオブジェクトはfig.legends属性にリスト要素として格納されています14。ただし、legend_outキーワードのあるlmplotcatplotではlegend_out=Falseを指定した場合、左上の最初のAxesax.legendが実行されます。従ってLegendオブジェクトはax.legend_が保持しています。

タイトルとタイトルもどき hueでしかグルーピングできないrelplot, linepltなどの凡例タイトルはLegendオブジェクトのset_titleメソッドを使った正式なタイトルです。しかし、huesizeにより複数のグルーピングを指定できるlmplot, pairplot, boxplotなどの凡例にタイトルのように表示されるカラム名の実体は、Stack Overflowのこの回答で指摘されている通り、マーカーのない文字のみの凡例です。つまりax(fig).legendメソッドのtitleキーワードやLegendオブジェクトのset_titleメソッドでは変更できません。

figureレベル関数の凡例の位置 figureレベル関数がデフォルトで凡例作成に使っているfig.legendは、figureレベル関数内で作成されたFigureオブジェクトのサイズから右端中央のちょうどよい位置を算出して凡例を配置しています。これはつまり、プロット作成後に画像サイズを変えると凡例が不恰好な位置にくる可能性が高いことを意味します。

以上の注意点を踏まえてStack Overflowの凡例関連の質問や以下の例を見ると、seabornの凡例調整が一気にクリアになると思います。以下ではよくある三つの調整(タイトル、位置、ラベル)を扱います。

タイトルまたはタイトルもどきを変える

グルーピングにhueのみを使うAPIの場合は正規の凡例タイトルを変えれば良いです。

# グルーピングがhueのみのAPIはlegendのset_titleが使える
grid = sns.lmplot(x="total_bill", y="tip", col="time", hue="smoker",
                  data=tips)
grid.fig.set_size_inches((9, 3))
lg = grid.fig.legends[0] # legend_out=Falseの場合は lg = grid.fig.axes[0].legend_
print(type(lg))
# <class 'matplotlib.legend.Legend'>
print(lg.texts)
# [Text(0, 0, 'Yes'), Text(0, 0, 'No')]
print(lg.get_title())
# Text(0, 0, 'smoker')
lg.set_title('changed\nvia set_title')

download.png
三つめの注意点の通り、画像サイズを変更したため凡例がおかしな位置にきています。これを修正する方法は後述します。

複数のグルーピング基準を設定できるAPIの場合は、タイトルもどきの正体である凡例のlabelを変える必要があります。labelの実体はLegend.textsリストに入っているTextオブジェクトなので、set_textメソッドを使って該当要素を変更します。

# 複数グルーピングのあるAPIはラベルを変える
grid = sns.relplot(x="total_bill", y="tip", col="time",
                   hue="smoker", size="size",
                   data=tips)
grid.fig.set_size_inches((8, 3))
lg = grid.fig.legends[0]
print(lg.texts) # タイトルもどきとラベルは同じオブジェクト
# [Text(0, 0, 'smoker'), Text(0, 0, 'Yes'), Text(0, 0, 'No'), Text(0, 0, 'size'), Text(0, 0, '0'), Text(0, 0, '2'), Text(0, 0, '4'), Text(0, 0, '6')]
print(lg.get_title()) # 正式なタイトルはない
# Text(0, 0, '')
lg.texts[0].set_text('SMOKER') # 元はsmoker
lg.texts[3].set_text('SIZE') # 元はsize

download-1.png

axesレベル関数でもLegendオブジェクトを取得する場所が変わっているだけでやることは同じです。

# 複数グルーピングのあるAPIなのでラベルを変える
plt.figure(figsize=(4, 3))
ax = sns.lineplot(x="timepoint", y="signal",
                  hue="region", style="event",
                  data=fmri)
lg = ax.legend_
print(lg.texts)
# [Text(0, 0, 'region'), Text(0, 0, 'parietal'), Text(0, 0, 'frontal'), Text(0, 0, 'event'), Text(0, 0, 'stim'), Text(0, 0, 'cue')]
print(lg.get_title())
# Text(0, 0, '')
lg.texts[0].set_text('REGION') # 元はregion
lg.texts[3].set_text('EVENT') # 元はevent

download-2.png

凡例の位置を変える

seabornの各関数の凡例に関する部分のソースを確認すると、axesレベル関数の場合は凡例の位置にはキーワードなしのax.legendメソッドが実行されているのでデフォルトのloc="best"が指定されています。figureレベル関数の場合はfig.legendメソッド実行時にloc="center right"が指定されていています15。これらを変更するキーワードはseabornには用意されていません。つまりデフォルトの位置で納得がいかない場合は、matplotlibのLegendオブジェクトにアクセスする必要があり、手軽なものから順に以下のような方針があり得ます。

  1. seabornで作った凡例の_loc属性を変える方法16
  2. 位置を指定して新しく作り直す方法
    1. 簡単に作れる場合
    2. ハンドルとラベルを引き継いだ方が良い場合
  3. seabornで作った凡例のbbox_to_anchorを変える方法17

たいていの場合は一番手軽な1で済むでしょう。1がダメなら2でlocbbox_to_anchorを指定して微調整すれば解決すると思います。したがってわざわざ3を使う意味はないと思いますが、何らかの事情で凡例の作り直しが許されない場合に使えるかもしれません。あるいはmatplotlibのパーツの位置調整でよく出てくるbbox_to_anchorがどういうものか理解する助けになると思います。

figureレベル関数でlegend_out=Falseを使った場合、つまりax.legendメソッドで作られた凡例の場合を例にします。axesレベル関数でもほぼ同じコードが使えます。以下の例ではloc="best"によって凡例は左上に配置されています。

# legend_out=Falseだとax.legendのloc='best'がデフォルト
grid = sns.lmplot(x="total_bill", y="tip", col="time", hue="smoker",
                  data=tips, legend_out=False)
grid.fig.set_size_inches((9, 3))
grid.fig.suptitle('default: loc="best"', y=1.1)

download-6.png

まずは一番手軽な_locを変更する方法で右下に動かします。legendメソッドでlocを指定する際には"lower right"などの文字列が使えましたが、_locでは数字しか指定できません。文字列と数字の対応はドキュメントを参照してください。

grid.fig.axes[0].legend_._loc = 4 # lower right
grid.fig.suptitle('ax.legend_._loc = 4 (lower right)', y=1.1)
grid.fig

download-7.png

次は、seabornがax.legendメソッドで作った凡例を、自分で位置指定をしたax.legendメソッドで上書きする方法です。Axesオブジェクトは一つしか凡例を持てないので、もう一度ax.legendメソッドを実行すると先に作ったものが上書きされる仕様を利用しています。

grid.fig.axes[0].legend(title='smoker\nvia ax.legend', loc='center right')
grid.fig.suptitle('ax.legend + title, loc', y=1.1)
grid.fig

download.png

複数グルーピングされタイトルもどきを含むプロットも場所はloc="best"で制御されています。以下の例の場合は右上に配置されています。

plt.figure(figsize=(4, 3))
# Plot the responses for different events and regions
ax = sns.lineplot(x="timepoint", y="signal",
                  hue="region", style="event",
                  data=fmri)
ax.set_title('default: loc="best"')

download-1.png

この凡例を上書きで位置を指定する場合は、seabornが作ったタイトルもどきなどをそのまま利用するのがよいでしょう。ax.get_legend_handles_labelsメソッドによってhandle(線やマーカー)とlabel(文字列)を取得してax.legendに渡すと、面倒なタイトルもどきの部分を自分で作ることなく位置を変更できます。

plt.figure(figsize=(4, 3))
ax = sns.lineplot(x="timepoint", y="signal",
                  hue="region", style="event",
                  data=fmri)
# lineplotが作った凡例の材料を取得
handles, labels = ax.get_legend_handles_labels()
# 上書きする際に位置を指定
ax.legend(handles, labels, loc='upper left', bbox_to_anchor=(1, 1), frameon=False)
ax.set_title('ax.get_legend_handles_labels()\n+ax.legend+loc, bbox_to_anchor')

download-2.png

凡例の背景色や枠線を消すためにframeon=Falseとしています。

次に、以下のようにfigureレベル関数の画像サイズを変更したら凡例が微妙な位置にきてしまったケースで凡例の位置を修正します。

grid = sns.relplot(x="total_bill", y="tip", col="time",
            hue="smoker", size="size",
            data=tips)
grid.fig.set_size_inches((8, 3))

download-3.png

画像サイズ変更前と同じような位置に戻してみましょう。今回の凡例にもタイトルもどきが含まれるので、handleとlabelを拝借する方針でいきます。しかし、fig.legendメソッドで作った凡例のhandleとlabelを取得するのに、先の例で使ったax.get_legend_handles_labelsメソッドは使えません。なぜならfig.legendFigureオブジェクトに所属する凡例を作るメソッドであり、ax.get_legend_handles_labelsAxesオブジェクトに所属する凡例を対象とした便利メソッドだからです。そこでLegendオブジェクトの属性からhandleとlabelを別々に取得します。

lg = grid.fig.legends[0] # Figureは複数の凡例を保持できるのでリストの最初の凡例を指定する
handles = lg.legendHandles # handleが保持されているリスト
labels = [t.get_text() for t in lg.texts] # labelを保持するTextオブジェクトのリストから文字列のみを抽出
lg.remove() # 削除
# fig.legendよりもax.legendのほうが位置を指定しやすい(transformを使う必要がない)
grid.fig.axes[1].legend(handles, labels, loc='center left', bbox_to_anchor=(1, 0.5),
                        frameon=False, )
grid.fig

download-4.png

ところで、fig.legendメソッドは「figに所属する各Axesオブジェクトからhandleとlabelをかき集めて凡例を作る」という仕様になっています。つまりfigに含まれるAxesのどれかがhandleとlabelを保持しているはずなので、fig.legendが作ったLegendオブジェクトにアクセスしなくとも、しかるべきAxesオブジェクトに対してax.get_legend_handles_labelsを使えばhandleとlabelが一気に取得できるはずです。seabornのfigureレベル関数の場合はgrid.fig.axes[0]がしかるべきAxesです。

print(grid.fig.axes[0].get_legend_handles_labels())
output
 ([<matplotlib.collections.PathCollection at 0x13f8c0128>,
  <matplotlib.collections.PathCollection at 0x13f9c9fd0>,
  <matplotlib.collections.PathCollection at 0x13f9f2860>,
  <matplotlib.collections.PathCollection at 0x13f9f2be0>,
  <matplotlib.collections.PathCollection at 0x13f9f2f60>,
  <matplotlib.collections.PathCollection at 0x13f9fa0f0>,
  <matplotlib.collections.PathCollection at 0x13f9fa748>,
  <matplotlib.collections.PathCollection at 0x13f8af7f0>],
 ['smoker', 'Yes', 'No', 'size', '0', '2', '4', '6'])
grid = sns.relplot(x="total_bill", y="tip", col="time",
                   hue="smoker", size="size",
                   data=tips)
grid.fig.set_size_inches((8, 3))
grid.fig.legends[0].remove() # Figureの凡例を削除
handles, labels = grid.fig.axes[0].get_legend_handles_labels()
# 今度は右axes外側の右上へ
grid.fig.axes[1].legend(handles, labels, loc='upper left', bbox_to_anchor=(1, 1), frameon=False, )
grid.fig.suptitle('ax.get_legend_handles_labels()\n+ax.legend+loc, bbox_to_anchor', y=1.15)

download-6.png

最後に、seabornが作ったLegendオブジェクトを上書きすることなくそのまま使って位置を変更する方法を紹介します。実際にこの方法が必要な機会は少ないでしょうが、locbbox_to_anchorキーワードをどう指定すればいいか理解する助けにはなるでしょう。

grid = sns.lmplot(x="total_bill", y="tip", col="time", hue="smoker",
                  data=tips, legend_out=False)
grid.fig.set_size_inches((8, 3))
ax = grid.fig.axes[0] # 凡例のあるaxes
lg = ax.legend_ # Legendオブジェクト
lg._loc = 4 # lower right

# Axes描画領域の左下を(0, 0)右上を(1, 1)と考えたときのbounding box(凡例の位置を指定するのに使う領域または点)
bb = lg.get_bbox_to_anchor().inverse_transformed(ax.transAxes) 

# Axes描画領域の左下を(0, 0)右上を(1, 1)と考えたときの凡例の位置指定に使う座標
lg_x, lg_y = 0.3, 0.5 
# bounding boxと呼んではいるが四隅を同じ座標にして点として利用する
bb.set_points(np.array([[lg_x, lg_y],
                        [lg_x, lg_y]]))
# 凡例をanchorする座標として新しいbounding boxを指定(アンカー=元はいかりを下ろすという意味)
# _locはbouding boxのどの四隅に凡例を持っていくかという意味。bouding boxが点の場合は「凡例のどの四隅を点に寄せるか」という意味。
lg.set_bbox_to_anchor(bb, transform = ax.transAxes)

grid.fig.suptitle(f'loc="lower right", bbox_to_anchor: ({lg_x}, {lg_y}) set by bb.set_points', y=1.1)

# bounding boxの点を黒のバツで表示。
ax.scatter([lg_x], [lg_y], transform=ax.transAxes, marker='x', color='k', zorder=3)

download-9.png

詳細はコードのコメントをみてください。bbox_to_anchorはそのままで_locを左上に変更するとこれらの役割がより明確にわかるでしょう。

lg._loc = 2 # upper left
grid.fig.suptitle(f'loc="upper left", bbox_to_anchor: ({lg_x}, {lg_y}) set by bb.set_points', y=1.1)
grid.fig

download-10.png

凡例のラベルを変える

「凡例のラベル」というのは、直前のグラフでいうところのYesとNoです。seabornではDataFrameの保持する値が凡例のラベルにそのまま使われるので、seabornの設計思想に則ってラベルを変えるならDataFrameの値を変えるべきです。しかし、なんらかの都合により元データを変更したくない場合は、DataFrameはそのままで可視化の段階で凡例のラベルを変えることもできます。凡例のラベルの実体であるTextオブジェクトのset_textメソッドで変更します。

grid = sns.relplot(x="total_bill", y="tip", col="time",
            hue="smoker", size="size",
            data=tips)
grid.fig.set_size_inches((8, 3))
lg = grid.fig.legends[0]
lg.texts[1].set_text('1') # 元はYes
lg.texts[2].set_text('0') # 元はNo

download-11.png

カラーバー

matplotlibのカラーバーの振る舞いは基本を踏まえていないと何が起こっているか非常にわかりづらいため、細かいところが気になりだすと解決するのに平気で数時間かかることがあります。matplotlibを意識せずともカラーバーを作ってくれるseabornの場合はユーザーにとっては輪をかけてわかりづらいパーツでしょう。Stack Overflowにもseabornユーザーによるカラーバー関連の質問は多いです。以前私が書いたカラーバー関連の記事を見るとわかる通り、細かいことを述べ出したらキリがないので、ここでは、修正頻度が高いだろうカラーバーの位置、タイトル、目盛り位置、目盛りラベル関連の例を示します。

まずはデフォルト設定でヒートマップを作ってみます。

flights_long = sns.load_dataset("flights")
flights = flights_long.pivot("month", "year", "passengers")

fig, ax = plt.subplots(figsize=(6, 6))
sns.heatmap(flights, annot=True, fmt="d", linewidths=.5, ax=ax)

download-12.png

heatmapドキュメントを見るとcbar_kwsというのがあります。ここで辞書型パラメータを指定することでmatplotlibのfig.colorbarにパラメータを渡すことができます。以下の例ではカラーバーの位置を上部にし、カラーバーのタイトル(実際は軸ラベル)をつけて、マイナー目盛りをつけました。

fig, ax = plt.subplots(figsize=(6, 6))
sns.heatmap(flights, annot=True, fmt="d", linewidths=.5, ax=ax, 
            cbar_kws = dict(use_gridspec=False, location="top",
                            label='No. of passengers'))
# minor tickはColorbarオブジェクトのメソッドを利用
ax.collections[0].colorbar.minorticks_on()

download-13.png

locationを指定するにはuse_gridspec=Falseである必要があるので注意してください18

メジャー目盛りの位置を指定するには手動かlocatorを使う方法があります。以下の例はticks=[200, 400, 600]としても同じ結果が得られますが、値の表示範囲が変わった際に自動的に対応してくれるlocatorを使うことをオススメします。

from matplotlib.ticker import MultipleLocator

fig, ax = plt.subplots(figsize=(6, 6))
sns.heatmap(flights, annot=True, fmt="d", linewidths=.5, ax=ax, 
            cbar_kws = dict(use_gridspec=False, location="top",
                            label='No. of passengers', ticks=MultipleLocator(200)))
ax.collections[0].colorbar.minorticks_on()

download-14.png

軸ラベルや目盛りラベルのフォントサイズは以下のように指定できます。マイナー目盛りを内向きにすると、表示レイヤーの順番のせいで目盛りがカラーバーの下に埋もれてしまうので、カラーバーが一番下のレイヤーに来るようにzorderを最小値の0にしています。

cax = ax.collections[0].colorbar.ax # ColorbarオブジェクトからカラーバーのAxesオブジェクトにアクセス
# cax = fig.axes[-1] # これでもよい
cax.tick_params(which='minor', direction='in')
cax.tick_params(which='major', labelsize=20)
cax.xaxis.label.set_fontsize(20)
cax.collections[0].set_zorder(0) # colorbarの色部分を一番下のレイヤーに
fig

download.png

何はともあれマニュアル

2018年7月にリリースされた0.9.0でマニュアル、特にintroductionが大幅に改善されたようです。これまで多くの人にとっての混乱のもとだったfigureレベル関数とaxesレベル関数の区別についても明記されるようになりました。この記事の例を追えば大抵のことはできるようになると思いますが、何はともあれ使いたい関数にどのようなパラメータがあるかドキュメントで確認しておきましょう。

seabornの見栄えの良さに酔わない

この記事のお題からはそれますが、日本語でseabornについて調べてみるとかっこいいから程度の理由でpalletehueを濫用している人が多いのが気になりました。かっこいいからという理由だけの多色化は可視化のご法度です。seabornのドキュメントにも以下のように明記されています。
Choosing color palettes — seaborn 0.9.0 documentation

Color is more important than other aspects of figure style because color can reveal patterns in the data if used effectively or hide those patterns if used poorly.
(意訳)色使いは、きちんと使えばデータからパターンを浮かび上がらせ、下手に使えば逆にそれらを隠してしまうという意味で、プロットにおける他のどの要素よりも重要です。

多色化がご法度なのはグラフに限らずプレゼンテーションスライドでも一緒です。デザインやブランディングに力を入れている組織の人が作ったスライドを見れば、グラフを含めて色数が絞られているのに気づくでしょう。色の濫用は控えましょう。


  1. Qiitaではこちらこちらなどが有名でしょうか。最近投稿されたこちらこちらは網羅的ですね。 

  2. 公式ドキュメントにも"Many tasks can be accomplished with only seaborn functions, but further customization might require using matplotlib directly." (訳:だいたいseabornだけで大丈夫だけど凝った調整は直接matplotlibを使わないとダメかも)と書いてあります。 

  3. 問題に遭遇するたびにググって一本釣りした対症療法から断片的な知識を得てどうにかやっている人にとっての最後の砦であるStack Overflowにも質問・回答共にこれらを混同している例があります。 

  4. ここを把握していないと混乱しやすいです。たとえばStack Overflowのこの回答は典型的な間違いの例で、なぜかベストアンサーになってますが質問者の問題は解決できていません。 

  5. 直前までまとめてあきらめてしまっていた記事もありました。 

  6. GridFacetGridPairGridJointGridのコードはaxisgrid.pyClusterGridのコードはmatrix.pyにあります。 

  7. 例えばFacetGridのコードの該当部分はこちら 

  8. 例えばregplotのコードの該当部分はこちら。current axesを設定しているため、明示されていなくてもcurrent figureが使われています。 

  9. このコードGriddef setの部分を参照。 

  10. これは「FacetGridのタイトルを変えたいけどうまくいかない」というStack Overflowの質問への回答でseaborn作者のmwaskomが「データのカラム名そのものを変えるべき、無理ならmatplotlibで直に変えて」と言っていることからも分かります。可視化に伴うヒューマンエラーが減るだろうということみたいです。 

  11. *_varが名前で*_nameが値なのは直感的ではないので、間違って設定されている気もします。そのうち修正されるかもしれません。 

  12. 例えばrelplotならheight=5, aspect=1デフォルトです。 

  13. データ構造に従った可視化を重要視しているseabornの設計思想からすると、見た目の一つである凡例を変えたいならデータの表現を整えるべきということになりますが、データの表現(例えば1/0とするかYes/Noとするか)はデータサイズや諸々の事情によりそう簡単に変えられるものでなかったりするので、データをいじらずに見た目を調整することが理にかなっていることもあると思います。 

  14. Stack Overflowではこれらのプロットでfig.legendにより作られた凡例をgrid._legendで取得するという回答が多いですが、アンダースコアが付いているということはプライベート変数なので利用が推奨されるものではないはずです。本稿で紹介している例のようにpublicなgrid.fig.legendsから取得しましょう。 

  15. Gridオブジェクトのadd_legendメソッドを読むと、ひとまずcenter rightに配置した後、凡例がプロットと重ならないように画像の幅を合わせていることがわかります。 

  16. どうも凡例の位置を指定するlocキーワードはプライベート変数である_locでしか変更できないようです。 

  17. SOのこの回答を参考にしました。 

  18. そもそもlocationキーワードの存在自体がfig.colorbarドキュメントに書かれておらず、fig.colorbarが内部で呼んでいるmake_axesメソッド(ドキュメント)とmake_axes_gridspecメソッド(ドキュメント)を自分で探し出すか、Stack Overflowのこの回答などを(幸いにも)見つけだす必要があります。 

484
466
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
484
466