ML_BearのKaggleな日常

元WEBマーケターのMLエンジニアがKaggleに挑戦する日々を綴ります

Kaggleで戦いたい人のためのpandas実戦入門

はじめに

  • 自分は元々pandasが苦手でKaggleコンペ参加時は基本的にBigQuery上のSQLで特徴量を作り、最低限のpandas操作でデータ処理をしていました。
  • しかし、あるコードコンペティションに参加することになり、pythonで軽快にデータ処理をこなす必要が出てきたので勉強しました。
  • そこで、当時の勉強メモをもとに「これだけ知っていればKaggleでそこそこ戦えるかな」と思っているpandasの主要機能をまとめました。
注記
  • 実戦入門 のつもりが ほぼ辞書 になってしまいました orz
  • pandasとはなんぞや的な内容は書いていません
    (import pandasやDataFrameとは何かなど)
  • pandas1.0系でも動くように書いたつもりですが間違ってたらすみません

目次

Options

jupyter notebook で DataFrame の表示が省略されないようにする。 なんだかんだ書き方をよく忘れる。

pd.set_option('display.max_columns', None)
pd.set_option('display.max_rows', None)

DaraFrame 読み書き

CSVファイル

読み込み

read_csv は意外とオプションが多いのでなかなか覚えきれません。

# 基本
df = pd.read_csv('train.csv')

# headerがないとき (列名は連番になる)
df = pd.read_csv('train.csv', header=None)

# headerがなくて自分で列名指定したいとき
df = pd.read_csv('train.csv', names=('col_1', 'col_2'))

# 利用する列を指定したいとき
df = pd.read_csv('train.csv', usecols=['col_1', 'col_3'])

# lamda式も利用可能
df = pd.read_csv('train.csv', usecols=lambda x: x is not 'col_2')

# 列名: 読み込んだあとの変更
df = df.rename(columns={'c': 'col_1'})

# 型指定で読み込み (指定した列以外は自動推定)
## メモリ逼迫しているとき以外は、型指定せず read_csv して、
## 後述の `reduce_mem_usage` を使うことも多い
df = pd.read_csv('train.csv', dtype={'col_1': str, 'col_3': str})

## 型: 読み込んだあとの変更
df = df['col_1'].astype(int)  # float / str / np.int8 ...

# 時間系データをparse
df = pd.read_csv('train.csv', parse_dates=['created_at', 'updated_at'])

書き出し

# 基本
df.to_csv('file_name.csv')

# index不要のとき (kaggle submission fileは不要なので忘れがち)
submission.to_csv('submission.csv', index=False)

Pickleファイル

# 基本
df = pd.read_pickle('df.pickle')
df.to_pickle('df.pickle')

# データが重いときはzip化できる (が遅くて実用に耐えないらしい)
## 書き出し: 拡張子を zip や gzip にするだけでよい
df.to_pickle('df.pickle.zip')

## 読み込み: read_pickle は拡張子を見て自動的に解凍処理をしてくれる
df = pd.read_pickle('df.pickle.zip')

メモリ使用量削減の工夫

ファイルを読み込んだ直後にメモリ使用量削減するクセを付けておくと色々はかどります。

型変更

# kaggleでよく使われる `reduce_mem_usage` でメモリ使用量削減
## 内部では各カラムの値域に合わせて型変更を行っている
## `reduce_mem_usage` 実装は ref 参照
df = reduce_mem_usage(df)

# 実践的には read_csv した直後にメモリ使用量削減を行うことも多い
df = df.read_csv('train.csv')\
       .pipe(reduce_mem_usage)

# 余談だが、pipeを使うと可読性向上することが多い
# f(g(h(df), arg1=1), arg2=2, arg3=3)
df.pipe(h) \
  .pipe(g, arg1=1) \
  .pipe(f, arg2=2, arg3=3)

不要カラム削除

import gc

# dropでも良い: df.drop('col_1', axis=1, inplace=True)
del df['col_1']; gc.collect();

データクリーニング

欠損データ処理

# 欠損がある行を削除
df1.dropna(how='any')

# 特定の列で欠損している行を無視
df = df[~df['col_1'].isnull()]

# 埋める
df1.fillna(value=0)

重複排除

# 基本
df2.drop_duplicates()

# 重複しているカラムの指定
df2.drop_duplicates(['col_1'])

# 残す列の指定
df2.drop_duplicates(['col_1'], keep='last')  # keep='first' / False(drop all)

補間 (interpolate)

DataFrame操作

DataFrame 情報表示

# 行数,列数,メモリ使用量,データ型,非欠損要素数の表示
df.info()

# 行数 x 列数 取得
df.shape

# 行数取得
len(df)

# 最初 / 最後のN行表示
df.head(5)
df.tail(5)

# カラム名一覧を取得
df.columns

# 各要素の要約統計量を取得
## 数値型要素の min/max/mean/stdなどを取得
df.describe()

## カテゴリ型要素の count/unique/freq/stdなどを取得
df.describe(exclude='number')

Slice (iloc / loc / (ix))

# 基本
df.iloc[3:5, 0:2]
df.loc[:, ['col_1', 'col_2']]

# 行は数値で指定して、列は名前で指定する
# (バージョンによっては ix でもできるが廃止された)
df.loc[df.index[[3, 4, 8]], ['col_3', 'col_5']]

型による列選択

# 除外もできる
df.select_dtypes(
    include=['number', 'bool'],
    exclude=['object'])

条件指定による行選択

# 基本
df[df.age >= 25]

# OR条件
df[(df.age <= 19) | (df.age >= 30)]

# AND条件
df[(df.age >= 25) & (df.age <= 34)]

## betweenでも書ける (あまり見ないが)
df[df['age'].between(25, 34)]

# IN
df[df.user_id.isin(target_user_list)]

# query記法: 賛否両論あるが個人的には好き
df.query('age >= 25') \
  .query('gender == "male"')

indexリセット

# 基本
df = df.reset_index()

# 破壊的変更
df.reset_index(inplace=True)

# drop=Falseにするとindexが列として追加される
df.reset_index(drop=False, inplace=True)

列削除

# 基本
df = df.drop(['col_1'], axis=1)

# 破壊的変更
df = df.drop(['col_1'], axis=1, inplace=True)

Numpy Array 化

# df['col_1'] のままだと index が付いてきて
# 他のdfにくっつけるときにバグを引き落とすようなこともあるので
# numpy array にして後続の処理を行うことも多々ある
df['col_1'].values

連結・結合

連結

# concat
## 基本 (縦に積む: カラムは各DataFrameの和集合
df = pd.concat([df_1, df_2, df_3])

## 横につなげる
df = pd.concat([df_1, df_2], axis=1)

## 各DataFrameに共通のカラムのみで積む
df = pd.concat([df_1, df_2, df_3], join='inner')

結合

merge: キーを指定しての結合
# 基本 (内部結合)
df = pd.merge(df, df_sub, on='key')

# 複数のカラムをキーとする
df = pd.merge(df, df_sub, on=['key_1', 'key_2'])

# 左結合
df = pd.merge(df, df_sub, on='key', how='left')

# 左右でカラム名が違うとき
df = pd.merge(df, df_sub, 
              left_on='key_left', right_on='key_right') \
       .drop('key_left', axis=1)  # キーが両方残るのでどちらか消す
join: indexを利用した結合
# 基本 (左結合: mergeと違うので注意)
df_1.join(df_2)

# 内部結合
df_1.join(df_2, how='inner')

ランダムサンプリング

# 100行抽出
df.sample(n=100)

# 25%抽出
df.sample(frac=0.25)

# seed固定
df.sample(frac=0.25, random_state=42)

# 重複許可: デフォルトはreplace=False
df.sample(frac=0.25, replace=True)

# 列をサンプリング
df.sample(frac=0.25, axis=1)

ソート

# 基本
df.sort_values(by='col_1')

# indexでソート
df.sort_index(axis=1, ascending=False)

# キーを複数 & 降昇順指定
df.sort_values(by=['col_1', 'col_2'],
               ascending=[False, True])

argmax / TOP-N 系の処理

# 最も値が小さな行/列を見つける
df['col1'].idxmax()

# 最も和が小さな列を見つける
df.sum().idxmin()

# TOP-N: col_1で上位5件を出す → 同一順位であればcol_2を見る
df.nlargest(5, ['col_1', 'col_2'])  # .smallest: 下位N件

各種演算

よく使う関数基礎

# 集計
df['col_1'].sum()  # mean / max / min / count /  ...

# ユニーク値取得
df['col_1'].unique()

# ユニーク要素個数 (count distinct)
df['col_1'].nunique()

# percentile
df['col_1'].quantile([0.25, 0.75])

# clipping
df['col_1'].clip(-4, 6)

出現頻度カウント (value_counts)

# (NaN除く)
df['col_1'].value_counts()

# 出現頻度カウント(NaN含む)
df['col_1'].value_counts(dropna=False)

# 出現頻度カウント (合計を1に正規化)
df['col_1'].value_counts(normalize=True)

値の書き換え (apply / map)

Series各要素の書き換え: map

# 各要素に特定の処理
f_brackets = lambda x: '[{}]'.format(x)
df['col_1'].map(f_brackets)
# 0    [11]
# 1    [21]
# 2    [31]
# Name: col_1, dtype: object

# dictを渡して値の置換
df['priority'] = df['priority'].map({'yes': True, 'no': False})

DataFrameの各行・各列の書き換え: apply

# 基本
df['col_1'].apply(lambda x: max(x))

# もちろん自身で定義した関数でも良い
df['col_1'].apply(lambda x: custom_func(x))

# 進捗を表示するときは
# from tqdm._tqdm_notebook import tqdm_notebook
df['col_1'].progress_apply(lambda x: custom_func(x))

その他の書き換え (replace / np.where)

# replace
df['animal'] = df['animal'].replace('snake', 'python')

# np.where
df['logic'] = np.where(df['AAA'] > 5, 'high', 'low')

# np.where: 複雑ver.
condition_1 = (
    (df.title == 'Bird Measurer (Assessment)') & \
    (df.event_code == 4110)
)
condition_2 = (
    (df.title != 'Bird Measurer (Assessment)') & \
    (df.type == 'Assessment') & \
    (df.event_code == 4100)
)
df['win_code'] = np.where(condition_1 | condition_2, 1, 0)

集約 (agg)

# 基本
df.groupby(['key_id'])\
  .agg({
      'col_1': ['max', 'mean', 'sum', 'std', 'nunique'],
      'col_2': [np.ptp, np.median]  # np.ptp: max - min
    })

# 全ての列を一律で集約したいときはリスト内包表記で書いてしまっても良い
df.groupby(['key_id_1', 'key_id_2'])\
  .agg({
      col: ['max', 'mean', 'sum', 'std']
      for col in cols
  })

集約結果の活用例

ほぼイディオムだが、最初は慣れないと処理に手間取るので例を書いておく。

# 集約
agg_df = df.groupby(['key_id']) \
           .agg({'col_1': ['max', 'min']})

# カラム名が max / min になり、どのキーのものか区別できないので修正する
# マルチインデックスになっているのでバラして rename する
agg_df.columns = [
    '_'.join(col) for col in agg_df.columns.values]

# 集約結果はindexにkey_idが入っているのでreset_indexで出す
agg_df.reset_index(inplace=True)

# key_idをキーとして元のDataFrameと結合
df = pd.merge(df, agg_df, on='key_id', how='left')

ピボットテーブルによる集計

pd.pivot_table(df, values=['D', 'E'], index=['A', 'C'],
               aggfunc={'D': np.mean,
                        'E': [min, max, np.mean]})
#            D         E
#            mean      max  mean      min
# A   C
# bar large  5.500000  9.0  7.500000  6.0
#     small  5.500000  9.0  8.500000  8.0
# foo large  2.000000  5.0  4.500000  4.0
#     small  2.333333  6.0  4.333333  2.0

ループを回さず配列同士の演算

列方向の平均値との差分を算出する時に便利です

# `df['{col}_diff_to_col_mean] = df['{col}'] - df['{col}'].mean()` 的な処理を一括でやる時
df.sub(df.mean(axis=0), axis=1)

# sub 以外にも add / div / mul (掛け算) もある
# 以下は `df['{col}_div_by_col_max] = df['{col}'] / df['{col}'].max()` の一括処理
df.div(df.max(axis=0), axis=1)

ビン詰め (cut / qcut)

# df['col_1']の最小値と最大値の間を4分割 → その境界を使ってビン詰め
# つまり、各ビンに含まれる個数がバラける
pd.cut(df['col_1'], 4)

# df['col_1']の要素数を4等分してビンを作る → その後に境界を求める
# つまり、ビンの間隔がバラける
pd.qcut(df['col_1'], 4)

時系列データでよく使う処理

shift: 行・列方向に値をずらす

# 2行下にずらす
df.shift(periods=2)

# 1行上にずらす
df.shift(periods=-1)

# 2列ずらす (あまり使わない)
df.shift(periods=2, axis='columns')

rolling: 移動平均などの算出

# window幅=3の窓関数により合計値を算出
df['col_1'].rolling(3).sum()

# 複数の
df['col_1'].rolling(3) \
           .agg([sum, min, max, 'mean'])

cumsum: 累積和

同様の関数に cummax, cummin もある

# df
#      A    B
# 0  2.0  1.0
# 1  3.0  NaN
# 2  1.0  0.0

# 上記のdfの累計和を算出
df.cumsum()
#      A    B
# 0  2.0  1.0
# 1  5.0  NaN
# 2  6.0  1.0

diff, pct_change: 行・列の差分・変化率を取得

# 例で使うdataframe
#    col_1  col_2
# 0      1      2
# 1      2      4
# 2      3      8
# 3      4     16

# 基本: 1行前との差分を算出
df.diff()
#    col_1  col_2
# 0    NaN    NaN
# 1    1.0    2.0
# 2    1.0    4.0
# 3    1.0    8.0

# 2行前との差分算出
df.diff(2)
#    col_1  col_2
# 0    NaN    NaN
# 1    NaN    NaN
# 2    2.0    6.0
# 3    2.0   12.0

# 負の数も指定可能
df.diff(-1)
#    col_1  col_2
# 0   -1.0   -2.0
# 1   -1.0   -4.0
# 2   -1.0   -8.0
# 3    NaN    NaN

# 変化率を取得するときは `pct_change`
df.pct_change()
#       col_1  col_2
# 0       NaN    NaN
# 1  1.000000    1.0
# 2  0.500000    1.0
# 3  0.333333    1.0

# 計算対象がdatetimeの場合は頻度コードで指定可能
# 以下の例では `2日前` のデータとの変化率を算出
df.pct_change(freq='2D')

時間単位での集約

# 5分おきに平均、最大値を集計
# 頻度コード `min` `H` などの詳細は ref.2 に非常に詳しいので参照のこと
funcs = {'Mean': np.mean, 'Max': np.max}
df['col_1'].resample("5min").apply(funcs)
  1. pandasで時系列データをリサンプリングするresample, asfreq
  2. pandasの時系列データにおける頻度(引数freq)の指定方法

カテゴリ変数エンコーディング

カテゴリ変数エンコーディングの種類についてはこの資料が詳しい

One-Hot Encoding

# この DataFrame を処理する
#    name  gender
# 0  hoge    male
# 1  fuga     NaN
# 2  hage  female

# prefixを付けることでなんのカラムのOne-Hotかわかりやすくなる
tmp = pd.get_dummies(df['gender'], prefix='gender')
#    gender_female  gender_male
# 0              0            1
# 1              0            0
# 2              1            0

# 結合したあと元のカラムを削除する
df = df.join(tmp).drop('gender', axis=1)
#    name  gender_female  gender_male
# 0  hoge              0            1
# 1  fuga              0            0
# 2  hage              1            0

Label Encoding

from sklearn.preprocessing import LabelEncoder

# trainとtestに分かれているデータを一括でLabelEncodingする例
cat_cols = ['category_col_1', 'category_col_2']
for col in cat_cols:
    # 慣例的に `le` と略すことが多い気がする
    le = LabelEncoder().fit(list(
        # train & test のラベルの和集合を取る
        set(train[col].unique()).union(
        set(test[col].unique()))
    ))
    train[f'{col}'] = le.transform(train[col])
    test[f'{col}'] = le.transform(test[col])

# label encoding したらメモリ使用量も減らせるので忘れずに
train = reduce_mem_usage(train)
test = reduce_mem_usage(test)
  • 注記
    • 上記方法だとtestにのみ含まれるラベルもencodingされてしまう
    • 気持ち悪い場合は、trainにないものは一括で -1 とかに書き換えてしまう (個人的にはあまり気にしていないので正しいやり方かどうか不安…。)
  • kaggle本実装
    • kaggle本ではtrainに出てくるものだけでLabelEnconding

Frequency Encoding

for col in cat_cols:
    freq_encoding = train[col].value_counts()
    # ラベルの出現回数で置換
    train[col] = train[col].map(freq_encoding)
    test[col] = test[col].map(freq_encoding)

Target Encoding

# 超雑にやるとき (非推奨)
## col_1の各ラベルに対して target(correct) の平均値とカウントを算出
## 一定のカウント未満(仮に1000件)のラベルは無視して集計する、という例
target_encoding = df.groupby('col_1') \
                    .agg({'correct': ['mean', 'count']}) \
                    .reset_index() \
                    # 少数ラベルはリークの原因になるので消す
                    .query('count >= 1000')  \
                    .rename(columns={
                        'correct': 'target_encoded_col_1',
                     }) \
                    # カウントは足切りに使っただけなので消す
                    .drop('count', axis=1)  
train = pd.merge(
    train, target_encoding, on='col_1', how='left')
test = pd.merge(
    test, target_encoding, on='col_1', how='left')
  • 上記の例は非常に雑な実装です。真面目にやるときはKaggle本の実装を読んでFoldごとに計算しましょう

文字列操作

pandas official method list にたくさん載っているので一度目を通すことをおすすめします。

基本

# 文字数
series.str.len()

# 置換
series.str.replace(' ', '_')

# 'm' から始まる(終わる)かどうか
series.str.starswith('m')  # endswith

# 表現を含んでいるかどうか
pattern = r'[0-9][a-z]'
series.str.contains(pattern)

クリーニング

# 大文字/小文字
series.str.lower()  # .upper()

# capitalize (male → Male)
series.str.capitalize()

# 英数字抽出: 最初の適合部分だけだけ
## マッチが複数の場合はDFが返ってくる
## extractall: すべての適合部分がマルチインデックスで返ってくる
series.str.extract('([a-zA-Z\s]+)', expand=False)

# 前後の空白削除
series.str.strip()

# 文字の変換
## 変換前: Qiitaは、プログラミングに関する知識を記録・共有するためのサービスです。
## 変換後: Qiitaは,プログラミングに関する知識を記録共有するためのサービスです.
table = str.maketrans({
    '、': ',',
    '。': '.',
    '・': '',
})
result = text.translate(table)

文字の変換にはstr.translate()が便利

日付系処理

基本

# 基本: 読み込み時に変換忘れたときとか
df['timestamp'] = pd.to_datetime(df['timestamp'])

# 日付のリストを作成
dates = pd.date_range('20130101', periods=6)

# 日付のリストを作成: 秒単位で100個
pd.date_range('20120101', periods=100, freq='S')

# 日付でフィルタ
df['20130102':'20130104']

高度な日付抽出

  • pandasにはとても複雑な日付抽出の仕組みが実装されており、毎月の第4土曜日月初第一営業日といった抽出も一瞬です。(日本の祝日が対応していないので後述のjpholidayなどで多少変更は必要ですが。)
  • pandasの時系列データにおける頻度(引数freq)の指定方法 に詳しいので、日付関係の実装が必要な際はぜひ一読されることをおすすめします。
# 月の最終日を抽出する
pd.date_range('2020-01-01', '2020-12-31', freq='M')
# DatetimeIndex(['2020-01-31', '2020-02-29', '2020-03-31', '2020-04-30',
#                '2020-05-31', '2020-06-30', '2020-07-31', '2020-08-31',
#                '2020-09-30', '2020-10-31', '2020-11-30', '2020-12-31'],
#               dtype='datetime64[ns]', freq='M')

# 2020年の第4土曜日を抽出する
pd.date_range('2020-01-01', '2020-12-31', freq='WOM-4SAT')
# DatetimeIndex(['2020-01-25', '2020-02-22', '2020-03-28', '2020-04-25',
#                '2020-05-23', '2020-06-27', '2020-07-25', '2020-08-22',
#                '2020-09-26', '2020-10-24', '2020-11-28', '2020-12-26'],
#               dtype='datetime64[ns]', freq='WOM-4SAT')

祝日判定

  • pandasではないしkaggleでも使うことも(たぶん)ありませんが、実務上便利なので掲載しておきます。
  • jpholiday official
import jpholiday
import datetime

# 指定日が祝日か判定
jpholiday.is_holiday(datetime.date(2017, 1, 1)) # True
jpholiday.is_holiday(datetime.date(2017, 1, 3)) # False

# 指定月の祝日を取得
jpholiday.month_holidays(2017, 5)
# [(datetime.date(2017, 5, 3), '憲法記念日'),
#  (datetime.date(2017, 5, 4), 'みどりの日'),
#  (datetime.date(2017, 5, 5), 'こどもの日')]

可視化

デザインを綺麗にするおまじない

このQiita記事に載っているおまじないを書いておくと、グラフがとても綺麗になるのでとてもおすすめです。

import matplotlib
import matplotlib.pyplot as plt

plt.style.use('ggplot') 
font = {'family' : 'meiryo'}
matplotlib.rc('font', **font)

シンプルなグラフ

import pandas as pd
import matplotlib as mpl
import matplotlib.pyplot as plt

# 基本
df['col_1'].plot()

# 複数のカラムのプロットを 2x2 のタイル状に表示
# (カラム数がタイル数を超えていると怒られる)
df.plot(subplots=True, layout=(2, 2))

# 上記でX軸,Y軸の共通化
df.plot(subplots=True, layout=(2, 2),
        sharex=True, sharey=True)

ヒストグラム

# ヒストグラム
df['col_1'].plot.hist()

# binを20に増やす / バーの幅を細くして間を開ける
df['col_1'].plot.hist(bins=20, rwidth=.8)

# X軸のレンジを指定
## 0-100歳を5歳刻みで表示するイメージ
df['col_1'].plot.hist(bins=range(0, 101, 5), rwidth=.8)

# ヒストグラムが重なる時に透過させる
df['col_1'].plot.hist(alpha=0.5)

# Y軸の最小値・最大値を固定
df['col_1'].plot.hist(ylim=(0, 0.25))

箱ひげ図

df['col_1'].plot.box()

分布図

df.plot.scatter(x='col_1', y='col_2')

並列処理

  • pandasでの処理は残念ながら速くはないと思います。BigQuery等と比較すると残念なレベルです。(まぁ処理の速さそのものを比較するのはアンフェアですが…。)
  • 大量の特徴量を全て正規化するときや、大量の要素にmapをかける時とかは並列処理を駆使すると便利だと思います。
from multiprocessing import Pool, cpu_count

def parallelize_dataframe(df, func, columnwise=False):
    num_partitions = cpu_count()
    num_cores = cpu_count()
    pool = Pool(num_cores)

    if columnwise:  # 列方向に分割して並列処理
        df_split = [df[col_name] for col_name in df.columns]
        df = pd.concat(pool.map(func, df_split), axis=1)
    else:  # 行方向に分割して並列処理
        df_split = np.array_split(df, num_partitions)
        df = pd.concat(pool.map(func, df_split))

    pool.close()
    pool.join()
    return df

# 適当な関数にDataFrameを突っ込んで列方向に並列処理する
df = parallelize_dataframe(df, custom_func, columnwise=True)

おまけ: Excel読み書き

kaggleでは使わないけど実務で使う人一定数いる? (僕は使ったことない)

# write
df.to_excel('foo.xlsx', sheet_name='Sheet1')

# read
pd.read_excel('foo.xlsx', 'Sheet1', index_col=None, na_values=['NA'])

pandasを身につけるには?

まずは、おとなしく公式Tutorialに載ってるようなmaterialを以下のような順番で一通り回るのが最速かと思います。(可視化以外)

  1. 10 Minutes to pandas
  2. Pandas cookbook
  3. Cheet Sheet
  4. Cookbook

実践的な問題をやりたいときは前処理大全をやるのも良いかもですが、Kaggleコンペに参加する場合は公開Notebookを見ながら練習する程度でも十分かと思います。

おわりに

Kaggle関係の色々な記事を書いているので、良かったら読んでみてください〜。

実践的なTips集

naotaka1128.hatenadiary.jp

コンペ参戦記

naotaka1128.hatenadiary.jp

naotaka1128.hatenadiary.jp

naotaka1128.hatenadiary.jp

naotaka1128.hatenadiary.jp