静かなる名辞

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

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


【python】機械学習でpandas.get_dummiesを使ってはいけない

はじめに

 「pandasのget_dummiesでダミー変数が作れるぜ」という記事がとてもたくさんあって初心者を混乱させているのですが、これは「データ分析」には使えても「機械学習」には向きません。もう少し正確に言い換えると「訓練データからモデルを作り、未知のデータの予測を行うタスク」には使い勝手が悪いものです。

 機械学習に使ってはいけないというのは大げさかもしれませんが、でも間違った使い方をよく見かけますし、こう言い切った方がぶっちゃけ良いと思います。

 この記事では「こういうときにはget_dummies使うんじゃねえ!」ということと、どういう問題があるのかと、代替方法について書きます。

pandas.get_dummies — pandas 0.25.1 documentation

問題点

 こんなデータを考えましょう。

>>> import pandas as pd
>>> df = pd.DataFrame({"A":["hoge", "fuga"], "B":["a", "b"]})
>>> df
      A  B
0  hoge  a
1  fuga  b
>>> pd.get_dummies(df)
   A_fuga  A_hoge  B_a  B_b
0       0       1    1    0
1       1       0    0    1

 問題なさそうに見えますか?

 でも、複数のデータに対して適用しようとするととたんに大問題が発生します。普通、kaggleのコンペとかならtrainとtestのデータはあるわけですよね。

>>> df_train = pd.DataFrame({"A":["hoge", "fuga"], "B":["a", "b"]})
>>> df_test = pd.DataFrame({"A":["hoge", "piyo"], "B":["a", "c"]})
>>> pd.get_dummies(df_train)
   A_fuga  A_hoge  B_a  B_b
0       0       1    1    0
1       1       0    0    1
>>> pd.get_dummies(df_test)
   A_hoge  A_piyo  B_a  B_c
0       1       0    1    0
1       0       1    0    1

 shapeは同じ。だけど、各カラムの意味するものは異なっています。一致しているのはB_aだけという惨状です。

 ユニークな要素は6つあるので、下のようになればまずまずの結果と言っていいかもしれません。

# trainに対して
   A_fuga  A_hoge  A_piyo  B_a  B_b  B_c  
0       0       1       0    1    0    0
1       1       0       0    0    1    0
# testに対して
   A_fuga  A_hoge  A_piyo  B_a  B_b  B_c
0       0       1       0    1    0    0
1       0       0       1    0    0    1

 実際は学習データに含まれない値なんて落ちてくれて構わない(逆に落ちないと厄介)ので、理想的な結果はこうでしょうか。

# trainに対して
   A_fuga  A_hoge  B_a  B_b
0       0       1    1    0
1       1       0    0    1
# testに対して
   A_fuga  A_hoge  B_a  B_b
0       0       1    1    0
1       0       0    0    0

 ドキュメントを軽く読んでいろいろ試した感じ、これをget_dummiesで得るのは無理っぽいです。つかえねー。
(私が見落としているだけかもしれないので、「できるよ」という人はコメントで教えて下さい。確認した上で記事に反映させていただきます。)
(↑さっそくコメントを頂いて、追記させていただきました。この章の末尾を御覧ください。)

 こういう問題があるので、get_dummiesはダメと言っています。

 なお、先に示した6列のデータなら、pandas.concatしてから変換すれば得ることができます。

>>> ret = pd.get_dummies(pd.concat([df_train, df_test]))
   A_fuga  A_hoge  A_piyo  B_a  B_b  B_c
0       0       1       0    1    0    0
1       1       0       0    0    1    0
0       0       1       0    1    0    0
1       0       0       1    0    0    1
>>> ret = pd.get_dummies(pd.concat([df_train, df_test]))
>>> X_train, X_test = ret.iloc[:2], ret.iloc[2:]
>>> X_train
   A_fuga  A_hoge  A_piyo  B_a  B_b  B_c
0       0       1       0    1    0    0
1       1       0       0    0    1    0
>>> X_test
   A_fuga  A_hoge  A_piyo  B_a  B_b  B_c
0       0       1       0    1    0    0
1       0       0       1    0    0    1

 これをやっているコードも見かけたことがありますが、「予測モデル側で学習データぜんぶ持っておくの?」ということを考えると現実的なソリューションではないでしょう。おすすめしません。

追記
 列をpandas.Categorical型とすれば、明示的にカテゴリを指定することで変換が可能なようです。

df_train = pd.DataFrame({"A": ["hoge", "fuga"], "B": ["a", "b"]})
df_test = pd.DataFrame({"A": ["hoge", "piyo"], "B": ["a", "c"]})
df_test["A"] = pd.Categorical(df_test["A"], categories=["hoge", "fuga"])
df_test["B"] = pd.Categorical(df_test["B"], categories=["a", "b"])
pd.get_dummies(df_test)

テナジマ様コメントより

 scikit-learnでやるのと比べて記述は増えますが、pandasの枠組みの中で取り扱うこと自体は可能なようです。

 pandas.Categorical — pandas 0.25.1 documentation

代替する方法

 sklearnのOneHotEncoderで変換すれば一発です。

sklearn.preprocessing.OneHotEncoder — scikit-learn 0.21.3 documentation

>>> from sklearn.preprocessing import OneHotEncoder
>>> ohe = OneHotEncoder(handle_unknown="ignore", sparse=False)
>>> ohe.fit(df_train)
OneHotEncoder(categorical_features=None, categories=None, drop=None,
              dtype=<class 'numpy.float64'>, handle_unknown='ignore',
              n_values=None, sparse=False)
>>> ohe.transform(df_train)
array([[0., 1., 1., 0.],
       [1., 0., 0., 1.]])
>>> ohe.transform(df_test)
array([[0., 1., 1., 0.],
       [0., 0., 0., 0.]])

 一撃で理想的な結果を得られています。scikit-learnは偉大ですね。

 pandasなんて最初から要らなかった。

 これはscikit-learnのモデルなので、Pipelineなどと組み合わせて使うのにも親和性が高いです。というか、そのように使ってください(Pipelineにすることで、transformするべきところでfit_transformするといった凡ミスを防げます)。

 使い方についてはこっちの記事も参照してください。

【python】sklearnでのカテゴリ変数の取り扱いまとめ LabelEncoder, OneHotEncoderなど - 静かなる名辞

 ColumnTransformerと組み合わせると使い方の幅が広がります。カテゴリ変数だけ投げて数値変数はそのまま通すといった処理が可能になります。

scikit-learnのColumnTransformerを使ってみる - 静かなる名辞

 あとこれは完全に余談ですが、その気になればnanもsklearnの中で落とせます。前処理からすべてscikit-learnの枠組みの中で書けるので、pandasの出る幕はCSVの読み込みと探索的データ分析でやる各種プロットとか以外にはないと言っても過言ではないでしょう。

5.4. Imputation of missing values — scikit-learn 0.21.3 documentation

 ……え、結果がpandasのDataFrameになってないのがいやだって? そしたら、結果を改めてDataFrameに変換すればいいんじゃないでしょうか。こんな感じですね。

>>> pd.DataFrame(ohe.transform(df_test), columns=[c + "_" + x for lst, c in zip(ohe.categories_, "AB") for x in lst])
   A_fuga  A_hoge  B_a  B_b
0     0.0     1.0  1.0  0.0
1     0.0     0.0  0.0  0.0

まとめ

 pandasは基本的にこういう用途には向いていないので、安易に使わないほうが良いという話です。機械学習ライブラリとして枠組みを整備してくれているscikit-learnは偉大なツールなので、積極的にこっちを活用していけばいいと思います。