Pythonのリストをn個に分割する

めったに使わないのですが、前回の記事がリストをn個ずつに分割するだったので今回はリストをn個のリストに分割する方法を紹介します。
ちなみに、目的が機械学習のクロスバリデーションであれば、scikit-learnに専用のメソッドがあるのでそちらを使いましょう。
今回の記事はそれ以外の用途で、何かしらの事情があってリストをn分割する必要が発生した時に使います。

さて、まず簡単に思いつくのは前回の記事同様にリストのスライスを使う方法です。
元のデータのサイズをnで割って区切り位置を決め、その位置で区切ります。
コードにすると次のようになりますね。
例として、サイズが23のデータを5分割しています。
途中、スライスする位置をintで整数に丸めているのは、単にスライスの表記が整数しか受け付けないからです。

# サンプルのデータ生成
data = list(range(23))
print(data)
# [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22]
print(len(data))
# 23

# 5分割する
n = 5
size = len(data)

for i in range(n):
    start = int((size*i/n))
    end = int((size*(i+1)/n))
    print(data[start:end])
"""
[0, 1, 2, 3]
[4, 5, 6, 7, 8]
[9, 10, 11, 12]
[13, 14, 15, 16, 17]
[18, 19, 20, 21, 22]
"""

特に何も変哲のないコードですし、無事にリストが5分割されました。

ただ、一点気持悪いというか少なくとも僕の好みには合わない点があります。
それが分割結果の各リストのサイズです。数えてみると、4個、5個、4個、5個、5個、となっています。23が5で割り切れないので、数が不揃いになるのは仕方ないのですが、個人的には、4/4/5/5/5 か、 5/5/5/4/4 のどちらかで切りたいです。

しかし、これを実装するのはそこそこ手間がかかります。元のデータ長を分けたいグループ数で整数除算し、商とを余を求めて分割後の各グループに属する要素数を求め、その要素数から区切り位置を決め、その位置で切る手順をコードに起こす必要があるからです。
やってみたのが次のコードです。(確認用のprint文や説明のコメントのせいで余計に面倒なコードに見えてしまっていますね。)

import numpy as np


# data は上のコード例と同じものを使う。
data = list(range(23))
n = 5
size = len(data)

# データの件数を分けたいグループ数で割って商と余りを求める
quotient, remainder = divmod(size, n)
print("商:", quotient)
# 商: 4
print("余り:", remainder)
# 余り: 3

# [0] に続けて各グループの要素数を指定するリストを作る
section_sizes = ([0] + remainder * [quotient+1] + (n-remainder) * [quotient])
print(section_sizes)
# [0, 5, 5, 5, 4, 4]

# 累積和をとって、スライスする点のリストにする
slice_points = list(np.cumsum(section_sizes))
print(slice_points)
# [0, 5, 10, 15, 19, 23]

# 作成したスライス位置を使ってリストを切る
for i in range(n):
    start = slice_points[i]
    end = slice_points[i+1]
    print(data[start:end])

"""
[0, 1, 2, 3, 4]
[5, 6, 7, 8, 9]
[10, 11, 12, 13, 14]
[15, 16, 17, 18]
[19, 20, 21, 22]
"""

はい、これで、5個、5個、5個、4個、4個、に区切れましたね。

途中累積和を取るためにnumpyをインポートしてcumsumまで使っています。
ただ、どうせnumpyを使うことになるのであれば、実はnumpyに専用のメソッドが用意されているので断然そちらがお勧めです。

参考: numpy.array_split

numpyのarray用に実装されたメソッドだと思いますが、ただのlistに対しても動作してくれます。これを使うと、たったこれだけのコードになります。

data = list(range(23))
n = 5
print(np.array_split(data, n))
"""
[array([0, 1, 2, 3, 4]),
 array([5, 6, 7, 8, 9]),
 array([10, 11, 12, 13, 14]),
 array([15, 16, 17, 18]),
 array([19, 20, 21, 22])]
"""

めっちゃ簡単ですね。メソッドの戻り値はn分割した各グループのリストになります。
分割された各グループは array 型に変換されるのでその点だけ注意してください。
元のデータがarray型でなくても結果はarray型になります。

Pythonのリストをn個ずつに分割する

今回の記事はPythonのlistのスライスの小ネタです。リストを長さnのリストに分割する方法を紹介します。

これは次のようなコードで実現できます。
例として、長さが17のリストを生成し、n=5個ずつに分けてprintしています。

data = list(range(17))
print(data)
# [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
print(len(data))
# 17

n = 5
for i in range(0, len(data), n):
    print(data[i: i+n])

"""
[0, 1, 2, 3, 4]
[5, 6, 7, 8, 9]
[10, 11, 12, 13, 14]
[15, 16]
"""

range(0, len(data), n)によって、 0, 5, 10, 15 というn=5 ずつ増える数列を生成して、[i: i+5]というスライスで配列を切り出しているだけですね。

さっと書いて動くのを見ると何の変哲もないコードのように思えますが、実はPythonのlistのスライスの非常に便利な仕様を活用しています。
それは、スライスの範囲が元のリストのインデックスからはみ出していても問題なく動くと言うことです。

i=15の時、[i: i+5]は[15: 20]ですが、元のlist は長さが17なので、20番目の要素などありません。しかし、このスライスは、切り出せた分だけ切り出して動いてくれるのでこのコードがエラーにならずに動作するのです。

もしこの仕様がなければ、i+nが配列の長さを超えていないかどうかで分岐を一つ書く必要があるところでした。

ちなみに、スライスではなくインデックスで要素を取り出す場合は当然ですがインデックスの最大値を超えた値を入れるとエラーになります。

print(data[15: 20])  # スライスの範囲がlistの長さを超えていても動く
# [15, 16]

print(data[100: 200])  # これも動く
# []

try:
    print(data[20])  # これはエラーになる
except Exception as e:
    print(e)
# list index out of range

正直この技を使う機会はあまりないのですが、例えばAmazon Comprehendような1回に渡せるデータ数に上限があるAPIで大量のデータを処理するときなどに利用できます。
APIが受け入れてくれる上限のデータ数でリストを区切って順に実行したりできますね。

Pythonの所属検査演算(in)について

Pythonでは、ある要素が集合や配列に存在しているかどうか、inという式を使って判定できます。この度改めてドキュメントを読んでみたのと、配列の配列などちょっと特殊な用途について挙動を調べたのでまとめておきます。

ドキュメントはこちらです。in のことは所属検査演算と呼ぶようです。URLから推測すると英語名は、 membership test operations のようですね。
参考: 6.10.2. 所属検査演算 式 (expression) — Python 3.9.4 ドキュメント

演算子 in および not in は所属関係を調べます。とある通りで、
x in s は xがsの要素だったらTrue、そうでない場合はFalseを返します。not in は in の否定です。この記事ではこの s を色々変えながら挙動を見ていきましょう。

配列や集合、タプルに対する挙動

まずは一番基本的な配列や集合に対する挙動です。まず配列についてみていきますが、これは特に説明することもなく、配列sの要素のどれかとxが一致すれば x in s はTrueになります。

list_data = [1, 2, 3, 4, 5]
print(3 in list_data)
# True
print(8 in list_data)
# False
print(2 not in list_data)
# False

集合やタプルの場合も同様です。タプルはこれ以降コード例を省略しますが配列と同じように動きます。

set_data = {1, 2, 3, 4, 5}
print(3 in set_data)
# True
print(8 in set_data)
# False
print(2 not in set_data)
# False

tuple_data = (1, 2, 3, 4, 5)
print(3 in tuple_data)
# True
print(8 in tuple_data)
# False
print(2 not in tuple_data)
# False

ここで、少し注意が必要なのは、 None についても機能するということです。SQLの挙動に慣れていると、NULLが絡むとTrueでもFalseでもなくNULLが返ってくるので、Noneが絡むとNoneが返ってくるような気がしてしまいますが、None == None とみなすようでSQLとは違った動きになります。

print(None in [1, 2, None, 3])
# True
print(None in [1, 2, 4, 5])
# False
print(None in {1, 2, None, 3})
# True

もっと言うと、numpyの nan についても使えます。np.nan == np.nan は False なのでこれは不思議な挙動です。

import numpy as np


print(np.nan == np.nan)
# False
print(np.nan in [1, 2, 3, np.nan])
# True

配列の部分列や、集合の部分集合については使えません。 xがsの部分列や部分集合の場合も
x in s はFalseが返ってきます。
部分集合のジャッジをしたい場合は不等号が使えるのでそちらを使いましょう。

# 部分列はFalseになる
print([2, 3] in [1, 2, 3, 4])
# False

# 部分集合もFalseになる
print({2, 3} in {1, 2, 3, 4})
# False

# 部分集合は不等号で判定できる。
print({2, 3} <= {1, 2, 3, 4})
# True

次に、配列の配列について検証しましたが、なんとこれが正常に動作します。hashableな形でないとダメだと思い込んでいたので意外でした。

# 配列の配列も動く
print([1, 2] in [[1, 2], [3, 4], [5, 6]])
# True

# もちろん、含まない場合はFalse
print([2, 3] in [[1, 2], [3, 4], [5, 6]])
# False

# 要素の要素についてはFalseになる
print(3 in [[1, 2], [3, 4], [5, 6]])
# False

では、集合の集合は?と思ったのですが、集合(set)はhashableなものしか要素に持てないので、集合の集合自体作れません。なので気にしなくて大丈夫です。
タプルのタプルは、当然配列と同じように動作してくれます。

辞書(dict)に対する挙動

辞書sに対して、x in s を使うと、xが辞書sのキーに含まれていた場合にTrue、含まれていない場合にFalseを返してくれます。キーではなく値の中にあるかどうかを知りたいってばあいはvalues()、キーだけでなくキーと値のペアで含まれているかどうかを知りたいって場合はitems()をそれぞれ併用しましょう。

dict_data = {
    "apple": "りんご",
    "orange": "みかん",
    "banana": "バナナ" 
}

# キーの中に一致するものがあればTrue
print("apple" in dict_data)
# True

# keys()メソッドでキーの一覧を取得して判定しても挙動は同じ
print("apple" in dict_data.keys())
# True

# 値の中に一致するものがあったとしてもこれはFalse
print("みかん" in dict_data)
# False

# 値の中に一致するものがあるかどうか見る場合は、values()メソッドを使う
print("みかん" in dict_data.values())
# True

# キーと値のペアで判定をしたい場合はitems()メソッドを使う。
print(("apple", "りんご") in dict_data.items())
# True

# キーと値がそれぞれ存在していても組み合わせが違うとFalseになる
print(("apple", "バナナ") in dict_data.items())
# False

文字列に対する挙動

文字列sと、文字x対してinを使うと、xがsに含まれている場合にTrueを返してきます。これだけだと、配列と要素の場合と同じように見えるのですが、実は文字列の独特の挙動として、文字列xが文字列sの部分文字列の場合もTrueを返してくれると言うものがあります。便利ですね。実装としては、 x in y は y.find(x) != -1 と等価になっているそうです。

# 文字が含まれていればTrue
print("c" in "abcde" )
# True

# 部分列であればTrue
print("bcd" in "abcde" )
# True

# 個々の文字が含まれていても順番が違うとFalse
print("ba" in "abcde" )
# False

文字列についてはもう一つ注意があって、空文字列は他の任意の文字列の部分文字列とみなされます。要するに次の式はどちらもTrueです。

print("" in "abcde")
# True

print("" in "")
# True

その他の型 (ユーザー定義型)における in

これまで、Pythonの基本的な各型における所属検査演算子の使い方を見てきましたが、各ライブライで実装されているようなクラスにおいても in は使えますし、自分で実装するクラスにおいても、inの振る舞いを定義して実装することができます。
その方法は、 class において、 __contains__() メソッドを実装することです。

__contains__() メソッドが実装されているクラスにおいては、
x in y は、 y.__contains__(x) が Trueを返す場合にTrueになり、そうでない場合にFalseになります。

実験したところ、__contains__が、if文でTrueと判定されるようなもの、(空白ではない文字列、0ではない数値、空ではない配列など)を返した場合は Trueになり、if文でFalseと判定されるようなもの(False,None,0など)を返した場合はFalseになるので、 __contains__ と in の結果が一致する、と言うわけではないようです。

大変奇妙な例で恐縮ですが実験したのが次の結果です。

class myclass():
    def __contains__(self, y):
        return "含みます"


mc = myclass()

# __contains__ メソッドを呼び出すとメソッドの結果がそのまま返される
print(mc.__contains__("a"))
# 含みます

# in だと True か False に変換される
print("a" in mc)
# True

__contains__ が実装されていないが __iter__ が実装されているクラスの場合(要するにイテレーター)の場合は、反復の途中で x に等しい要素が登場した場合に Trueになります。
また変な例なのですが、__iter__(と、セットで使う__next__)だけ実装したようなクラスを作ったのでそれで実験します。このクラスは[1,2,3,4,5]を順番に返します。

class myclass2():
    def __init__(self):
        self._i = 0

    def __iter__(self):
        return self

    def __next__(self):
        if self._i == 5:
            raise StopIteration()
        self._i += 1
        return self._i


mc2 = myclass2()
print(2 in mc2)
# True
mc2 = myclass2()
print(6 in mc2)
# False

__contains__も__iter__も実装されていない場合は、最後に、__getitem__()が試されます。
__getitem__() は 辞書型のように[]でアクセスしてきた時の挙動を定義する特殊メソッドですね。これは単に x == y[i] となる iが見つかれば True, そうでない場合はFalseとなるようです。
これもまた変な例ですが、__getitem__だけ実装されたクラスで実験しました。

class myclass3():
    def __getitem__(self, i):
        # 無限ループを避けるためにiが大きくなったらエラーにする
        if i >= 100:
            raise
        return i**2


mc3 = myclass3()

# 平方数ならTrue、 mc3[4] == 16 だから。
print(16 in mc3)
# True

# 平方数でない場合はエラーになるまで探し続ける
print(18 in mc3)
# RuntimeError: No active exception to reraise

改めてドキュメントを読んでみて色々試した結果、それなりに理解が深まった気がします。

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で定義されているので、もちろん他の図形でも指定できます。