LoginSignup
33
42

More than 1 year has passed since last update.

Pythonでの欠損値補完(代入法) scikit-learnとpandas

Last updated at Posted at 2021-06-27

機械学習での前処理で欠損値補完を何となく平均値や中央値などでやっていましたが、少し踏み込んで調べてみました。欠損値も機械学習で補完する方法できないかな、程度に思っていましたが、やはりありますね。
欠損値補完は統計学の世界では「代入法」、欠損値は「欠測データ(missing data)」と呼ぶようです。
章立て・内容は以下のScikit-Learnユーザガイドをベースにしています。

Scikit-Learnユーザガイドとは別にWikipediaの「代入法」はもっと大きな概念ですし、日本語なのでわかりやすいです。

欠損値補完方法(代入法)

種類

補完元の特徴量基準での分類

補完に使う特徴量を基準とすると大きく以下の2種類の方法があります。

  1. 単変量補完(Univariate feature imputation): 欠損値が生じている特徴量の欠損していない値(平均値など)を使って補完
  2. 多変量補完(Multivariate feature imputation): 欠損値が生じている特徴量以外の特徴量も含めて使って補完

補完先特徴量での分類

補完する対象の特徴量を基準とすると大きく以下の2種類の方法があります。

  1. 単一代入法(Single Imputation): 代入(補完)する欠損値を1つのみ提供する作成
  2. 多重代入法(Multiple Imputation): 代入(補完)する欠損値を複数作成し、複数のデータセットで機械学習等をし、結果を統合する

このサイトも参考にしました。

単変量補完

Scikit-LearnではSimpleImputer関数を使って単変量欠損値補完をします。

平均値補完

以下のデータで、平均値で欠損値補完をします。

index 特徴量1 特徴量2
0 1 NaN -> 5
1 3 5
2 NaN -> 2 NaN -> 5
平均 2=$\frac{1+3}{2}$ 5=$\frac{5}{1}$

平均値補完: Scikit-learn SimpleImputer関数

SimpleImputer関数はデフォルトで平均値補完です。String型の特徴量を含んでいるとデフォルト設定(平均値補完)ではエラーとなるので注意しましょう。

import numpy as np
import pandas as pd
from sklearn.impute import SimpleImputer

df_train = pd.DataFrame([[1, np.nan, 'cat1'],
                        [3, 5, 'cat1'],
                        [np.nan, np.nan, np.nan]])
print(SimpleImputer().fit_transform(df_train.iloc[:,[0,1]]))
出力結果
[[1. 5.]
 [3. 5.]
 [2. 5.]]

平均値補完: Pandas fillna関数

DataFrame使った平均値補完はこう書きます。訓練時はこれでいいんですが、推論時はやりにくそうな気がします。String型の特徴量を含んでいると欠損値はNaNのままです。

print(df_train.fillna(df_train.mean()))

最頻値補完

カテゴリ型の特徴量の場合はパラメータstrategymost_frequentを使うのが一般的かと思います。

index 特徴量
0 cat1
1 cat1
2 NaN -> cat1
最頻値 cat1

最頻値補完: Scikit-learn SimpleImputer関数

平均値補完で使ったDataFrameをそのまま使います。

print(SimpleImputer(strategy='most_frequent').fit_transform(df_train.iloc[:,[2]]))
出力結果
[['cat1']
 ['cat1']
 ['cat1']]

Noneの場合はmissing_valuesで指定しましょう。こんなことあるかわかりませんが、Nonenp.nanの両方を対象に一括補完はできないようです。指定できるのは1つ。

> SimpleImputer(missing_values=None, strategy='most_frequent').fit_transform(df_train.iloc[:,[2]])
[['cat1']
 ['cat1']
 ['cat1']]

最頻値補完: Pandas fillna関数

(実質Seriesだけど)DataFrame使った平均値補完はこう書きます。やはり、訓練時はこれでいいんですが、推論時はやりにくそうな気がします。SimpleImputer関数と違ってNoneも補完対象としてくれます。

print(df_train[2].fillna(df_train[2].mode()[0]))
出力結果
0    cat1
1    cat1
2    cat1
Name: 2, dtype: object

SimpleImputeradd_indicatorオプション

SimpleImputeradd_indicatorオプションがあります。欠損値だったデータを1としてマーキングしてくれます。以下は平均値補完のデータ例です。これにより、欠損値だったけど平均値等で補完したものかがわかり、1つの特徴量として機械学習モデルに使えます。

index 特徴量 Indicator
0 1 0
1 3 0
2 NaN -> 2 1

パラメータadd_indicatorTrueを渡すだけです。Numpy配列で返り、特徴量が増えるので、複数特徴量に使った場合に後処理が少し面倒な気がします。
同じことが後述するMissingIndicator関数を使ってできます。

print(SimpleImputer(add_indicator=True).fit_transform(df_train.iloc[:,[0]]))
出力結果
[[1. 0.]
 [3. 0.]
 [2. 1.]]

多変量補完

Scikit-LearnではIterativeImputer関数を使うかKNNImputer関数を使います。

IterativeImputer関数

IterativeImputer関数は、2021年6月の最新Scikit-LearnのVer0.24.2ではexperimental(実験的)な扱いのため、ご注意ください。以下はその記述の抜粋。

Note This estimator is still experimental for now: default parameters or details of behaviour might change without any deprecation cycle. Resolving the following issues would help stabilize IterativeImputer: convergence criteria (#14338), default estimators (#13286), and use of random state (#15611). To use it, you need to explicitly import enable_iterative_imputer.

適当な配列を作ってみました。特徴量1の2倍が特徴量2の配列で、両者ともに1つずつ欠損値があります。
1回の関数呼出で両特徴量を一気に欠損値補完してくれるのが非常に嬉しいです。

index 特徴量1 特徴量2
0 1 2
1 2 4
2 3 6
3 4 NaN
4 NaN 10

Default(ベイジアンリッジ)実行

Defaultのestimatorはベイジアンリッジ(BayesianRidge)です。調べていないですが、本当はおそらくデータ標準化を事前にしておいた方がいいのでしょう。今回は、その辺は無視しておきます。
from sklearn.experimental import enable_iterative_imputerは、おまじない部分で、experimentalだということを強く意識させたいのでしょう。
ちなみにstring型の項目使うとエラー起きます

import numpy as np
import pandas as pd
from sklearn.experimental import enable_iterative_imputer
from sklearn.impute import IterativeImputer

print(IterativeImputer().fit_transform([[1, 2], 
                                        [2, 4], 
                                        [3, 6], 
                                        [np.nan, 8], 
                                        [5, np.nan]]))

欠損値部分はだいたい合っていますね。

出力結果
[[1.         2.        ]
 [2.         4.        ]
 [3.         6.        ]
 [4.00399557 8.        ]
 [5.         9.9920119 ]]

RandomForestRegressor

何かとお手軽なランダムフォレストを使ってみます。

from sklearn.ensemble import RandomForestRegressor
print(IterativeImputer(RandomForestRegressor()).fit_transform([[1, 2], 
                                                               [2, 4], 
                                                               [3, 6], 
                                                               [np.nan, 8], 
                                                               [5, np.nan]]))

ベイジアンリッジより結果が悪いですが、データが少量なのでそんなものでしょう。

出力結果
[[1.   2.  ]
 [2.   4.  ]
 [3.   6.  ]
 [4.33 8.  ]
 [5.   7.22]]

DataFrame化

DataFrame化パターンも書いておきます。
KaggleのTitanicチャレンジデータを使います。説明変数の数値系項目だけを読み込みます。

import pandas as pd

df = pd.read_csv('./titanic/train.csv', usecols=[2, 5, 6, 7, 9])
print(df.info())

Ageに欠損値があるのがわかります。

df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 891 entries, 0 to 890
Data columns (total 5 columns):
 #   Column  Non-Null Count  Dtype  
---  ------  --------------  -----  
 0   Pclass  891 non-null    int64  
 1   Age     714 non-null    float64
 2   SibSp   891 non-null    int64  
 3   Parch   891 non-null    int64  
 4   Fare    891 non-null    float64
dtypes: float64(2), int64(3)
memory usage: 34.9 KB

df_imputed.columns = df.columnsで列名をコピーしています。

df_imputed = pd.DataFrame(IterativeImputer().fit_transform(df))
df_imputed.columns = df.columns
print(df_imputed.info())

欠損値を埋めてくれているのがわかります。

df_imputed.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 891 entries, 0 to 890
Data columns (total 5 columns):
 #   Column  Non-Null Count  Dtype  
---  ------  --------------  -----  
 0   Pclass  891 non-null    float64
 1   Age     891 non-null    float64
 2   SibSp   891 non-null    float64
 3   Parch   891 non-null    float64
 4   Fare    891 non-null    float64
dtypes: float64(5)
memory usage: 34.9 KB

IterativeImputeradd_indicatorオプション

IterativeImputerにもSimpleImputerと同じくadd_indicatorオプションがあります。Default値はFalseですがTrueを渡します。先程と違い、欠損値がない特徴量の列も3列目に加えました。

print(IterativeImputer(add_indicator=True).fit_transform([[1, 2, 1],
                                                          [2, 4, 2], 
                                                          [3, 6, 3], 
                                                          [np.nan, 8, 4], 
                                                          [5, np.nan, 5]]))

欠損値がある特徴量の数だけ列が増えます(欠損のない特徴量に対する列は増えない)。DataFrameの場合、列名つけ直すのが少し面倒そうです。

出力結果
[[1.         2.         1.         0.         0.        ]
 [2.         4.         2.         0.         0.        ]
 [3.         6.         3.         0.         0.        ]
 [4.0000001  8.         4.         1.         0.        ]
 [5.         9.99999974 5.         0.         1.        ]]

多重代入法(Multiple Imputation)で使う

多重代入法(Multiple Imputation)で使うには、パラメータsample_posteriorTrueを渡す必要があり、その前提としてestimatorのpredict関数がreturn_stdパラメータを使える必要があります。予測値の標準偏差を返せる、という前提を満たすestimatorは非常に限られるっぽく、軽く調べた限りでは以下の関数くらいです(5分程度しか調べていないです)。

多重代入法を試しはしませんでしたが、これで動きそう、というリンクだけ載せておきます。

0 or 1 の場合

boolean系の変数へどうするのか?と思いました。ユーザガイド「6.4.3. Multivariate feature imputation」のサンプルコードでやっているようにnp.roundっぽいです。

>>> import numpy as np
>>> from sklearn.experimental import enable_iterative_imputer
>>> from sklearn.impute import IterativeImputer
>>> imp = IterativeImputer(max_iter=10, random_state=0)
>>> imp.fit([[1, 2], [3, 6], [4, 8], [np.nan, 3], [7, np.nan]])
IterativeImputer(random_state=0)
>>> X_test = [[np.nan, 2], [6, np.nan], [np.nan, 6]]
>>> # the model learns that the second feature is double the first
>>> print(np.round(imp.transform(X_test)))
[[ 1.  2.]
 [ 6. 12.]
 [ 3.  6.]]

ここでもそんなQAあり。

KNNImputer関数

KNNで欠損値補完するのであれば、KNNImputer関数があります。こちらはexperimentalではないので、少し安心です。
KNNについては、以前はじパタで勉強したときに記事「はじパタ全力解説: 第5章 k最近傍法(kNN法)」を書きました。そこでは分類の方法と覚えていましたが、回帰でも使えるのですね。

IterativeImputerのときと同じ配列を使います。特徴量1の2倍が特徴量2の配列で、両者ともに1つずつ欠損値があります。ただ、少量データのKNNなので、実行結果は意味ないですが、プログラムの書き方参考程度で。
こちらも1回の関数呼出で両特徴量を一気に欠損値補完してくれるのが非常に嬉しいです。

index 特徴量1 特徴量2
0 1 2
1 2 4
2 3 6
3 4 NaN
4 NaN 10
import numpy as np
import pandas as pd
from sklearn.impute import KNNImputer

print(KNNImputer(n_neighbors=2).fit_transform([[1, 2], 
                                        [2, 4], 
                                        [3, 6], 
                                        [np.nan, 8], 
                                        [5, np.nan]]))

IterativeImputerでベイジアンリッジやランダムフォレスト使ったときに比べるとひどい結果です。ですが、データが悪いだけ。

出力結果
[[1.  2. ]
 [2.  4. ]
 [3.  6. ]
 [2.5 8. ]
 [5.  5. ]]

少し大きなカリフォルニア住宅価格のデータセットで試してみます。

from sklearn.datasets import fetch_california_housing

dataset = fetch_california_housing()
df = pd.DataFrame(dataset.data[:, [6, 7]], 
                       columns=[dataset.feature_names[6], dataset.feature_names[7]])
print(df.info())

約2万件あって欠損値がないです。

出力結果
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 20640 entries, 0 to 20639
Data columns (total 2 columns):
 #   Column     Non-Null Count  Dtype  
---  ------     --------------  -----  
 0   Latitude   20640 non-null  float64
 1   Longitude  20640 non-null  float64
dtypes: float64(2)
memory usage: 322.6 KB
None

約1/10のデータを欠損させます。

# 1/10をTrueにする
mask = ~np.random.default_rng().integers(10, size=df.shape).astype(np.bool_)
df[mask] = np.nan
print(df.info())

2000件くらいがNullになりましたね。

出力結果
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 20640 entries, 0 to 20639
Data columns (total 2 columns):
 #   Column     Non-Null Count  Dtype  
---  ------     --------------  -----  
 0   Latitude   18496 non-null  float64
 1   Longitude  18610 non-null  float64
dtypes: float64(2)
memory usage: 322.6 KB
None

KNNで欠損値補完。

df_imputed = pd.DataFrame(KNNImputer(n_neighbors=2).fit_transform(df))
df_imputed.columns = df.columns
print(df_imputed.info())

欠損値なくなっていますね。

出力結果
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 20640 entries, 0 to 20639
Data columns (total 2 columns):
 #   Column     Non-Null Count  Dtype  
---  ------     --------------  -----  
 0   Latitude   20640 non-null  float64
 1   Longitude  20640 non-null  float64
dtypes: float64(2)
memory usage: 322.6 KB
None

散布図でぱっと見るだけしてみます。本当はパラメータadd_indicatorなどを欠損値レコードをマーキングして色を変えるとか、推論までして精度見るとかすればいいのでしょうか、面倒なので省略。

import matplotlib.pyplot as plt

fig, axes = plt.subplots(nrows=1, ncols=2, figsize=(12, 3))
df.plot.scatter(x='Latitude', y='Longitude', ax=axes[0])
df_imputed.plot.scatter(x='Latitude', y='Longitude', ax=axes[1])
plt.show()

左が欠損値補完前で右が後。あまり変わらないですね。
image.png

今回、試していませんがmissingpyというパッケージにmissforest関数があるようです。

欠損値のマーキング MissingIndicator

Scikit-learnに欠損値をマーキングするMissingIndicator関数があります。欠損値補完する関数のadd_indicatorオプションの機能を切り出した関数です。
SimpleImputeradd_indicatorオプション」で書きましたが、例えば以下のデータでIndicator列を作り、補完したデータ"2"は他と違うんだよ、という意味を持たせます。そして、以下の用途に使います(他用途もあるかも)。

  1. Indicatorも一つの特徴量とする
  2. グラフ化してIndocatorによって色など変えて欠損値補完がうまくできているかを確認
index 特徴量 Indicator
0 1 False
1 3 False
2 NaN True

基本の使い方

では、簡単な使い方です。

import numpy as np
from sklearn.impute import MissingIndicator
X = np.array([[1],
              [3],
              [np.nan]])
print(MissingIndicator().fit_transform(X))

add_indicatorオプションで使ったときと違い、False/Trueで返ってくるようです。

出力結果
[[False]
 [False]
 [ True]]

欠損値が無い特徴量がある場合(デフォルト)

add_indicatorオプションと同じくデフォルトで欠損値がない特徴量のIndicatorは作られません

X = np.array([[1, 1],
              [3, 1],
              [np.nan, 1]])
print(MissingIndicator().fit_transform(X))

2列目のIndicatorなし。

出力結果
[[False]
 [False]
 [ True]]

欠損値が無い特徴量がある場合(features='all')

パラメータfeaturesallを指定すると、欠損値がない特徴量のIndicatorが作られます

X = np.array([[1, 1],
              [3, 1],
              [np.nan, 1]])
print(MissingIndicator(features='all').fit_transform(X))
出力結果
[[False False]
 [False False]
 [ True False]]

fitとtransform

訓練とテストでデータ分けた場合です。fittransformを分けて実行します。

X_train = np.array([[1, 1],
                    [3, 1],
                    [np.nan, 1]])
X_test = np.array([[1, 1],
                   [np.nan, 1],
                   [3, 1]])
indicator = MissingIndicator()
indicator.fit(X_train)
print(indicator.transform(X_test))

2行目に欠損値を示すTrueが来ていますね。

出力結果
[[False]
 [ True]
 [False]]

fitとtransform(欠損値がどの特徴量で発生するかわからない場合)

デフォルトパラメータでfit時にある特徴量に欠損値がなく、transform時にその特徴量で欠損値が出てしまうとエラー発生します。

X_train = np.array([[1, 1],
                    [3, 1],
                    [np.nan, 1]])
X_test = np.array([[1, 1],
                   [np.nan, 1],
                   [3, np.nan]])

indicator = MissingIndicator()
indicator.fit(X_train)
print(indicator.transform(X_test))

エラー内容。

エラー
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-26-0a974e4754a8> in <module>
     11 indicator = MissingIndicator()
     12 indicator.fit(X_train)
---> 13 print(indicator.transform(X_test))

~/Apps/python/venv/py39/lib/python3.9/site-packages/sklearn/impute/_base.py in transform(self, X)
    809             features_diff_fit_trans = np.setdiff1d(features, self.features_)
    810             if (self.error_on_new and features_diff_fit_trans.size > 0):
--> 811                 raise ValueError("The features {} have missing values "
    812                                  "in transform but have no missing values "
    813                                  "in fit.".format(features_diff_fit_trans))

ValueError: The features [1] have missing values in transform but have no missing values in fit.

エラーを起こさせないためには、パラメーターfeaturesに'all'を渡します。

X_train = np.array([[1, 1],
                    [3, 1],
                    [np.nan, 1]])
X_test = np.array([[1, 1],
                   [np.nan, 1],
                   [3, np.nan]])

indicator = MissingIndicator(features='all')
indicator.fit(X_train)
print(indicator.transform(X_test))

全特徴量の欠損値が出力。

出力結果
[[False False]
 [ True False]
 [False  True]]

またはパラメータerror_on_newFalseを指定。

X_train = np.array([[1, 1],
                    [3, 1],
                    [np.nan, 1]])
X_test = np.array([[1, 1],
                   [np.nan, 1],
                   [3, np.nan]])

indicator = MissingIndicator(error_on_new=False)
indicator.fit(X_train)
print(indicator.transform(X_test))

この場合は、fit時に欠損値がなかった特徴量のIndicatorは返りません。

出力結果
[[False]
 [ True]
 [False]]

実用

ユーザガイド「6.4.6. Marking imputed values」に記載がありますが、FeatureUnion関数Pilelineと組み合わせるのがMissingIndicatorの実用的な使い方のようです。
今回はそこまで試しませんが、いずれ更新するかもしれません。
FeatureUnion関数に関しては、すぐに理解できなかったため、以下の記事を書きました。

部分削除方法

欠損値補完じゃないけど、部分削除という方法もあります。以下の2つの手法があります。

  1. リストワイズ削除(完全ケース削除): レコード単位で、1つでも欠損値があればレコード削除
  2. ペアワイズ削除: 回帰・分類等目的に必要な特徴量に欠損値が生じていればレコード削除

DataFrame上ではdropna関数を使うだけです。

df.dropna()

もっと詳しい使い方は、この辺の記事見るとすぐに理解できると思います。

参考リンク

以下の記事を参考にしました。

33
42
2

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
33
42