Pandasのデータを割合に変換する

業務で集計したデータを「実数だけではなく割合でも出して欲しい」というオーダーを受けることはよくあります。そんな時に、PandasのDataFrameのデータを列ごとや、行ごとの割合に変換する方法のまとめです。

DataFrameの話に入る前に、まずSeries型でやってみましょう。これは非常に簡単で、元のデータをその合計で割るだけです。

import pandas as pd


# 元のデータ
sr = pd.Series([30, 0, 40, 30, 10])
# 合計で割ると割合になる
print(sr/sr.sum())
"""
0    0.272727
1    0.000000
2    0.363636
3    0.272727
4    0.090909
dtype: float64
"""

続いて、DataFrame のデータを列ごとに、その列の値の和に占める割合に変換する方法を見ていきます。実はこれも簡単でDataFrameにたいしてsum()メソッドを実行すると列ごとの和が得られ、元のDataFrameをその和で割るといい感じにブロードキャストされて望む結果が得られます。
ブロードキャストについてはこちらも参照ください。今回の例で言えば、型が(5, 3) と (3,) なのでブロードキャストされます。
参考: NumPyのブロードキャストで変換できる型

# 元のデータを生成する
df = pd.DataFrame(
    {
        "col1": [0, 60, 80, 60, 0],
        "col2": [10, 80, None, 20, 40],
        "col3": [30, 0, 40, 30, 10],
    }
)
print(df)
"""
   col1  col2  col3
0     0  10.0    30
1    60  80.0     0
2    80   NaN    40
3    60  20.0    30
4     0  40.0    10
"""

# sum() すると列ごとの和が得られる
print(df.sum())
"""
col1    200.0
col2    150.0
col3    110.0
dtype: float64
"""

print(df/df.sum())
"""
   col1      col2      col3
0   0.0  0.066667  0.272727
1   0.3  0.533333  0.000000
2   0.4       NaN  0.363636
3   0.3  0.133333  0.272727
4   0.0  0.266667  0.090909
"""

ここからがこの記事の本題です。

列ごとに割合に変換するのは簡単でしたが、行ごとに割合に変換するのはこのようにはうまくいきません。sum(axis=1) で各行ごとの和は出せますが、それで元のデータフレームを割ろうとすると適切にブロードキャストされないからです。なんか変な結果が戻ってきます。

print(df/df.sum(axis=1))
"""
   col1  col2  col3   0   1   2   3   4
0   NaN   NaN   NaN NaN NaN NaN NaN NaN
1   NaN   NaN   NaN NaN NaN NaN NaN NaN
2   NaN   NaN   NaN NaN NaN NaN NaN NaN
3   NaN   NaN   NaN NaN NaN NaN NaN NaN
4   NaN   NaN   NaN NaN NaN NaN NaN NaN
"""

対処法はいくつかあると思います。一つは、「列ごとの処理は簡単で行ごとの処理が難しいなら行列入れ替えればいい」という発想に基づくものです。単純に転置して割合に変換した後もう一回転置します。

# 転置したDataFrameを作る
df_t = df.T
print((df_t/df_t.sum()).T)
"""
       col1      col2      col3
0  0.000000  0.250000  0.750000
1  0.428571  0.571429  0.000000
2  0.666667       NaN  0.333333
3  0.545455  0.181818  0.272727
4  0.000000  0.800000  0.200000
"""

もう一つ、applyメソッドをaxis=1を指定して使い行ごとのSeriesに対して、割合に変換する方法もあります。個人的にはこちらの方が若干スマートに思えます。
(ちなみに、axis=0 (デフォルト)で実行すると列ごとに割合に変換してくれます)

print(df.apply(lambda x: x/x.sum(), axis=1))
"""
       col1      col2      col3
0  0.000000  0.250000  0.750000
1  0.428571  0.571429  0.000000
2  0.666667       NaN  0.333333
3  0.545455  0.181818  0.272727
4  0.000000  0.800000  0.200000
"""

さて、 lambda 式の中で、 x.sum() ってメソッドが出てきますが、ここがちょっとしたコツです。ここで呼び出されるsum()はSeriesオブジェクトのメソッドのsum()なのですが、これはNaNを無視して和をとってくれます。そのため、index 2 の行(3行目)は、元の値が[80, NaN, 40] ですが、これの和を120として、元の値を割ってくれているわけです。

ここで、x.sum() とせずに、 sum(x)と、Python組み込みメソッドのsum()を呼び出すと結果が変わります。これはNaNを無視せず、NaNが含まれる和はNaNにしてしまうのです。そのため、sum(x)を使うと次のような結果になります。

print(df.apply(lambda x:x/sum(x), axis=1))
"""
       col1      col2      col3
0  0.000000  0.250000  0.750000
1  0.428571  0.571429  0.000000
2       NaN       NaN       NaN
3  0.545455  0.181818  0.272727
4  0.000000  0.800000  0.200000
"""

index 2 の行が全部 NaNになってしまいましたね。元のデータにNaNがなければ気にしなくて良い違いなのですが、うっかりしていると見落としがちな性質なので気をつけましょう。

当然ですが、Series型のデータに対しても、もし元のデータがNaNを含んでいたら、sum(sr)で割るのと、sr.sum()で割るのは結果が変わります。

sr = pd.Series([30, None, 40, 30, 10])
print(sr/sum(sr))
"""
0   NaN
1   NaN
2   NaN
3   NaN
4   NaN
dtype: float64
"""

print(sr/sr.sum())
"""
0    0.272727
1         NaN
2    0.363636
3    0.272727
4    0.090909
dtype: float64
"""

pandasの日付データを週単位で丸める(to_periodを使う方法)

前回の記事では、トレジャーデータで週単位の集計をする方法を紹介しましたが、今回はすでにDBから抽出が終わっているデータを週単位で集計する方法を紹介します。

日時の列をindexに設定してresampleするとか、方法はいろいろあるのですが、週単位の集計の場合、個人的にはto_period メソッドを使って丸めるのが一番気に入っているのでそれを紹介します。

ドキュメントはこちらです。
参考: pandas.Series.dt.to_period — pandas 1.3.2 documentation

とりあえずデータを作っておきます。

import pandas as pd


df = pd.DataFrame({
    'date': [
        '2021-08-01',
        '2021-08-02',
        '2021-08-03',
        '2021-08-04',
        '2021-08-05',
        '2021-08-06',
        '2021-08-07',
        '2021-08-08',
        '2021-08-09',
        '2021-08-10',
    ]
})
print(df)
"""
         date
0  2021-08-01
1  2021-08-02
2  2021-08-03
3  2021-08-04
4  2021-08-05
5  2021-08-06
6  2021-08-07
7  2021-08-08
8  2021-08-09
9  2021-08-10
"""

to_period メソッドは、 datetime系の型の列でなければ使えないので、pd.to_datetime() して型を変換します。

df["date"] = pd.to_datetime(df["date"])

さて、これで準備が整いました。1週間単位で日付を丸めたい場合は、最初の引数(freq)に”W”を指定して to_period メソッドを使えばOKです。

print(df["date"].dt.to_period("W"))
"""
0    2021-07-26/2021-08-01
1    2021-08-02/2021-08-08
2    2021-08-02/2021-08-08
3    2021-08-02/2021-08-08
4    2021-08-02/2021-08-08
5    2021-08-02/2021-08-08
6    2021-08-02/2021-08-08
7    2021-08-02/2021-08-08
8    2021-08-09/2021-08-15
9    2021-08-09/2021-08-15
Name: date, dtype: period[W-SUN]
"""

2021-08-01 は、 2021-07-26(月)〜2021-08-01(日)の週に丸められ、
2021-08-02〜2021-08-08は、2021-08-02(月)〜2021-08-08(日)の週に丸められましたね。
ちなみにこの結果は、Periodというデータ型になっています。
元と同じようにdatetime型で扱いたい場合や、丸めた週の頭の日付にしたいという場合はさらに変換する必要があります。自分はもっぱら次の形で使うことが多いです。

df["week"] = df["date"].dt.to_period("W").dt.to_timestamp()
print(df)
"""
        date       week
0 2021-08-01 2021-07-26
1 2021-08-02 2021-08-02
2 2021-08-03 2021-08-02
3 2021-08-04 2021-08-02
4 2021-08-05 2021-08-02
5 2021-08-06 2021-08-02
6 2021-08-07 2021-08-02
7 2021-08-08 2021-08-02
8 2021-08-09 2021-08-09
9 2021-08-10 2021-08-09
"""

あとは、この週単位に変換した列を使って groupbyして、sumなりcountなり望みの集計をすることで、Pandasのデータを週単位で集計することができます。

週単位以外の基準で集計したい場合、”W”以外の対応した文字を使えば実現可能です。
例えば月単位なら”M”、日単位なら”D”などです。
利用可能な集計基準と対応する文字は、こちらのページにまとまっています。
参考: Offset aliases

ただ、TD_TIME_TRUNCの記事でも似たような話を書きましたが、日単位や月単位で集計したいのであれば、to_periodして、timestumpに戻して、とやるよりも、strftimeなどを使った方が簡単だと思います。例えば月単位で集計したいなら次のように変換できます。

print(df["date"].dt.strftime("%Y-%m-01"))
"""
0    2021-08-01
1    2021-08-01
2    2021-08-01
3    2021-08-01
4    2021-08-01
5    2021-08-01
6    2021-08-01
7    2021-08-01
8    2021-08-01
9    2021-08-01
Name: date, dtype: object
"""

日単位の場合は、 “%Y-%m-%d”です。

ドキュメントになぜか記載がないのですが、週単位で丸める場合、週の始まりの曜日(実際にコードで指定するのは週の終わりの曜日)を指定することもできます。
方法は簡単で、”W”の代わりに、”W-WED”(水曜日終わり、木曜日始まり)、や、
“W-FRI”(金曜日終わり、土曜日始まり)などを指定します。
“W”は”W-SUN”(日曜日終わり、月曜日始まり)と同じ挙動になります。
基本的に”W”を使っていれば良いと思うのですが、開始日を変えたいこともあると思いますので覚えておくと役に立つ場面もあるかもしれません。

pandasのメソッドで、上位n件や下位n件のデータを取得する

先日紹介したbar chart raceのライブラリのドキュメントやソースコードを読んでいて、その中で nlargest というメソッドを見つけたのでその紹介です。その対となる nsmallest というメソッドもあります。

これが何をするメソッドとかというと、DataFrameやSeriesのデータの値が大きい方からn件(nlargest)や小さい方からn件(nsmallest)を取得してくれるものです。
え、sort_values() して、 head(n)やtail(n)すればいいじゃん、という声も聞こえてきそうですし、実際僕もそう思ってるのですが、多少の利点がちゃんとあるので読んでいただければ幸いです。

公式ドキュメントはこちらになります。
pandas.DataFrame.nlargest
pandas.DataFrame.nsmallest
pandas.Series.nlargest
pandas.Series.nsmallest

使い方は簡単で、Seriesの方であれば、取得したい件数を最初の引数nに渡してあげるだけ、DataFrameの方は、取得したい件数と合わせて、どの列の上位/下位を取得したのかを2つ目の引数columnsに渡してあげればOKです。

とりあえず、適当に作ったDataFrameに対して適当に列を指定して5項目ほど取得してみましょう。

import pandas as pd
import numpy as np


# 50行3列の乱数データを生成する
data = np.random.randint(1, 50, size=(50, 3))
df = pd.DataFrame(data, columns=["col1", "col2", "col3"])
print(df.shape)
# (50, 3)

print(df.nlargest(5, "col2"))
"""
    col1  col2  col3
46    30    48    28
17    47    47    31
33     9    45    30
16     3    44    33
26    16    44     2
"""

見ての通り、指定した”col2″でソートした上でその値が大きいものから順番に、5項目選択されています。

nlargest/ nsmallest にはもう一つ、keepという引数があります。これは、値が等し鋳物が複数あって、n位にランクインするものが一意に決められないときにその取り扱いを指定するものです。
“first”(デフォルト)を指定すると、元のデータで先に登場指定したものが優先され、”last”を指定すると、最後に登場したものが優先されます。また、”all”にすると、同率だったものが全部含まれます。

print(df.nlargest(5, "col2", keep="first"))
"""
    col1  col2  col3
46    30    48    28
17    47    47    31
33     9    45    30
16     3    44    33
26    16    44     2
"""

print(df.nlargest(5, "col2", keep="last"))
"""
    col1  col2  col3
46    30    48    28
17    47    47    31
33     9    45    30
40    48    44     1
26    16    44     2
"""

print(df.nlargest(5, "col2", keep="all"))
"""
    col1  col2  col3
46    30    48    28
17    47    47    31
33     9    45    30
16     3    44    33
26    16    44     2
40    48    44     1
"""

“col2″に値が44のレコードが3つ存在するのですが、”first”と”last”で選択されたレコードが違うのがわかりますね。そして”all”を指定すると3レコードとも返され、結果が6行になっています。

このkeep引数が存在することのほか、sort_values/head に比べると、速度面でも優れているそうです。

This method is equivalent to df.sort_values(columns, ascending=False).head(n), but more performant.

とドキュメントにもあります。
ソースを読んで無いので予想ですが、sort_values/headの方は最終的な結果に必要ない行まで全部ソートを完了させるに対して、nlargest/nsmallestの方は必要なデータだけ並べ替えてソートを打ち切ってるのではないかと思っています。

コードの実行例は載せませんでしたが、nsmallestもnlargestと同じように使うことができ、こちらは結果が小さい順に取得されます。

pandasのデータの順位を取得する

稀にではあるのですが、Pandasのデータ(DataFrame/Series)のデータの順位を取得したくなることがあります。
これまでは、DataFrameの列内の順位であれば、sort_valuesで並べ替えて、インデックスを振り直して、といった手順で対応することが多かったです。しかし、この方法では、値が等しい項目の扱いが少々厄介になります。また、最近、列内の順位ではなく、各行ごとに行内での順位を取得したいことがあり、ちょっと面倒だなと感じることがありました。

そこで、改めて調べてみたのですが、DataFrameもSeriesもそれぞれ、rankというメソッドを持っていて、これを使えば簡単に順位が取得できることがわかりました。
参考:
pandas.DataFrame.rank
pandas.Series.rank

使い方非常に簡単で、rank()を呼び出すだけです。適当なDataFrameでやってみます。

import pandas as pd


# 適当にデータを生成する
df = pd.DataFrame(
    {
        "col1": [20, 30, None, 20, 10, 20],
        "col2": [10, 50, 20, 20, 30, 60],
        "col3": [30, None, 60, None, 20, 80]
    }
)
print(df)
"""
   col1  col2  col3
0  20.0    10  30.0
1  30.0    50   NaN
2   NaN    20  60.0
3  20.0    20   NaN
4  10.0    30  20.0
5  20.0    60  80.0
"""

# 列内の順位を取得する
print(df.rank())
"""
   col1  col2  col3
0   3.0   1.0   2.0
1   5.0   5.0   NaN
2   NaN   2.5   3.0
3   3.0   2.5   NaN
4   1.0   4.0   1.0
5   3.0   6.0   4.0
"""

結果を見てわかる通り、順序は昇順で、値が小さいほど高順位(数値が小さい)ですね。

さて、このrank()メソッドはとても気が利いていて、多くの引数で細かく結果を制御できます。
まず、列ごとではなく、行ごとの順位が欲しい場合は、axis引数に1を渡します。
ちなみに、Seriesの方のドキュメントにも、axis引数があって、1を渡せるような記載があるのですがこれはおそらくドキュメントの誤りです。(普通にエラーになります。)

# 行内の順位を取得する
print(df.rank(axis=1))
"""
   col1  col2  col3
0   2.0   1.0   3.0
1   1.0   2.0   NaN
2   NaN   1.0   2.0
3   1.5   1.5   NaN
4   1.0   3.0   2.0
5   1.0   2.0   3.0
"""

昇順ではなく降順の順位が欲しい、という場合は、ascending にFalse を渡します。(デフォルトはTrueです。)

# 降順の順位を取得する
print(df.rank(ascending=False))
"""
   col1  col2  col3
0   3.0   6.0   3.0
1   1.0   2.0   NaN
2   NaN   4.5   2.0
3   3.0   4.5   NaN
4   5.0   3.0   4.0
5   3.0   1.0   1.0
"""

na_option という引数で、NaN値に対応する順位を指定できます。
“keep”(デフォルト) であれば、NaNのままです。
“top”にすると、最も高い順位(要するに1)がNaN値に振り分けられます。
“bottom”にすると、逆にもっとも低い順位が割り振られます。
それぞれ実行した結果が以下です。

df = pd.DataFrame(
    {"data":  [20, 30, None, 20, 10, 20]}
)
df["na_keep"] = df.data.rank(na_option="keep")
df["na_top"] = df.data.rank(na_option="top")
df["na_bottom"] = df.data.rank(na_option="bottom")

print(df)
"""
   data  na_keep  na_top  na_bottom
0  20.0      3.0     4.0        3.0
1  30.0      5.0     6.0        5.0
2   NaN      NaN     1.0        6.0
3  20.0      3.0     4.0        3.0
4  10.0      1.0     2.0        1.0
5  20.0      3.0     4.0        3.0
"""

さて、最初の方のコードの実行例で、2.5など小数点の順位のものがあるのがわかると思います。これは同率順位の項目に対して、デフォルトではその平均順位を返す設定になっているからです。
この設定は、 method 引数で制御できます。値はデフォルトの’average’の他、最小値(もっとも高順位)を採用する’min’、その逆に最大値を採用する’max’、元の配列に表示されていた順に順位がつく’first’、’min’と同じように、最小値が採用されるが、その次の順位の項目の順位が数が飛ばないように採番される’dense’の5種類の値が指定できます。
ちょっとわかりにくいと思うので実例でやってみます。

df = pd.DataFrame(
    {"data":  [20, 30, 40, 20, 10, 20, 40]}
)
df["m_average"] = df.data.rank(method="average")
df["m_min"] = df.data.rank(method="min")
df["m_max"] = df.data.rank(method="max")
df["m_first"] = df.data.rank(method="first")
df["m_dense"] = df.data.rank(method="dense")

print(df)
"""
   data  m_average  m_min  m_max  m_first  m_dense
0    20        3.0    2.0    4.0      2.0      2.0
1    30        5.0    5.0    5.0      5.0      3.0
2    40        6.5    6.0    7.0      6.0      4.0
3    20        3.0    2.0    4.0      3.0      2.0
4    10        1.0    1.0    1.0      1.0      1.0
5    20        3.0    2.0    4.0      4.0      2.0
6    40        6.5    6.0    7.0      7.0      4.0
"""

値が20の項目が3つあって順位的には、2位,3位,4位に相当するのですが、
averageであれば3、minであれば2、maxであれば4が割り振られているのが確認できましたね。firstであれば元の配列に出てきた通り、2,3,4位が当てられています。
そして、denseの結果を見ると、minと同様に20は2位になっているのですが、その次の30が、minの時は5位だったのに、denseでは欠番がなくこれが3位になっています。

あとは、あまり使わないと思うのですが、 pct という引数をTrueにすると、順位の数値ではなくパーセンタイルで結果が受け取れます。

df = pd.DataFrame(
    {"data":  [20, 30, 10, 20, 40]}
)
df["pct_false"] = df.data.rank(pct=False)
df["pct_true"] = df.data.rank(pct=True)
print(df)
"""
   data  pct_false  pct_true
0    20        2.5       0.5
1    30        4.0       0.8
2    10        1.0       0.2
3    20        2.5       0.5
4    40        5.0       1.0
"""

順位が一番低い項目が1になるのは想像通りですが、最高順位の項目は0では無いんですね。

スクラッチでBar Chart Raceを実装(コード供養)

前回の記事で、Bar Chart Raceを作るライブラリを紹介しましたが、実は僕はこのライブラリが登場するよりも前、スクラッチでBar Chart Raceを実装したことがあります。
便利なライブラリが登場したので、今後スクラッチで作ることはおそらく無いのですが、せっかく作ったコードが勿体無いので供養も兼ねて紹介させていただこうと思います。

棒の伸びもライブラリのように滑らかな動きでは無いですし、順位の入れ替わりなどもバーが上下に滑らかに移動して入れ替わるのではなく、パッと切り替わるなど、全体的にパラパラ漫画感が強く出てる出来栄えなのであまり期待せずによろしくお願いします。

データだけではライブラリ付属のコロナウィルス感染者のデータを拝借します。僕のコードはNaNに対応できないので、NaNは0埋めしておきます。

# データだけはライブラリから拝借
import bar_chart_race as bcr
# サンプルデータ読み込み
df = bcr.load_dataset('covid19')
# NaNに対応できてないので0埋めしておく
df.fillna(0, inplace=True)

では、早速作っていきます。実装としては、matplotlibのアニメーション機能を使います。
FuncAnimation を使うので、実装としては次の記事と似ています。
参考: matplotlibの3次元プロットを回転するアニメーションで保存する

まず、パラパラ漫画の各コマを生成する関数を実装します。

import matplotlib.pyplot as plt
import matplotlib.ticker as ticker


def draw_barchart(date):
    target_row = df.loc[date]
    target_data = target_row.T.sort_values(ascending=True).tail(10)

    ax.clear()
    # 棒グラフを描写
    ax.barh(target_data.index, target_data.values)
    dx = target_data.max() / 200

    for i, (name, value) in enumerate(target_data.items()):
        # 棒の先端部に項目名を出力
        ax.text(value-dx, i, name, size=14, ha='right',
                va='bottom', color="white", weight=600)
        # 棒の先に値を出力
        ax.text(value+dx, i, f'{value:,.0f}', size=14, ha='left', va='center')

    # 日付を出力
    ax.text(1, 0.4, date.strftime("%Y-%m-%d"), transform=ax.transAxes,
            color='#777777', size=23, ha='right', weight=800)
    # x軸のメモリの設定
    ax.xaxis.set_major_formatter(ticker.StrMethodFormatter('{x:,.0f}'))
    ax.xaxis.set_ticks_position('top')
    ax.tick_params(axis='x', colors='#777777', labelsize=12)
    # y軸のメモリ(項目名)を消す
    ax.set_yticks([])

    ax.margins(0, 0.01)
    ax.grid(which='major', axis='x', linestyle='-')
    ax.set_axisbelow(True)

    # 外枠を消す
    plt.box(False)

コメントを多めに付けましたが、関数の中で順に、棒グラフを書いたり文字を挿入したりメモリを調整したりとコマを組み立てています。
上記の関数でパラパラ漫画のコマが描写できるので、それを使って、アニメーションにします。

import matplotlib.animation as animation

fig = plt.figure(figsize=(10, 6), facecolor="w")
ax = fig.add_subplot(111)

animator = animation.FuncAnimation(
    fig, draw_barchart, frames=df.index, interval=400)
animator.save('bar-chart-race.mp4', writer="ffmpeg")

これで出力されるのが次の動画です。

やっぱり全体的にカクカクなりますね。
データとデータの間を補完してコマ数をもっと増やすなどしないとなめらなかなアニメーションにならないようです。

PythonのライブラリでBar Chart Raceを作ってみた

皆さんもどこかでご覧になったことがあると思うのですが、項目ごとに増加し続けるデータの面白い可視化方法として、Bar Chart Race というものがあります。
棒グラフがグイグイ伸びて順位を争っているようなアニメーションですね。

その、Bar Chart Race をPythonで手軽に作れるライブラリを見つけたので今回の記事ではそれを紹介します。
その名も、 bar_chart_race です。 そのままですね。
公式ドキュメントはこちらになります。
Bar Chart Race

インストールはpipでもcondaでも可能です。(僕はcondaで入れました)
次の2行のコードのどちらかを実行してください。

pip install bar_chart_race
conda install -c conda-forge bar_chart_race

使い方は非常に簡単で、ライブラリをインポートしたら、bar_chart_raceというメソッドにデータと保存するファイル名を渡すだけです。

実際にデータを渡す前に、どんなデータを渡せばいいのか確認しておきましょう。
このライブラリ自体にサンプルデータとして国別のコロナウィルス感染者数のデータが同梱されているので、それをみてみます。

import bar_chart_race as bcr


# サンプルデータ読み込み
df = bcr.load_dataset('covid19')

# データのサイズ
print(df.shape)
# (57, 20)

print(df.index[: 5])
"""
DatetimeIndex(['2020-02-26', '2020-02-27', '2020-02-28', '2020-02-29',
               '2020-03-01'],
              dtype='datetime64[ns]', name='date', freq=None)
"""
print(df.columns[: 5])
"""
Index(['Belgium', 'Brazil', 'Canada', 'China', 'France'], dtype='object')
"""

上記の通り、インデックスに日付、カラムに国名(比較するアイテム)を持ったデータフレームがこのライブラリが想定しているデータ形式のようです。

早速、Bar Chart Raceを作ってみましょう。まずはメソッドに単純に渡して初期設定の出来を見てみます。

bcr.bar_chart_race(
    df=df,
    filename='covid19_default.mp4',
)

初期設定でも十分な見栄えのBar Chart Race ができましたね。

さらにこのライブラリは、見た目を変えたい場合に備えて、非常に多くのオプションが用意されています。
公式ドキュメントに色々設定を加えたバージョンも出てるのでそれも見ておきましょう。

bcr.bar_chart_race(
    df=df,
    filename='covid19_horiz.mp4',
    orientation='h',
    sort='desc',
    n_bars=6,
    fixed_order=False,
    fixed_max=True,
    steps_per_period=10,
    interpolate_period=False,
    label_bars=True,
    bar_size=.95,
    period_label={'x': .99, 'y': .25, 'ha': 'right', 'va': 'center'},
    period_fmt='%B %d, %Y',
    period_summary_func=lambda v, r: {'x': .99, 'y': .18,
                                      's': f'Total deaths: {v.nlargest(6).sum():,.0f}',
                                      'ha': 'right', 'size': 8, 'family': 'Courier New'},
    perpendicular_bar_func='median',
    period_length=500,
    figsize=(5, 3),
    dpi=144,
    cmap='dark12',
    title='COVID-19 Deaths by Country',
    title_size='',
    bar_label_size=7,
    tick_label_size=7,
    shared_fontdict={'family' : 'Helvetica', 'color' : '.1'},
    scale='linear',
    writer=None,
    fig=None,
    bar_kwargs={'alpha': .7},
    filter_column_colors=False)  

これの出力がこちらです。

引数名を見れば各引数が何を設定しているのかは大まかにわかるのではないかと思います。
また、公式ドキュメントのAPIリファレンスにも解説が充実しています。

Matplotlibで多角形や円などの図形を描写する

Matplotlibのグラフに図形を入れる方法の紹介です。
(強調したい部分に丸や四角で目印をつけたり矢印を引いたりできます。)

Matplotlibのグラフに図形を入れるには、 matplotlib.patches の下に定義されている各種クラスを使います。
長方形は、 matplotlib.patches.Rectangle,
円は、 matplotlib.patches.Circle, など、図形に応じたクラスが用意されているので、
それぞれインスタンスを作成し、
ax.add_patch() で作ったインスタンスをグラフに挿入します。

Rectangle は左下の座標と幅と高さ、Circleは中心の座標と半径など、それぞれ固有のオプションがあり、それを指定することで思い通りの位置とサイズの図形を作れます。
詳しくはドキュメントの各クラスの説明をご参照ください。
参考: matplotlib.patches — Matplotlib 3.4.2 documentation

これらのクラスは全て、
matplotlib.patches.Patch
を継承して実装されています。
塗りつぶしの色や線のスタイルの指定、模様を入れるなど、汎用的な引数の説明は、matplotlib.patches.Patchのページに説明があるのでこちらも合わせて参照すると良いでしょう。

全てを紹介はしませんが、いくつかの図形を実際に書いてみたコードが以下です。


import matplotlib.pyplot as plt
from matplotlib import patches


fig = plt.figure(facecolor="w")
ax = fig.add_subplot(1, 1, 1, aspect="equal")

# 長方形
patch = patches.Rectangle(
    xy=(20, 10),  # 左下の頂点の座標
    width=30,  #  長方形の幅
    height=50,  # 長方形の高さ
    angle=10,  # 傾き
    facecolor="b",  # 塗りつぶしの色
    edgecolor="c",  # 辺の色
    linewidth=3,  # 辺の線幅
    hatch=".",  # 塗りつぶしの模様 {'/', '\', '|', '-', '+', 'x', 'o', 'O', '.', '*'}
    # 辺のスタイル  {'-', '--', '-.', ':', '', (offset, on-off-seq), ...}
    linestyle="-.",
    fill=True,  # 塗りつぶしあり。Falseにすると塗りつぶし無し。
)
ax.add_patch(patch)

# 円
patch = patches.Circle(
    xy=(80, 40),  #  中心
    radius=20,  # 半径
    fill=False,  # 塗りつぶし無し
)
ax.add_patch(patch)

# 矢印
patch = patches.Arrow(
    x=110,  # 始点のx座標
    y=20,  # 始点のy座標
    dx=60,  # x軸方向の長さ。 x+dx が終点のx座標。
    dy=20,  # y軸方向の長さ。 y+dy が終点のy座標。
    width=30,  # 矢印の幅
)
ax.add_patch(patch)

# 楕円
patch = patches.Ellipse(
    xy=(40, 90),  # 中心
    width=50,  # 横幅
    height=20,  # 高さ
    angle=-30,  # 傾き
)
ax.add_patch(patch)

# 多角形
patch = patches.Polygon(
    # 頂点の座標を n*2 次元配列で指定
    xy=[
        [80, 100],
        [80, 80],
        [110, 70],
        [120, 90],
        [100, 110]
    ]
)
ax.add_patch(patch)

# 正多角形
patch = patches.RegularPolygon(
    xy=(140, 70),  # 中心の座標
    numVertices=7,  # 頂点の数
    radius=20,  # 半径
    orientation=10,  # 角度
)
ax.add_patch(patch)

ax.autoscale()
plt.show()

patchたちを追加した最後に、 ax.autoscale() して、挿入した図形たちがグラフの描写範囲に収まるように調整しています。
これをしないと、グラフの描写範囲がデフォルトの x座標の区間[0, 1]、y座標の区間[0, 1] のままになってしまい、せっかく描いた図形が見えなくなってしまいます。

上記のコードで、以下の図が出力されます。

参考として長方形だけ、facecolor、edgecolorなど、色々指定しましたが、
これは継承元のPatchで定義されているので、もちろん他の図形でも指定できます。

PythonでBase64エンコードとデコード

Base64というのは、データ(バイト列)を、64種類の文字(と、パディング用の”=”を含めた65文字)で表現するエンコード方法です。
64種類の文字の内訳はアルファベット小文字(a-z)26種類、大文字(A-Z)26種類、数値(0-9)10種類、記号(+,/)2種類です。
文字データしかやり取りのできないプロトコルで一般のデータを送受したりするために使われます。

詳しくはWikipediaをご参照ください。
参考: Base64 – Wikipedia

今回の記事は、このBase64のエンコードをPythonで実装する方法の紹介です。

Pythonには標準ライブラリに専用のモジュールが用意されています。
参考: base64 — Base16, Base32, Base64, Base85 データの符号化 — Python 3.9.4 ドキュメント

まず、文字列をbase64エンコーディングしてみましょう。
使い方は簡単で、base64.b64encodeに、バイトデータを渡してあげるだけです。
string型のデータはそのままでは受け取れない(エラー:a bytes-like object is required, not ‘str’ が発生する)ので、
元のテキストをencode()メソッドを使ってbyteデータに変換するのがポイントです。


import base64


text = "ハローワールド!"
print(base64.b64encode(text.encode()))
# b'44OP44Ot44O844Ov44O844Or44OJ77yB'

出力結果が b’〜’ となっているのでわかる通り、結果はbyte型で得られます。
base64の結果を文字列で欲しい時は、decode()する必要があります。


print(base64.b64encode(text.encode()).decode())
# 44OP44Ot44O844Ov44O844Or44OJ77yB

逆に、Base64のデータを、元のデータに戻したい場合は、base64.b64decode()を使います。
不思議なことに、このメソッドは、string型のデータもbyte型のデータも両方受け取ってくれます。
結果はbyte型で受け取ることになるので、元の文字列型のデータとして結果を得たい場合は、改めてdecode()する必要があります。


# byteを渡した場合
print(base64.b64decode(b'44OP44Ot44O844Ov44O844Or44OJ77yB'))
# b'\xe3\x83\x8f\xe3\x83\xad\xe3\x83\xbc\xe3\x83\xaf\xe3\x83\xbc\xe3\x83\xab\xe3\x83\x89\xef\xbc\x81'
print(base64.b64decode(b'44OP44Ot44O844Ov44O844Or44OJ77yB').decode())
# ハローワールド!

# stringを渡した場合
print(base64.b64decode('44OP44Ot44O844Ov44O844Or44OJ77yB'))
# b'\xe3\x83\x8f\xe3\x83\xad\xe3\x83\xbc\xe3\x83\xaf\xe3\x83\xbc\xe3\x83\xab\xe3\x83\x89\xef\xbc\x81'
print(base64.b64decode('44OP44Ot44O844Ov44O844Or44OJ77yB').decode())
# ハローワールド!

以上で、文字列データをBase64エンコード/デコードできました。
ただ、Base64の本領は、文字列ではない一般のデータを文字列で表現できることにあります。
なので、サンプルとして画像データをBase64エンコードするコードも紹介しておきます。
といっても、やることは単純で、画像をバイナリとして読み込んで、文字列のときと同じメソッドに渡すだけです。

結果はものすごく長いデータになるので出力しませんが、以下のようなコードで、img_base64変数に、
sample.jpg ファイルをBase64した結果が格納されます。


with open("./sample.jpg", "rb") as f:
    img = f.read()

img_base64 = base64.b64encode(img)

元々がbyte型なので文字列の時より単純ですね。
逆変換も文字列の時と同様にbase64.b64decodeでできます。
逆変換した結果を別のファイル名で保存して、元の画像と同じものであることを確認しておきましょう。(結果省略)


with open("./sample2.jpg", "wb") as f:
    f.write(base64.b64decode(img_base64))

失敗しやすい処理にリトライをスクラッチで実装する

とあるSDKを使って実装している処理で、利用しているAPIのエラーが頻発するようになり、
エラーが起きたらリトライする処理をスクラッチで作る必要があったのでその時の実装をメモしておきます。

今回は利用しているSDKのメソッドにリトライ処理が内包されておらず、
気軽にライブラリを追加できる環境でもなかったのでスクラッチで実装しましたが、
通常は他の手段がないか探すことをお勧めします。

例えば、requestsのようなライブラリは引数でリトライ回数を指定できますし、
世の中にはリトライを行う専用のライブラリなども転がっています。

この記事のコードはそれらが使えなかった場合の最後の手段です。

実際の処理はお見せできないので、サンプルとして一定確率(80%)エラーになる関数を作っておきます。


import numpy as np


def main_function():
    num = int(np.random.choice([0, 1], p=[0.8, 0.2]))
    return 1/num

さて、これを成功するまでリトライする関数を作ります。
実装するにあたって定めた要件は以下の通りです。

– 規定回数リトライする。(今回は5回と定めました。)
– 初回の実行と合わせて、実際に実行を試みるのは、{1+リトライ回数}回。
– 発生したエラーは都度表示する。
– 規定回数全てエラーになったら、最後に発生したエラーを呼び出し元に返す。
– エラーから次のリトライまでは、1秒,2秒,4秒,8秒,…と間隔をあける。

書いてみたのが次のコードです。


from time import sleep


max_retry_count = 5  # リトライ回数

for retry_count in range(max_retry_count+1):
    try:
        print(main_function())  # 実行したい処理
        break  # 成功したらループを抜ける
    except Exception as e:
        print(e)
        if retry_count == max_retry_count:
            print(f"規定回数({max_retry_count}回)のリトライに失敗しました。")
            raise
        # {2^リトライ回数}秒待ちを入れる
        dely = 2**retry_count
        print(f"{dely}秒後にリトライします。{retry_count+1}/{max_retry_count}回目。")
        sleep(dely)

2回のリトライ(3回目の試行)で成功すると出力は以下のようになります。


division by zero
1秒後にリトライします。1/5回目。
division by zero
2秒後にリトライします。2/5回目。
1.0

最後まで失敗すると以下のようになります。


division by zero
1秒後にリトライします。1/5回目。
division by zero
2秒後にリトライします。2/5回目。
division by zero
4秒後にリトライします。3/5回目。
division by zero
8秒後にリトライします。4/5回目。
division by zero
16秒後にリトライします。5/5回目。
division by zero
規定回数(5回)のリトライに失敗しました。
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
 in 
      7 for retry_count in range(max_retry_count+1):
      8     try:
----> 9         print(main_function())  # 実行したい処理
     10         break  # 成功したらループを抜ける
     11     except Exception as e:

 in main_function()
      3 def main_function():
      4     num = int(np.random.choice([0, 1], p=[0.8, 0.2]))
----> 5     return 1/num

ZeroDivisionError: division by zero

ちゃんとエラーになりましたね。

PyMySQLのcursorclassについて

PyMySQLの公式ドキュメントのExamplesで使われている、
cursorclass=pymysql.cursors.DictCursor
の話です。


import pymysql.cursors

# Connect to the database
connection = pymysql.connect(
    host='localhost',
    user='user',
    password='passwd',
    database='db',
    charset='utf8mb4',
    cursorclass=pymysql.cursors.DictCursor # これの話。
)

ドキュメントにも詳しい説明はないし、pep249でも言及されていないようなのでPyMySQLのソースコードも含めて調べてみました。

結論から言えば、実用上は黙って cursorclass=pymysql.cursors.DictCursor を指定しておけばよく、この記事は無駄知識の類のものになります。

まず、cursorclass に指定できる値は pymysql.cursors.DictCursor 以外に何があるのかですが、
これは、こちらのファイルで定義されている4種類のクラスが指定できます。
Github: pymysql/cursors.py

– pymysql.cursors.Cursor
– pymysql.cursors.DictCursor
– pymysql.cursors.SSCursor
– pymysql.cursors.SSDictCursor

https://github.com/PyMySQL/PyMySQL/blob/master/pymysql/connections.py#L179
に、


class Connection:
    # 中略
    def __init__(
        # 中略
        cursorclass=Cursor,

とある通り、デフォルトは、 pymysql.cursors.Cursor です。

この4種類のカーソルの違いですが、
Cursor と SSCursor は結果をタプルで返し、
DictCursor と SSDictCursor は結果を辞書で返してくれます。
結果の形に、CursorとSSCursor、DictCursorとSSDictCursorの間にはそれぞれ違いはありません。

SSとつく二つの方ですが、これらは主にデータが非常に大きいときや、ネットワークが遅いときなどに使います。
SSCursorのコメントがわかりやすいですね。

Unbuffered Cursor, mainly useful for queries that return a lot of data,
or for connections to remote servers over a slow network.
Instead of copying every row of data into a buffer, this will fetch
rows as needed. The upside of this is the client uses much less memory,
and rows are returned much faster when traveling over a slow network
or if the result set is very big.
There are limitations, though. The MySQL protocol doesn’t support
returning the total number of rows, so the only way to tell how many rows
there are is to iterate over every row returned. Also, it currently isn’t
possible to scroll backwards, as only the current row is held in memory.

個人的な感想としては最近の端末には十分なメモリが搭載されていて、数百万行単位のレコードを扱うときも、SS無しの方で十分さばけているので、
とりあえず DictCursor を使って、本当にメモリ不足で困った時だけ SSDictCursor を検討したらいいのかなと思っています。

Cursor(タプル) と DictCursor(辞書)についてはそれぞれ実行して結果を比較しておきましょう。

まず、cursorclassにpymysql.cursors.Cursorを指定した(もしくは何も指定しなかった場合)の結果です。
テーブルは以前の記事で作ったやつをそのまま使います。


with connection.cursor() as cursor:
        sql = "SELECT id, email, password FROM users"
        cursor.execute(sql)
        result = cursor.fetchall()
print(result)
"""
(
    (1, 'webmaster@python.org', 'very-secret'),
    (2, 'sato@python.org', 'very-secret'),
    (3, 'suzuki@python.org', 'very-secret'),
    (4, 'takahashi@python.org', 'very-secret'),
    (5, 'tanaka@python.org', 'very-secret')
)
"""

1レコードごとに結果がタプルで戻っているだけでなく、fetchallすると戻り値はタプルのタプルになっていますね。
戻ってきた結果の各値が、SELECT句のどの列の値なのかが明示されていないので、自分でマッピングする必要があります。
正直これは少し使いにくいです。

続いて、pymysql.cursors.DictCursor を指定した場合の結果です。


with connection.cursor() as cursor:
        sql = "SELECT id, email, password FROM users"
        cursor.execute(sql)
        result = cursor.fetchall()
print(result)
"""
[
    {'id': 1, 'email': 'webmaster@python.org', 'password': 'very-secret'},
    {'id': 2, 'email': 'sato@python.org', 'password': 'very-secret'},
    {'id': 3, 'email': 'suzuki@python.org', 'password': 'very-secret'},
    {'id': 4, 'email': 'takahashi@python.org', 'password': 'very-secret'},
    {'id': 5, 'email': 'tanaka@python.org', 'password': 'very-secret'}
]
"""

ご覧の通り、1レコードごとに「列名:値」の辞書として値が得られ、それらの配列として結果が返されます。
各値がSELECT句のどの列のものなのかはっきりしているのでとても便利です。
また、このままpandasのデータフレームに変換することもできます。
通常はこれを使えば良いでしょう。