📊

Altair で Diverging Stacked Bar Chart を作る

2021/08/09に公開

本稿の内容を収録した Jupyter Notebook ファイル

0. はじめに

Altair は,Python で宣言的にデータの可視化ができるライブラリです.比較的少ない数のデータ(デフォルトでは5000行)を美しく可視化するのに向いています. Pandas との親和性も高いので,アンケートの集計と可視化に便利です.

Diverging Stacked Bar Chart (両側積み上げ棒グラフ?)は,特にリッカート尺度を可視化するのに便利なチャートです.通常の積み上げ棒グラフと異なり,0%を基準にポジティブな意見とネガティブな意見を反対側に積み上げていきます.

スペースを取りがち,中立の選択肢を中寄せにしたときにどのバーも同じ位置に揃わないなどいくつかの欠点が指摘されているものの(意見コメントコメント),依然としてこれは意見の偏りを可視化するためのよい選択肢だと考えています.

可視化の流れは以下の通りです.

  1. データをポジティブ・ネガティブな回答に分割する.中立の回答は半分に分けた値を使う.
    • 処理前:反対 20%, やや反対 20%, 中立 20%, やや賛成 20%, 賛成 20%
    • 処理後(正):中立 10%, やや賛成 20%, 賛成 20%
    • 処理後(負):中立 10%, やや反対 20%, 反対 20%
  2. 各選択肢について,色を塗るべき区間に直す.
    • 処理前(正):中立 10%, やや賛成 20%, 賛成 20%
    • 処理後(正):中立 0-10%, やや賛成 10-30%, 賛成 30-50%
  3. ネガティブな回答の区間を反転させる.
  4. ポジティブ・ネガティブなデータをひとまとめにし,プロットする.
    • 今回の場合,中立の重複は解決しなくともよい

1. 中立の選択肢が中央にあるプロット

準備

import pandas as pd
import numpy as np
from numpy.random import default_rng
import altair as alt

元データ

ダミーデータの生成コード
# generate dummy data

choices = ['反対', 'やや反対', 'わからない', 'やや賛成', '賛成']

def generate_dummy_data_per_group(n, mu, sigma, bins):
    rng = default_rng()
    values = rng.normal(mu, sigma, size=n)
    values_digitized = np.digitize(values, bins=bins)
    df = pd.DataFrame(pd.Series(values_digitized).rename('value'));
    return df

def generate_dummy_data(n_groups=5, choices=choices):
    bins = np.linspace(0, 1, len(choices)+1)
    bins[0] = -np.inf
    bins[-1] = np.inf
    
    # generate data for each group and concat
    rng = default_rng()
    data = pd.DataFrame()
    for i in range(5):
        tmp = generate_dummy_data_per_group(n=int(rng.uniform(100,200)), mu=rng.normal(0.5, 0.2), sigma=0.5, bins=bins)
        tmp['attr'] = i
        data = pd.concat([data, tmp])

    # aggregate and convert to proportion (optional)
    data = data.reset_index(drop=True)
    data_count = data.groupby(['attr', 'value']).size().rename('count')
    data_proportion = pd.DataFrame(data_count.groupby(level=0).apply(lambda x: x / float(x.sum())).rename('proportion')).reset_index()
    return data_proportion

data = generate_dummy_data().rename({'proportion':'x'}, axis=1)

以下のようなアンケートのデータを考えます.属性 attr ごとに, value と回答した人数の割合が x に集計されているものとします.

attr value x
0 0 1 0.198347
1 0 2 0.132231
2 0 3 0.107438
3 0 4 0.148760
4 0 5 0.413223
... ... ... ...
24 4 5 0.161850

value の元データは,たとえば ['反対', 'やや反対', 'わからない', 'やや賛成', '賛成'] とします.元データの値を直接入れてもかまいませんが,数値にコーディングした列があったほうが扱いやすいので,そのようにします.

正負の選択肢それぞれについて累積和に変換し,結果を連結

まず,データフレームを正・負・中立の回答に分割します.中立の回答は,あとで正負両方のデータフレームに連結するのであらかじめ値を半分にしておきます.

mid_value = 3
pos = data[data['value'] > mid_value].copy()
neg = data[data['value'] < mid_value].copy()
mid = data[data['value'] == mid_value].copy()
mid_half = mid.copy()
mid_half['x'] = mid_half['x'] * .5

次に,それぞれについて集計するための関数を定義しておきます.

def to_cumsum_interval(df, negative=False, strong_first=False):
    tmp_cs = df.set_index(
        ['attr', 'value']
    ).sort_index(
        ascending = negative != (not strong_first) #xor
    ).groupby(level=0).cumsum()
    tmp_cs_x2 = tmp_cs.groupby(level=0).shift().rename({'x':'x2'}, axis=1).fillna(0)
    result = tmp_cs.join(tmp_cs_x2).reset_index()
    if(negative):
        result['x'] *= -1
        result['x2'] *= -1
    return result

最後に,正負それぞれについて集計し,結果を連結します.ついでに選択肢の文言もくっつけておきます.

pos_i = to_cumsum_interval(pd.concat([pos, mid_half]))
neg_i = to_cumsum_interval(pd.concat([neg, mid_half]), negative=True)
alt_data = pd.concat([pos_i, neg_i])
alt_data['value_label'] = alt_data['value'].apply(lambda x : choices[x-1])

# show data
alt_data.sort_values(['attr','value']).reset_index(drop=True)

集計結果は以下のようになります.「わからない」が2つに分かれていますが,正しくレンダリングされるので今回はこれで問題ありません.

attr value x x2 value_label
0 0 1 -0.789256 -0.202479 反対
1 0 2 -0.202479 -0.086777 やや反対
2 0 3 0.086777 0.000000 わからない
3 0 3 -0.086777 -0.000000 わからない
4 0 4 0.144628 0.086777 やや賛成
5 0 5 0.210744 0.144628 賛成
6 1 1 -0.725694 -0.260417 反対
... ... ... ... ... ...

プロット

以下のコードを実行すると,冒頭に示したようなプロットが得られます.
公式のサンプルでは色を直接指定していますが,今回のように中立の選択肢がきちんと真ん中にある場合は redblue などの diverging color scheme を直接使うことができます.

alt.Chart(alt_data).mark_bar().encode(
    x = alt.X(
        'x',
        axis = alt.Axis(
            title = 'Percentage',
            format = '%',
        )
    ),
    x2 = 'x2',
    y = 'attr:O',
    color = alt.Color(
        'value_label',
        scale = alt.Scale(
            domain = choices,
            scheme = 'redblue',
        )
    ),
)

2. 強い意見が中央にあるプロット

上の集計部分のコードを以下のように書き換えます.他は同じです.

pos_i = to_cumsum_interval(pd.concat([pos, mid_half]), strong_first=True)
neg_i = to_cumsum_interval(pd.concat([neg, mid_half]), negative=True, strong_first=True)
alt_data = pd.concat([pos_i, neg_i])
alt_data['value_label'] = alt_data['value'].apply(lambda x : choices[x-1])

3. 中立意見を別にしたプロット

まず,上の集計部分のコードを以下のように書き換えます.

pos_i = to_cumsum_interval(pos, strong_first=True)
neg_i = to_cumsum_interval(neg, negative=True, strong_first=True)
mid_i = to_cumsum_interval(mid)
alt_data = pd.concat([pos_i, neg_i])
alt_data['value_label'] = alt_data['value'].apply(lambda x : choices[x-1])
mid_i['value_label'] = mid_i['value'].apply(lambda x : choices[x-1])

次に,プロットを別々に作って連結します.右に連結するプロットのY軸は見た目をすっきりさせたいので削ってしまいます.途中でプロットの幅を明示的に指定しているのは,今のところ Altair では左右のグラフの縮尺を明示的に揃える方法が私の知るかぎりないためです.

pn_chart = alt.Chart(
    alt_data,
    width=420 # specify plot width to ensure both plot have same scale
).mark_bar().encode(
    x = alt.X(
        'x',
        axis = alt.Axis(
            title = 'Percentage',
            format = '%',
        )
    ),
    x2 = 'x2',
    y = 'attr:O',
    color = alt.Color(
        'value_label',
        scale=alt.Scale(
            domain=choices,
            scheme='redblue',
        )
    ),
)

mid_chart = alt.Chart(
    mid_i,
    width=60 # specify plot width to ensure both plot have same scale
).mark_bar().encode(
    x = alt.X(
        'x',
        axis = alt.Axis(
            title = 'Percentage',
            format = '%',
        )
    ),
    y = alt.Y(
        'attr:O',
        axis=None,
    ),
    color = alt.Color(
        'value_label',
        scale=alt.Scale(
            domain=choices,
            scheme='redblue',
        )
    ),
)

(pn_chart | mid_chart).configure_concat(spacing=0)

参考. 100%積み上げ棒グラフ

集計部分がないのでコードは非常にシンプルです.

コード
alt_data = data.copy()
alt_data['value_text'] = alt_data['value'].apply(lambda x : choices[x-1])

alt.Chart(alt_data).mark_bar().encode(
    x = alt.X(
        'x',
        axis = alt.Axis(
            title = 'Percentage',
            format = '%',
        )
    ),
    y = 'attr:O',
    color = alt.Color(
        'value_text',
        scale=alt.Scale(
            domain=choices,
            scheme='redblue',
        )
    ),
    order=alt.Order(
        'value',
        sort='ascending'
    )
)

Discussion