約3カ月間開催されていたIEEE-CISコンペの解法をまとめていきます。私も参加して運よく銅メダルを獲得できました。

メダルは獲得できたものの筋の良い解き方がわかったわけではなく、最後はPublic Kernelと自分のモデルを混ぜてたまたまメダル圏内に入れた棚ぼたといった感じでしたのでしっかり復習して勉強していきます。
コンペ概要
本コンペはVesta Coportationの決済履歴より不正取引を検知するシステムの改善が目的でデータと評価指標は下記の通りでした。
訓練データ:(590540, 394)
テストデータ:(506691, 394)
評価指標:AUC
細かくデータを見ていくと、欠損値の数が9割以上あるコラムがあったり、訓練データとテストデータのAdversal validationでAUC=1近く算出されていたという事もあり、コンペが終わるまでは恐らくシェイク(PublicとPrivateで順位が大きく変動)するだろうと騒がれていました。
結果、上位陣はほとんどシェイクしておらず下位陣は私含めてValidationの取り方によって変動していました。↓PublicとPrivateの変動散布図のようです。(こちらのDiscussionより)Solutionを見る限り、上位陣は同一のクレジットカードを特定し、そのサンプルに対して特別に処理を加えていたようです。というのもホストのDiscussionでFraudと判定された人に対してはその後の取引も全てFraudと判定するシステムであるという記述があったのでそれを利用したようです。

従って「単体の取引が不正取引か否かを分類するモデル」というより、「あるクレジットカードが不正利用か否かを捉えるモデルを作成する」というのが本コンペの肝になっていました。
上位解法まとめ
User ID特定
1位のチームではD1はクレジットカードが発行されてからの日付ということでD1nというコラム(TransactionDay – D1)を作成して同一userを特定しています。

またTransaction DayからD3のコラムを引いたD3nは各顧客の最後の取引を表すコラムとなるようで、こちらも利用していたようです。
このようなユーザー特定する知見って今後使うことあるのかな・・・?と思いましたが、Home Creditの時も同様に同一人物の特定を行うことでスコアを上げられたようなので取引履歴から同一ユーザーを特定して、それに絡めた特徴量が効くというのは汎用的なもので、今後も同様の問題に遭遇したら真っ先に特定できないかに注力するの良さそうに思えます。
特徴量エンジニアリング全般
・エンコーディングのテクニック
1位のチームが公開していたNotebookでエンコードの関数があったのでこちらは今後特徴量を作成する際に流用できそうです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
# FREQUENCY ENCODE TOGETHER def encode_FE(df1, df2, cols): for col in cols: df = pd.concat([df1[col],df2[col]]) vc = df.value_counts(dropna=True, normalize=True).to_dict() vc[-1] = -1 nm = col+'_FE' df1[nm] = df1[col].map(vc) df1[nm] = df1[nm].astype('float32') df2[nm] = df2[col].map(vc) df2[nm] = df2[nm].astype('float32') print(nm,', ',end='') # LABEL ENCODE def encode_LE(col,train=X_train,test=X_test,verbose=True): df_comb = pd.concat([train[col],test[col]],axis=0) df_comb,_ = df_comb.factorize(sort=True) nm = col if df_comb.max()>32000: train[nm] = df_comb[:len(train)].astype('int32') test[nm] = df_comb[len(train):].astype('int32') else: train[nm] = df_comb[:len(train)].astype('int16') test[nm] = df_comb[len(train):].astype('int16') del df_comb; x=gc.collect() if verbose: print(nm,', ',end='') |
ラベルエンコーディングをfactorizeを用いて行っています。このメソッドは文字列に対してはsklearnのLabelEncoderと同じようにエンコードしますが、欠損値に対して自動で-1に置き換えしてくれるようです。(sklearnの方はエラーが出る)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
# GROUP AGGREGATION MEAN AND STD # https://www.kaggle.com/kyakovlev/ieee-fe-with-some-eda def encode_AG(main_columns, uids, aggregations=['mean'], train_df=X_train, test_df=X_test, fillna=True, usena=False): # AGGREGATION OF MAIN WITH UID FOR GIVEN STATISTICS for main_column in main_columns: for col in uids: for agg_type in aggregations: new_col_name = main_column+'_'+col+'_'+agg_type temp_df = pd.concat([train_df[[col, main_column]], test_df[[col,main_column]]]) if usena: temp_df.loc[temp_df[main_column]==-1,main_column] = np.nan temp_df = temp_df.groupby([col])[main_column].agg([agg_type]).reset_index().rename( columns={agg_type: new_col_name}) temp_df.index = list(temp_df[col]) temp_df = temp_df[new_col_name].to_dict() train_df[new_col_name] = train_df[col].map(temp_df).astype('float32') test_df[new_col_name] = test_df[col].map(temp_df).astype('float32') if fillna: train_df[new_col_name].fillna(-1,inplace=True) test_df[new_col_name].fillna(-1,inplace=True) print("'"+new_col_name+"'",', ',end='') # COMBINE FEATURES def encode_CB(col1,col2,df1=X_train,df2=X_test): nm = col1+'_'+col2 df1[nm] = df1[col1].astype(str)+'_'+df1[col2].astype(str) df2[nm] = df2[col1].astype(str)+'_'+df2[col2].astype(str) encode_LE(nm,verbose=False) print(nm,', ',end='') # GROUP AGGREGATION NUNIQUE def encode_AG2(main_columns, uids, train_df=X_train, test_df=X_test): for main_column in main_columns: for col in uids: comb = pd.concat([train_df[[col]+[main_column]],test_df[[col]+[main_column]]],axis=0) mp = comb.groupby(col)[main_column].agg(['nunique'])['nunique'].to_dict() train_df[col+'_'+main_column+'_ct'] = train_df[col].map(mp).astype('float32') test_df[col+'_'+main_column+'_ct'] = test_df[col].map(mp).astype('float32') print(col+'_'+main_column+'_ct, ',end='') # GROUP AGGREGATE 使い方の例 encode_AG(['TransactionAmt','D9','D11'],['card1','card1_addr1','card1_addr1_P_emaildomain'],['mean','std'],usena=True) # COMBINE COLUMNS CARD1+ADDR1, CARD1+ADDR1+P_EMAILDOMAIN encode_CB('card1','addr1') # AGGREGATE encode_AG2(['P_emaildomain','dist1','DT_M','id_02','cents'], ['uid'], train_df=X_train, test_df=X_test) |
最初のグループ集計関数では一つ目の引数で集計したい値、次にグループとしてまとめたい値、最後に平均や偏差など指定することで特徴量を作り出しています。他にも最大値を取ったり最小値を取ったり使い道はありそうです。
2つ目の関数は2変数の組み合わせの変数を作り、その後ラベルエンコーディングを行う処理を行います。
最後のencode_AG2の関数はユーザーID毎で任意の変数のカウントエンコーディングを行います。これで各ユーザーの特徴を捉えることができそうです。
次元削減
VコラムはV1-V339と数が多かったため、欠損値の出るパターンが同じものや高い相関がある組み合わせが多かったため次元削減を行っているチームが多数ありました。
1位のチームでは欠損値のパターンが同じ変数同士をグループとして、その中で高い相関(r > 0.75)のコラムを見つけて一方のみを使用してモデルを作成したようです。(今回はPCAより精度がよかったらしい)
Seq 2 Dec
13位の方はuse_id毎のtargetをfloatにしてtarget encodingを行っていました。これをやると取引の順番の情報をモデルに与えることができるようです。

他にはモデルの多様性を増加させるために交差検証のn_foldsの数を5,7,13,17,19(素数)にしていたということで今後試してみたいと思います。
特徴量選択
今回trainとtestの分布が違うことがわかっていたため、過学習しないようにAdversal validationやks_samplingを用いて特徴量を選択していた人が多かったと思います。
1位のチームでは全体の学習データ2017年12月〜5月から最初の1ヶ月を学習データ、最後の1ヶ月をテストデータとして、全ての特徴量を単体でモデルに入れた時にAUCが0.5より大きいかという実験で各特徴量の時系列性の強さを確認していました。
このときテストデータでのAUCが低いということはその特徴量が時系列で大きく分布が変わってしまいPrivateでも使える特徴量ではないだろうという判断のようです。
5位の方は下記のようにtrainデータをpublicとprivateに模擬して、trainのoofの精度が高い時に模擬puclicとprivateの精度も高い傾向があるという結果から、分布の違いによる精度への影響は限定的であるとして、Adversal validationから特徴量を絞ることは行わなかったようです。

さらに特徴量の選択ではないですが、このチームはSingle Transactionのユーザーに絞りモデルを作成してその出力を特徴量として加えたそうです。
というのも正例のラベルの付け方で複数取引を行っているユーザーの場合、最初の取引がisFraud=1と判定された場合に次の取引がどんなに普通の取引でもその後の取引は無条件でラベル1が与えられるため、そのようなデータを含めて学習させたモデルだとSingle TransactionでisFraud=1のデータを上手く分類できないだろうという発想のようです。
その他
41th solutionでgithubのコードがあり実験結果のまとめ方が良い感じだったので自分のパイプラインに早速取り入れてみました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
def update_tracking(self, field, value, csv_file="../input/tracking.csv", integer=False, digits=None, drop_incomplete_rows=False): try: df = pd.read_csv(csv_file, index_col=[0]) except FileNotFoundError: df = pd.DataFrame() if integer: value = round(value) elif digits is not None: value = round(value, digits) if drop_incomplete_rows: df = df.loc[~df['AUC'].isna()] df.loc[run_id, field] = str(value) # Model number is index df.to_csv(csv_file) |
任意の場所で結果をCSV出すことができるので、パラメータやCVの結果などを効率的に管理できるようになりそうです。
最後に
今回のコンペはユーザーIDを特定できるか否かでスコアに大きく差が出てしまいました。実務ではユーザー情報が与えられるので本質的じゃないという声も上がっていますが、確かに実務経験を積む目的でkaggleをやっている人にとっては微妙かもしれません。
とは言え、上位陣の特徴量選択の方法validationの切り方、効率的な実験管理などはとても勉強になる部分が多く今回のコンペでも色々なテクニックを勉強できました。
次はテーブルコンペ2つと自然言語処理のコンペにチャレンジしていこうと思います。