機械学習での前処理で欠損値補完を何となく平均値や中央値などでやっていましたが、少し踏み込んで調べてみました。欠損値も機械学習で補完する方法できないかな、程度に思っていましたが、やはりありますね。
欠損値補完は統計学の世界では「代入法」、欠損値は「欠測データ(missing data)」と呼ぶようです。
章立て・内容は以下のScikit-Learnユーザガイドをベースにしています。
Scikit-Learnユーザガイドとは別にWikipediaの「代入法」はもっと大きな概念ですし、日本語なのでわかりやすいです。
欠損値補完方法(代入法)
種類
補完元の特徴量基準での分類
補完に使う特徴量を基準とすると大きく以下の2種類の方法があります。
- 単変量補完(Univariate feature imputation): 欠損値が生じている特徴量の欠損していない値(平均値など)を使って補完
- 多変量補完(Multivariate feature imputation): 欠損値が生じている特徴量以外の特徴量も含めて使って補完
補完先特徴量での分類
補完する対象の特徴量を基準とすると大きく以下の2種類の方法があります。
- 単一代入法(Single Imputation): 代入(補完)する欠損値を1つのみ提供する作成
- 多重代入法(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()))
最頻値補完
カテゴリ型の特徴量の場合はパラメータstrategy
にmost_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
で指定しましょう。こんなことあるかわかりませんが、None
とnp.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
SimpleImputer
とadd_indicator
オプション
SimpleImputer
にadd_indicator
オプションがあります。欠損値だったデータを1としてマーキングしてくれます。以下は平均値補完のデータ例です。これにより、欠損値だったけど平均値等で補完したものかがわかり、1つの特徴量として機械学習モデルに使えます。
index | 特徴量 | Indicator |
---|---|---|
0 | 1 | 0 |
1 | 3 | 0 |
2 | NaN -> 2 | 1 |
パラメータadd_indicator
にTrue
を渡すだけです。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に欠損値があるのがわかります。
<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())
欠損値を埋めてくれているのがわかります。
<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
IterativeImputer
とadd_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_posterior
にTrue
を渡す必要があり、その前提として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()
今回、試していませんがmissingpy
というパッケージにmissforest
関数があるようです。
欠損値のマーキング MissingIndicator
Scikit-learnに欠損値をマーキングするMissingIndicator
関数があります。欠損値補完する関数のadd_indicator
オプションの機能を切り出した関数です。
「SimpleImputer
とadd_indicator
オプション」で書きましたが、例えば以下のデータでIndicator列を作り、補完したデータ"2"は他と違うんだよ、という意味を持たせます。そして、以下の用途に使います(他用途もあるかも)。
- Indicatorも一つの特徴量とする
- グラフ化して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')
パラメータfeatures
にall
を指定すると、欠損値がない特徴量の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
訓練とテストでデータ分けた場合です。fit
とtransform
を分けて実行します。
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_new
にFalse
を指定。
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つでも欠損値があればレコード削除
- ペアワイズ削除: 回帰・分類等目的に必要な特徴量に欠損値が生じていればレコード削除
DataFrame上ではdropna
関数を使うだけです。
df.dropna()
もっと詳しい使い方は、この辺の記事見るとすぐに理解できると思います。
参考リンク
以下の記事を参考にしました。