一癖ある円グラフを突破する!ライブラリ「Bokeh」で円グラフの中に値を入れる

2019.09.26

データアナリティクス事業本部@札幌の佐藤です。
Bokeh Tutorial の「03 - Data Sources and Transformations」にさらっと記載されている円グラフについてです。
Bokehの特性上少し描画や、円グラフの中にラベル(文字・数字)をセットする方法が少し複雑なところがあり、手間取ったのでポイントなど書いていけたらなと思います。

今回は『アイカツオンパレード!』放送記念の「データカードダス アイドル総選挙」(現在は投票終了)の中間結果を使用しながら各属性ごとの比率を描画します。

データカードダス アイドル総選挙

「データカードダス アイカツフレンズ! かがやきのジュエル3弾」の1プレイごとに1票投票することができます。
各シリーズのキャラクター56人に投票することができます。
投票数1位のキャラクターは新しいプレミアムドレスのカードが収録されるので、人気キャラクターやシリーズであまり高レアドレスに恵まれなかったキャラクターがトップ20に入ってくる面白いランキングでした。

私も推しの霧矢あおいのためにお金を注いていました。 中間結果は6位で、なんだか総選挙系ではいつもこの位置にいるような気がするなと思っていくる今日この頃です。

その中間結果時点での結果は以下で見られます。
データカードダス アイドル総選挙 中間発表

中間結果に出てくるトップ20の中で各属性に分けると以下のようになります。

キュート クール セクシー ポップ そのほか
5 7 4 3 1

※ココは4属性に属していることが分からなかったので、そのほかにしています。

この情報を使用して描画していきたいと思います。

円グラフを描画する

こんな感じのグラフができます。

円グラフは wedge を使用することで実装可能です。

また、各pieをどのように設定するかですが、Bokehは数値を入れて自動で処理してくれる機能はありません。
そのため円周の公式を使用して各要素ごとの円周を算出、Bokehの from bokeh.transform import cumsum で累計を渡してあげる必要があります。

from math import pi
import pandas as pd

from bokeh.models.sources import ColumnDataSource
from bokeh.plotting import figure
from bokeh.transform import cumsum 
from bokeh.io import output_notebook, show

output_notebook()

ranking = [["キュート", 5, "#ff99cc"], 
          ["そのほか", 1, "grey"], 
          ["ポップ", 3, "#ffc000"], 
          ["セクシー", 4, "#cc99ff"], 
          ["クール", 7, "#66ccff"]]

df = pd.DataFrame(ranking,
                  columns=["group", "count", "color"])

values = df["count"]

data = {"name": df["group"],        
        "angle": [value / sum(values) * 2 * pi for value in values], # 円周の長さを求める
        "color": df["color"]}      

p = figure(plot_height=600,
           plot_width=600, 
           title="アイドル総選挙各属性別割合")

p.wedge(x=0,                                                     # 描画するX座標
        y=0,                                                     # 描画するY座標
        radius=0.7,                                              # 描画する半径のサイズ
        start_angle=cumsum("angle", include_zero=True),          # 開始角度
        end_angle=cumsum("angle"),                               # 終了する角度
        line_color="white",                                      # 線の色
        fill_color="color",                                      # pieの色
        legend="name",                                           # 凡例
        source=ColumnDataSource(data))

p.axis.axis_label=None
p.axis.visible=False
p.grid.grid_line_color = None

show(p)

ちょっと複雑な内容としてstart_angleinclude_zero=True に、end_angleinclude_zero=False にしてあげる必要があります。

以下ドキュメントに記載の通り、Falseの場合は初期値がなく最後の累計値が存在します。 Trueの場合は、初期値0が存在し、最後の累計値が存在しないので、 start_angle には0が end_angle には最後の累計値が必要なためです。

source = ColumnDataSource(data=dict(foo=[1, 2, 3, 4]))

CumSum(field='foo')
# -> [1, 3, 6, 10]

CumSum(field='foo', include_zero=True)
# -> [0, 1, 3, 6]

bokeh.models.expressions

また、データを渡すときの注意点ですが、描画される順番は反時計回りです。
一般的に円グラフ見るときは時計回りだと思いますので、その感覚でデータを渡すと想定と結果が異なる表示になります。

「アイカツ!」シリーズの各属性は、キュート→クール→セクシー→ポップの順番であることが多いので、データを渡すときは表示の逆順で渡しています。

実装自体は、円周の長さを求めなければいけないという問題はありますが、実装方法が分かってしまえばそんなに難しくないですね。

ただこれだとそれぞれの割合がどのくらいなのかが見えないので、視覚的にしか判断できないです。
次に数字(ラベル)をグラフの中に描画したいと思います。

グラフの中にラベルをセットする

Bokehはグラフ中に文字や数値を表示したい場合、LabelSet というメソッドを使用して、ラベルをグラフにセットさせる必要があります。 その際に、 LabelSet の引数に xy があるように座標を指定する必要があります。

円グラフで各pieの座標をどう出すのかというところですが、三角関数のサインとコサインを使用して座標を特定します。
座標の特定のために各pieのまでの累計値 - 対象pieの半分を求めた後に円周の長さを計算させます。
これは数字の表記を各pieの中心に表示させるためです。

from math import pi
import pandas as pd
import numpy as np

from bokeh.models.sources import ColumnDataSource
from bokeh.plotting import figure
from bokeh.transform import cumsum 
from bokeh.io import output_notebook, show
from bokeh.models import LabelSet

output_notebook()

ranking = [["キュート", 5, "#ff99cc"], 
          ["そのほか", 1, "grey"], 
          ["ポップ", 3, "#ffc000"], 
          ["セクシー", 4, "#cc99ff"], 
          ["クール", 7, "#66ccff"]]

df = pd.DataFrame(ranking,
                  columns=["group", "count", "color"])

values = df["count"]

data = {"name": df["group"],        
        "angle": [value / sum(values) * 2 * pi for value in values], 
        'cumulative_angle':[(sum(values[0:i+1]) - (value / 2)) / sum(values) * 2 * pi for i, value in enumerate(values)], # 三角関数のinput
        'percent': [value / sum(values) * 100 for value in values], # パーセンテージのセット
        "color": df["color"]}

data['sin'] = np.sin(data['cumulative_angle']) * 0.4            # サインの値を算出
data['cos'] = np.cos(data['cumulative_angle']) * 0.4            # コサインの値を算出
data['label'] = ["{:.1f}%".format(p) for p in data['percent']] # 表示させる値を表示(小数点第一位まで)

source = ColumnDataSource(data=data)

p = figure(plot_height=600,
           plot_width=600, 
           title="アイドル総選挙各属性別割合")

p.wedge(x=0,
        y=0,
        radius=0.7,
        start_angle=cumsum("angle", include_zero=True),
        end_angle=cumsum("angle"), 
        line_color="white",
        fill_color="color",
        legend="name",
        source=source)

# 表に数値をセットする。
labels = LabelSet(x='cos', 
                  y='sin', 
                  text="label", 
                  text_font_size="18pt", 
                  text_color="black",
                  text_align="center",
                  source=source)
# 追加した数値を表に追加
p.add_layout(labels)

p.axis.axis_label=None
p.axis.visible=False
p.grid.grid_line_color = None

show(p)

三角関数を使わないと座標を求められないので数学要素があってかなり抵抗があるかなと思いますが、実装方法が分かればそんなに難しくないと思います。

更に円グラフに情報を追加したいのであれば、同じように実装して add_layout で追加すると表示されます。

ツールチップなどを使えば、さらに円グラフが分かりやすくなりますね。

最後に

円グラフは描画方法が少し複雑で、とっつきにくさがあったのですが覚えてしまえばそんなに難しくないのかなと思いました。

描画順の都合上、凡例がきれいに並んでいない状態になっていますが、これを何とかする方法については別で記載しようと思います。

参考

adding percentage label to Bokeh Pie chart