平均値で埋めるだけじゃない！少し踏み込んだ欠損値補完

# 欠損値の種類
実は、欠損値には３種類あります
たくさんの文献を漁ったところ、[こちら](https://axa.biopapyrus.jp/machine-learning/preprocessing/missing-data.html)がわかりやすかったので引用させていただきます。
これら３つの種類に合わせて欠損値を補完していくことが、精度向上のために必要です。

> ### Missing Completely At Random (MCAR)
MCAR は完全にランダムで生じる欠損値である。ほとんどの欠損値補完アルゴリズムが有効である。

> ### Missing At Random (MAR)
MAR は、観測データに依存してランダムに生じる欠損値である。例えば、水稲標本を調べたデータで、ある品種（例えば、コシヒカリ）だけに限って特定の特徴量（例えば、乾燥重量）が欠損になっている場合である。MAR の場合は、ほかの特徴量と関係を利用して、ある程度バイアスを抑えて補正することができる。


> ### Missing Not At Random (MNAR)
MNAR は、欠損データに依存して生じる欠損値である。例えば、水稲標本を調べたデータで、ななつ星という品種が、ほとんど栽培実験が行われていないために、そもそもデータとして収集されていないような欠損値のことである。MNAR の場合、そもそもデータが少ない（存在しない）ので、補正が極めて困難である。

# 前提
本記事では、欠損値「補完」を前提としているので、欠損値をまるごと削除する方法はご紹介しません。

~~~python
df.dropna(inplace=True)
~~~

また、本記事は [kaggleで勝つデータ分析の技術](https://www.amazon.co.jp/Kaggle%E3%81%A7%E5%8B%9D%E3%81%A4%E3%83%87%E3%83%BC%E3%82%BF%E5%88%86%E6%9E%90%E3%81%AE%E6%8A%80%E8%A1%93-%E9%96%80%E8%84%87-%E5%A4%A7%E8%BC%94-ebook/dp/B07YTDBC3Z)を参考にしています。

# 欠損値補完まとめ

1. 欠損値のまま取り扱う
2. 欠損値を代表値で埋める
3. 欠損値を他の変数から予測する
4. 欠損値から新たな特徴量を作成する

### 有効性

| 施策  | MCAR | MAR | MNAR |
|:--|:--:|:--:|:--:|
| 1. 欠損値のまま取り扱う | O | O | △ | 
| 2. 欠損値を代表値で埋める | O | △[^1] | X |
| 3. 欠損値を他の変数から予測する | O | O | X |
| 4. 欠損値から新たな特徴量を作成する | O | △[^2] | X |

[^1]: 本記事の[2-2](https://qiita.com/bigshiny_0328/items/d6b8eb1be2971e1e81be#2-2-%E3%82%AB%E3%83%86%E3%82%B4%E3%83%AA%E3%81%94%E3%81%A8%E3%81%AE%E7%B5%B1%E8%A8%88%E9%87%8F%E3%81%A7%E5%9F%8B%E3%82%81%E3%82%8B)参照。他の変数から代表値を決める場合は有効
[^2]: 本記事の[4-3](https://qiita.com/bigshiny_0328/items/d6b8eb1be2971e1e81be#4-3-%E6%AC%A0%E6%90%8D%E5%80%A4%E3%81%AE%E7%B5%84%E3%81%BF%E5%90%88%E3%82%8F%E3%81%9B%E3%82%92%E6%96%B0%E3%81%9F%E3%81%AA%E5%A4%89%E6%95%B0%E3%81%A8%E3%81%99%E3%82%8B)参照。他の変数との関連から新たな特徴量を作成する場合は有効

これらを一つずつ噛み砕いて説明していきます。
なお、本記事で掲載するコードは [github](https://github.com/bigshiny/processing_null/blob/master/03_03_processing_null.ipynb)  上にpush済みです

## 必要ライブラリ

~~~python
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を除外（削除）・置換（穴埋め）・抽出](https://note.nkmk.me/python-pandas-nan-dropna-fillna/)から拝借した[こちらのデータ](https://raw.githubusercontent.com/nkmk/python-snippets/217ee1c1fde5d816726f083185036af252a39647/notebook/data/src/sample_pandas_normal_nan.csv)を少しだけ編集して使わせていただいています。

~~~python
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. 全データの統計量で埋める
全てのレコードから算出した代表値で埋める

### 平均値
~~~python
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 |

### 中央値
~~~python
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 |

### 最頻値
~~~python
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`の欠損値を補完する

~~~python
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)
~~~

~~~python

df_state = df.copy()
ba_age, ba_point = BayesianAverage(40, 3), BayesianAverage(80, 3)  # <- ハイパーパラメータ
# ba_age: 40歳の人を３人追加する設定 (本来であれば、クラスごとに傾向が異なるので、クラスごとに異なるオブジェクトを生成する必要がある)
# 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`を予測する

~~~python

# ====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 | <font color="Red">**CA**</font> | NaN |

`state`の最後の行にある欠損値は「CA」と予測できた

# 4. 欠損値から新たな特徴量を作成する
欠損値から新たな特徴量を作成するアイデアはいくらでも出てきそうですが、ここでは二つ紹介します。

## 4-1. 欠損しているかどうかを表す二値変数を新たに作る
[メリット] 後に欠損値を埋めたとしても、「欠損していた」という情報が失われない

~~~python

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. レコードごとに欠損している変数の数を数える

~~~python
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が欠損
~~~

# 参考文献

- [欠損値 | 機械学習における欠損値の取り扱い方法 - 農業情報](https://axa.biopapyrus.jp/machine-learning/preprocessing/missing-data.html)

- [kaggleで勝つデータ分析の技術](https://www.amazon.co.jp/Kaggle%E3%81%A7%E5%8B%9D%E3%81%A4%E3%83%87%E3%83%BC%E3%82%BF%E5%88%86%E6%9E%90%E3%81%AE%E6%8A%80%E8%A1%93-%E9%96%80%E8%84%87-%E5%A4%A7%E8%BC%94-ebook/dp/B07YTDBC3Z)

- [pandasで欠損値NaNを除外（削除）・置換（穴埋め）・抽出](https://note.nkmk.me/python-pandas-nan-dropna-fillna/)