最近はデータ分析のコンペに参加する機会があるのですが、コンペで提供されるデータセットによって、適切な特徴量抽出しなければ結果に効かない事が多かったです。自分の考えをまとめるため、特徴量抽出で自分と同様に困っている人の役に立てれば思い、記事を投稿します。今回は数値データの特徴量についていくつか書きます。
利用データですが、馴染みのあるデータの方がデータの傾向をイメージしやすいので、ポケモンのデータセットを利用しました。データは、第6世代(X・Y)までのポケモンのタイプ・努力値・世代・伝説ポケモンかのフラグで構成されています。
ライブラリー読み込み
import pandas as pd
import numpy as np
%matplotlib inline
import matplotlib.pyplot as plt
import seaborn as sns
# 特徴量エンジニアリング用
from sklearn.preprocessing import StandardScaler, MinMaxScaler
from sklearn.preprocessing import PowerTransformer
figsize = (10, 7)
データ読み込み
df = pd.read_csv('./data/121_280_bundle_archive.zip')
データ
# | Name | Type 1 | Type 2 | Total | HP | Attack | Defense | Sp. Atk | Sp. Def | Speed | Generation | Legendary |
---|---|---|---|---|---|---|---|---|---|---|---|---|
1 | Bulbasaur | Grass | Poison | 318 | 45 | 49 | 49 | 65 | 65 | 45 | 1 | False |
2 | Ivysaur | Grass | Poison | 405 | 60 | 62 | 63 | 80 | 80 | 60 | 1 | False |
3 | Venusaur | Grass | Poison | 525 | 80 | 82 | 83 | 100 | 100 | 80 | 1 | False |
3 | VenusaurMega Venusaur | Grass | Poison | 625 | 80 | 100 | 123 | 122 | 120 | 80 | 1 | False |
4 | Charmander | Fire | NaN | 309 | 39 | 52 | 43 | 60 | 50 | 65 | 1 | False |
スケーリング
スケーリングは、特徴量の分布を変えずにある一定の範囲に押し込むイメージです(図参照)。スケーリングする方法として、標準化とMin-Maxスケーリングの2つの手法が代表的です。
標準化
標準化は特徴量の分布を平均0、分散1の標準正規分布に押し込みます。標準化の式は下記です。具体的には、特徴量からの平均を求め、それを標準偏差(平均からどれくらい離れているかの程度)で割ることで求めています。コードとスケーリング後の結果を見たほうがイメージしやすいと思います。
# 標準化
scaler = StandardScaler()
scaled_hp = scaler.fit_transform(df[['HP']])
scaled_hp = pd.DataFrame(scaled_hp, columns=['HP'])
# 標準化前後を比較
fig, ax = plt.subplots(1, 2, figsize=figsize)
sns.distplot(df['HP'], ax=ax[0])
ax[0].set_title('Untransformed')
sns.distplot(scaled_hp, ax=ax[1])
ax[1].set_title('Transformed')
plt.savefig('標準化.png')
Min-Maxスケーリング
Min-Maxスケーリングは特徴量を0〜1に押し込むのに使います。標準化の式は下記です。特徴量の最大値や最小値を利用するため、外れ値の影響を受けやすい危険性もあります。こちらもコードとスケーリング後のものを載せておきます。分布がそのままで、値が0〜1の間に押し込まれているのが確認できます。
# Min-Max スケーリング
scaler = MinMaxScaler()
scaled_hp = pd.DataFrame(scaler.fit_transform(df[['HP']]))
# スケーリング前後を比較
fig, ax = plt.subplots(1, 2, figsize=figsize)
sns.distplot(df['HP'], ax=ax[0])
ax[0].set_title('Untransformed')
sns.distplot(scaled_hp, ax=ax[1])
ax[1].set_title('Transformed')
plt.savefig('./Min-Maxスケーリング.png')
標準化とスケーリングどっちを選んだ方が良い?
特徴量エンジニアリングにおいて、標準化とスケーリングどちらを選ぶか迷った時はどっちを選んだほうが良いでしょうか。基本的に画像データにはスケーリングを利用し、それ以外の数値は標準化を利用するのが鉄板です。画像データは0〜255と上限下限が決まっているのに対して、それ以外のデータは取りうる値の範囲に開きが大きくなる可能性があります。そのため、外れ値が多く含まれている状態でスケーリングを行うと、スケーリングした分布が外れ値の影響を受ける可能性があります。
非線形変換
標準化とMin-Maxスケーリングが特徴量の分布を維持する線形変換だったと比較して、非線形変換は特徴量の分布を変えてしまいます。説明を聞くよりは、変換後の分布を見たほうが早いと思います。非線形変換を2つほど紹介します。データの分布が正規分布に従わない場合、正規分布に直すのに使います。
べき変換
非線形変換の代表的な方法として、べき変換があります。対数変換はデータに0が含まれているとうまく行かないので、通常はlog(x+1)で変換を行います。ポケモンデータをべき変換したものを載せます。
# べき変換
log_hp = df['HP']
log_hp = pd.DataFrame(np.log1p(log_hp))
# 標準化前後を比較
fig, ax = plt.subplots(1, 2, figsize=figsize)
sns.distplot(df['HP'], ax=ax[0])
ax[0].set_title('Untransformed')
sns.distplot(log_hp, ax=ax[1])
ax[1].set_title('Transformed')
plt.savefig('べき変換.png')
Box-Cox変換
Box-Cox変換はデータが正の時のみ利用でき、正規分布に従わないデータを無理やり正規分布に直します。あまり差がないのですが、Box-Cox変換のコードと変換前後の分布の比較を載せておきます。
# Box-Cox変換
# データが負の数を取るかチェック
check = df['Speed'].min()
if check > 0:
pt = PowerTransformer(method='box-cox')
boxcox = pd.DataFrame(pt.fit_transform(df[['Speed']]), columns=['Speed'])
# Box-Cox変換を比較
fig, ax = plt.subplots(1, 2, figsize=figsize)
sns.distplot(df['Speed'], ax=ax[0])
ax[0].set_title('Untransformed')
sns.distplot(boxcox, ax=ax[1])
ax[1].set_title('Transformed')
# 歪度と尖度の比較
print(f'変換前 歪度: {df["Speed"].skew(): .2f}, 尖度: {df["Speed"].kurtosis(): .2f}')
print(f'変換後 歪度: {boxcox["Speed"].skew(): .2f}, 尖度: {boxcox["Speed"].kurtosis(): .2f}')
# 出力結果
# 変換前 歪度: 0.36, 尖度: -0.24
# 変換後 歪度: -0.05, 尖度: -0.40
plt.savefig('Box-Cox変換.png')
Clipping
Clipping(日本語訳: 切り取り)は文字通り、データをある閾値で切り取る手法です。閾値より大きいor小さいデータ(外れ値)を排除する事ができます。ポケモンの例で見ます。Speedが10以下150以上のデータを切り取ります。
# Clipping
# Clipping
clipping_hp = df['Speed'].clip(10, 130)
# クリップの前後を比較
fig, ax = plt.subplots(1, 2, figsize=figsize)
sns.distplot(df['Speed'], ax=ax[0])
ax[0].set_title('Unclipped')
sns.distplot(clipping_hp, ax=ax[1])
ax[1].set_title('Clipped')
plt.savefig('Clipping.png')
Binning
Binningはある間隔に入るデータを、その間隔を表す値に置き換える手法です。例として、スピードのデータを〜50、50〜100、100〜150、150〜の区間でわけ、区間ごとに別の変数(ここでは0, 1, 2, 3の4つ)に置き換えたときのコードを見てみましょう。特定な区間のデータを別の変数(この場合はカテゴリーデータ)に置き換えられていますね。
# Binning
binned = pd.cut(df['Speed'], 4, labels=False)
print('---------Binning前----------')
print(df['Speed'].head())
print('---------Binning後----------')
print(binned.head())
# 実行結果
# ---------Binning前----------
# 0 45
# 1 60
# 2 80
# 3 80
# 4 65
# Name: Speed, dtype: int64
# ---------Binning後----------
# 0 0
# 1 1
# 2 1
# 3 1
# 4 1
# Name: Speed, dtype: int64
さいごに
画像データかどうか or データ正規分布に従っているかによって適応すべき変換方法が異なります。様々なデータを試しながら、データがどのように変換されるか試して見ると面白いかもしれません。ポケモンデータだと正規分布からズレているデータはあまりなかったのですが、住宅価格予測のためのデータセットで試しても良いかもしれないですね!