LoginSignup
19
21

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-06-28

欠損値の種類

実は、欠損値には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で勝つデータ分析の技術を参考にしています。

欠損値補完まとめ

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

有効性

施策 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 ごとの代表値でagepointの欠損値を補完する

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)成分はstateCAの人のageの平均値

$\dfrac{0 + 40 \times 3}{0 + 3}=40$

(3,2)成分はstateTXの人のpointの平均値

$\dfrac{70 + 80 \times 3}{1 + 3}=77.5$

後者のテーブル: Bayesian Averageで欠損値を埋めたテーブル

3. 欠損値を他の変数から予測する

今作った df_bayes_aveagepointを説明変数として、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が欠損

参考文献


  1. 本記事の2-2参照。他の変数から代表値を決める場合は有効 

  2. 本記事の4-3参照。他の変数との関連から新たな特徴量を作成する場合は有効 

19
21
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
21