Prestoで行番号や順位を振る関数まとめ

タイトルにはPrestoって書いてますが、例によってTreasureDataで検証しています。

集計業務を行なっていて、レコードに番号や順位を振りたい場面は意外にあるものです。レコードを何らかのグループごとに分けて、そのグループ内で特定の列ごとにソートしてそのグループ内で何番目のレコードかな、という情報を取得します。

そのようなときに使える関数がPrestoには複数実装されており、主に次の3つが使えます。
– ROW_NUMBER()
– RANK()
– DENSE_RANK()

ROW_NUMBER は単純で、1,2,3,4,… と通し番号を振ります。
RANKは順位なので、もしソートキーになる列の値が等しければ、同じ番号を振ってくれます。例えば、2位と3位が同じ値だったら、1,2,2,4,…という具合です。
DENSE_RANKはRANKと似ていますが、同じ順位のレコードが発生したとき、その次のレコードの番号を振るときに値が飛びません。先程の例で言えば、1,2,2,3,…と番号を振ります。

これらの関数は全てWINDOW関数と呼ばれるカテゴリに属しており、ドキュメントも次のページにまとまっています。
参考: Window Functions

これらのWINDOW関数は利用する時は、必ず OVER() という関数をセットで使います。

ROW_NUMBER() OVER (PARTITION BY [グループ分けする列] ORDER BY [ソートする列])

のようなイメージです。

PARTITION BY は必須ではなく、指定しなければ全レコードを通した番号を振ってくれ、指定すれば、指定した列の値が等しいレコードでグループを作って、そのグループ内での番号を振ってくれます。

ORDER BY も必須ではなく、未指定であればROW_NUMBER()の場合は、DBがSELECTするときに抽出した順で番号を振ってくれますが、普通は何かしら欲しい順序があると思うので指定するようにしましょう。
RANK() と DENSE_RANK() については、ORDER BYを指定しないと、どの列で順位をつけたら良いか不明なので、全部1を返してしまいます。これらを使う時は必ず指定しましょう。
ソートする列名の後ろに、 ASC(昇順)/DESC(降順) を指定することもできます。省略した場合は、ASC(昇順)です。

PARTITION BY, ORDER BY ともに、カンマ区切りで複数の列を指定することもできます。

さて、文章で説明ばっかり書いちゃったので実際にやってみましょう。
使うデータは実データではなく、最初のWITH句で生成したダミーデータです。以下のレコードを生成してます。
id, category, value
1, ‘A’, 20
2, ‘A’, 10
3, ‘A’, 20
4, ‘A’, 30
5, ‘B’, 15
6, ‘B’, 30

WITH sample_table AS(
    SELECT 
        id,
        category,
        value
    FROM
        UNNEST(
            ARRAY[1, 2, 3, 4, 5, 6],
            ARRAY['A', 'A', 'A', 'A', 'B', 'B'],
            ARRAY[20, 10, 20, 30, 15, 30]
        ) AS t(
            id,
            category,
            value
        )
)
SELECT 
    id,
    category,
    value,
    ROW_NUMBER() OVER(PARTITION BY category ORDER BY value) AS row_number_,
    RANK() OVER(PARTITION BY category ORDER BY value) AS rank_,
    DENSE_RANK() OVER(PARTITION BY category ORDER BY value) AS dense_rank_
FROM
    sample_table

結果は次のようになります。

idcategoryvaluerow_number_rank_dense_rank_
2A10111
1A20222
3A20322
4A30443
5B15111
6B30222

想定通りの結果が得られましたね。

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”を使っていれば良いと思うのですが、開始日を変えたいこともあると思いますので覚えておくと役に立つ場面もあるかもしれません。

トレジャーデータで1週間単位の集計

トレジャーデータのUDFである、TD_DATE_TRUNCの紹介記事です。この記事ではHiveではなく、Prestoを利用することを前提としています。

色々な集計業務を行う中で、週単位の集計を行う必要が出てくる場面は結構あります。トレジャーデータでは、そのような場面に備えて、タイムスタンプを週単位の値に変換してくれるUDFが容易されています。それが、冒頭に挙げたTD_DATE_TRUNCです。

ドキュメント: TD_DATE_TRUNC

使い方は簡単で、以下の構文で利用します。

TD_DATE_TRUNC(
    '集計したい時間の単位を示す文字列',
    元のタイムスタンプ,
    'タイムゾーン文字列'
)

集計したい時間の単位を示す文字列は次の単語が使えます。
minute/ hour/ day/ week/ month/ quarter/ year

ただし、後述しますが、 日単位(day)や月単位(month)などで集計したい場合は、TD_TIME_FORMATを使った方が手軽に目当ての結果を得やすいので、 実際に使う機会があるのは、週単位(week) かクオーター(quarter)に限られると思います。
(そのため、この記事のタイトルも1週間単位の集計としました)

さて、実際に使ってみましょう。例えば元の時刻が ‘2021-08-11 13:24:16’ だったとします。
(日本時間の場合)この時刻のタイムスタンプは、1628655856 です。そこで、これを週単位で切り詰めると次のようになります。

SELECT
    TD_DATE_TRUNC(
        'week',
        1628655856,
        'JST'
    )

上のクエリの結果は、 1628434800 です。このタイムスタンプがいつを表すかというと、’2021-08-09 00:00:00′ となり、元の時刻と同じ週の月曜日のちょうど0時となります。

この例で分かる通り、 TD_DATE_TRUNC はLONG型のタイムスタンプを受け取って、LONG型のタイムスタンプを返します。
実用上はタイムスタンプで結果を得てもいつのことなのかわかりにくいので、TD_TIME_FORMATで整形することになるでしょう。
time 列を週ごとにまとめて集計するであれば次のような書き方になると思います。

SELECT
    TD_TIME_FORMAT(
        TD_DATE_TRUNC(
            'week',
            time,
            'JST'
        ),
        'yyyy-MM-dd',
        'JST'
    )
FROM
    table_name

元々の時刻として、time列ではなく、’2021-08-11 13:24:16’のような文字列で時刻が入っている列を使う場合、タイムスタンプに変換する必要があるので、事前に、TD_TIME_PARSE する必要があります。 例えば、 created_at という列を週単位に集計するのであれば次のようなクエリになります。

SELECT
    TD_TIME_FORMAT(
        TD_DATE_TRUNC(
            'week',
            TD_TIME_PARSE(created_at, 'JST'),
            'JST'
        ),
        'yyyy-MM-dd',
        'JST'
    )
FROM
    table_name

以上のようにして、週単位での集計ができるようになりました。
四半期単位での集計がしたい時は、 week を quarter に置き換えることで同様に実行できます。

さて、最初の方で少し述べましたが、 TD_DATE_TRUNC は day や month でも使うことができます。しかし、日単位や月単位で集計したい場合は、 TD_TIME_FORMAT だけで目的を果たすことができるので、TD_DATE_TRUNCはむしろ使わない方がクエリがスッキリすると思います。

例えば、次の二つのクエリ内の2列は結果が同じです。明らかに2列目の方が記述が少なくて楽ですね。

SELECT
    -- TD_DATE_TRUNCを使った月単位の集計
    TD_TIME_FORMAT(
        TD_DATE_TRUNC(
            'month',
            time,
            'JST'
        ),
        'yyyy-MM-dd',
        'JST'
    ),
    -- TD_TIME_FORMAT を使った月単位の集計
    TD_TIME_FORMAT(time, 'yyyy-MM-01', 'JST')
FROM
    table_name

sedコマンドの使い方メモ

テキストの編集は大抵vimでやっちゃうので滅多に使わないのですが、稀にコマンドラインでファイルの置換を完結させたり、一つのテンプレートファイルから中身を一部置換したファイルを複数生成する必要が発生し、それをコマンドラインで済ませたいことがあります。
そんなときに、sed (Stream Editor) コマンドを使うのですが、その度に使い方を調べているのでここメモしておきます。

基本的な使い方は、
sed -e {編集コマンド} {入力ファイル}
です。パイプラインで利用する場合は{入力ファイル}を省略できます。
ファイル中の aaa を xxx に置換する場合は次のように書きます。

$ cat input.txt
aaa,bbb,ccc
ddd,eee,fff

$ sed -e s/aaa/xxx/g input.txt
xxx,bbb,ccc
ddd,eee,fff

-e は省略可能で、省略した場合は最初の引数が編集コマンドとみなされます。
なので、大抵の場合は -e を省略して大丈夫です。

$ cat input.txt | sed s/aaa/xxx/g
xxx,bbb,ccc
ddd,eee,fff

-e オプションは複数指定することもできて、同時に複数の置換をかけることもできます。

$ sed -e s/aaa/xxx/g -e s/bbb/yyy/g input.txt
xxx,yyy,ccc
ddd,eee,fff

編集コマンドは、シングルクオーテーション、もしくはダブルクオーテーションで囲むこともできます。(置換前後の文字列のどちらかにスペースを含む場合は、確実に囲むようしましょう。そうしないとエラーになります。)

$ sed "s/aaa/X Y G/g" input.txt
X Y G,bbb,ccc
ddd,eee,fff

置換結果を別のファイルに出力する時は、他のシェルコマンド同様にリダイレクションしてあげれば大丈夫です。僕はもっぱらその形で使います。
例えば、 input.txt の aaa を xxx に置換した output.txt を生成するには次のようにします。

$ sed "s/aaa/xxx/g" input.txt > output.txt

元のファイルをそのまま書き換えることもでき、その場合は
-i {拡張子} オプションをつけます。
すると、{元のファイル名}{拡張子} というファイル名でバックアップを取った上で、入力ファイルを書き換えてくれます。
バックアップはいらないよという場合は、拡張子として長さ0の文字列を渡します。

$ sed -i '' -e 's/aaa/xxx/g' input.txt

この時、” をつけ忘れると、 input.txt-e という変な名前のファイルが残ってしまうので注意してください。

多くの編集コマンドを同時に実行したい場合などは、編集コマンドをまとめたファイルを用意しておき、それを -f オプションで渡すこともできます。

$ cat edit.sed
s/aaa/xxx/g
s/bbb/y y y/g
s/ccc/zzz/g

$ sed -f edit.sed input.txt
xxx,y y y,zzz
ddd,eee,fff

あとは、入力ファイルはスペース区切りで複数同時に渡すことも可能です。
-i オプションをつけている場合は、渡した入力ファイルたちがそれぞれ編集されます。
-i オプションがない場合は、それぞれのファイルを編集した結果が連結されて標準出力に返されます。

sed コマンドで文字列を置換することに関して主に知っておくべきことはこれくらいかなと思います。

Prestoで各行ごとに複数列の値の中から最大値/最小値を取得する

※Prestoと書いていますが、正確にはTreasure Dataで動かすことを念頭に置いた記事です。
ただ、この記事で紹介するGREATEST / LEAST という関数はMySQLにも実装されているようなので、MySQLでも同じように動作すると思います。

そんなに頻繁にあることでは無いのですが、DBのとあるテーブルのデータについて、行ごとに、複数列の最大値を取得したいことがありました。

これ、行と列が逆なら簡単です。MAX関数使うだけです。

SELECT
    MAX(col1),
    MAX(col2),
    MAX(col3)
FROM
    table_name

上のクエリで、列ごとに最大値が取得できます。
今回やりたいのはその逆で、行ごとの最大値が欲しいのです。
列が3つくらいであれば、CASE文で対応することもできなくは無いかなと思います。
こんなふうに。

SELECT
    id,
    CASE
        WHEN col1>=col2 AND col1>=col3 THEN col1
        WHEN col2>=col3 AND col2>=col1 THEN col2
        WHEN col3>=col1 AND col3>=col2 THEN col3
    END AS max_value
FROM
    table_name

ただ、列数が増えるとこの方法で対応するのはなかなか厄介です。(あまりやりたく無い)
それに、上のクエリでは対象の列にNULLが含まれていた場合に正常に動作しないので、NULLも考慮する必要がある場合はもっと複雑なクエリを書く必要があります。

もう少しスマートな方法としては、以前紹介した横縦変換の方法で値を縦持ちに変換して、
GROUP BY と MAX を使うこともできるかと思います。
参考: PrestoのUNNESTを利用した横縦変換

WITH
    unpivot_table AS (
        SELECT
            id,
              t.key,
              t.value
        FROM
            table_name
        CROSS JOIN UNNEST (
            array['col1', 'col2', 'col3'],
            array[col1, col2, col3]
        ) AS t (key, value)
    )
SELECT
    id,
    MAX(value) AS max_value
FROM
    unpivot_table
GROUP BY
    id

ただ、これはこれで仰々しくてちょっと嫌だなと思っていました。

それでドキュメントを調べてみると、どうやらGREATEST というメソッドが用意されていたようです。
参考: GREATEST and LEAST

これを使うと非常に話は単純で、次のクエリで行ごとに3列(co1, col2, col3)の最大値が取得できます。

SELECT
    id,
    GREATEST(
        col1,
        col2,
        col3
    ) AS max_value
FROM
    table_name

同様に、最小値を求めるLEAST も容易されています。(使用例略)

ちなみに、どの列が最大だったのかを取得できる GREATEST_BY みたいなのもあるといいなと思って探してみたのですが、流石にそれはなさそうでした。最大値と合わせてどの列が最大だったのかも欲しい場合は、上の UNNEST を使うクエリで縦持ちに変換して、MAX_BYするのが現実的かなと思います。(もしくはSQLで実行するのを諦めてPythonなどで書くか)

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では無いんですね。

WordPress 5.X系のブロックエディタで Prism.js を使う方法

WordPress 5系で新しくなったエディタ(ブロックエディタ、もしくはGutenbergというらしいですね)を使いにくいと感じていたので、つい最近まで4系のまま使い続けていたのですが、サポート期間終了の警告が出るようになってしまったので、諦めて5系にバージョンアップしました。

この新しいエディタには慣れるしか無いので諦めて使っていこうと思います。

実は先日のBar Chart Raceの記事はブロックエディタで書いたのですが、ソースコードのシンタックスハイライトをやってくれているPrism.js を動作させる方法がなかなかわからず苦戦したので、使い方を記録しておこうと思います。

Prismjs のページで公式な対処法を探したのですが、そこでは記載を見つけられなかったのであくまで僕はこうやって解決したという非公式な方法になります。

具体的には、次の手順でprismjsが動作してくれます。

  1. ブロックを追加するときに、「コード」のブロックを選択して追加する。
    もしくはブロック追加後に + ボタンを押して「コード」に変換する。
  2. 右ペインのメニューの「ブロック」の「高度な設定」タブを開き、追加 CSS クラスに「language-python」など、有効化したい言語のクラスを設定する。

例えば、ブロックを「コード」にしただけで、追加CSSを設定しないと次のような表示になります。

print("Hello World!")

追加CSSにlanguage-pythonを入れるとこうなります。

print("Hello World!")

公式ドキュメント等で確認できてないので少々不安ではありますが、ちゃんと動作してるように見えますね。

スクラッチで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")

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

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