Python
matplotlib
pandas
Visualization
seaborn

「データ視覚化のデザイン #1」をmatplotlibで実装する

データ視覚化のいろはを無視したグラフはニュースや学術論文によく現れます。いろんな資料からかきあつめたり苦労して測定したデータ、あるいは自分の部署の成果をかっこよく見せたい気持ちはわかりますが、たいていの場合「よく見せる」という欲求は色の濫用や3D化などデザイン要素の足し算として現れがちです。結果としてよく見せたいデータがごちゃごちゃした印象になってしまい、メッセージを読み取りにくいだけでなく時に誤解を生む図に仕上がってしまっていることも多いでしょう。

「データ視覚化のいろは」とは書きましたが、自分は実際に体系的に学んだことがあるわけではなく、ウェブや論文などで目にした良い例と悪い例からぼんやりと「こうすべきなのかな」という指針を認識している程度です。そんな折に、UXやUIを突き詰めたサービスで有名なTHE GUILDの方がデータ視覚化に関するnote記事を公開しているのをみつけました。
データ視覚化のデザイン #1|Go Ando / THE GUILD|note
どれも私が実際に使うことはあまりないタイプの可視化の話ですが、こういう図を作る機会のある人は多いようでTwitterでも好評です。
#goando_datasketch hashtag on Twitter
データ視覚化のデザイン - Twitter Search

そんな中で以下のツイートが目にとまりました。

確かに指針を知るのと実装するのは別ですね1。可視化の例を見る限り、ウェブに散らばる断片的情報を頼りにmatplotlibを使ってきた人がこれを実現するとなると時間がかかりそうです。以前Qiitaに書いたArtistに関する基礎知識があればいろいろ自由に設定できることを示す良い例だなと思ったので、いくつか実装してみました。みなさん見慣れていると思われるデフォルトの見た目が下の画像のようになります。設定の解説もついているのでぜひ今後の参考にしてください。ggplotは別の方が書いてくれることを期待しましょう。

手入れ前後の比較

見た目調整の個別相談を始めました。記事の最後にリンクがあります。

準備

Jupyter notebook, python 3.6, matplotlib 2.2.0を使っています。

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

matplotlib._version.get_versions()['version']
# 2.2.0

色や要素の数を勝手にいい感じにしてくれるseabornで実現できる部分もあるかもしれませんが、私は全然使ったことがないのでmatplotlibを直にいじっていきます2

noteの記事と同じようなデータを使って同じような図を作るので日本語フォントが使えるようにしましょう。matplotlibへの日本語フォント導入はmatplotlibで日本語 - Qiitaを参照してください。

matplotlib.font_manager._rebuild() # 問題の原因になることが多いフォントキャッシュを再構築しておきます
plt.rcParams['font.family'] = 'IPAexGothic'

すっきりバープロット

フリーザの戦闘力がいかに突出しているかを水平方向の視線移動だけで示した図です。以下の三点が考慮されています。

  1. 伝えたいメッセージを明確にする
  2. データの大きさを表すには比較する
  3. どういう形が一番メッセージを表現できるか考える

これの右側の図を作ります。まずは戦闘力データを用意して、デフォルト設定のまま水平バープロットを作ってみます。

combatpower = pd.DataFrame([530000, 120000, 10000],
                           index=['フリーザ', 'ギニュー', 'クリリン'],
                           columns=['戦闘力'])
# pd.Seriesでも可能(columnsの代わりにname)

combatpower.plot.barh()
# combatpower.plot(kind='barh') # これも同じ

download-1.png

全然だめですね。例と同じにするためには主に以下の設定が必要です。

  1. 凡例を消す
  2. バーの間を詰める
  3. y軸の順番を逆に
  4. 四方の枠(spines)を消す
  5. y軸x軸のtickを消す
  6. x軸のtick label(10000など)を消す
  7. y軸のラベルサイズを大きく
  8. バーの右側に実際の値を表示
# 1. 凡例を消す legend=False
# 2. バーの間を詰める width=0.8
fig, ax = plt.subplots(figsize=(6, 3))
combatpower.plot.barh(legend=False, ax=ax, width=0.8)

# 3. y軸の順番を逆に
ax.invert_yaxis()

# 4. 四方の枠(spines)を消す
[spine.set_visible(False) for spine in ax.spines.values()]
# 以下でも可能
# sides = ['left', 'right', 'top', 'bottom']
# [ax.spines[side].set_visible(False) for side in sides] 

# 5. y軸x軸のtickを消す
# 6. x軸のtick label(10000など)を消す
ax.tick_params(bottom=False, left=False, labelbottom=False)

# 7. y軸のラベルサイズを大きく
ax.tick_params(axis='y', labelsize='x-large')

# 8. バーの右側に実際の値を表示
vmax = combatpower['戦闘力'].max()
for i, value in enumerate(combatpower['戦闘力']):
    ax.text(value+vmax*0.02, i, f'{value:,}', fontsize='x-large', va='center', color='C0')

download-3.png

フォントが違うので少し印象が異なりますが、だいたい再現できました。

解説

barプロットの幅はwidthfigsizeで調整

各バーの幅はwidthオプションによりx軸のスケールを使って指定できます。matplotlibのドキュメントによるとplt.bar/barhのデフォルトはwidth/height=0.8ですが、pandasのplot.bar/barhではwidth=0.45程度の値がデフォルトになっています3。今回のようにカテゴリ名が各バーに対応する場合は各バーの位置は整数値が振られているので、横長の画像に対してwidth=0.8がちょうどよいです。同じwidth/heightでもfigsizeの縦横比によって印象が変わります。

四方の枠はax.spines

Artistの基礎でも書きましたが、公式ドキュメントを見ると通常「グラフの枠」と呼ばれるパーツはmatplotlibではspine(脊柱、背骨)と呼ばれていることがわかります。
anatomy1.png

このオブジェクトはax.spinesというリストにOrderedDictとして格納されています。使い方は通常の辞書と同じです。今回はdict.valuesを使い辞書の値であるSpineオブジェクトをリスト内包表記でイテレートし非表示設定しています。色を変えるset_colorなども同様の手法で使えます。

ax.spines
output
OrderedDict([('left', <matplotlib.spines.Spine at 0x1135c41d0>),
             ('right', <matplotlib.spines.Spine at 0x11338df28>),
             ('bottom', <matplotlib.spines.Spine at 0x1135d9080>),
             ('top', <matplotlib.spines.Spine at 0x1135d9198>)])

tickとtick labelの調整はax.tick_paramsが便利

tick(軸目盛り)とtick label(軸目盛りラベル)を消したり見た目を調整する際は、Tickオブジェクトなどに直接アクセスせずとも、ax.tick_paramsでたいていのことが済みます。x軸だけ、両軸、メジャー、マイナーなどの対象の指定方法や調整可能項目は公式ドキュメントを参照してください。

ax.textの位置とフォーマット

バーの右側に数値を表示する際にバーとの間に一定の余白を確保するためにバーの最大値(ここではフリーザの戦闘力)を使って必要な余白を算出して位置をずらしています4。また、単純に戦闘力を文字列にして表示するとコンマがないので、Python 3.6のf-strings機能を使ってカンマ付き数字f'{value:,}'にフォーマットしています。

'CN'記法による色指定

ver.2から導入された便利な色指定方法です。詳しくはこちらの記事を参照してください。

凡例は使わない

これも右側の図を再現してみます5。まずは図からだいたいの数字を目で読み取りDataFrameにします。データ表示した際に見やすいので会社名をindexにしていますが、もしかしたらデータ解析のお作法的には会社名をcolumnにすべきかもしれません。

carsales = pd.DataFrame([[970, 1010, 1015, 1008],
                         [975, 1020, 1002, 1035],
                         [975, 985, 995, 999]],
                        index=['Toyota', 'VW', 'GM'], columns=[2013, 2014, 2015, 2016])
carsales

Screen Shot 2018-05-23 at 15.08.08.png

初期設定のままプロットしてみます。データを表示した際の見やすさを優先して会社をindexにしたので、転置してxとyを入れ替える必要があります。

carsales.T.plot()

download.png

図の再現のためにいじるべき点は以下です。

  1. 色を原色のRGBとは違ういい感じの赤青緑に
  2. 凡例を消す
  3. 線幅を太くする
  4. y軸ラベルを表示
  5. x軸y軸の表示範囲を変更
  6. y軸ラベルの色を変更
  7. x軸y軸のtick位置を変更
  8. x軸y軸のtickを消す
  9. x軸y軸のtick labelの色を変更
  10. y軸のグリッドを表示
  11. 左右と上の枠を消す
  12. 下の枠の色を変更
  13. 通常の凡例の代わりにプロットの右側にindexを表示
# tickの位置、tick labelのフォーマットの設定はtickerを使う
# https://matplotlib.org/api/ticker_api.html 参照
from matplotlib.ticker import MultipleLocator

# 1. 色を原色のRGBとは違ういい感じの赤青緑に
# Set1 https://matplotlib.org/examples/color/colormaps_reference.html
# https://stackoverflow.com/questions/46148193/how-to-set-default-matplotlib-axis-colour-cycle
from cycler import cycler
c = plt.get_cmap('Set1').colors
plt.rcParams['axes.prop_cycle'] = cycler(color=c)

fig, ax = plt.subplots(figsize=(5, 3))
# 2. 凡例を消す
# 3. 線幅を太くする
carsales.T.plot(ax=ax, linewidth=4, legend=False)

# 4. y軸ラベルを表示
# 5. x軸y軸の表示範囲を変更
year = carsales.columns.values
ax.set(ylabel='販売台数(万)', ylim=(950, 1050), xlim=(year.min(), year.max()))

# 6. y軸ラベルの色を変更
ax.yaxis.label.set_color('gray')

# 7. x軸y軸のtick位置を変更
ax.yaxis.set_major_locator(MultipleLocator(50))
ax.xaxis.set_major_locator(MultipleLocator(1))

# 8. x軸y軸のtickを消す
ax.tick_params(bottom=False, left=False)

# 9. x軸y軸のtick labelの色を変更
ax.tick_params(axis='y', colors='gray') # colorではなくcolors
ax.tick_params(axis='x', colors='dimgray') # colorではなくcolors

# 10. y軸のグリッドを表示
ax.grid(axis='y')

# 11. 左右と上の枠を消す
ax.spines['right'].set_visible(False)
ax.spines['left'].set_visible(False)
ax.spines['top'].set_visible(False)

# 12. 下の枠の色を変更
ax.spines['bottom'].set_color('dimgray')

# 13. 通常の凡例の代わりにプロットの右側にindexを表示
for i, name in enumerate(carsales.index.values):
    ax.text(year.max()+0.03, ax.lines[i].get_data()[1][-1], name,
            color=f'C{i}', fontsize='large', va='center')

download.png

色の再現はかなり手抜きです。また、IPAexGothicフォントにはボールドがないので、weightは指定していません。

次は一本だけ目立たせる場合です。

前の例との違いは線と凡例代わりの文字列の色指定だけです。ここでは

  1. ひとまず全部灰色でプロット
  2. 注目したい線だけ色を変更
  3. 文字列はforループ内でifを使って色指定

という方針で実装しました。

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

# 1. ひとまず全部灰色でプロット
carsales.T.plot(ax=ax, linewidth=4, legend=False, color='lightgray')

# 2. 注目したい線だけ色を変更
# 目立たせたいデータのLine2Dオブジェクトをax.linesリストから取得。
# 今回のGMはリストの最後にあるので[-1]
ax.lines[-1].set_color('limegreen')

### 上と同じ部分は省略 ###

# 3. 文字列はforループ内でifを使って色指定
for i, name in enumerate(carsales.index.values):
    if name == 'GM':
        color = 'limegreen'
        size = 'x-large'
    else:
        color = 'gray'
        size = 'large'
    ax.text(year.max()+0.03, ax.lines[i].get_data()[1][-1], name,
            color=color, fontsize=size, va='center')

download-1.png

解説

color cycleに使う色の変更

color cycleとは複数データをプロットした際に自動的に割り振られる色のことです。設定されている色数以上のデータに対して最初の色から順に繰り返し使えるようにCyclerオブジェクトというものが使われています。詳しくはこの記事を参照してください。デフォルトではtab10という10色が設定されていますが、これを変えるには以下のコマンドを実行します。

plt.rcParams['axes.prop_cycle'] = cycler(color=色リスト)

色リストは自分で作っても良いのですが、ここではとりあえず原色ではない赤青緑がほしかったのでドキュメントに載っているcolormap一覧から、離散的な色のリストであるQualitative colormapからSet1を選びました。Colormap.colorsにより色のリストが取得できます。

ax.setset_*系を一括設定

見た目を整えたグラフを作るとどうしてもax.set_***がずらっと並んでしまいます。何をしているかが一瞥できるので悪くはないのですが、行数を減らして短く書く方法を知っておくのもよいでしょう。ax.set_hoge=fuga系のコマンドはax.set({'hoge':fuga})という形で辞書に入れて一行で書けます。set_***コマンド以外にも使えるようですが、詳しくは知りません。

Line2D.get_dataでプロットに使った数値を取得する

matplotlibでは全てのパーツがArtistと呼ばれるオブジェクトであり、描画に使う位置や見た目の数値は(おそらく)全て属性として各オブジェクトが保持しています。つまり一度プロットした線の元になったxy形式のデータや色も線の実体であるLine2Dオブジェクトにアクセスすると取得できます。今回は

ax.lines[i].get_data()[1][-1]

の部分でax.linesリストのi番目にあるLine2Dオブジェクトのget_dataメソッドによりxyデータを取得し、yデータの一番最後の値をax.textの位置に使っています。

目盛りラベルは傾けない

それっぽいデータを適当に作り、初期設定のままbarプロットを作ってみます。color cycleはデフォルトのtab10に戻してあります。

mau = np.linspace(450, 990, 12) + np.random.randint(-50, 50, 12)
timeindex = pd.date_range('2017/5', periods=12, freq='MS')
# `freq='M'`だと月末の日付になってしまうので注意
mau = pd.Series(mau, index=timeindex, name='MAU')
mau.plot.bar()

download-1.png
色とx軸のtick labelが派手なことになってしまいました。実は、色の統一はcolorオプションで簡単にできるのですが、DatetimeIndexのtick labelが曲者です。pandasで時系列データを扱っている人の間では常識なのかもしれませんが、どうもpandasのDatetimeIndexはmatplotlib側にdatetime型として渡されないためmatplotlib側のdatetimeフォーマット関連機能(今回は月のみ抽出)が使えないようです。
python - Matplotlib DateFormatter for axis label not working - Stack Overflow

したがって、上記SOの回答にもあるように直接matplotlibを使ってbarプロットを作る必要があります。

plt.bar(mau.index, mau, width=10) 
# widthに何も指定しないと初期値width=0.8(0.8日分)となりほぼ線になってしまう。

download-2.png

手を加えるべき点は以下です。

  1. バーの色と幅を変更
  2. y軸ラベルを表示、色を指定
  3. y軸のtick位置の変更
  4. x軸y軸のtickを消す
  5. x軸y軸のtick labelの色を変更
  6. y軸のグリッドを表示
  7. 左右と上の枠を消す
  8. 下の枠の色を変更
  9. x軸のtick labelを月だけにする
  10. x軸のtickの位置を毎月ごとにする
  11. 各年の最初の月の下に年を表示
import matplotlib.dates as mdates

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

# 1. バーの色と幅を変更
ax.bar(mau.index, mau, width=20, color='coral', zorder=2, align='center')

# 2. y軸ラベルを表示、色を指定
ax.set_ylabel(mau.name, color='gray')

# 3. y軸のtick位置の変更
ax.yaxis.set_major_locator(MultipleLocator(500))

# 4. x軸y軸のtickを消す
ax.tick_params(bottom=False, left=False)

# 5. x軸y軸のtick labelの色を変更
ax.tick_params(axis='x', colors='dimgray')
ax.tick_params(axis='y', colors='gray')

# 6. y軸のグリッドを表示
ax.grid(axis='y')

# 7. 左右と上の枠を消す
ax.spines['left'].set_visible(False)
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)

# 8. 下の枠の色を変更
ax.spines['bottom'].set_color('dimgray')

# ax.xaxis_date() # 必要ないっぽい

# 9. x軸のtick labelを月だけにする
# '%-m'はstrftimeのleading zeroなしmonth表記
ax.xaxis.set_major_formatter(mdates.DateFormatter('%-m')) 

# 10. x軸のtickの位置を毎月ごとにする
ax.xaxis.set_major_locator(mdates.MonthLocator())

# 一度figをdrawしないとmajor tickのpositionが更新されない
fig.canvas.draw()

# 11. 各年の最初の月の下に年を表示
for key, gr in mau.groupby(mau.index.year):
    i = np.where(mau.index == gr.index[0])[0][0]
    pos = ax.get_xmajorticklabels()[i].get_position()
#    ax.text(pos[0]-15, -200, key) # 線なしバージョン
# Axesの外に線を書くのは非常にめんどくさいのでannotateで代用
# annotateも矢印の始点の調整が意味不明なので空白で無理やり調整
# textcoordsを指定せずにデフォルトのデータ座標を使うとなぜか空白が反映されない。
    ax.annotate(f'         {key}', xy=(pos[0]-15, 0), xycoords='data',
                xytext=(0, -30), textcoords='offset points', color='dimgray',
                ha='center', va='bottom',
                arrowprops={'arrowstyle':'-', 'color':'dimgray'})

download-4.png

解説

グリッドの線がバーの上に重ならないようにzorderを指定

グラフの描画は基本的にパーツを重ねていくだけなので、例えば複数のax.plotは実行順によって線の重なり順をコントロールできます。しかし、plotとbarのように異なるオブジェクトはオブジェクトごとに重なり順が定義されているようで、実行順を変えても重なり順は変化しません。これはzorderオプションでどうにか調整が可能です。「どうにか」というのはzorder自体もあまり振る舞いがはっきりせず、とりあえず上にもってきたいパーツではzorder=10にしたり、全て順番を指定する場合はzorderを1ずつ増やすのではなく5ずつ増やすなどのある程度の試行錯誤が必要です。今回はzorderの指定をしない状態だとax.gridが上に来てしまったのでax.barzorder=2にしたら解決しました。

すでに表示されている要素の位置に合わせて新しい要素を追加する

例えば、後から追加したcolorbarの高さをcontourfの図と同じにしたい場合などに、すでにあるcontourfのAxesの位置を取得してcolorbarの高さ設定に使うと、ちょうどいい数値を探るために試行錯誤を繰り返す必要がなく楽です。今回の例では、2017と2018の数字を表示する際に「各年の最初の月の半月分前の位置にannotateで線を引く」という部分にこの考え方が使われています。

get_positionは一度fig.canvas.draw()しないとダメ

描画した各オブジェクトの位置はそのオブジェクトに対してget_positionメソッドを使えば取得できるのですが、一度描画しないと正しい値を取得できません。Jupyter notebookのinline表示を使っている場合はget_positionで正しい数値を得るために二つの方法があります。一つは、セル実行後にFigureが表示されてから次のセルで目的のオブジェクトに対してget_positionを実行する方法です。もう一つはget_positionの前にfig.canvas.draw()を実行してFigureを表示することなく仮想的に一旦描画する方法です。今回は一つのセルで完結する後者の方法を使っています。

Axesの外に線を引く

この部分はちょっと手こずりました。今回はAxes内の点から線を引きたかったのでax.annotateを使えましたが、以下のSOの回答によると、一般にAxesの外に線を引くには専用のAxesを作るかなりめんどうなテクニックを使う必要があるようです。どうしてもAxesの外に線を引きたい場合は、ここだけ手で加工するのも悪くない選択肢だと思います。
pandas - How to add hierarchical axis across subplots in order to label groups? - Stack Overflow
また、ax.annotateの矢印の始点は以下の回答のように調整できるはずなのですが、Axesの外だとうまくいきませんでした6
python - How to set starting points of arrows in matplotlib's annotate? - Stack Overflow


元のnote記事は第一回目ということなので、またいい例があればこのような記事を書きます。

個別相談始めました

matplotlib 最後の一歩
見た目の微調整に苦労してる方、コーヒー1杯で相談に乗ります。


  1. 特にmatplotlibは「ここをこうやってこうしたいのにさっぱりわからない…」ということが何度も発生します。 

  2. seabornなどのmatplotlibラッパーがいい感じにしてくれなかった場合は結局matplotlibを直にいじる必要が多いようなので、seabornユーザーにも役立つ話だと思います。 

  3. 大変ややこしいことに、バー幅の調整はplt.barではwidthplt.barhではheightを使わなければなりません。一方、pandasはplot.barでもplot.barhでもwidthを使えます。 

  4. textのpaddingに関するパラメータがあるかもしれませんが、みつかりませんでした。 

  5. 原点が0でないグラフをけしからんとする向きもあるでしょうが、こういう業界ビッグ3の覇権争いみたいな図は順位の変動がはっきりわかった方が適切な文脈もあるでしょうしこのまま再現します。 

  6. ちなみにこれら二つの回答はmatplotlibリポジトリでもたまに見かける名物回答者ImportanceOfBeingErnestさんのもので、彼の回答はいつも丁寧かつ正確でかなり信頼できます。