💻

序盤に試すテーブルデータの特徴量エンジニアリング

2021/12/03に公開

この記事はKaggle Advent Calendar 2021の4日目の記事です.

はじめに

この記事ではテーブルデータコンペティションにおいて,主に数値データ,カテゴリデータをもとに特徴量を作成する方法をまとめました.発展的な内容というより,初めてコンペに参加する方でも使える汎用的な特徴量エンジニアリングを紹介します.

特徴量エンジニアリング!...そのまえに

モデルについて

特徴量エンジニアリングはモデルによって処理が変わることがあります.

例えば勾配ブースティング決定木(GBDT)といった決定木はスケーリングする必要がなく,またLightGBMなどは欠損値をそのまま扱うことができます.一方でニューラルネットワーク(NN)や線形回帰モデルはスケーリングおよび欠損値補完をする必要があります.
このこと以外にも,決定木は各特徴量間で差や比率を表現することが苦手であるために明示的に作る必要があるケースや,線形回帰は相関係数が高い特徴量同士をいれてしまうと多重共線性が生じるといったようなケースがあります.

そのため,特徴量エンジニアリングはモデルの性質を理解して行うことが重要です.
ただ,テーブルデータコンペの序盤の進め方としてはLightGBMを試すのがいいかと思います.その理由については,u++さんの「初手LightGBM」をする7つの理由が参考になるかと思います.
この記事にある理由に加えて,他のモデルを使う場合でも作成した特徴量はLightGBMで簡単に組み込みやすく,特徴量作成自体にバグがないか,どれくらい精度に貢献するかのデバッグとして使えるという点も挙げられます.

便利なライブラリxfeat

xfeatPreferred Networksが公開した特徴量を作成するための様々なEncoderを実装しています.そのため,特徴量作成のコードがオレオレコードになりにくく,チームで参加するときには可読性の高く共有がしやすいコードを書くことができます.

今回紹介するコードの多くはこのxfeatを使って書くことができます.
詳しい実装は特徴量エンジニアリングのライブラリ xfeat を使ってみて便利だったことに記載されています.また,ぐぐりらさんのxfeatのaggregationで自作の関数を使いたいには集約関数として自作の関数を作るときの内容が書かれており,実装の幅が広がります.

特徴量エンジニアリング

特徴量エンジニアリングについて,「(単一の)数値データ」「(単一の)カテゴリデータ」「テキストデータ」「数値データ × カテゴリデータ」「数値データ × 数値データ」「カテゴリデータ × カテゴリデータ」「スケーリング」の大きく7つの枠組みでどのような手法を取るかを紹介します.

(単一の)数値データ

大小関係が保持される変換

モデルによって大小関係が保持される変換する処理が効果的に働く場合と働かない場合があり,後述するスケーリングにも関わります.

決定木ではモデルの性質上,対数化といった大小関係が保持される変換は基本的にしてもしなくても精度に関係ありません(ただ対数化した変数を使って新しく特徴量を作成する場合にはその限りではありません).一方で線形回帰モデルやNNは,裾が伸びた分布に対して対数化することなどが効果的に働く場合があります.

一方で大小関係が保持されない変形として,ML_Bearさんの【随時更新】Kaggleテーブルデータコンペできっと役立つTipsまとめには,小数点以下を取り出すような処理が紹介されています.単一の数値データでは,使うモデルにもよりますが,大小関係が保持される・されないといったことを意識して特徴量作成しなければ,無駄に次元数を増やすことになります.

ラグ特徴量(時系列の場合)

時系列データであれば,過去(未来)のデータを使うことが効果的なことが多いです.また,空間構造を持ったデータでも,距離的な関係があるデータを入れることが効果的に働くことがあります.

基本的にはGBDTなどのモデルで明示的に作る必要が多いです.NNの場合はLSTMやtransformerなどの時系列データに扱えるモデルを扱うことで時系列間の関係を学習させることができますが,ラグ特徴量を作成したほうが精度が向上する場合があります.

ラグ特徴量は,前後に動かすshiftやshiftしたデータともとのデータの差を取るdiff,また移動平均を最初に試すことが多いです.これはpandasのshiftやdiff,rollingのメソッドを使うことで簡単に実装できます.注意する点として,時系列順にソートされているか確認する必要があることと,系列単位でラグ特徴量を作らないといけないためにgroupbyを使う必要があることが挙げられます.

特に後者では,例えばこういったデータがあったとき,

group value1 value2
0 A 5 31
1 A 6 27
2 A 3 25
3 A 7 21
4 B 3 16
5 B 4 18
6 B 5 16

以下のコードで1つ前のデータがshiftしたラグ特徴量を作成することができます.
(欠損値を含むデータになるためGDBT以外のモデルでは注意が必要です)

group = 'group'
feature_cols = ['value1', 'value2']

lag = 1
df = pd.concat([
    df, df.groupby(group)[feature_cols].shift(lag).add_prefix(f'shift{lag}_')
], axis=1)
group value1 value2 shift1_value1 shift1_value2
0 A 5 31 nan nan
1 A 6 27 5 31
2 A 3 25 6 27
3 A 7 21 3 25
4 B 3 16 nan nan
5 B 4 18 3 16
6 B 5 16 4 18

またdiffやrollingも含めたコードは以下のように書くことができます.

outputs = [df]
grp_df = df.groupby(group)[feature_cols]

for lag in [-3, -2, -1, 1, 2, 3]:
   # shift
   outputs.append(grp_df.shift(lag).add_prefix(f'shift{lag}_'))
   # diff
   outputs.append(grp_df.diff(lag).add_prefix(f'diff{lag}_'))

# rolling
for window in [3]:
    tmp_df = grp_df.rolling(window, min_periods=1)
    # 移動平均を取る
    tmp_df = tmp_df.mean().add_prefix(f'rolling{window}_mean_')
    outputs.append(tmp_df.reset_index(drop=True))

df = pd.concat(outputs, axis=1)

(単一の)カテゴリデータ

カテゴリデータは基本的にそのまま特徴量として扱えず,数値化する必要があります.
(また数値データに対してビニング処理をしてカテゴリ変数として扱うこともできます.)

One-Hot Encoding

例えばある列に「赤,青,青,黄」と並んでいた時,新しい列として「赤であるか,青であるか,黄色であるか」と水準数分作成して,正しければ1,異なれば0とダミー変数化する手法です.決定木以外のモデルに使われるEncodingです.

この手法の問題点として,カテゴリ数が多いほどほとんど0の疎な特徴量が作られ,学習時間がかかってしまうことや必要なメモリが増えてしまうことが挙げられます.

Kaggle本ではこの問題に対して,

  • one-hot encoding以外の別のencoding手法を検討する
  • 何らかの規則でグルーピングして、カテゴリ変数の水準の数を減らす
  • 頻度の少ないカテゴリをすべて「その他のカテゴリ」のようにまとめてしまう

ことが挙げられています.

個人的にはNNの場合はembedding層を用いて実数ベクトルに変換することが多いです(Entity Embeddingという手法として知られています).また後述するCount EncodingやTarget Encoding, 集約統計量で数値化することも多いです.

実装コードはpd.Categoricalとpd.get_dummiesを使って以下のように行えます.(詳しくはpandasでカテゴリ変数をダミー変数に変換(get_dummies)をご参照ください.)

train_outputs = [train]
test_outputs = [test]

for col in cat_cols:
    categories = set(train[col])
    train[col] = pd.Categorical(train[col], categories=categories)
    test[col] = pd.Categorical(test[col], categories=categories)
    train_outputs.append(pd.get_dummies(train[col]).add_prefix(f'{col}_'))
    test_outputs.append(pd.get_dummies(test[col]).add_prefix(f'{col}_'))
    
train = pd.concat(train_outputs, axis=1)
test = pd.concat(test_outputs, axis=1)

Label Encoding

単純に数値ラベルに変換する手法で,例えば「赤,青,青,黄」と並んでいれば赤を0,青を1,黄を2と置き換えて「0, 1, 1, 2」と数値化します.次元数を増やさずGBDTなどの決定木に有効なEncodingです.

sklearnのLabelEncoderを用いた実装は以下の通りです.

from sklearn.preprocessing import LabelEncoder

for col in cat_cols:
    encoder = LabelEncoder()
    encoder.fit(train[col])
    train[f'label_{col}'] = encoder.transform(train[col])
    test[f'label_{col}'] = encoder.transform(test[col])

また内包表記を使ってシンプルに実装することができます.

for col in cat_cols:
    encoder = {c: i for i, c in enumerate(train[col].unique())}
    train[f'label_{col}'] = train[col].map(encoder)
    test[f'label_{col}'] = test[col].map(encoder)

Count Encoding

カテゴリ変数を出現回数に置き換える手法で,例えば「赤,青,青,黄」と並んでいれば出現回数は赤は1回,青は2回,黄は1回なので「1, 2, 2, 1」と数値化します.

実装はpandasのvalue_countsを使うことでシンプルにできます.

for col in categories_columns:
    encoder = train[col].value_counts()
    train[f'label_{col}'] = train[col].map(encoder)
    test[f'label_{col}'] = test[col].map(encoder)

Target Encoding

カテゴリ変数をそれぞれ目的変数の期待値に置き換える手法です.Cardinality(水準数)が高いほど効果が期待される手法ですが,trainとtestの分布が異なっていたり,学習時のデータの分割に合わせて処理しなければリーク(学習時のスコアが異常に高くなってしまう)してしまう恐れがあります.詳しい説明はKaggle本に記載されておりますので,そちらをぜひご参照ください.
またhakubishin3さんのTarget Encoding はなぜ有効なのかは,Cardinalityが高い場合にGDBTの特性上,Label EncodingよりもTarget Encodingのほうが効率的であることがわかりやすく書かれています.

実装は以下の通りで,ここでfoldsはcross-validationして各レコードにfoldのindexが振られているpd.Seriesのデータになっています.(xfeatでの実装は特徴量エンジニアリングのライブラリ xfeat を使ってみて便利だったことを参照してください.)

from sklearn.model_selection import KFold

def get_kfold(train, n_splits, seed):
    kf = KFold(n_splits=n_splits, shuffle=True, random_state=seed)
    fold_series = []
    for fold, (idx_train, idx_valid) in enumerate(kf.split(train)):
        fold_series.append(pd.Series(fold, index=idx_valid))
    fold_series = pd.concat(fold_series).sort_index()
    return fold_series
    
def get_targetencoding(train, test, folds: pd.Series, col: str):
    for fold in folds.unique():
        idx_train, idx_valid = (folds!=fold), (folds==fold)
        group = train[idx_train].groupby(col)['target'].mean().to_dict()
        train.loc[idx_valid, f'target_{col}'] = train.loc[idx_valid, col].map(group)
    group = train.groupby(col)['target'].mean().to_dict()
    test[f'target_{col}'] = test[col].map(group)
    return train, test

folds = get_kfold(train, 5, 42)
for col in cat_cols:
    train, test = get_targetencoding(train, test, folds, col)

テキストデータ

テキストデータに関してはHidehisa Araiさんのテーブルデータ向けの自然言語特徴抽出術が非常に参考になりますので,ぜひそちらをご参照ください.

個人的にテキストデータが入っているときは,最初にtf-idfでベクトル化してTruncatedSVDで次元削減したものを特徴量に用います.次にBERTで,タスクに合ってライセンスやそのコンペのルールに抵触しないpretrainingモデルを使いembeddingしたデータをPCAなどで次元削減させて特徴量に用います.(もしテキストデータ特徴量のfeature importanceが高ければテキストデータのみでモデルを作ってスタッキングする場合もあります.)

数値データ × カテゴリデータ

集約統計量

数値データとカテゴリデータでは,集約統計量を新しい特徴量として作成できます.
集約自体,複数のテーブルデータがある際に有効な場合が多いです.例えばユーザーと購入した商品が1対多になっているテーブルで,あるユーザーがこれまでに購入した金額の合計値(sum)や1商品あたりの平均値(mean)などの統計量をユーザーの特徴量として使うことができます.

一方で1つのテーブルだけでも同様に集約統計量を作成することが可能で,カテゴリデータの一種の数値化として扱うことができます.そのため水準数が少ないカテゴリ変数はあまり効果がない可能性があります.

agg_cols = ['min', 'max', 'mean', 'std']

for col in cat_cols:
    grp_df = train.groupby(col)[num_cols].agg(agg_cols)
    grp_df.columns = [f'{col}_' + '_'.join(c) for c in grp_df.columns]
    train = train.merge(grp_df, on=col, how='left')
    test = test.merge(grp_df, on=col, how='left')    

また最大値や最小値,中央値,平均値といった集約統計量ともとの数値データとの差を取ることで新しい特徴量を作成することができます.(先ほど例に出した,ユーザーと商品とのデータであれば,平均値との差をみることで普段購入している商品よりも高いか安いかといった特徴量を作成することができます.)

数値データ × 数値データ

数値データ間の四則演算

各数値データ間で四則演算を取ることで新しい特徴量を作ることができます.
特にGBDTでは差や比率を直接表現することが苦手なので明示的に作成したほうが精度が向上することがあります.

例えば,atmaCup#10では美術作品の属性情報として,作品の高さや横幅,奥行きなどが与えられていました.この情報から単純に積を取るだけで面積や体積,割れば作品のアスペクト比といった新しい特徴量を作成することができます.
作製期間の始まりと終わりの差を取れば作成期間年数が作成できますし,他の情報を使えば何歳のときの作品かといった特徴量も作成することができます.

この数値データ間の特徴量作成は,個人的には全ての数値データを使って闇雲に四則演算をして新しい特徴量を作らず,数値データ間でなんらかの意味のある新しい特徴量を作ることを心がけています.一方で大量に特徴量を作成した後に,LightGBMなどで学習させて,そのfeature importanceの上位の変数だけ用いるといったことをされる方もいます.

カテゴリデータ × カテゴリデータ

カテゴリデータ間を組み合わせて新しいカテゴリデータを作成することができ,今まで紹介したEncodingを使って新しい特徴量とすることができます.一方で元々水準数が高いカテゴリデータ同士を組み合わせるとtrainもしくはtestにしか存在しない,または頻度が少ないデータが生成され,あまり意味をなさずに次元だけ増えてしまうデータになるため注意が必要です.

この実装は愚直にデータ間で組み合わせてもいいのですが,網羅的に組み合わせる際はxfeatのConcatCombinationを使うことで非常に簡単に書けます.(ちなみに数値データ間での組み合わせはxfeatのArithmeticCombinationsで書くことができます.)

import xfeat

for i in [2, 3]:
    concat = xfeat.ConcatCombination(r=i, drop_origin=True)
    train = pd.concat([train, concat.fit_transform(train[cat_cols])], axis=1)
    test = pd.concat([test, concat.fit_transform(test[cat_cols])], axis=1)

スケーリング

スケーリングは特徴量を作成した後に何らかの方法で各数値データ間の大きさを調整する方法です.ただモデルやデータによって一意にこのスケーリングをやればいいというものはないように思います.

そもそも決定木を使用する際は,特徴量に対する対数化や,0から1の範囲に正規化するような大小関係が保存される変換の影響はほとんどありません.数値の大小関係で学習するモデルであるため,基本的にスケーリングを行う必要がありません.
一方で,線形回帰などはスケールの大きい変数ほど回帰係数が小さくなり,正則化がかかりにくいといった問題が生じてしまうので,NN含めスケーリングを行ったほうがいいことが多いです.(後述しますが二値変数や疎ベクトルに対してはスケーリングをしないほうが良い場合もあります.)

StandardScaler
各変数をその平均の差をとり,標準偏差で割ることで平均0,分散1の変数に変換します.疎ベクトルの場合は,平均値で差をとることで密ベクトルに変換してしまうため,モデルによっては疎ベクトルを前提に最適化していることもあり不適な場合があります.また,二値変数で偏りがあるときは標準偏差が小さな値になるため,変換後の値が非常に大きくなってしまう場合があり,注意が必要です.

# train: 学習用データのDataFrame
# test: 評価用データのDataFrame
# num_cols: 数値変数のカラム名の配列
# cat_cols: カテゴリ変数のカラム名の配列
from sklearn.preprocessing import StandardScaler

scaler = StandardScaler()
scaler.fit(train[num_cols])
train[num_cols] = scaler.transform(train[num_cols])
test[num_cols] = scaler.transform(test[num_cols])

MinMaxScaler
各変数をその最小値で差をとり,最大値と最小値の差で割ることで各変数の最小値を0,最大値を1に変換します.極端に大きな・小さな値があると,その値に引っ張られたスケーリングをしてしまい悪影響を及ぼす可能性があるため注意が必要です.

from sklearn.preprocessing import MinMaxScaler

scaler = MinMaxScaler()
scaler.fit(train[num_cols])
train[num_cols] = scaler.transform(train[num_cols])
test[num_cols] = scaler.transform(test[num_cols])

RobustScaler
極端に大きな・小さな値がある場合に使えるスケーリングで,直近ではGoogle Brain - Ventilator Pressure Prediction(圧力コンペ)で多くの方が使われていたスケーリングです.(ちなみにこのコンペはスケーリングによって精度がかなり変動しました.データの特性にもよりますが,様々なスケーリングを試すことが大事な場合もありそうです.)
標準では,各変数をその中央値で差をとり,75パーセンタイル値と25パーセンタイル値の差で割ることで各変数の中央値を0に変換します.

from sklearn.preprocessing import RobustScaler

scaler = RobustScaler(quantile_range=(25.0, 75.0))
scaler.fit(train[num_cols])
train[num_cols] = scaler.transform(train[num_cols])
test[num_cols] = scaler.transform(test[num_cols])

RankGauss
各変数を順位に変換して正規分布になるように変換します.NNモデルにおいて他のスケーリングよりも良い性能を示すことがあるとのことで,QuantileTransformerを使い,n_quantilesの値を十分に大きくしてoutput_distributionを'normal'に指定することで,実装することができます.

from sklearn.preprocessing import QuantileTransformer

scaler = QuantileTransformer(
    n_quantiles=100,
    random_state=42,
    output_distribution='normal'
)
scaler.fit(train[num_cols])
train[num_cols] = scaler.transform(train[num_cols])
test[num_cols] = scaler.transform(test[num_cols])

対数変換
一方的に裾が伸びた分布になった変数に対しては,対数変換が有効な場合があります.0を含むデータに対してはlog(x+1)とする変換や,負の値を含む場合は負の値に対して-log(-x-1)とする変換を行います.また単純にsqrtをとることも有効な場合があります.
後述しますが,対数変換をしたデータをもとに変数間で新しく特徴量を作成することが有効な場合もあります.また,対数変換後に他のスケーリングを行っても特段の問題がないことが多いです.
対数変換を一般化したものとしてはBox-Cox変換やYeo-Johnson変換があります.

1subした後のコンペの進め方

これまでに紹介した特徴量エンジニアリングは,汎用的にどんなテーブルデータコンペでも使える手法ですが,そもそもこれらの手法は多くの参加者が行っているため,上位に入るためには他の参加者がやっていない,深く取り組んでいないことをやる必要があります.

特徴量の改善

特徴量エンジニアリングでやれることといえば,EDA(探索的データ分析)を深く取り組んで新しい特徴量を作成する糸口を見つけることと,タスクによってはドメイン知識を活かした特徴量を組み込むことです.
EDAは変数間や目的変数との散布図や,PCAやt-SNEなどで次元削減した散布図の各点に目的変数に応じた色を載せたものを可視化することで変数間の関係性を調べることができます.
ドメイン知識を活かせた特徴量を作れれば他の参加者と差をつけることができますが,なかなか作ることは難しいです.ただ似た過去コンペの手法を使うことや,コンペのホストが提示している論文などをしっかり調査することで,他の参加者と大きく差をつけることができる場合があります.

モデルの改善

モデルのハイパーパラメータをチューニングすることや,そもそも他のモデルを使うことを模索してもいいかもしれません.最近の,特にKaggleのコンペではテーブルデータでもNNが強いことが多く,特徴量を深堀するよりNNの構造をいろいろ試す方がいい場合もあります.

一方でGBDTやNN以外のモデルでも高精度に予測できる場合も多く,例えばIndoor Location & Navigationコンペの2thのチームのRezaさんのsolutionではガウス過程回帰とk次近傍法を使って比較的短時間で高精度の絶対位置を予測しており,タスクに合わせたモデルを使うことで他の参加者と大きく突き放すことができるかもしれません.

また複数のモデルを作ることでアンサンブルやスタッキングに使えます.ただ序盤からアンサンブル前提のモデルを組んでしまうと,実験スピードが落ちてしまいますので,最終的にアンサンブルをするものとして取り組むといいかもしれません.

コンペ特有の手法

コンペ特有の効く手法(magic)を発見するのが結果的に大きく差をつけることが多いです.例えば,Indoor Location & Navigationでは絶対位置と相対位置を最適化でうまく組み込む手法(saitoさんのCost Minimization)が強く,上位のチームでは離散最適化に落とし込むことで大きく精度を向上できました.
この記事の公開日ではまだ評価中であるOptiver Realized Volatility Predictionでは,時系列の順序がホストにより直接特定できないようなものにされていましたが,それを特定したり,時間的な距離を作製して近い距離のデータを取り組む(一種のラグ特徴量)を作成することで大きく精度を向上させることができました.

こういったmagicを発見することで勝つことはできますが,どうやって発見するかはかなり難しいと思います.おそらくEDAをしっかり行うことや,過去コンペを調査する,競プロなど他の分野の知見を活かすことで発見できるのかもしれません(ここは安定して上位に入られる方に聞いてみたいところです).

おわりに

今回この記事は,u++さんのKaggleにおける「特徴量エンジニアリング」の位置づけ 〜『機械学習のための特徴量エンジニアリング』に寄せて〜の記事を参考に,より汎用的に使える「守り」の特徴量エンジニアリングをまとめられたらと思い執筆しました.

おそらくコンペに何度も参加されている方は自分の中で「守り」の特徴量エンジニアリングがあると思いますし,初めて参加される方にとっては一番最初に差をつけられてしまうところになってしまいます.この記事を通して,そういったところを少しでもサポートできたら幸いです.

Discussion