LoginSignup
76
52

More than 5 years have passed since last update.

特徴量エンジニアリングとしてのOne-Hotベクトルの必要性と、PandasでSparseな行列を使うときのGroupbyの†闇†の話

Posted at

PandasでOne-HotベクトルをメモリケチるためにSparse行列(疎行列)として記録してGroupbyしたら値が消えて、1日分の処理丸々無駄にしまいました。疎行列のGroupbyで悲しい思いをする人が出ないように書いておきます。

環境:Pandas 0.23.4 Final

前置きが若干長いので、†闇†の部分だけ読みたい方は、「PandasのGroupbyとSparse行列の†闇†」まで飛んでください。

One-Hotベクトルとは

あるカラムだけ1で他のカラムは0な行列の表現。カテゴリー変数でよく使います。古典的な統計の教科書では「ダミー変数」という言い方もします。PandasのOneHotベクトルを作る関数get_dummiesはこれが由来です。

例えば、3つのクラスがあったとして、それぞれ$0, 1, 2$としましょう。今データのラベルが、

$$y=(0,1,2,1,0)$$

とします。これのOne-Hotベクトルは以下のようになります。

Y=\begin{bmatrix}1&0&0\\ 0&1&0 \\ 0&0&1 \\ 0&1&0 \\ 1&0&0\end{bmatrix}

このように0と1がいっぱい並んでいるのがOne-Hotベクトルの特徴です。

One-Hotベクトルのメリット

カテゴリー変数をOne-Hotベクトル化するメリットですが、逆にOne-Hotベクトル化しない場合のデメリットを考えたほうがわかりやすいです。例えば、今ある花の大きさを考えたいとします。3つのクラスで次のような特徴があったとします。

  • $y=0$のクラスは、やたらと花が大きい(平均で20cmぐらいある)
  • $y=1$のクラスは、とても花が小さい(平均で1cmもない)
  • $y=2$のクラスは、花の大きさがそこそこ(平均で5cmぐらい)

花の大きさを$L$、カテゴリー変数を$C$、パラメーターを$a,b$として、

$$L=aC+b$$

という簡単な回帰モデルを考えます。もし、Cがカテゴリー変数そのままだったら、$C=(0,1,2)$と行った値が代入されますが、このような式(データ)から花の長さLを予測するのはかなり難しいです。なぜなら、カテゴリー変数の「値」と花の長さに関係がないからです。

サンプルプログラムを書いてみました。クラス0,1,2に対して50個ずつ、計150個サンプルを擬似的に作りました。ただの単回帰分析でやってみます。

import numpy as np
from sklearn.linear_model import LinearRegression
from sklearn.metrics import r2_score
import matplotlib.pyplot as plt

np.random.seed(72)
# カテゴリー0は花が大きい、1は極めて小さい、2は普通
L = np.concatenate((np.random.normal(20.0, 2.0, size=50),
                    np.random.normal(1.0, 0.1, size=50),
                    np.random.normal(5.0, 1.0, size=50))).reshape(-1,1)
C = np.concatenate((np.repeat(0, 50), np.repeat(1, 50), np.repeat(2, 50))).reshape(-1,1)

regressor = LinearRegression()
regressor.fit(C, L)
L_pred = regressor.predict(C)

# R2スコア
r2 = r2_score(L, L_pred)

# 予測値と真の値のプロット
xlabel = np.arange(150)
plt.subplot(2,1,1)
plt.scatter(xlabel, L[:,0])
plt.title("True")
plt.subplot(2,1,2)
plt.scatter(xlabel, L_pred[:,0])
plt.title("Pred")
plt.suptitle("R2 = "+str(r2))
plt.show()

one-hot-01.png

1つ目のクラスは予測できていますが、2つ目のクラスがダメダメです。相関係数も0.546と低いです。しかし、これは単回帰分析だから精度が悪いのではありません。カテゴリー変数(特徴量)の扱いがダメなのです。

データの生成と回帰モデルとの予測の間にOne-Hotエンコーディングをはさみます。これはSklearnのOneHotEncoderでやらせています。

from sklearn.preprocessing import OneHotEncoder

encoder = OneHotEncoder(categories="auto", sparse=False)
C_onehot = encoder.fit_transform(C)

regressor = LinearRegression()
regressor.fit(C_onehot, L)
L_pred = regressor.predict(C_onehot)

One-Hotエンコーディングをはさむと単回帰でもグッと精度が上がります。

one-hot-02.png

カテゴリー変数をOne-Hotベクトルに変えただけで、相関係数が0.546→0.972まで上がりました。2番目のクラスもちゃんと予測できていますね。「回帰モデルが悪いのではなく、特徴量の扱いがダメ」という教科書に載せられそうな美しい例ですね。

One-Hotベクトルのデメリット

何でもかんでもOne-Hotベクトルにすればいいという話ではなくて、ちゃんとデメリットもあります。それはメモリ使用量や計算量が爆発的に増えるということです。

理解しやすいのはメモリ使用量です。データが150個程度だと気にすることはありませんが、例えばデータが100万個でカテゴリーが1000個だったとします。ディープラーニングで使うことも考えて、最大でfloat32(4バイト変数)の型を割り当てるとします1。このメモリサイズは「1M×1k×4≒4GB」も必要です。One-Hotエンコーディングしない場合だったら1M×4≒4MBで十分です。

メモリ問題の対策としてのSparse行列

計算量の問題まで加味すると何らかの次元削減を検討する必要がありますが、メモリ問題だけならSparse行列(疎行列)を使えばOKです。例えば先程のSklearnのOneHotEncoderならオプションの「sparse=True」にするだけで疎行列で出力できます。

encoder = OneHotEncoder(categories="auto", sparse=True)
C_onehot = encoder.fit_transform(C)
print(C_onehot)
出力
  (0, 0)        1.0
  (1, 0)        1.0
  (2, 0)        1.0
  (3, 0)        1.0
  (4, 0)        1.0
  (5, 0)        1.0
  (6, 0)        1.0
  (7, 0)        1.0
  (8, 0)        1.0
  (9, 0)        1.0
 : :

このように1でない成分のインデックスと値を記録するというデータ構造です。One-Hotベクトルのようにほとんどが0の行列を扱う場合には強い味方です。

PandasのGroupbyとSparse行列の†闇†

ここからが†闇†の話です。先程はOneHotエンコーディングにSklearnのOneHotEncoderを使いましたが、より統合的な手法としてPandasのget_dummies()という関数があります。これはNanを勝手に落としてくれる機能があったり優れものです2

get_dummies(sparse=False)(というか密行列一般?)でgroupbyをする場合は全く問題ありません。例えば次のようなデータにおいて、「class」変数をOneHotエンコーディングして、「group」変数でグルーピングするものとします。ここからapply関数を使ってグルーピングされたDataFrameをそのまま出力する関数を実行します。

import pandas as pd
import numpy as np

def identity(x):
    print(x)

df = pd.DataFrame({"some_value":np.arange(5),
                   "class":np.array([3,0,1,3,-1]),
                   "group":np.array([0,0,0,1,1])})
df = pd.get_dummies(df, columns=["class"], sparse=False)
print(df)
df.groupby("group").apply(identity)

出力
   some_value  group  class_-1  class_0  class_1  class_3
0           0      0         0        0        0        1
1           1      0         0        1        0        0
2           2      0         0        0        1        0
3           3      1         0        0        0        1
4           4      1         1        0        0        0
   some_value  group  class_-1  class_0  class_1  class_3
0           0      0         0        0        0        1
1           1      0         0        1        0        0
2           2      0         0        0        1        0
   some_value  group  class_-1  class_0  class_1  class_3
0           0      0         0        0        0        1
1           1      0         0        1        0        0
2           2      0         0        0        1        0
   some_value  group  class_-1  class_0  class_1  class_3
3           3      1         0        0        0        1
4           4      1         1        0        0        0

このように、ちゃんとOneHotベクトルもグルーピングされました。「group=0」の場合だけ2回呼ばれていますが、これは仕様らしいです。

上のコードは密行列の場合はちゃんと動きますが、疎行列にすると闇の魔術になります。疎行列の場合はget_dummiesで「sparse=True」とします。

df = pd.get_dummies(df, columns=["class"], sparse=True)
疎行列の場合
   some_value  group  class_-1  class_0  class_1  class_3
0           0      0         0        0        0        1
1           1      0         0        1        0        0
2           2      0         0        0        1        0
3           3      1         0        0        0        1
4           4      1         1        0        0        0
   some_value  group  class_-1  class_0  class_1  class_3
0           0      0         0        0        0        1
1           1      0         0        1        0        0
2           2      0         0        0        1        0
   some_value  group  class_-1  class_0  class_1  class_3
0           0      0       NaN      NaN      NaN      NaN
1           1      0       NaN      NaN      NaN      NaN
2           2      0       NaN      NaN      NaN      NaN
   some_value  group  class_-1  class_0  class_1  class_3
3           3      1       NaN      NaN      NaN      NaN
4           4      1       NaN      NaN      NaN      NaN

Nanが発生してしまいました。ちなみにapplyの関数の中で他のsome_valueでソートするともっと意味不明なことがおこります。

def identity(x):
    x = x.sort_values("some_value", 0, False)
    print(x)
applyの関数内でソート
   some_value  group  class_-1  class_0  class_1  class_3
0           0      0         0        0        0        1
1           1      0         0        1        0        0
2           2      0         0        0        1        0
3           3      1         0        0        0        1
4           4      1         1        0        0        0
   some_value  group  class_-1  class_0  class_1  class_3
2           2      0         0        0        1        0
1           1      0         0        1        0        0
0           0      0         0        0        0        1
   some_value  group  class_-1  class_0  class_1  class_3
2           2      0         0        0        0        0
1           1      0         0        0        0        0
0           0      0         0        0        0        0
   some_value  group  class_-1  class_0  class_1  class_3
4           4      1         0        0        0        0
3           3      1         0        0        0        0

Nanではなく、One-Hotベクトルの値が消えます。自分はこれで1日走らせた前処理が全部無駄になりました。

原因はapplyの関数の中で返り値がなかったから

ちなみにこの疎行列でGroupbyしたときにNanが出る現象を回避する方法はあって、applyで食わす関数の返り値にDataFrameを入れるということです。

恒等出力の場合はreturnで自分自身を返す

最初のidentityの場合は「return x」を入れましょう。

def identity(x):
    print(x)
    return x # これを追加
自分自身を返す場合
   some_value  group  class_-1  class_0  class_1  class_3
0           0      0         0        0        0        1
1           1      0         0        1        0        0
2           2      0         0        0        1        0
3           3      1         0        0        0        1
4           4      1         1        0        0        0
   some_value  group  class_-1  class_0  class_1  class_3
0           0      0         0        0        0        1
1           1      0         0        1        0        0
2           2      0         0        0        1        0
   some_value  group  class_-1  class_0  class_1  class_3
0           0      0         0        0        0        1
1           1      0         0        1        0        0
2           2      0         0        0        1        0
   some_value  group  class_-1  class_0  class_1  class_3
3           3      1         0        0        0        1
4           4      1         1        0        0        0

これで期待された結果が出ました。

ソートする場合は、さらにsort_valuesの中で「inplace=True」を追加する

ソートする場合は、ソートしたDataFrameを返すだけではうまくいきません。

ダメな例
def identity(x):
    x = x.sort_values("some_value", 0, False)
    print(x)
    return x
OneHotベクトルが消えたまま
   some_value  group  class_-1  class_0  class_1  class_3
2           2      0         0        0        0        0
1           1      0         0        0        0        0
0           0      0         0        0        0        0
   some_value  group  class_-1  class_0  class_1  class_3
4           4      1         0        0        0        0
3           3      1         0        0        0        0

さらに、sort_valuesの中で「inplace=True」を追加して、元の引数のDataFrameを上書きするようにします。

OKな例
def identity(x):
    x.sort_values("some_value", 0, False, inplace=True)
    print(x)
    return x
OneHotベクトルが戻る
   some_value  group  class_-1  class_0  class_1  class_3
0           0      0         0        0        0        1
1           1      0         0        1        0        0
2           2      0         0        0        1        0
3           3      1         0        0        0        1
4           4      1         1        0        0        0
   some_value  group  class_-1  class_0  class_1  class_3
2           2      0         0        0        1        0
1           1      0         0        1        0        0
0           0      0         0        0        0        1
   some_value  group  class_-1  class_0  class_1  class_3
2           2      0         0        0        1        0
1           1      0         0        1        0        0
0           0      0         0        0        0        1
   some_value  group  class_-1  class_0  class_1  class_3
4           4      1         1        0        0        0
3           3      1         0        0        0        1

これでようやく期待された出力が得られました。ちなみに、これ「疎行列が悪いんじゃん。applyの中で密行列に変換すればよくね?」というのはダメです。相変わらずOneHotベクトルが消えました。

再びダメな例
def identity(x):
    dense = x.to_dense()
    dense = dense.sort_values("some_value", 0, False)
    print(dense)
    return dense

Sprase行列のGruopby、非常に†闇†が深いなと思いました。大きいデータでやる前に、簡単な例で期待された出力になるか確認するのを強く推奨します。returnを省略したり、ソートの置き換えをしなくても疎行列ではない場合はうまく行ってしまいます。

なぜreturnを入れるのを忘れてしまったかというと、Pandasがただのメソッドチェーンとして使えて便利だったからです。密行列で試してうまく行ったから、疎行列でもうまくいくだろうと思ったのが浅はかでした。おそらくですが、密行列は値コピーされるが、疎行列の場合シャローコピーで参照しか渡されないのでしょうね。メソッド内のprintなのに、returnを入れればちゃんと機能するのが†闇†。

(やっぱりapplyで最初のグループを2回呼んでる仕様がいけないんじゃないの)

教訓

Sparse行列のGroupbyは闇が深いが、

  • 必ずapplyの関数で、DataFrameを返すようにする
  • 疎行列の参照を考えて、メソッド内の置き換え操作は「inplace=True」などで上書きするようにする

とすると防衛術になるのではないかということでした。

正直疎行列なんて小手先の方法使わないで、Pandasのバックエンドのデータ構造をSQL等(SQLでなくても例えばRedisをバックエンドにしたらどうだろう?)にして、全てインメモリでやらせるのではなく永続化しながらストレージと連携してクエリ操作をする、データベースとPandasをもっと密な形で動かせるライブラリが欲しいなと思いました。これだったらメモリ問題まず解消すると思います。


  1. 実際にOne-Hotベクトルに変換するときはもっとコンパクトな型(Pandasのget_dummiesだったらデフォルトはnp.uint8)で十分ですが、どこかで分類器に食わせるときにもっと高価な型が必要になることがあります。 

  2. ここらへんに書いた https://blog.shikoan.com/pandas-get-dummies/ 

76
52
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
76
52