細かすぎて伝わらないpandasテクニック集

LIFULLアドベントカレンダーのトリは、クリスマスよりも明日の有馬記念が大事、そんな@kazuktymがお届けします。

# パンダジスタへの道
機械学習によるデータの前処理など、大量のデータ操作を簡単に実現するためにpandasをよく利用します。なるべくならPythonプログラミングに頼らず、pandasのDataFrame上でデータ処理を完結したいとパンダジスタの誰もが思っていることでしょう。（パンダジスタという言葉は一般用語ではありませんので、用法・用量を守って正しくお使いください）

今回は、利用するケースがそうそう無いかもしれない、しかし知っているときっと助かるはず、という細かすぎて伝わらないpandasの活用例を紹介します。


## 今回使用するDataFrame
```
import pandas as pd

df = pd.DataFrame({
    'レース番号': [1, 1, 1, 1, 1, 2, 2, 2, 2],
    '馬名': ['アアアウィーク', 'イイイテイオー', 'ウウウブラック', 'エエエキャップ', 'オオオシャトル', 'カカカワンダー', 'キキキグルーヴ', 'クククブルボン', 'ケケケシャワー'],
    '生年月日': [20180311, 20180301, 20180221, 20180211, 20180201, 20180131, 20180121, 20180111, 20180101],
    '走破タイム': [2000, 1580, 2005, 1585, 1590, 2010, 1595, 2015, 2015],
    '着順':  [4, 1, 5, 2, 3, 2, 1, 3, 4]
})
```

| レース番号 | 馬名    |生年月日| 走破タイム | 着順 |
|:---------|:--------|:------|:------|:-----|
| 1 | アアアウィーク | 20180311|2000 | 4      |
| 1 | イイイテイオー | 20180301|1580 | 1      |
| 1 | ウウウブラック | 20180221|2005 | 5      |
| 1 | エエエキャップ | 20180211|1585 | 2      |
| 1 | オオオシャトル | 20180201|1590 | 3      |
| 2 | カカカワンダー | 20180131|2010 | 2      |
| 2 | キキキグルーヴ | 20180121|1595 | 1      |
| 2 | クククブルボン | 20180111|2015 | 3      |
| 2 | ケケケシャワー | 20180101|2015 | 4      |

競馬の予想モデルを作るイメージで、上記のDataFrameを使用していきます。

## グループ化したデータをゴニョゴニョしたい時
pandasではgroupbyを活用する場面が何かと多いと思います。今回の例で言うと、レースごとにグループ化してデータを加工する際に重宝します。groupbyしたデータをもっと柔軟に操作できたらいいのにという場面によく出くわすので、そんな時に役立つ事例をまとめてみました。

### グループごとにソート順で先頭n件抽出する
早くもちょっと何言ってるかわからないですが、実際にサンプルコードで見てみましょう。

```
df = df.sort_values('走破タイム', ascending=True)
df.groupby('レース番号').head(3)
```
各レースごとに走破タイムの早い順に3頭抜き出したいケースがあったときに、上記のようにDataFrame全体をまずは走破タイムの昇順でソートして、groupbyの結果をheadして先頭n件取り出します。

| レース番号 | 馬名    |生年月日| 走破タイム | 着順 |
|:---------|:--------|:------|:------|:-----|
| 1 | イイイテイオー | 20180301|1580 | 1      |
| 1 | エエエキャップ | 20180211|1585 | 2      |
| 1 | オオオシャトル | 20180201|1590 | 3      |
| 2 | キキキグルーヴ | 20180121|1595 | 1      |
| 2 | カカカワンダー | 20180131|2010 | 2      |
| 2 | クククブルボン | 20180111|2015 | 3      |

ただしこの方法には一つ問題があります。指定したn件のデータがインデックス順にきっちり抽出されてしまうため、同じ走破タイムの馬が複数いた場合、拾えないレコードが存在します。（今回の例で言うと`ケケケシャワー`が該当します）

上記の方法は、ソート対象の値が重複しないと分かっているケースで使用するとよいでしょう。重複するレコードも抽出したい場合は、以下の方法で実現することが可能です。

```
indexes = df.groupby('レース番号')['走破タイム'].nsmallest(3, keep='all').index.values
df[df.index.isin(map(lambda x: x[1], indexes))]
```
| レース番号 | 馬名    |生年月日| 走破タイム | 着順 |
|:---------|:--------|:------|:------|:-----|
| 1 | イイイテイオー | 20180301|1580 | 1      |
| 1 | エエエキャップ | 20180211|1585 | 2      |
| 1 | オオオシャトル | 20180201|1590 | 3      |
| 2 | キキキグルーヴ | 20180121|1595 | 1      |
| 2 | カカカワンダー | 20180131|2010 | 2      |
| 2 | クククブルボン | 20180111|2015 | 3      |
| 2 | ケケケシャワー | 20180101|2015 | 4      |

nsmallest関数の`keep='all'`オプションを使うことで、重複したレコードを全て抽出できます。ただし、戻り値が走破タイムのSeriesになるので、インデックスを取り出して、後続処理で元のDataFrameと突合します。中々複雑ですね。

#### 2021/12/27加筆
rank関数というものがあり、同様の操作ができると指摘頂きました。（知らなかった...）

```
df[df.groupby('レース番号')['走破タイム'].rank() < 4]
```
めちゃくちゃ簡単ですね。

### グループごとに条件にマッチする割合を算出する
続いてもちょっと何言ってるかわからないですが、実際にサンプルコードで見てみましょう。

```
df.groupby(['レース番号'])['走破タイム'].apply(lambda x: (x < 2000).sum() / x.count()).to_frame()
```
| レース番号 | 走破タイム（割合） |
|:---------|:--------|
| 1 | 0.60 |
| 2 | 0.25 |

こちらは、レースごとに走破タイムが2000(競馬では2分00.0秒を表します)を切る馬の割合を算出しています。apply関数を使用して、groupbyした結果に独自の関数を適用しています。

#### 2021/12/27加筆
apply関数は処理が重いため、条件による抽出を先に実施してから、groupbyする方が処理効率が優れているようです。

```
(df['走破タイム'] < 2000).groupby(df['レース番号']).mean().to_frame()
```

### グループにデータがn件以上存在しない場合は、DataFrameから除外する
こちらもなかなかマニアックな処理ですね。

```
df.groupby(['レース番号']).filter(lambda d: len(d) >= 5)
```
| レース番号 | 馬名    |生年月日| 走破タイム | 着順 |
|:---------|:--------|:------|:------|:-----|
| 1 | アアアウィーク | 20180311|2000 | 4      |
| 1 | イイイテイオー | 20180301|1580 | 1      |
| 1 | ウウウブラック | 20180221|2005 | 5      |
| 1 | エエエキャップ | 20180211|1585 | 2      |
| 1 | オオオシャトル | 20180201|1590 | 3      |

filter関数を使って、指定レコード数を満たすグループのみ抽出しています。

### グループごとに特定カラムを区切り文字で結合する
例として、グループ内の馬名を連結します。

```
df.groupby('レース番号')['馬名'].apply('/'.join).to_frame()
```
| レース番号 | 馬名（連結文字列） |
|:---------|:--------|
| 1 | アアアウィーク/イイイテイオー/ウウウブラック/エエエキャップ/オオオシャトル |
| 2 | カカカワンダー/キキキグルーヴ/クククブルボン/ケケケシャワー |

## CSVファイルからDataFrameを生成する際に、任意の関数で値を変換する
CSVファイルを読み込んで、DataFrameを生成することは往々にしてあります。その読み込み処理と同時に任意の関数を実行して値を変換することができます。

```data.csv
レース番号,馬名,生年月日,走破タイム,着順
1,アアアウィーク,20180311,2000,4
1,イイイテイオー,20180301,1580,1
1,ウウウブラック,20180221,2005,5
1,エエエキャップ,20180211,1585,2
1,オオオシャトル,20180201,1590,3
2,カカカワンダー,20180131,2010,2
2,キキキグルーヴ,20180121,1595,1
2,クククブルボン,20180111,2015,3
2,ケケケシャワー,20180101,2015,4
```

```
#  競馬表記の走破タイムを実際の秒に変換
def cnv_second(val):
    min_to_sec = int(val[:1]) * 60
    decimal_sec = int(val[1:]) / 10
    return min_to_sec + decimal_sec

df = pd.read_csv('data.csv', converters={'生年月日':lambda x : str(x), '走破タイム': cnv_second})
```
| レース番号 | 馬名    |生年月日| 走破タイム | 着順 |
|:---------|:--------|:------|:------|:-----|
| 1 | アアアウィーク | 20180311|120.0 | 4      |
| 1 | イイイテイオー | 20180301|118.0 | 1      |
| 1 | ウウウブラック | 20180221|120.5 | 5      |
| 1 | エエエキャップ | 20180211|118.5 | 2      |
| 1 | オオオシャトル | 20180201|119.0 | 3      |
| 2 | カカカワンダー | 20180131|121.0 | 2      |
| 2 | キキキグルーヴ | 20180121|119.5 | 1      |
| 2 | クククブルボン | 20180111|121.5 | 3      |
| 2 | ケケケシャワー | 20180101|121.5 | 4      |

read_csv関数のconvertersオプションを使用します。サンプルでは、生年月日を文字列型に変換し、走破タイムを実際の秒数に変換しています。lambdaを使った無名関数でも、実際に定義した関数のどちらでも使用可能です。

## 特定の条件にマッチするレコードにフラグ値やカテゴリ変数を追加する
DataFrameで複雑な条件を扱いたい場合、先に対応するフラグ値やカテゴリ変数のカラムを作っておいた方が処理が楽になる場面もあります。
#### フラグ値を追加する例
走破タイムが2分未満かつ着順が3着以内のレコードにTrueを追加します。

```
df['要チェックフラグ'] = (df['走破タイム'] < 2000) & (df['着順'] <= 3)
```

| レース番号 | 馬名    |生年月日| 走破タイム | 着順 | 要チェックフラグ |
|:---------|:--------|:------|:------|:-----|:-------------|
| 1 | アアアウィーク | 20180311|2000 | 4      | False        |
| 1 | イイイテイオー | 20180301|1580 | 1      | True         |
| 1 | ウウウブラック | 20180221|2005 | 5      | False        |
| 1 | エエエキャップ | 20180211|1585 | 2      | True         |
| 1 | オオオシャトル | 20180201|1590 | 3      | True         |
| 2 | カカカワンダー | 20180131|2010 | 2      | False        |
| 2 | キキキグルーヴ | 20180121|1595 | 1      | True         |
| 2 | クククブルボン | 20180111|2015 | 3      | False        |
| 2 | ケケケシャワー | 20180101|2015 | 4      | False        |

#### カテゴリ変数を追加する例
以下の条件に応じて、対応するカテゴリ値を追加します。

| 条件      | カテゴリ値 |
|:---------|:--------|
| 走破タイムが2分未満かつ着順が3着以内 | excellent |
| 走破タイムが2分以上かつ着順が3着以内 | good      |
| 着順が4着以下                    | bad       |

```
df.loc[(df['走破タイム'] < 2000) & (df['着順'] <= 3), 'レイティング'] = 'excellent'
df.loc[(df['走破タイム'] >= 2000) & (df['着順'] <= 3), 'レイティング'] = 'good'
df.loc[df['着順'] > 3, 'レイティング'] = 'bad'
```

| レース番号 | 馬名    |生年月日| 走破タイム | 着順 | レイティング |
|:---------|:--------|:------|:------|:-----|:-------------|
| 1 | アアアウィーク | 20180311|2000 | 4      | bad          |
| 1 | イイイテイオー | 20180301|1580 | 1      | excellent    |
| 1 | ウウウブラック | 20180221|2005 | 5      | bad          |
| 1 | エエエキャップ | 20180211|1585 | 2      | excellent    |
| 1 | オオオシャトル | 20180201|1590 | 3      | excellent    |
| 2 | カカカワンダー | 20180131|2010 | 2      | good         |
| 2 | キキキグルーヴ | 20180121|1595 | 1      | excellent    |
| 2 | クククブルボン | 20180111|2015 | 3      | good         |
| 2 | ケケケシャワー | 20180101|2015 | 4      | bad          |

## 特定カラムのソート順でDataFrameを指定の割合に分割する
最後までちょっと何言ってるかわからないですが、実際にサンプルコードで見てみましょう。

```
df = df.sort_values('生年月日', ascending=True)
ratio = round(len(df) * 0.7)
df_major = df[:ratio] # 7割
df_minor = df[ratio:] # 3割
```

| レース番号 | 馬名    |生年月日| 走破タイム | 着順 |
|:---------|:--------|:------|:------|:-----|
| 2 | ケケケシャワー | 20180101|2015 | 4      |
| 2 | クククブルボン | 20180111|2015 | 3      |
| 2 | キキキグルーヴ | 20180121|1595 | 1      |
| 2 | カカカワンダー | 20180131|2010 | 2      |
| 1 | オオオシャトル | 20180201|1590 | 3      |
| 1 | エエエキャップ | 20180211|1585 | 2      |


| レース番号 | 馬名    |生年月日| 走破タイム | 着順 |
|:---------|:--------|:------|:------|:-----|
| 1 | ウウウブラック | 20180221|2005 | 5      |
| 1 | イイイテイオー | 20180301|1580 | 1      |
| 1 | アアアウィーク | 20180311|2000 | 4      |

例では、生年月日順に7:3の割合でDataFrameを分割しています。機械学習では、学習データと評価データに分割して、モデル構築することが一般的ですが、時系列の概念がある場合は、時間を軸に上記の方法で分割すると簡単です。

# さいごに
以上が現在の自分の引き出しにある、細かすぎて伝わらないpandasの活用例でした。私のように、データ加工処理をpandasで頑張って完結したい人の参考になれば幸いです。またノウハウが溜まったら第2弾を投稿したいと思います。