静かなる名辞

pythonとプログラミングのこと

2019/03/22:TechAcademyがteratailの質問・回答を盗用していた件
2019/03/26:TechAcademy盗用事件 公式発表と深まる疑念


【python】sklearnのIterativeImputerで欠損値補完

 注意:IterativeImputerは本記事の執筆時点(2019年11月)で実験的な実装とされており、最新の仕様等はこの記事の内容と異なる可能性があります。常にstable版の公式のドキュメントを確認してください。

 公式のドキュメント
sklearn.impute.IterativeImputer — scikit-learn 0.21.3 documentation

はじめに

 説明変数の欠損値補完には様々な方法があり、いろいろな研究がなされています。

 直感的に思いつくのは、欠損部分は他の変数から回帰すれば良いという発想です。それを発展させて高級にすると、多重代入法というような手法になったりするらしいのですが、詳しくないので説明できません。どうせよく知らなくても機械学習モデルは構築できます。

 scikit-learnではIterativeImputerなるモデルを「開発中」です。ここで開発中と書いたのは、公式ですら

Note This estimator is still experimental for now: the predictions and the API might change without any deprecation cycle.

 という扱いになっているからです。「いつの間にか仕様が変わったり、最悪消えたりしてても文句言うなよ!」といったところでしょうか。それでも一応masterブランチにマージされていて、普通にsklearnをインストールすれば(2019年11月時点では)使えます。

 ということで、便利そうなので紹介します。ただし、そこそこ癖があるので気をつけます。

使い方

 まずリファレンスにも書いてあることですが、こいつを使うためには余計なものもimportする必要があります。

from sklearn.experimental import enable_iterative_imputer  # 本当におまじない
from sklearn.impute import IterativeImputer  # 使うのこっち

 使っているlintによっては「imported but unused」とか言われたりしますが、無視してください。

 あとはハイパーパラメータをどうするかですが、あんまり凝ったことはしないのをおすすめします。と言いつつ、何も設定しないで使うのもおすすめしません。

 重要なパラメータを重要な順番に並べてみます。

  • estimator=None,

 内部で回帰を行って補完するので、回帰モデルを渡せるという訳です。デフォルトはベイジアンリッジ(sklearn.linear_model.BayesianRidge)になります。なんでデフォルトがこれなのかは後述します。

  • sample_posterior=False

 そのままだと単一代入法、Trueにすると多重代入法になるのだと思いますが、いかんせん私が知識不足で、またドキュメントの記述がそっけないので細かいアルゴリズムまではよくわかりません。多重代入法の方がかっこよくて性能も良さそうに思えますが、実はその場合は

Estimator must support return_std in its predict method if set to True.

 という制約があり、これは割ときつい制約です(対応しているモデルはほとんどありません)。デフォルトのBayesianRidgeはベイズ法なので予測の標準偏差が出せるということから選定されていると思われます。

  • max_iter=10
  • tol=0.001

 特に説明は要らないと思います。なんか上手く行かないときはこの辺をいじる(増やしてみる)。

 ということで、議論の余地があるのは多重代入法にするのか単一代入法にするのかです。多重代入法の場合、現実的なestimatorの選択肢はたぶんベイジアンリッジだけで、これは「ベイズでリッジだからつよつよ」と見せかけて基本的にただの線形モデルです(回帰式が一次式のやつ)。機械学習に突っ込むようなデータだとこれは微妙なことが多そうなので、多重代入法を諦めて単一代入法にすることで自由に好きな非線形回帰手法を使うということにした方が良いケースが多いと思います。

 あとは何で回帰するか? ですが、ここの回帰モデルのパラメータチューニングのことなんて考えたくもないので、適当な設定でもそこそこ動く奴を使います。と言って真っ先に思いつくのはランダムフォレストですね。

 以上をまとめると、だいたいこんな感じで定義することになると思います。

from sklearn.ensemble import RandomForestRegressor
from sklearn.experimental import enable_iterative_imputer
from sklearn.impute import IterativeImputer

rf = RandomForestRegressor(n_estimators=500, n_jobs=-1)
imp = IterativeImputer(estimator=rf)

 他のパラメータは気になったら適当に弄ってください。大抵は弄らなくても大勢に影響しません。あと、ランダムフォレストはそこそこ遅いので、気になったら木を浅くして本数を減らすことで高速向きのチューニングにするか、他のものも試してみてください。
 

実験

 SimpleImputerのときと同じことを試します。

【python】sklearnのSimpleImputerで欠損値補完をしてみる - 静かなる名辞

 irisを欠損させて補完して予測精度を見ます。

import numpy as np
from sklearn.datasets import load_iris
from sklearn.neighbors import KNeighborsClassifier    
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestRegressor
from sklearn.experimental import enable_iterative_imputer
from sklearn.impute import IterativeImputer
from sklearn.pipeline import Pipeline
from sklearn.metrics import classification_report

# 再現性確保
np.random.seed(0)

# データ生成
dataset = load_iris()
X_train, X_test, y_train, y_test = train_test_split(
    dataset.data, dataset.target, stratify=dataset.target, random_state=0)
mask = ~np.random.randint(10, size=X_train.shape).astype(np.bool)
X_train[mask] = np.nan

# モデル定義
knn = KNeighborsClassifier()

# 処理
def drop():
    print("# drop")
    idx = ~(np.isnan(X_train).any(axis=1))
    X_train_, y_train_ = X_train[idx], y_train[idx]
    knn.fit(X_train_, y_train_)
    y_pred = knn.predict(X_test)
    print(classification_report(
        y_test, y_pred, digits=3, 
        target_names=dataset.target_names))
                    
def impute():
    print("# impute")

    rf = RandomForestRegressor(
        n_estimators=200, max_depth=3, n_jobs=-1)
    imp = IterativeImputer(estimator=rf, max_iter=20)
    pl = Pipeline([("imputer", imp), ("KNN", knn)])
    pl.fit(X_train, y_train)
    y_pred = pl.predict(X_test)
    print(classification_report(
        y_test, y_pred, digits=3, 
        target_names=dataset.target_names))
    
drop()
impute()
# drop
              precision    recall  f1-score   support

      setosa      1.000     1.000     1.000        13
  versicolor      0.929     1.000     0.963        13
   virginica      1.000     0.917     0.957        12

    accuracy                          0.974        38
   macro avg      0.976     0.972     0.973        38
weighted avg      0.976     0.974     0.974        38

# impute
              precision    recall  f1-score   support

      setosa      1.000     1.000     1.000        13
  versicolor      1.000     1.000     1.000        13
   virginica      1.000     1.000     1.000        12

    accuracy                          1.000        38
   macro avg      1.000     1.000     1.000        38
weighted avg      1.000     1.000     1.000        38

 単に欠損値のある行を学習データから落とすより良いわけです。まあ、irisのときは平均値補完でやっても同じ結果になりましたが。

 データセットをwineにしてみます。

# drop
              precision    recall  f1-score   support

     class_0      0.812     0.867     0.839        15
     class_1      0.750     0.667     0.706        18
     class_2      0.538     0.583     0.560        12

    accuracy                          0.711        45
   macro avg      0.700     0.706     0.702        45
weighted avg      0.714     0.711     0.711        45

# impute
              precision    recall  f1-score   support

     class_0      0.800     0.800     0.800        15
     class_1      0.733     0.611     0.667        18
     class_2      0.533     0.667     0.593        12

    accuracy                          0.689        45
   macro avg      0.689     0.693     0.686        45
weighted avg      0.702     0.689     0.691        45

 dropより良くなるかと思いましたが、そうでもないようです。平均値で補完するよりは良いみたいですが……。

考察

 そもそも欠損値補完というアプローチは、

  • 欠損値が多すぎるとそもそもまともに予測できないし、予測の悪さが大勢に影響を及ぼして全体のパフォーマンスを悪化させかねない
  • 欠損値が少ないならdropしても大勢に影響はない

 という矛盾を抱えている訳ですが、そこそこの欠損があるときにdropするよりちょっと良くなるかな? という可能性を追求するためにあるものだと思います。

まとめ

 使うの面倒くさいしこの先どうなるかは読めないけど、使えるみたいです。ただし、できるからといってパフォーマンスの上で凄い効能があるとか、そういうものではないみたいです。