欠損値の種類
実は、欠損値には3種類あります
たくさんの文献を漁ったところ、こちらがわかりやすかったので引用させていただきます。
これら3つの種類に合わせて欠損値を補完していくことが、精度向上のために必要です。
Missing Completely At Random (MCAR)
MCAR は完全にランダムで生じる欠損値である。ほとんどの欠損値補完アルゴリズムが有効である。
Missing At Random (MAR)
MAR は、観測データに依存してランダムに生じる欠損値である。例えば、水稲標本を調べたデータで、ある品種(例えば、コシヒカリ)だけに限って特定の特徴量(例えば、乾燥重量)が欠損になっている場合である。MAR の場合は、ほかの特徴量と関係を利用して、ある程度バイアスを抑えて補正することができる。
Missing Not At Random (MNAR)
MNAR は、欠損データに依存して生じる欠損値である。例えば、水稲標本を調べたデータで、ななつ星という品種が、ほとんど栽培実験が行われていないために、そもそもデータとして収集されていないような欠損値のことである。MNAR の場合、そもそもデータが少ない(存在しない)ので、補正が極めて困難である。
前提
本記事では、欠損値「補完」を前提としているので、欠損値をまるごと削除する方法はご紹介しません。
df.dropna(inplace=True)
また、本記事は kaggleで勝つデータ分析の技術を参考にしています。
欠損値補完まとめ
- 欠損値のまま取り扱う
- 欠損値を代表値で埋める
- 欠損値を他の変数から予測する
- 欠損値から新たな特徴量を作成する
有効性
施策 | MCAR | MAR | MNAR |
---|---|---|---|
1. 欠損値のまま取り扱う | O | O | △ |
2. 欠損値を代表値で埋める | O | △1 | X |
3. 欠損値を他の変数から予測する | O | O | X |
4. 欠損値から新たな特徴量を作成する | O | △2 | X |
これらを一つずつ噛み砕いて説明していきます。
なお、本記事で掲載するコードは github 上にpush済みです
必要ライブラリ
import pandas as pd
import numpy as np
import xgboost as xgb
from IPython.core.display import display
from typing import List, Union
データ準備
記事pandasで欠損値NaNを除外(削除)・置換(穴埋め)・抽出から拝借したこちらのデータを少しだけ編集して使わせていただいています。
df = pd.read_csv('sample_nan.csv')
display(df)
name | age | state | point | |
---|---|---|---|---|
0 | Alice | 24.0 | NY | NaN |
1 | Charlie | NaN | CA | NaN |
2 | Dave | 68.0 | TX | 70.0 |
3 | Ellen | NaN | CA | 88.0 |
4 | Frank | 30.0 | NaN | NaN |
1. 欠損値のまま取り扱う
GBDTモデルは欠損値を埋めずにそのまま入力することができる
「欠損値は何かしらの理由があって欠損している」という情報を持っているため、安易に埋めるのはかえって精度を下げかねない
2. 欠損値を代表値で埋める
欠損値を「平均値」「中央値」「最頻値」などの代表値で埋める
次の二つの方法がある。
- 全データの統計量で埋める
- カテゴリごとの統計量で埋める
2-1. 全データの統計量で埋める
全てのレコードから算出した代表値で埋める
平均値
df_mean = df.copy()
df_mean.fillna(df_mean.mean(), inplace=True) # 平均値
display(df_mean)
name | age | state | point | |
---|---|---|---|---|
0 | Alice | 24.000000 | NY | 79.0 |
1 | Charlie | 40.666667 | CA | 79.0 |
2 | Dave | 68.000000 | TX | 70.0 |
3 | Ellen | 40.666667 | CA | 88.0 |
4 | Frank | 30.000000 | NaN | 79.0 |
中央値
df_median = df.copy()
df_median.fillna(df_median.median(), inplace=True) # 中央値
display(df_median)
name | age | state | point | |
---|---|---|---|---|
0 | Alice | 24.0 | NY | 79.0 |
1 | Charlie | 30.0 | CA | 79.0 |
2 | Dave | 68.0 | TX | 70.0 |
3 | Ellen | 30.0 | CA | 88.0 |
4 | Frank | 30.0 | NaN | 79.0 |
最頻値
df_mode = df.copy()
df_mode.fillna(df_mode.mode().iloc[0], inplace=True) # 最頻値
display(df_mode)
name | age | state | point | |
---|---|---|---|---|
0 | Alice | 24.0 | NY | 70.0 |
1 | Charlie | 24.0 | CA | 70.0 |
2 | Dave | 68.0 | TX | 70.0 |
3 | Ellen | 24.0 | CA | 88.0 |
4 | Frank | 30.0 | CA | 70.0 |
2-2. カテゴリごとの統計量で埋める
あるカテゴリ変数ごとの統計量で欠損値を補完する
今回は、state
ごとの代表値でage
とpoint
の欠損値を補完する
class BayesianAverage:
"""
Bayesian Averageを計算する
"""
def __init__(self, m: Union[float, int], C: int):
"""
イニシャライザ
@param m: あらかじめの値
@param C: 値mの観測回数
"""
self.m = m
self.C = C
def predict(self, xs: List[Union[float, int]]):
"""
Bayesian Averageを計算する関数
あらかじめ値mのデータをC回観測した経験の上で、実測値の平均を計算する
@param xs: 実測値のリスト
@return: Bayesian Average
"""
return (np.sum(xs) + (self.C * self.m)) / (len(xs) + self.C)
df_state = df.copy()
ba_age, ba_point = BayesianAverage(40, 3), BayesianAverage(80, 3) # <- ハイパーパラメータ
# ba_age: 40歳の人を3人追加する設定 (本来であれば、クラスごとに傾向が異なるので、クラスごとに異なるオブジェクトを生成する必要がある)
# ba_point: 80点の人を3人追加する設定 (本来であれば、クラスごとに傾向が異なるので、クラスごとに異なるオブジェクトを生成する必要がある)
df_state = df_state.groupby('state').agg({'age': ba_age.predict, 'point': ba_point.predict})
display(df_state) # 一つ一つの値はBayesian Average
# age point
# state
# CA 40.0 82.0
# NY 36.0 80.0
# TX 47.0 77.5
# これらを埋める
df_bayes_ave = df.copy()
for column in ['age', 'point']:
for state, data in df_bayes_ave.groupby('state'):
bayes_ave = df_state.loc[state, column]
df_bayes_ave.loc[df_bayes_ave['state'] == state, column] = data[column].fillna(value=bayes_ave)
display(df_bayes_ave)
state | age | point |
---|---|---|
CA | 40.0 | 82.0 |
NY | 36.0 | 80.0 |
TX | 47.0 | 77.5 |
name | age | state | point | |
---|---|---|---|---|
0 | Alice | 24.0 | NY | 80.0 |
1 | Charlie | 40.0 | CA | 82.0 |
2 | Dave | 68.0 | TX | 70.0 |
3 | Ellen | 40.0 | CA | 88.0 |
4 | Frank | 30.0 | NaN | NaN |
前者のテーブル: 一つ一つの値がBayesian Averageとなっている。
例えば、(1,1)
成分はstate
がCA
の人のage
の平均値
$\dfrac{0 + 40 \times 3}{0 + 3}=40$
(3,2)
成分はstate
がTX
の人のpoint
の平均値
$\dfrac{70 + 80 \times 3}{1 + 3}=77.5$
後者のテーブル: Bayesian Averageで欠損値を埋めたテーブル
3. 欠損値を他の変数から予測する
今作った df_bayes_ave
のage
とpoint
を説明変数として、state
の最後の行にある欠損値NaNを予測する
Step1:
state
が欠損していないレコードの集まりと、state
が欠損しているレコードの集まりで分ける
そして、前者を「訓練データ」、後者を「テストデータ」とする
Step2:
訓練データを予測モデルに通して、state
を予測するように学習する
Step3:
テストデータを予測モデルに通して、NaNであるstate
を予測する
# ====Step1: データ準備==============
# [前提] df_bayes_aveを用いて、stateの最後の行NaNを予測する
# state -> indexへの変換辞書
state_s2i = {state: i for i, state in enumerate(df_bayes_ave['state'].dropna().unique())}
print(f'stateの種類: {state_s2i}') # stateの種類: {'NY': 0, 'CA': 1, 'TX': 2}
X_train = df_bayes_ave[['age', 'point']].dropna() # 訓練データ(説明変数)には欠損を含めない
y_train = df_bayes_ave['state'].dropna().map(state_s2i) # 訓練データ(目的変数)には欠損を含めない
X_test = df_bayes_ave[df_bayes_ave.isnull().any(axis=1)][['age', 'point']] # テストデータは欠損が含まれているレコードのみ抽出
# ================================
# ======Step2: 学習=================
# xgboostのオブジェクトに変換する
dtrain = xgb.DMatrix(X_train, label=y_train)
# ハイパーパラメータ設定
param = {'max_depth': 2, 'eta': 0.1, 'objective': 'multi:softmax', 'num_class': len(state_s2i)}
# 学習
bst = xgb.train(param, dtrain, 20)
# ===============================
# ======Step3: 予測=================
# 予測
dtest = xgb.DMatrix(X_test)
pred_test = bst.predict(dtest)
print(pred_test)
# [1.]
# ================================
# index -> stateへの変換辞書
state_i2s = {i: state for state, i in state_s2i.items()}
# 予測値を欠損値に代入する
X_test['state'] = [state_i2s[int(pred)] for pred in pred_test]
# 欠損がない訓練データと欠損を埋めたテストデータを結合
train_test = pd.concat([X_train.join(y_train.map(state_i2s)), X_test])
df_bayes_ave2 = pd.concat([df_bayes_ave['name'], train_test], axis=1)[['name', 'age', 'state', 'point']]
display(df_bayes_ave2)
name | age | state | point | |
---|---|---|---|---|
0 | Alice | 24.0 | NY | 80.0 |
1 | Charlie | 40.0 | CA | 82.0 |
2 | Dave | 68.0 | TX | 70.0 |
3 | Ellen | 40.0 | CA | 88.0 |
4 | Frank | 30.0 | CA | NaN |
state
の最後の行にある欠損値は「CA」と予測できた
4. 欠損値から新たな特徴量を作成する
欠損値から新たな特徴量を作成するアイデアはいくらでも出てきそうですが、ここでは二つ紹介します。
4-1. 欠損しているかどうかを表す二値変数を新たに作る
[メリット] 後に欠損値を埋めたとしても、「欠損していた」という情報が失われない
df_isnull = df.copy()
df_isnull['point_isnull'] = df_isnull.isnull()['point'].map({True: 1, False: 0})
df_isnull['point'].fillna(np.mean(df_isnull['point']), inplace=True) # 平均値で埋める
display(df_isnull)
name | age | state | point | point_isnull | |
---|---|---|---|---|---|
0 | Alice | 24.0 | NY | 79.0 | 1 |
1 | Charlie | NaN | CA | 79.0 | 1 |
2 | Dave | 68.0 | TX | 70.0 | 0 |
3 | Ellen | NaN | CA | 88.0 | 0 |
4 | Frank | 30.0 | NaN | 79.0 | 1 |
4-2. レコードごとに欠損している変数の数を数える
df_null_num = df.copy()
df_null_num['null_num'] = df_null_num.isnull().sum(axis=1)
display(df_null_num)
name | age | state | point | null_num | |
---|---|---|---|---|---|
0 | Alice | 24.0 | NY | NaN | 1 |
1 | Charlie | NaN | CA | NaN | 2 |
2 | Dave | 68.0 | TX | 70.0 | 0 |
3 | Ellen | NaN | CA | 88.0 | 1 |
4 | Frank | 30.0 | NaN | NaN | 2 |
4-3. 欠損値の組み合わせを新たな変数とする
複数の説明変数で、いくつかの欠損値を保持していた場合、その組み合わせを考える。欠損値を含む各レコードで、どの組み合わせになっているかを新たな変数とする
name | age | state | point | |
---|---|---|---|---|
0 | Alice | 24.0 | NY | NaN |
1 | Charlie | NaN | CA | NaN |
2 | Dave | 68.0 | TX | 70.0 |
3 | Ellen | NaN | CA | 88.0 |
4 | Frank | 30.0 | NaN | NaN |
↓
name | age | state | point | null_comb | |
---|---|---|---|---|---|
0 | Alice | 24.0 | NY | NaN | 1 |
1 | Charlie | NaN | CA | NaN | 2 |
2 | Dave | 68.0 | TX | 70.0 | 0 |
3 | Ellen | NaN | CA | 88.0 | 3 |
4 | Frank | 30.0 | NaN | NaN | 4 |
# null_combの説明
0: 欠損を含まない
1: pointのみ欠損
2: age, pointが欠損
3: ageのみ欠損
4: state, pointが欠損