Bokehでヒートマップ



# Figure.rectとFigure.image

bokeh公式の[Gallery](https://docs.bokeh.org/en/2.4.1/docs/gallery.html)には`rect`を使ったヒートマップの[例](https://docs.bokeh.org/en/2.4.1/docs/gallery/unemployment.html)が載っているが、`image`を使った[例](https://docs.bokeh.org/en/2.4.1/docs/gallery/image.html)でもヒートマップを作成できる。東京都が公開している新型コロナの陽性者データを使い、年代別のヒートマップを2つのやり方で作ってみる。

# バージョン情報

- python 3.8.8
- bokeh 2.3.0
- jupyterlab 2.2.6

# 使用データ

東京都の[都内の最新感染動向](https://stopcovid19.metro.tokyo.lg.jp/)で公開している新型コロナの[陽性者のデータ](https://catalog.data.metro.tokyo.lg.jp/dataset/t000010d0000000068)。

```python
import numpy as np
import pandas as pd

from bokeh.plotting import figure, show, output_notebook
from bokeh.models import ColumnDataSource, ColorBar, LogColorMapper
from bokeh.transform import log_cmap

output_notebook()
```

# データ加工

年代別に集計して`rect`と`image`それぞれに必要な形に変換する。


```python
# データが大きいので必要な列だけ読み込む。年月日と年代ともう一列適当に。groupbyで集計するのに必要になる。
df = pd.read_csv('data/tokyo_positive.csv',
                 usecols=[0, 4, 8],
                 parse_dates=['公表_年月日'])
print(len(df))
print(df.head())
```

    377159
       No     公表_年月日 患者_年代
    0   1 2020-01-24   40代
    1   2 2020-01-25   30代
    2   3 2020-01-30   30代
    3   4 2020-02-13   70代
    4   5 2020-02-14   50代
    
```python
AGES = ['10歳未満',
        '10代',
        '20代',
        '30代',
        '40代',
        '50代',
        '60代',
        '70代',
        '80代',
        '90代',
        '100歳以上',
        '不明']
```

```python
# groupby

# groupbyで順番が崩れないように年代のデータ型をカテゴリーに
df['患者_年代'] = df['患者_年代'].str.replace('-', '不明')
df['患者_年代'] = pd.Categorical(df['患者_年代'], categories=AGES, ordered=True)

# 日付、年代で集計
nums = df.groupby(['公表_年月日', '患者_年代'], as_index=False).count()
nums.columns = ['date', 'age', 'nums']

nums.head(15)
```

<div>
<style scoped>
    .dataframe tbody tr th:only-of-type {
        vertical-align: middle;
    }

    .dataframe tbody tr th {
        vertical-align: top;
    }

    .dataframe thead th {
        text-align: right;
    }
</style>
<table border="1" class="dataframe">
  <thead>
    <tr style="text-align: right;">
      <th></th>
      <th>date</th>
      <th>age</th>
      <th>nums</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th>0</th>
      <td>2020-01-24</td>
      <td>10歳未満</td>
      <td>0</td>
    </tr>
    <tr>
      <th>1</th>
      <td>2020-01-24</td>
      <td>10代</td>
      <td>0</td>
    </tr>
    <tr>
      <th>2</th>
      <td>2020-01-24</td>
      <td>20代</td>
      <td>0</td>
    </tr>
    <tr>
      <th>3</th>
      <td>2020-01-24</td>
      <td>30代</td>
      <td>0</td>
    </tr>
    <tr>
      <th>4</th>
      <td>2020-01-24</td>
      <td>40代</td>
      <td>1</td>
    </tr>
    <tr>
      <th>5</th>
      <td>2020-01-24</td>
      <td>50代</td>
      <td>0</td>
    </tr>
    <tr>
      <th>6</th>
      <td>2020-01-24</td>
      <td>60代</td>
      <td>0</td>
    </tr>
    <tr>
      <th>7</th>
      <td>2020-01-24</td>
      <td>70代</td>
      <td>0</td>
    </tr>
    <tr>
      <th>8</th>
      <td>2020-01-24</td>
      <td>80代</td>
      <td>0</td>
    </tr>
    <tr>
      <th>9</th>
      <td>2020-01-24</td>
      <td>90代</td>
      <td>0</td>
    </tr>
    <tr>
      <th>10</th>
      <td>2020-01-24</td>
      <td>100歳以上</td>
      <td>0</td>
    </tr>
    <tr>
      <th>11</th>
      <td>2020-01-24</td>
      <td>不明</td>
      <td>0</td>
    </tr>
    <tr>
      <th>12</th>
      <td>2020-01-25</td>
      <td>10歳未満</td>
      <td>0</td>
    </tr>
    <tr>
      <th>13</th>
      <td>2020-01-25</td>
      <td>10代</td>
      <td>0</td>
    </tr>
    <tr>
      <th>14</th>
      <td>2020-01-25</td>
      <td>20代</td>
      <td>0</td>
    </tr>
  </tbody>
</table>
</div>




```python
# image用のデータ

# データの形を変更して欠けている日付を補う
nums_table = nums.pivot('age', 'date', 'nums')

new_columns = pd.date_range(nums_table.columns.min(), nums_table.columns.max())
nums_table = nums_table.reindex(columns=new_columns)

nums_table.fillna(0, inplace=True)
nums_table = nums_table.astype(int)

# 7日間移動平均のデータ
mean_table = nums_table.rolling(7, axis=1, min_periods=1, center=True).mean()

nums_table
```

<div>
<style scoped>
    .dataframe tbody tr th:only-of-type {
        vertical-align: middle;
    }

    .dataframe tbody tr th {
        vertical-align: top;
    }

    .dataframe thead th {
        text-align: right;
    }
</style>
<table border="1" class="dataframe">
  <thead>
    <tr style="text-align: right;">
      <th></th>
      <th>2020-01-24</th>
      <th>2020-01-25</th>
      <th>2020-01-26</th>
      <th>2020-01-27</th>
      <th>2020-01-28</th>
      <th>2020-01-29</th>
      <th>2020-01-30</th>
      <th>2020-01-31</th>
      <th>2020-02-01</th>
      <th>2020-02-02</th>
      <th>...</th>
      <th>2021-10-07</th>
      <th>2021-10-08</th>
      <th>2021-10-09</th>
      <th>2021-10-10</th>
      <th>2021-10-11</th>
      <th>2021-10-12</th>
      <th>2021-10-13</th>
      <th>2021-10-14</th>
      <th>2021-10-15</th>
      <th>2021-10-16</th>
    </tr>
    <tr>
      <th>age</th>
      <th></th>
      <th></th>
      <th></th>
      <th></th>
      <th></th>
      <th></th>
      <th></th>
      <th></th>
      <th></th>
      <th></th>
      <th></th>
      <th></th>
      <th></th>
      <th></th>
      <th></th>
      <th></th>
      <th></th>
      <th></th>
      <th></th>
      <th></th>
      <th></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th>10歳未満</th>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>...</td>
      <td>11</td>
      <td>16</td>
      <td>8</td>
      <td>8</td>
      <td>6</td>
      <td>9</td>
      <td>7</td>
      <td>6</td>
      <td>7</td>
      <td>3</td>
    </tr>
    <tr>
      <th>10代</th>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>...</td>
      <td>9</td>
      <td>14</td>
      <td>11</td>
      <td>6</td>
      <td>4</td>
      <td>9</td>
      <td>8</td>
      <td>7</td>
      <td>4</td>
      <td>2</td>
    </tr>
    <tr>
      <th>20代</th>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>...</td>
      <td>31</td>
      <td>33</td>
      <td>20</td>
      <td>11</td>
      <td>10</td>
      <td>18</td>
      <td>14</td>
      <td>13</td>
      <td>16</td>
      <td>13</td>
    </tr>
    <tr>
      <th>30代</th>
      <td>0</td>
      <td>1</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>1</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>...</td>
      <td>32</td>
      <td>26</td>
      <td>16</td>
      <td>8</td>
      <td>10</td>
      <td>13</td>
      <td>12</td>
      <td>9</td>
      <td>8</td>
      <td>14</td>
    </tr>
    <tr>
      <th>40代</th>
      <td>1</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>...</td>
      <td>15</td>
      <td>21</td>
      <td>8</td>
      <td>9</td>
      <td>7</td>
      <td>9</td>
      <td>8</td>
      <td>13</td>
      <td>8</td>
      <td>12</td>
    </tr>
    <tr>
      <th>50代</th>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>...</td>
      <td>17</td>
      <td>12</td>
      <td>5</td>
      <td>7</td>
      <td>8</td>
      <td>9</td>
      <td>10</td>
      <td>6</td>
      <td>3</td>
      <td>4</td>
    </tr>
    <tr>
      <th>60代</th>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>...</td>
      <td>11</td>
      <td>4</td>
      <td>5</td>
      <td>1</td>
      <td>0</td>
      <td>5</td>
      <td>4</td>
      <td>2</td>
      <td>5</td>
      <td>5</td>
    </tr>
    <tr>
      <th>70代</th>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>...</td>
      <td>8</td>
      <td>7</td>
      <td>5</td>
      <td>7</td>
      <td>2</td>
      <td>2</td>
      <td>4</td>
      <td>3</td>
      <td>3</td>
      <td>8</td>
    </tr>
    <tr>
      <th>80代</th>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>...</td>
      <td>7</td>
      <td>4</td>
      <td>3</td>
      <td>2</td>
      <td>0</td>
      <td>1</td>
      <td>2</td>
      <td>3</td>
      <td>3</td>
      <td>3</td>
    </tr>
    <tr>
      <th>90代</th>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>...</td>
      <td>2</td>
      <td>1</td>
      <td>1</td>
      <td>1</td>
      <td>2</td>
      <td>2</td>
      <td>3</td>
      <td>0</td>
      <td>0</td>
      <td>2</td>
    </tr>
    <tr>
      <th>100歳以上</th>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>...</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
    </tr>
    <tr>
      <th>不明</th>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>...</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
      <td>0</td>
    </tr>
  </tbody>
</table>
<p>12 rows × 632 columns</p>
</div>

```python
# rect用のデータ

# 陽性者数と平均をひとつのデータフレームに
rect_data = nums_table.unstack().reset_index()
rect_data.columns = ['date', 'age', 'nums']

mean = mean_table.unstack().reset_index()
rect_data['mean'] = mean.iloc[:, -1]

rect_data
```

<div>
<style scoped>
    .dataframe tbody tr th:only-of-type {
        vertical-align: middle;
    }

    .dataframe tbody tr th {
        vertical-align: top;
    }

    .dataframe thead th {
        text-align: right;
    }
</style>
<table border="1" class="dataframe">
  <thead>
    <tr style="text-align: right;">
      <th></th>
      <th>date</th>
      <th>age</th>
      <th>nums</th>
      <th>mean</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th>0</th>
      <td>2020-01-24</td>
      <td>10歳未満</td>
      <td>0</td>
      <td>0.00</td>
    </tr>
    <tr>
      <th>1</th>
      <td>2020-01-24</td>
      <td>10代</td>
      <td>0</td>
      <td>0.00</td>
    </tr>
    <tr>
      <th>2</th>
      <td>2020-01-24</td>
      <td>20代</td>
      <td>0</td>
      <td>0.00</td>
    </tr>
    <tr>
      <th>3</th>
      <td>2020-01-24</td>
      <td>30代</td>
      <td>0</td>
      <td>0.25</td>
    </tr>
    <tr>
      <th>4</th>
      <td>2020-01-24</td>
      <td>40代</td>
      <td>1</td>
      <td>0.25</td>
    </tr>
    <tr>
      <th>...</th>
      <td>...</td>
      <td>...</td>
      <td>...</td>
      <td>...</td>
    </tr>
    <tr>
      <th>7579</th>
      <td>2021-10-16</td>
      <td>70代</td>
      <td>8</td>
      <td>4.50</td>
    </tr>
    <tr>
      <th>7580</th>
      <td>2021-10-16</td>
      <td>80代</td>
      <td>3</td>
      <td>2.75</td>
    </tr>
    <tr>
      <th>7581</th>
      <td>2021-10-16</td>
      <td>90代</td>
      <td>2</td>
      <td>1.25</td>
    </tr>
    <tr>
      <th>7582</th>
      <td>2021-10-16</td>
      <td>100歳以上</td>
      <td>0</td>
      <td>0.00</td>
    </tr>
    <tr>
      <th>7583</th>
      <td>2021-10-16</td>
      <td>不明</td>
      <td>0</td>
      <td>0.00</td>
    </tr>
  </tbody>
</table>
<p>7584 rows × 4 columns</p>
</div>

# rect

座標(x, y)と幅と高さを指定して長方形を描く。

```python
# rectによるヒートマップ

tooltips = [('日付', '@date{%Y-%m-%d}'),
            ('年代', '@age'),
            ('陽性者', '@nums{,}人'),
            ('平均', '@mean{,.000}人')]

# ソース
source = ColumnDataSource(rect_data)

# Figure
p = figure(plot_width=800,
           plot_height=400,
           x_axis_type='datetime',
           y_range=AGES,
           tooltips=tooltips)
p.tools[-1].formatters = {'@date': 'datetime'}  # HoverToolの設定
p.x_range.range_padding = 0
p.axis.major_tick_in = 0
p.axis.axis_line_color = None

# ColorMapper
mapper = log_cmap('mean', palette='Turbo256',
                  low=1, low_color='#290029',
                  high=rect_data['mean'].max(),
                  )
# ColorBar
colorbar = ColorBar(color_mapper=mapper['transform'])
p.add_layout(colorbar, 'right')

# Renderer
# 軸がdatetime型の時、1は 1ミリ秒なので、1日分の幅を指定するには
# 1000 * 60 * 60 * 24　又は　1日分のtimedelta
p.rect(x='date', y='age',
       width=pd.Timedelta('1D'),
       height=1,
       color=mapper,
       source=source
       )

show(p)
```
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1714033/4f28443f-a5a6-dec6-3203-b21459b4dd46.jpeg" width="800px">

# image

画像として出力する。2次元のデータを渡し、画像の位置を座標(x, y)と幅、高さで指定する。


```python
# imageによるヒートマップ

tooltips = [('日付', '@date{%Y-%m-%d}'),
            ('年代', '@age'),
            ('陽性者', '@nums{,}人'),
            ('平均', '@mean{,.000}人')]

# 日付の最小値、最大値
minmax_dates = nums_table.columns.min(), nums_table.columns.max()

# ホバーツールに使う日付、年代の2次元データ
date_table, age_table = np.meshgrid(nums_table.columns, AGES)
data = dict(
    mean=[mean_table.to_numpy()],
    nums=[nums_table.to_numpy()],
    date=[date_table],
    age=[age_table]
)
# ソース
source = ColumnDataSource(data)

# Figure
p = figure(plot_width=800,
           plot_height=400,
           y_range=AGES,
           x_axis_type='datetime',
           tooltips=tooltips)
p.tools[-1].formatters = {'@date': 'datetime'}
p.x_range.range_padding = 0
p.axis.major_tick_in = 0
p.axis.axis_line_color = None

# ColorMapper
mapper = LogColorMapper(palette='Turbo256',
                        low=1, low_color='#290029',
                        high=mean_table.max().max()
                        )
# ColorBar
colorbar = ColorBar(color_mapper=mapper)
p.add_layout(colorbar, 'right')

# Renderer
# 座標(x, y)が画像の左下の位置
# dwが幅、dhが高さ
p.image(image='mean',
        x=minmax_dates[0],
        y=0,
        dw=minmax_dates[1] - minmax_dates[0] + pd.Timedelta('1D'),
        dh=len(AGES),
        color_mapper=mapper,
        source=source
        )

show(p)
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1714033/ad30351f-0e6d-2aa7-4dc5-65c365ff48c7.jpeg" width="800px">

# まとめ

`rect`はマスの数だけ長方形を描画している。数が増えるとズームや移動した時に一旦消えて再描画という動きになる。`image`は軽快に動く。一方で`rect`は枠線をつけたり、必要かどうかは別として長方形の幅や傾きを変えたりと融通が利く。