加具留矢流余

かぐるやるよ

機械学習の前処理を効率的にするPreprepっていうライブラリを作った

最近kaggleは放置して機械学習を使った競馬の予想をやってるんですけど、少しパラメータを変えただけのコードを実行するだけなのに、毎回一から前処理をしていると時間がかかりすぎて死ぬという問題が発生していました。

そんな悩みを解決するために、実行するときに前処理をキャッシュしておいて、次に実行するときには以前と変化した部分だけを計算するPreprepというライブラリを作りました。

GitHub - mikebird28/preprep

よく何言ってるかわからないと思うので実際に例を見てもらったほうが早い気がします。

import preprep
import pandas as pd


def func1(df):
    return df*2

def func2(df):
    return df*3

def func3(df):
    return df*4

def func4(df):
    return df*5

def func5(df,n = 1):
    return df*n



df = pd.DataFrame([[1,2,3],[4,5,6]])

#キャッシュするディレクトリを指定してインスタンスを作成
p = preprep.Preprep("./cache_files")

#前処理の関数を登録 func1からfunc3を順番に実行していく
p = p.add(func1,name = "func1")
p = p.add(func2,name = "func2")
p = p.add(func3,name = "func3")

#fit_geneで実際に前処理を実行
df = p.fit_gene(df,verbose = True)
print(df.head())

1回目の出力

[*] start running graph
[*] no cache exists for func1, calculate
[*] no cache exists for func2, calculate
[*] no cache exists for func3, calculate
    0    1    2
0  24   48   72
1  96  120  144

2回目の出力

[*] start running graph
[*] available cache for func1 exists, skip calculation
[*] available cache for func2 exists, skip calculation
[*] available cache for func3 exists, skip calculation
    0    1    2
0  24   48   72
1  96  120  144

1回目に実行したときはキャッシュが存在していないので全ての前処理を計算していますが、2回目に実行するときにはキャッシュが存在しているので計算をスキップしています。キャッシュが存在している状態で、fit_geneに渡すDataFrameが変化すると次のようになります。

#DataFrameの値を変更
#df = pd.DataFrame([[1,2,3],[4,5,6]])
df = pd.DataFrame([[1,2,3],[4,5,7]])

#キャッシュするディレクトリを指定してインスタンスを作成
p = preprep.Preprep("./cache_files")

#前処理の関数を登録
p = p.add(func1,name = "func1")
p = p.add(func2,name = "func2")
p = p.add(func3,name = "func3")

#fit_geneで実際に前処理を実行
df = p.fit_gene(df,verbose = True)
print(df.head())
[*] start running graph
[*] saved cache for func1 exists, but dataset hash value has changed, calculate
[*] saved cache for func2 exists, but dataset hash value has changed, calculate
[*] saved cache for func3 exists, but dataset hash value has changed, calculate
    0    1    2
0  24   48   72
1  96  120  168

ちゃんと入力されたDataFrameが変化していることを検出して計算をやり直しています。またfunc3中身を書き換えたり、他の関数に変えたりすると、func3以前はキャッシュから読み込んで、func3以降だけ計算をやり直してくれます。ちゃんと内部で依存グラフを作ってそれを解決しているので、今回のような関数を順番に実行していくだけでなく、枝分かれしたり、結合したりみたいな複雑なものでもちゃんと必要なとこだけ計算をやり直してくれます。


df = pd.DataFrame([[1,2,3],[4,5,7]])

p = preprep.Preprep("./cache_files")

#func3をfunc4に変更
p = p.add(func1,name = "func1")
p = p.add(func2,name = "func2")
#p = p.add(func3,name = "func3")
p = p.add(func4,name = "func3")

#fit_geneで実際に前処理を実行
df = p.fit_gene(df,verbose = True)
print(df.head())

出力

[*] start running graph
[*] available cache for func1 exists, skip calculation
[*] available cache for func2 exists, skip calculation
[*] saved cache for func3 exists, but op hash value has changed, calculate
     0    1    2
0   30   60   90
1  120  150  210

実際に自分で使っていますが、以前に比べて飛躍的に実験を回すスピードが上がったと思います。これまで前処理に毎回30分くらいかかっていましたが、preprepのおかげで変化したとこだけ実行するので今ではほとんど前処理に時間を取られません(入力するDataFrameが変わったりすると、1から計算しなおさないといけないのでやっぱり時間がかかりますが)。

インストールの方法ですがpipで配布しているので下記のコマンドでインストールできる(はず)です。

pip install preprep

まだ生まれたばっかのライブラリなのでバグが多かったりドキュメントが存在してなかったりするので今後その辺はこつこつ直していこうかなと思います。バグとか見つけたらここかgithubでぜひ教えていください。

TargetEncodingのスムーシング

はじめてのFeature Engineering で紹介したTargetEncoding, LikelihoodEncodingはカテゴリ変数の前処理としては非常に強力な手法だが、あるカテゴリに所属するデータの数が少ないと過学習の原因になってしまう可能性がある。データ数が少ないカテゴリを持つデータセットに対して効果的にTragetEncodingを使うために、この論文ではTargetEncodingのスムージングという手法を提案している。

まず通常のTargetEncoding、LikelihoodEncodingは以下のような式で表現できる。

{
\displaystyle
S_i = \frac{n_{iy}}{n_i}
}

ここで \displaystyle n_iクラスタi に所属しているデータの数、 n_{iy}クラスタiに所属していて目的変数が1の数を表している。 \displaystyle n_i  \displaystyle n_{iy} の数が十分に大きければ過学習を気にする必要は無いかもしれないが、これらの値が小さいと間接的に目的変数の値を予想できるようになってしまうので過学習の要因になってしまう。この問題を解決するために以下のように改良された式が提案されている。

{ \displaystyle
S_i = \lambda (n_i) \frac{n_{iy}}{n_i} + (1 - \lambda (n_i) ) \frac{n_y}{n_{tr}}
}

上で説明した式に加えて \displaystyle \lambda (n_i) \displaystyle n_i \displaystyle {n_{tr}} という変数が追加されている。、 \displaystyle {n_{tr}} はデータの総数、 \displaystyle n_y はデータセット全体の中で目的変数が1の数を表している。また \displaystyle \lambda (n_i) \displaystyle n_iに 対して単調増加で、0〜1の範囲を取る関数を表している。

この式は \displaystyle n_i の数が十分に大きければ第一項の影響が大きくなり普通のTargetEncodingと同じような値を示す。一方で  \displaystyle n_iの数が小さいと第二項の影響が大きくなりデータセット全体の目的変数が1の割合に近づく。このようにデータ数が少ないクラスタに対してはデータセット全体の値を用いることで、機械学習モデルが過学習することを防いでいる。

上の式で出てきた \displaystyle \lambda (n_i)には次のような関数を用いることが多いようだ。

{
\displaystyle
\lambda (n_i) = \frac {1}{1 + e^{- \frac{n_i - k} {f}}}
}

この関数はシグモイド関数的な特徴(k = 0、f = 1とすれば標準シグモイド関数)を示し、k、fの値を調整することでチューニングすることができる。

参考

はじめてのFeature Engineering

先日KaggleのAdTracking Competition に参加したが惨敗したため、特訓することにした。 Ad Tracking Competition で4位の人がFeature Engineeringについて非常にまとまった資料を公開していたので、その要約をまとめた。 勉強がてらPythonでサンプルコードを書いたので一緒においておく。

h2o-meetups/Feature Engineering.pdf at master · h2oai/h2o-meetups · GitHub

何故Feature Engineeringが必要なのか

価値のある特徴量を生成し、ノイズの原因となる特徴量を除去することでシンプルで精度のいいモデルを実現する。

たとえば下の図で、左の状態だと円形にプロットが分布していて、ニューラルネットワークSVMなどの非線形モデルでないと分類することができない。 しかし各点を極座標で表して右のように変換すれば、非線形の手法を使わなくても決定木や線形分類器などのシンプルな手法で識別できるようになる。

f:id:theflyingcat28:20180512214941p:plainf:id:theflyingcat28:20180512214951p:plain
座標変換の効果(上記の資料より引用)

具体的な手法(カテゴリ変数の場合)

多くの機械学習手法(ニューラルネットワークsvm)はカテゴリ変数を直接学習させることが出来ない。これらのモデルにカテゴリ変数を学習させたい場合には、カテゴリ変数を数値に変換する必要がある。よりモデルがカテゴリ変数の意味を理解しやすいように様々な手法が考案されている。カテゴリ変数の特徴(値に大小関係があるか、疎な入力でも問題がないか、特定の値だけが重要なのか)などを考えてどのような前処理を施すか決定することになる。

Label Encoding

単純にラベルを数字に置き換えるだけの処理。 多くの人が使ったことがあるのではないかと思う。下の例だとA→0、B→1、C→2という様に置き換えてる。pythonだとsklearnのLabelEncoderを使うのが楽。

idx category encoded_feature
0 A 0
1 A 0
2 A 0
3 B 1
4 B 1
5 C 2
import pandas as pd
from sklearn.preprocessing import LabelEncoder

df = pd.DataFrame({
    "category" : ["A","A","A","B","B","C"],
})

le = LabelEncoder()
df["encoded_feature"] = le.fit_transform(df["category"])
df.head(10)

One Hot Encoding

カテゴリ変数の各値ごとにカラムを分けて0、1で表現する方法。下の例だとcategory_A, category_B, category_Cという列を新しく作って、Aの場合には(1, 0, 0)、Bの場合には(0, 1 ,0)、Cの場合には(0, 0, 1)と表現するニューラルネットワークでカテゴリ変数を表すときはOne Hot Encodingが第一選択肢になる気がする。

LabelEncodingのときと同様、One Hot Encoderがsklearnに収録されているので、それを使うのが楽。pandasにはget_dummiesというメソッドが収録されているので、それを使っても同じことが出来る。

category category_A category_B category_C
0 A 1 0 0
1 A 1 0 0
2 A 1 0 0
3 B 0 1 0
4 B 0 1 0
5 C 0 0 1
#one hot  encoding sample
import pandas as pd
from sklearn.preprocessing import OneHotEncoder

df = pd.DataFrame({
    "category" : ["A","A","A","B","B","C"],
})

#文字をLabel Encodingして数値に変換
le = LabelEncoder()
df["encoded_feature"] = le.fit_transform(df["category"])

#One Hot Encoding
ohe = OneHotEncoder()
ohe_array = ohe.fit_transform(df[["encoded_feature"]])
df.drop("encoded_feature", axis = 1, inplace = True)

#DataFrameに変換
ohe_df = pd.DataFrame(ohe_array.todense()).astype("uint8")
ohe_df.columns = ["category_A","category_B","category_C"]

#元のDataFrameと結合
df = pd.concat([df,ohe_df], axis = 1)
df.head(10)

Frequency Encoding

各値の出現頻度で表現する方法。例えば以下の表におけるAの値は、3(Aの出現回数)/ 6(全体の行数)で0.5となる。pandasを使えばgroupbyとmergeを駆使して比較的に簡単に書くことが出来る。

category category_counts frequency_encoding
0 A 3 0.500000
1 A 3 0.500000
2 A 3 0.500000
3 B 2 0.333333
4 B 2 0.333333
5 C 1 0.166667
#Frequency Encoding Sample
import pandas as pd
from sklearn.preprocessing import OneHotEncoder

df = pd.DataFrame({
    "category" : ["A","A","A","B","B","C"],
})

#各カテゴリーの出現回数を計算
grouped = df.groupby("category").size().reset_index(name='category_counts')

#元のデータセットにカテゴリーをキーとして結合
df = df.merge(grouped, how = "left", on = "category")
df["frequency"] = df["category_counts"]/df["category_counts"].count()

df.head(10)

Target Mean Encoding

Likelihood Encodingとも呼ばれる。Google検索でのヒット数的にはTarget Mean Encodingの方がメジャー。あるカテゴリ変数の値を持ち目的変数が1の個数を、その値の出現回数で割ったもの。 下の例だと、Aの出現回数は3回、カテゴリーがAで目的変数が1を持つものの出現回数は2回なのでTarget Mean Encodingの値は2/3 = 0.666 となる。

category outcome category_counts outcome_counts target_mean_encoding
0 A 1 3 2 0.666667
1 A 1 3 2 0.666667
2 A 0 3 2 0.666667
3 B 1 2 1 0.500000
4 B 0 2 1 0.500000
5 C 1 1 1 1.000000

 

#Target Mean Sample
import pandas as pd
from sklearn.preprocessing import OneHotEncoder

df = pd.DataFrame({
    "category" : ["A","A","A","B","B","C"],
    "outcome" : [1,1,0,1,0,1],
})

単純に計算すれば次のようなコードになる。

#各カテゴリーともう一つの特徴量の出現回数を計算
grouped_category = df.groupby("category")["category"].count().reset_index(name='category_counts')
grouped_outcome = df.groupby("category")["outcome"].sum().reset_index(name='outcome_counts')

#元のデータセットにカテゴリーをキーとして結合
df = df.merge(grouped_category, how = "left", on = "category")
df = df.merge(grouped_outcome, how = "left", on = "category")

df["target_mean_encoding"] = df["outcome_counts"]/df["category_counts"]

df.head(10)

Target Mean Encodingには過学習を防ぐための leave one out schemaという手法も提案されている。これは上の表で1列目のAに対してTarget Mean Encodingを計算するときに、自分自身の値を除いて計算する方法だ。この方法を用いて1列目のAに対してTarget Mean Encoding を計算すると(自分を除いたカテゴリーがAで目的変数が1の総数) / (自分を除いたAの出現回数)= 0.5 となる。

category outcome category_counts outcome_counts target_mean_encoding
0 A 1 3 2 0.5
1 A 1 3 2 0.5
2 A 0 3 2 1.0
3 B 1 2 1 0.0
4 B 0 2 1 1.0
5 C 1 2 2 1.0
6 C 1 2 2 1.0
#Target Mean Sample with leave one out scheme
import pandas as pd
from sklearn.preprocessing import OneHotEncoder

df = pd.DataFrame({
    "category" : ["A","A","A","B","B","C","C"],
    "outcome" : [1,1,0,1,0,1,1],
})

#各カテゴリーの出現回数を計算
grouped_category = df.groupby("category")["category"].count().reset_index(name='category_counts')
grouped_outcome = df.groupby("category")["outcome"].sum().reset_index(name='outcome_counts')

#元のデータセットにカテゴリーをキーとして結合
df = df.merge(grouped_category, how = "left", on = "category")
df = df.merge(grouped_outcome, how = "left", on = "category")

#計算する際に自分自身の値は除く
df["target_mean_encoding"] = (df["outcome_counts"] - df["outcome"])/(df["category_counts"] - 1)

df.head(10)

おわりに

4種類のカテゴリ変数の数値化に関する方法を紹介した。「カテゴリー変数に対する手法」という目次をつけておいて、時間が無くて数値変数に対する手法の紹介まで出来なかったのが申し訳ない。 もとのスライドには、Weight of Evidecneと呼ばれる手法や、具体的な特徴量抽出の手順などが記載されているので時間があったら追記するかもしれない。

feather - pandasの読み書きを高速化

kaggleに参加して巨大なCSVファイルを読み書きしていると、それだけで数分近くかかってしまうことがある。 そこで紹介されていたfeatherというDataFrame保存用ライブラリを使ってみた。 featherはpandasのDataFrameを高速に保存、ロードするために開発されたApache Arrowpythonラッパーだ。

Apache Arrow特有のカラム型メモリ管理をすることで、データの読み書きを高速化しているそうだ。 ベースになっているApache Arrowが他言語や他オープンソース(SparkやHadoop)との互換性を重視しているために、feather形式で保存すればRなどでも読み込める。

featherのインストールはpipから簡単にできる。

pip install feather-format

使い方も簡単でwrite_dataframe、read_dataframeを呼ぶだけ。

import feather
import pandas as pd

#データフレームの読み込み
df = feather.read_dataframe(path_feather)

#データフレームの書き込み
feather.write_dataframe(df, path_feather)

またpandasのバージョン0.20.0からpandas.DataFrame.to_featherpandas.read_featherというメソッドが追加されているので、以下のような方法でも使える。

import pandas as pd

#データフレームの読み込み
df = pd.read_feather(path_feather)

#データフレームの書き込み
df.to_feather(path_feather)

約18列、150000行のデータのデータセットで、csvとfeatherそれぞれの読み書きの速度を計測すると、

読み込み速度

# csvからの読み込み
%time df = pd.read_csv(path_csv)

# feather formatからの読み込み
%time df = pd.read_feather(path_feather)
%time df = feather.read_dataframe(path_feather)
CPU times: user 29.1 s, sys: 27.7 s, total: 56.8 s
Wall time: 1min 38s
CPU times: user 5.53 s, sys: 10.1 s, total: 15.6 s
Wall time: 37.7 s
CPU times: user 5.46 s, sys: 11.1 s, total: 16.5 s
Wall time: 35.7 s

書き込み速度

# csvへの書き込み
%time df.to_csv(path_csv)
# featherへの書き込み
%time df.to_feather(path_feather)
%time feather.write_dataframe(df, path_feather)
CPU times: user 53 s, sys: 11.4 s, total: 1min 4s
Wall time: 1min 30s
CPU times: user 18.3 s, sys: 10.1 s, total: 28.4 s
Wall time: 39 s
CPU times: user 18.8 s, sys: 8.76 s, total: 27.5 s
Wall time: 35.5 s

実時間で読み書きともに約2.5倍程度と非常に高速に動作することが確認できた。データセットの保存や読み込みに使えば大幅な時間の節約になる。 長期のデータには向いていない(破損とかに弱い?)そうなので、あくまで前処理したデータのキャッシュなどに使うのがいいと思う。

Talking Data AdTracking Fraud Detection Challenge

先日開催されたTalking Data AdTracking Fraud Detection Challengeに参加した。 Talking Data AdTracking Fraud Detection Challengeは中国の広告企業TalkingData主催のコンペで、広告クリックのログから不正なクリックを検出するというコンペティションだ。

TalkingData AdTracking Fraud Detection Challenge | Kaggle

初めてのkaggleで最終的な結果は1012位/3967位、上位26%だった。最初のコンペティションとしては悪くない結果だとは思うが、個人的にはもう少し上を狙いたかった。

コンペティション概要

以下の表に示す特徴を持つ、約180,000,000のレコードを持つ巨大なデータセットが与えられた。これらの特徴は全てLabel Encodeされていて全て整数で与えられている。

特徴量 説明
ip クリック元のipアドレス
os クリック元のOSやバージョンに関する情報
app クリック元のアプリ
device クリック元の携帯電話の種類
channel クリックされた広告のid的なもの
click_time クリックされた時刻
is_attributed 目的変数 0は通常のクリック、1は不正なクリック

ノートパソコンで今回のコンペに参加したが到底データセット全てはメモリに乗らず、一部を利用して学習させる方針で挑んだ。以下実際に参加したログと感想。

4/17

click_timeをパースして、day、hour、minuteなどの特徴量に分解した。 また一時間ごとのipの出現回数や、一日ごとのip-osの組み合わせの出現回数を特徴に加えxgboostでデータの一部を学習した。 与えられた学習データを学習用とテスト用に分けて評価したところ、テストデータでは約96%を達成。 しかしテストデータを送信したところ94%程度しか達成できなかった。2900人中2700位とか。

4/18

データセットの前半と後半でデータの傾向が少し違うらしい。 データが膨大でメモリに乗らないので(mac book airでは厳しいものがある)後半の一部を使用。 また単位時間あたりのipアドレス数を初めとした特徴量を導入した。 これらの結果リーダーボードで96.3%を達成。3000人中1800位とか。まだまだ。

4/21

当初はxgboostを使っていたが、Kernelを見ていると多くの人がlightgbmを使っている人が多かったのでlightgbmを試してみた。 lightgbmはカテゴリ変数をいい感じに取り扱ってくれるらしくて、そのおかげか96.5%を達成できた。あとパラメータによるのかもしれないが、少し計算速度が速くなったように感じた。

4/25

Kernelで公開されていた、次のクリックまでの経過時間を表すnext_clickという特徴を導入した。 この特徴量を導入しただけで飛躍的に精度が向上し、精度97.5を達成し3100人中上位700位まで一気に順位があがった。

4/23

Test Dataには4, 5, 9, 10, 13, 14時のデータしか含まれていないという情報がKernelで流れていた。 そこで学習に使うデータも4, 5, 9, 10, 13, 14時に絞ってみたところ、精度97.82を達成して3300人中500位近くまで順位を上げることが出来た。

4/25日以降

他に何個か特徴量を試したが精度の向上に繋がることはなかった。途中で最高精度を達成したファイルを直接書き換えてしまい、ロールバックすることも出来ず精度97.5あたりをうろついていた。怒涛の勢いで追い上げをくらい、順位を3900人中1000位近くまで落としてしまった。

最終日に誰かがディスカッションボードで精度98.11(上位5%程度の精度)のKernelを公開して物議を醸していて笑った。

感想

初めてKaggleのコンペティションに参加してみての感想としては、Kernelで初心者向けのコードが公開されていたりするので、英語さえ読めれば参加のハードルは低いと思った。正直、機械学習用のsklearn、xgboost、lightgbm、kerasなどのライブラリはpythonが使えれば中身を理解していなくても動かせてしまう(それで勝てるかは別問題として)。参加してみるとDiscussionとかで色々な知識を身につけることが出来るので、機械学習とかに興味がある人はとりあえず参加してみれば力をつけることが出来るのではないかと思う。ただ常時kaggleのことが気になってしまって他の作業ができなくなるののにだけは注意方がいい。

やはり精度を上げるための肝は、いかに優れた特徴量を設計するかにかかっているように感じた。下手な特徴量では逆に精度をさげてしまうため、効率的に有効な特徴量を見つけていくノウハウを学ぶ必要がある。

今回の反省点としては、

  • 試してみた特徴量やプログラムは、リーダーボードのスコアと一緒に管理しておく。
  • 積極的にKernel、Discussionを見て人の意見を取り入れていくこと。
  • 一度作成したモデルもしっかり保管しておく。きちんと管理しておけば、最後の最後でBaggingやStackingして少し精度を挙げられる可能性がある。

あたりであろうか。

またCERNのコンペティションロシアの広告会社Avitoのコンペティションが開催されるようなので参加してみようかと思う。Kaggleに参加してみたいけど難しそうと思っている人は、とりあえず適当なこんぺ