- はじめに
- 1. featuretoolsによる特徴量の自動生成
- 2. カテゴリカルデータの扱い
- One Hot Encoding
- Ordinal Encoding (順序データのエンコーディング)
- LabelEncoder
- BinaryEncoder (2値 / 2進数変換)
- HashingEncoder (ハッシュ関数の適用)
- Target / Meanエンコーディング
- 3. その他のKaggleトリック
- 4. 緯度・経度データを扱う
- 5. AutoEncoders
- 6. 特徴量に対して適用可能ないくつかの標準化 / 正規化
- 7. その他の "直感に基づく" 処理
- まとめ
- 注
■はじめに
この文書は、Rahul Agarwal氏によるThe Hitchhiker’s Guide to Feature Extractionを翻訳したものです。表現は基本的に原文に準拠します。
この文書はPythonでfeaturetoolsライブラリを使用することを念頭に書かれていますが、Rからfeaturetoolsを呼び出せるfeaturetoolsRパッケージの使用例が、featuretoolsRパッケージで特徴量エンジニアリングにあります。
以下、翻訳です。
良い特徴量はあらゆる機械学習モデルの礎となります。
そして、良い特徴量を作成するには、ドメイン知識やクリエイティビティ、たくさんの時間が必要です。
この記事では、以下のことについて取り上げます。
- 特徴量生成に関する様々な手法: 自動、手動いずれについても
- カテゴリカルデータの扱いについての様々な手法
- 緯度、経度の扱い
- いくつかのKaggleにおけるトリック
- その他、特徴量生成についてのアイディア
TLDR; この記事は「特徴量エンジニアリング」について、私 (著者) が学び、活用してきた様々な手法やトリックを紹介するものです。
■1. featuretoolsによる特徴量の自動生成
featuretoolsについてご存じですか? ご存じでなければ、それを知ることはあなたの喜びにつながるでしょう。
featuretoolsは、自動で特徴量生成を行うためのフレームワークです。互いに関連する (リレーショナルな) データセットから、機械学習のための特徴量マトリックスを作成します。
どういう意味でしょうか? 実際に例を示し、featuretoolsの威力を体感してみましょう。
ここでは、3つのテーブルを使うことにします。Customers、Sessions、Transactionsです。
これらのデータセットは、タイムスタンプ、カテゴリカルデータ、数値データを含む、検証用にちょうど良いものと言えるでしょう。もし、私達がこのデータから特徴量を作成する場合、Pandasによるマージと集約が必要になるでしょう。featuretoolsライブラリは、それを簡単に実行してくれます。しかし、ライブラリを活用するために、いくつか知っておかなければならないことがあります。
featuretoolsは "エンティティセット" に対して動作します。 エンティティセットについては、互いに関連する複数のデータフレームを格納するバケツのようなものだと思ってください。細かいことは抜きにして、実際にエンティティセットを作ってみましょう。ここでは、名前を "customers" とします。はじめに、空のバケツを作ります。
# ライブラリの読み込み import featuretools as ft # 新しいエンティティセットの作成 es = ft.EntitySet(id = 'customers')
これに、データフレームを追加していきます。追加する順番は特に問題ではありません。既存のエンティティセットにデータフレームを追加するには以下のようにします。
# customersデータフレームからエンティティを作成する es = es.entity_from_dataframe(entity_id = 'customers', dataframe = customers_df, index = 'customer_id', time_index = 'join_date', variable_types = {"zip_code": ft.variable_types.ZIPCode})
entity_from_dataframe メソッドについて解説します。
- entity_id: エンティティの名前です。
- dataframe: 追加するデータフレームを指定します。
- index: テーブルを結合するためのプライマリキーを指定します。
- time_index: 各データセットの時刻を表す列を指定します。customers においては join_date、transactions においては transaction_time が該当します。
- variable_types: 特定の列の型を元の型から変換して扱う場合に指定します。この例では、zip_code 列について (訳注: おそらく数値型になっているところを) 別の型として扱うようにしています。
variable_types に指定可能な型には、以下のものがあります。
[featuretools.variable_types.variable.Datetime, featuretools.variable_types.variable.Numeric, featuretools.variable_types.variable.Timedelta, featuretools.variable_types.variable.Categorical, featuretools.variable_types.variable.Text, featuretools.variable_types.variable.Ordinal, featuretools.variable_types.variable.Boolean, featuretools.variable_types.variable.LatLong, featuretools.variable_types.variable.ZIPCode, featuretools.variable_types.variable.IPAddress, featuretools.variable_types.variable.EmailAddress, featuretools.variable_types.variable.URL, featuretools.variable_types.variable.PhoneNumber, featuretools.variable_types.variable.DateOfBirth, featuretools.variable_types.variable.CountryCode, featuretools.variable_types.variable.SubRegionCode, featuretools.variable_types.variable.FilePath]
1つの、リレーションがない状態のデータフレームをエンティティセットに追加した結果を見てみましょう。
続いて、他のデータフレームも追加しましょう。
# transactions_dfの追加 es = es.entity_from_dataframe(entity_id = 'transactions', dataframe = transactions_df, index = 'transaction_id', time_index = 'transaction_time', variable_types = {"product_id": ft.variable_types.Categorical})
# sessions_dfの追加 es = es.entity_from_dataframe(entity_id = 'sessions', dataframe = sessions_df, index = 'session_id', time_index = 'session_start')
結果を確認してみましょう。
すべてのデータフレームが登録されましたが、まだリレーションは構築されていません。これは、エンティティセットがまだcustomer_dfとsession_dfの customer_id が同一であることを知らない、という意味です。この情報をエンティティセットに追加しましょう。
# customer_idのリレーションを追加する cust_relationship = ft.Relationship(es['customers']['customer_id'], es['sessions']['customer_id']) # エンティティセットにリレーションを登録する es = es.add_relationship(cust_relationship) # session_idのリレーションを追加する sess_relationship = ft.Relationship(es['sessions']['session_id'], es['transactions']['session_id']) # エンティティセットにリレーションを登録する es = es.add_relationship(sess_relationship)
ふたたび、結果を確認してみましょう。
データセットにリレーションを設定できました。作業らしい作業はここまでで、特徴量加工の準備ができました。
特徴量の生成は、シンプルに以下のようにするだけです。
feature_matrix, feature_defs = ft.dfs(entityset = es, target_entity = 'customers', max_depth = 2)
feature_matrix.head()
結果として、73個の新しい特徴量が生成されました。特徴量の名前は、feature_defs で確認できます。生成した特徴の一部は以下のようになります。
[<Feature: NUM_UNIQUE(sessions.device)>, <Feature: MODE(sessions.device)>, <Feature: SUM(transactions.amount)>, <Feature: STD(transactions.amount)>, <Feature: MAX(transactions.amount)>, <Feature: SKEW(transactions.amount)>, <Feature: DAY(join_date)>, <Feature: YEAR(join_date)>, <Feature: MONTH(join_date)>, <Feature: WEEKDAY(join_date)>, <Feature: SUM(sessions.STD(transactions.amount))>, <Feature: SUM(sessions.MAX(transactions.amount))>, <Feature: SUM(sessions.SKEW(transactions.amount))>, <Feature: SUM(sessions.MIN(transactions.amount))>, <Feature: SUM(sessions.MEAN(transactions.amount))>, <Feature: SUM(sessions.NUM_UNIQUE(transactions.product_id))>, <Feature: STD(sessions.SUM(transactions.amount))>, <Feature: STD(sessions.MAX(transactions.amount))>, <Feature: STD(sessions.SKEW(transactions.amount))>, <Feature: STD(sessions.MIN(transactions.amount))>, <Feature: STD(sessions.MEAN(transactions.amount))>, <Feature: STD(sessions.COUNT(transactions))>, <Feature: STD(sessions.NUM_UNIQUE(transactions.product_id))>]
上記のうち、SUM(データフレーム1.STD(データフレーム2.amount)) や STD(データフレーム1.SUM(データフレーム2.amount)) とあるものは、max_depth オプションで指定した値によって変化します。ここでは、max_depth = 2 としたので、2つの特徴量の組み合わせが得られました。もし、max_depth = 3 とすると以下のような特徴量がさらに得られます。
MAX(データフレーム1.NUM_UNIQUE(データフレーム2.YEAR(特徴量))))
これらの特徴量を個別に生成するコードを手作業で書く時間を想像してみてください (この方が効率的ですよね?)。なお、max_depth を大きくすると、処理時間が長くなります。
■2. カテゴリカルデータの扱い
自動的な特徴量生成は効果的に働きます。しかし、シンプルなライブラリがすべてやってくれる現在において、私達データサイエンティストはなぜ必要とされるのでしょうか? この節では、その答えとなり得る、カテゴリカルデータの取り扱い方について紹介します。
■One Hot Encoding
カテゴリカルデータを、One Hot Encodingを使って数値に変換できます。カテゴリにn個の水準 (レベル) がある場合、n-1個の特徴量に変換できます。
上述のsession_dfテーブルには device 列があり、3つの水準が存在します (desktop, mobile, tablet)。これをOne Hot Encodingで変換するには以下のようにします。
pd.get_dummies(session_df['device'], drop_first = True)
One Hot Encodingはもっともシンプルな処理で、かつ多くの場合に正しく機能します。
■Ordinal Encoding (順序データのエンコーディング)
しばしば、カテゴリカルデータには順序関係が存在するものがあります。そのような場合、私はシンプルなmap / apply関数を使い、Pandasで新しい順序情報を保持した列を追加します。
例えば、気温について "high", "medium", "low" という3段階で記録されているデータフレームがあるとします。この特徴量は以下のようにエンコーディングできます。
map_dict = {'low': 0, 'medium': 1, 'high': 2} def map_values(x): return map_dict[x] df['Temperature_oe'] = df['Temperature'].apply(lambda x: map_values(x))
"low" < "medium" < "high" という順序関係を保持したままエンコーディングできました。
■LabelEncoder
LabelEncoderは、ラベルデータ (特に順序のない文字列) を数値に変換します。LabelEncoderは最初に出現するラベルを0、次を1というように変換します。この方法は、(決定木などの) ツリーモデルにおいては効果的に作用します。ラベルが多数あるカテゴリカルデータについて、この方法を使うと便利です。以下のように使用します。
from sklearn.preprocessing import LabelEncoder # LabelEncoderオブジェクトの生成 le = LabelEncoder() # LabelEncoderの適用 sessions_df['device_le'] = le.fit_transform(sessions_df['device']) sessions_df.head()
■BinaryEncoder (2値 / 2進数変換)
BinaryEncoderは、(ラベルを2進数表現するため) 水準がとても多いカテゴリカルデータをエンコーディングするのに適しています。例えば、1024個の水準があるカテゴリデータをOne Hot Encodingしようとすると、1023列が必要になりますが、BinaryEncoderではたった10列で済みます。
ここでは、FIFA19 (というゲーム) のデータを使用します。データには、クラブ (チーム) 名が含まれます。この特徴量は、652個の水準を有しています。One Hot Encodingでは651列を作成することになり、そのほとんどが0となるスパース (疎) な行列になっていしまいます。
BinaryEncoderでは、2^9 (512) < 652 < 2^10 (1024) の範囲の列数だけで事足ります。
BinaryEncoderは、category_encodersライブラリから簡単に利用できます。
from category_encoders.binary import BinaryEncoder # BinaryEncoderオブジェクトの生成 be = BinaryEncoder(cols = ['Club']) # BinaryEncoderの適用 players = be.fit_transform(players)
■HashingEncoder (ハッシュ関数の適用)
HashingEncoder (Feature hashing) は、文字列を0から任意の値までの範囲の数値に変換するブラックボックス関数と思っていただければじゅうぶんです。
BinaryEncoderと異なるのは、BinaryEncoderでは複数の列の値が1になり得るのに対して、HashingEncoderでは1になるのはひとつの列だけ、という点です。
players = pd.read_csv('../input/fifa19/data.csv') from category_encoders.hashing import HashingEncoder # HashingEncoderオブジェクトの生成 he = HashingEncoder(cols = ['Club']) # HashingEncoderの適用 players = he.fit_transform(players)
結果を見ると、変換後の値のコリジョン (衝突) が行っていることがわかります。例えば、JubentusとPSGが同じ結果になっています。しかし、多くの場合この手法は効果的に作用します。
■Target / Meanエンコーディング
このテクニックは、私がKaggleコンペティションで効果的であると感じたものです。もし、訓練 (学習) 用データと評価 (検証) 用データが同じデータセットの同じ期間から抽出されている場合 (クロスセクションデータ)、この少しずる賢い方法を利用できます。
例えば、Titanicコンペでは、評価用データは訓練用データからランダムに抽出されています。この場合、目的変数をカテゴリ変数の水準ごとにグループ化して平均したものを特徴量として加えることができます。
Targetエンコーディングを行う際には注意が必要です。この方法は過学習を誘発しやすいです。ここでは、KFoldによるデータセット分割を行いながら、Targetエンコーディングを実行します。
# コードの出典: https://medium.com/@pouryaayria/k-fold-target-encoding-dfe9a594874b from sklearn import base from sklearn.model_selection import KFold class KFoldTargetEncoderTrain(base.BaseEstimator, base.TransformerMixin): def __init__(self,colnames,targetName, n_fold=5, verbosity=True, discardOriginal_col=False): self.colnames = colnames self.targetName = targetName self.n_fold = n_fold self.verbosity = verbosity self.discardOriginal_col = discardOriginal_col def fit(self, X, y=None): return self def transform(self,X): assert(type(self.targetName) == str) assert(type(self.colnames) == str) assert(self.colnames in X.columns) assert(self.targetName in X.columns) mean_of_target = X[self.targetName].mean() kf = KFold(n_splits = self.n_fold, shuffle = True, random_state=2019) col_mean_name = self.colnames + '_' + 'Kfold_Target_Enc' X[col_mean_name] = np.nan for tr_ind, val_ind in kf.split(X): X_tr, X_val = X.iloc[tr_ind], X.iloc[val_ind] X.loc[X.index[val_ind], col_mean_name] = X_val[self.colnames].map(X_tr.groupby(self.colnames) [self.targetName].mean()) X[col_mean_name].fillna(mean_of_target, inplace = True) if self.verbosity: encoded_feature = X[col_mean_name].values print('Correlation between the new feature, {} and, {} is {}.'.format(col_mean_name,self.targetName, np.corrcoef(X[self.targetName].values, encoded_feature)[0][1])) if self.discardOriginal_col: X = X.drop(self.targetName, axis=1) return X
特徴量は以下のようにして生成できます。
targetc = KFoldTargetEncoderTrain('Pclass', 'Survived', n_fold = 5) new_train = targetc.fit_transform(train) new_train[['Pclass_KFold_Target_Enc', 'Pclass']]
3等船室では、0.261538や0.230570といった、Foldごとに平均された値が得られていることがわかります。
この特徴量は、グループごとに目的変数を平均したもので、(コンペには) 有用です。特徴量を眺めてみると、1等船室の乗客は、3等船室の乗客よりも生存傾向が高いことがわかります。
■3. その他のKaggleトリック
特徴量生成の際には役立ちませんが、生成後の "後処理" として有用なテクニックを紹介します。
■log lossクリッピング
この方法は、Jeremy Howardのニューラルネットワークに関する講義 (このへんでしょうか?) で学んだことを基にしており、以下のシンプルなアイディアによって成り立ちます。
Kaggleにおける分類タスクでは、(検証用データのラベルに対する) 確率を予測します。この際、予測確率を5% (0.05) から95% (0.95) の間に制限し、あまりはっきりと (1か0と) 言い切らない方がよいことがあります。[1]それにより、損失 (評価指標の悪化) を抑えることができます。コードとしては、シンプルに np.clip で実現できます。Log lossは明らかな正答または誤答に強くペナルティをかける
■Kaggleに登録 (Submission) する際にgzip圧縮する
データサイズが小さいほうが、アップロードにかかる時間を節約できます。
df.to_csv('submission.csv.gz', index = False, compression = 'gzip')
■4. 緯度・経度データを扱う
この節では、緯度・経度を特徴量としてどのように扱えばよいかを紹介します。
ここでは、KaggleのPlayground Competition (練習用) であるNew York City Taxi Trip Durationのデータを使います。訓練用データは以下のようになっています。
以下に示す関数の大部分は、KaggleユーザーBelugaによるカーネルにインスパイアされたものです。
このコンペでは、タクシーの移動 (乗車) 時間を予測します。私達は、乗車位置と降車位置の緯度・経度を与えられ、特徴量として扱います。その加工の仕方は以下のようになります。
■A. 2つの緯度・経度の間のHaversine距離を算出する
Haversine距離は、2つの緯度・経度から形成される円の大円距離で表されます。
def haversine_array(lat1, lng1, lat2, lng2): lat1, lng1, lat2, lng2 = map(np.radians, (lat1, lng1, lat2, lng2)) AVG_EARTH_RADIUS = 6371 # in km lat = lat2 - lat1 lng = lng2 - lng1 d = np.sin(lat * 0.5) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin(lng * 0.5) ** 2 h = 2 * AVG_EARTH_RADIUS * np.arcsin(np.sqrt(d)) return h
この関数を、以下のように適用できます。
train['haversine_distance'] = train.apply(lambda x: haversine_array(x['pickup_latitude'], x['pickup_longitude'], x['dropoff_latitude'], x['dropoff_longitude'] ) , axis = 1)
■B. 2つの緯度・経度間のマンハッタン距離を算出する
マンハッタン距離は、2点間の距離を直交する軸に沿って測定したものです。
def dummy_manhattan_distance(lat1, lng1, lat2, lng2): a = haversine_array(lat1, lng1, lat1, lng2) b = haversine_array(lat1, lng1, lat2, lng1) return a + b
この関数を、以下のように使うことができます。
train['manhattan_distance'] = train.apply(lambda x: dummy_manhattan_distance( x['pickup_latitude'], x['pickup_longitude'], x['dropoff_latitude'], x['dropoff_longitude'] ) ,axis = 1)
■C. 2つの緯度・経度間の方位角
方位角は、ある1点から別の点への、相対的な方向 (角度) です。
def bearing_array(lat1, lng1, lat2, lng2): AVG_EARTH_RADIUS = 6371 # in km lng_delta_rad = np.radians(lng2 - lng1) lat1, lng1, lat2, lng2 = map(np.radians, (lat1, lng1, lat2, lng2)) y = np.sin(lng_delta_rad) * np.cos(lat2) x = np.cos(lat1) * np.sin(lat2) - np.sin(lat1) * np.cos(lat2) * np.cos(lng_delta_rad) return np.degrees(np.arctan2(y, x))
この関数を、以下のように使うことができます。
train['bearing'] = train.apply(lambda x: bearing_array( x['pickup_latitude'], x['pickup_longitude'], x['dropoff_latitude'], x['dropoff_longitude']) ,axis=1)
■D. 乗車位置と降車位置の中間点
train.loc[:, 'center_latitude'] = (train['pickup_latitude'].values + train['dropoff_latitude'].values) / 2 train.loc[:, 'center_longitude'] = (train['pickup_longitude'].values + train['dropoff_longitude'].values) / 2
上記の加工を行った結果をまとめると以下のようになります。
■5. AutoEncoders
(ここは原文にもコードの記述はありません)
しばしば、特徴量生成にAutoEncodersを使う人達もいます。
■AutoEncodersとはなにか?
AutoEncodersは、ディープラーニングの関数の一種で、データXを出力Xにマッピングします。つまり、入力と出力が同じになるネットワークです。AutoEncodersははじめに、入力データをより低次元の「表象 / 表現」に圧縮します。そして、その情報から出力を再構築します。
私達は、この圧縮された「表象 / 表現」を取得し、特徴量として使うことができます。
■6. 特徴量に対して適用可能ないくつかの標準化 / 正規化
- Min-Maxスケーリング: 線形モデルやニューラルネットワークにおいて効果を発揮します。
- 標準化: 線形モデルやニューラルネットワークにおいて効果を発揮します。
- 目的変数・特徴量の対数変換: 目的変数や特徴量を対数変換します。線形モデルを用いる際に、データが正規分布していると仮定する場合、対数変換すると正規分布したデータが得られます。収入 (年収) のように歪んだ分布のデータを扱う際に効果的です。
(タクシーデータの) 乗車時間について例を示します。対数変換を行う前のデータは以下のように分布しています。
対数変換してみましょう。
train['log_trip_duration'] = train['trip_duration'].apply(lambda x: np.log(1+x))
対数変換することで、乗車時間のゆがみが低減され、よりモデルの仮定に沿うようになりました。
■7. その他の "直感に基づく" 処理
■日時データ
日時データを、ドメイン (対象領域) 知識と直感に基づき変換できます。例えば、自国で記録されている特徴量を、「夕方」「午後」「夜間」「前回の購買月 (からの経過?)」「前回の購入週 (からの経過?)」など、特定の用途に特化した形式に加工します。
■ドメイン特化型の特徴量
あなたが良くできたショッピングカートのデータを保有し、顧客を購買パターン (TripType) ごとにグループ分けしたいとします。ウォルマートがそのようなコンペを開催しています。
購買パターンのいくつかの例を示します。
- 日々の夕食購入
- 週ごとの日用品の買い込み
- 次の休祝日のためのプレゼント購入
- 季節ごとの衣服の購入
このタスクに取り組むために、「スタイリッシュであるかどうか」といった特徴量を顧客に付与することが考えられます。例えば、ショッピングカートにファッションアイテムがいくつ入っているかで判断します。また、「レアパターン」という特徴量を作成することも考えられます。データ全体でのアイテムの出現頻度をカウントし、頻度の低いアイテムをカートに入れたユーザーに付与します。
これらの (経験と直感に基づく) 特徴量は、効果を発揮することもあれば、発揮しないこともあります。観測値をそのまま使う場合よりも、(精度向上につながる) 多くの価値を提供してくれることもあります。
おそらく、このような特徴量生成を行うことで、「妊娠した10代女性を検出するモデル」を作成したのでしょう。妊娠した10代女性がカートに入れ、購入しがちなアイテムという特徴量を、分類アルゴリズムに投入したのだと思われます。
■交互作用特徴量
もし2つの特徴量AとBがある場合、A*B、A+B、A/B、A-Bなどの計算を行い、結果を特徴量として使用します。
例えば、家の値段 (購入価格) を予測する場合、その物件の縦横の寸法を掛け合わせて、「面積」を作成することは効果的でしょう。
別のケースとして、2つの特徴量の比率が重要な場合があります。例えば、クレジットカードの利用率 (残高に占める未払い金額) がわかったほうが、限度額と利用金額を個別に扱うよりも効果的です。
■まとめ
この記事では、私が特徴量生成に用いるテクニックの一部を紹介しました。
特徴量エンジニアリングに限界・制限はなく、あなたの想像力だけが制約条件となります。
なお、私が特徴量エンジニアリングを行うときには常に、自分がどのようなモデルを使おうとしているのかを意識しています。ある特徴量はランダムフォレストにおいては効果的に働く一方、ロジスティック回帰ではうまく働きません。
特徴量生成はトライアンドエラーの世界です。実際にやってみるまで、どのような手法が効果的であるかを知ることはできません。そして、常に時間と効能のトレードオフでもあります。
しばしば、特徴量生成には多くの処理時間を要します。その場合、Pandasの関数を並列化する (未訳) と効率的です。
私は、この記事をできるだけ網羅的にしようとしましたが、いくつかの便利な手法について書き洩らしたものがあるかもしれません。ぜひ (元記事の) コメントで教えてください。
この記事で紹介したすべてのコードは、Kaggleカーネルとして公開しており、自由に実行できます。
さらに理解を深めるには、KazanovaによるAdvanced machine learning specializationのHow to Win a Data Science Competition: Learn from Top Kagglersコースをぜひ受講してください。このコースでは、モデルの精度を向上させるための方法を直感的にわかりやすく解説しています。強くおすすめします。
私 (著者) は初心者にとってわかりやすい記事を今後も投稿し続けたいと思っています。ぜひ、感想やリクエストを教えてください。また、私のMediumのアカウントやブログをフォローしていただければ幸いです。また、Twitterアカウント@mlwhizでも、ご意見、建設的な批判などをお待ちしています。
翻訳は以上です。
■注
- [1]訳注: How to improve “Log-Loss” score. Kaggle trick. https://medium.com/@egor_vorobiev/how-to-improve-log-loss-score-kaggle-trick-3f95577839f1 などを読んだうえでの理解では、わずかな精度の差を競うコンペでは、1と予測して0だった時(あるいはその逆)に評価指標(Log loss)が一気に悪化するので、そういうリスクの大きい回答をそもそもしないようにする、という戦略なのではないかと思います。
関連ページ: featuretoolsRパッケージで特徴量エンジニアリング
カテゴリ: [Python,データサイエンス,データ分析]