kerasモデルに特徴量を選ぶ層を仕込んだ

kerasモデルの1段目に必要な列だけ取り出すレイヤーを入れる方法を模索したまとめです。３つ試して行列積でやる方法にしました。

# 何故やったか

特徴量を大量に作っておいて

- いろんな特徴量の組み合わせで学習したり
- 作ったモデル同士をアンサンブルしたり
- アンサンブルしたモデルを教師モデルとして蒸留したり

という事をやっていると、モデルごとに適切な特徴量を渡すコードを書くのがめちゃめちゃ面倒です。面倒って事は間違えやすいって事ですし、出来るだけ避けたいところです。

そこで、kerasモデルの1段目に必要な列だけを取り出すレイヤーを仕込むことにしました。kerasモデル内に入っていれば単一のhdf5ファイルとして書き出すことができますし、入力データの形が常に同じであればアンサンブルモデルを作成するときも簡単です。

# 試行環境

Windows10
AMD Ryzen7 1700X
GTX1080Ti
Tensorflow2.0.0RC0
jupyter notebook

# やり方と結果

MNISTをFlattenした784列を特徴量に見立てて、特徴量10個(10列)を選んで学習した場合と特徴量300個(300列)を選んで学習した場合の所要時間を比較しました。

| やり方       |       特徴量10個 |    特徴量300個    |
|:-----------------|:------------------:|:-------------------:|
| 1. tf.feature_column |     遅い    |   遅い  |
| 2. Lambda(lambda x: x[col:col+1])  |     60秒   |   103秒  |
| 3. Lambda(lambda x: tf.tensordot(x, A)  |    59秒   |   62秒       |

1は上手くいかなかったし2は遅いので、最終的に3の行列積を使うやり方を採用しました。

# packageを読み込む

```python:python
import numpy as np
import pandas as pd
import tensorflow as tf
from sklearn.model_selection import train_test_split

if int(tf.__version__.split('.')[0]) >= 2:
    from tensorflow import keras
else:
    import keras
```

# 1. tf.feature_columns + tf.keras.layers.Densefeature

まず、tensorflow公式の特徴量選択用のメソッドを試しました。
https://www.tensorflow.org/beta/tutorials/keras/feature_columns

tf.feature_columnsは文字列特徴量をone-hotエンコーディングしてくれたり、特徴量同士を掛け合わせも出来るようになっていたりと結構良さそうなんですが、tf.dataでラップしなきゃいけなかったり、functional APIでのやり方がどうしても見つけられなかったり、学習時間もえらくかかるようになってしまって使うのを断念しました。

```python:python
n = 10
cols = ['col_'+str(each) for each in np.arange(784)]
select_cols = [col for col in np.sort(np.random.permutation(cols)[:n])]

mnist = keras.datasets.mnist
(x_train, y_train), (x_test, y_test) = mnist.load_data()

x_train = pd.DataFrame(x_train.reshape(60000, 784), columns=cols)
x_test = pd.DataFrame(x_test.reshape(10000, 784), columns=cols)
x_train, x_valid, y_train, y_valid = train_test_split(x_train, y_train, test_size=0.2)
print(x_train.shape, y_train.shape, x_test.shape, y_test.shape)

# Pandasデータフレームからtf.dataデータセットを作るためのユーティリティメソッド
def df_to_dataset(x, y, shuffle=True, batch_size=32):
    ds = tf.data.Dataset.from_tensor_slices((dict(x), y))
    if shuffle:
        ds = ds.shuffle(buffer_size=len(x))
    ds = ds.batch(batch_size)
    return ds

batch_size = 32
train_ds = df_to_dataset(x_train, y_train, batch_size=batch_size)
val_ds = df_to_dataset(x_valid, y_valid, shuffle=False, batch_size=batch_size)
test_ds = df_to_dataset(x_test, y_test, shuffle=False, batch_size=batch_size)

for feature_batch, label_batch in train_ds.take(1):
    print('Every feature:', list(feature_batch.keys()))
    print('A batch of ages:', feature_batch['col_553'])
    print('A batch of targets:', label_batch )

feature_columns = [tf.feature_column.numeric_column(col) for col in select_cols]

model = keras.Sequential([
    keras.layers.DenseFeatures(feature_columns),
    keras.layers.Dense(128, activation='relu'),
    keras.layers.Dropout(0.2),
    keras.layers.Dense(10, activation=None),
    keras.layers.Activation('softmax')
])

# モデルをcompileします
model.compile(optimizer='adam',
              loss='sparse_categorical_crossentropy',
              metrics=['accuracy'])

# 学習して結果を出力します
model.fit(train_ds, 
          validation_data=val_ds, 
          epochs=10, verbose=1)
print(model.evaluate(train_ds, verbose=0))
print(model.evaluate(val_ds, verbose=0))
print(model.evaluate(test_ds, verbose=0))
```
僕の環境で上記を実行するとmodel.fitに1時間以上かかります。何か間違えてるのかな…？結局functional APIでやる方法も分からなかったので断念しました。

# 2. Lambdaで分割してからconcatenateする

公式なやり方がダメだったので、わりと何でもできるkeras.layers.Lambdaを使ってやってみます。例えば以下のようにすれば左から5列だけとり出すことができます。

```python:python
inputs = keras.layers.Input(input_shape=(100,))
x1 = keras.layers.Lambda(lambda x: x[:, :5])(inputs)
```

numpy.arrayだと arr[:, [2,8,56,201]] みたいにして簡単に列を拾えるので同じようにやってみたところ、keras.layers.Lambda()がこの書き方に対応してないようでダメでした。なので以下のように切り出してからconcatenateすることにします。

```python:python
inputs = keras.layers.Input(input_shape=(100,))
x1 = keras.layers.Lambda(lambda x: x[:, :5])(inputs)
x2 = keras.layers.Lambda(lambda x: x[:, 12:20])(inputs)
x = keras.layers.concatenate([x1, x2])
```
1列単位で取り出せるようにしてMNISTを学習したのが以下です。

```python:python
%%time
n = 10
select_cols = np.sort(np.random.permutation(np.arange(784))[:n])

# モデル構造を定義します
inputs = keras.layers.Input(shape=(28, 28))
x = keras.layers.Flatten()(inputs)
x = [keras.layers.Lambda(lambda x: x[:, col:col+1], output_shape=(1,))(x) for col in select_cols]
x = keras.layers.concatenate(x)
x = keras.layers.Dense(128, activation='relu')(x)
x = keras.layers.Dropout(0.2)(x)
x = keras.layers.Dense(10, activation=None)(x)
predictions = keras.layers.Activation('softmax')(x)

# 入出力を定義します
model = keras.Model(inputs=inputs, outputs=predictions)

# モデルをcompileします
model.compile(optimizer='adam',
              loss='sparse_categorical_crossentropy',
              metrics=['accuracy'])

# 学習して結果を出力します
hist = model.fit(x_train, y_train, validation_split=0.1, epochs=10, verbose=0)
print(model.evaluate(x_train, y_train, verbose=0))
print(model.evaluate(x_test, y_test, verbose=0))
```

動作自体は問題ないですが特徴量が増えるとグラフノードが増えるので遅くなります。特徴量10個だと60秒で学習終了するのが特徴量300個だと103秒になってしまうのでイマイチです。

# 3. Lambdaで行列積する

別の手を試します。下みたいに行列積をやれば必要な列だけ取り出すことができます。行列の計算はCUDAとtensorflowが得意とするところですし高速にやってくれそうです。

```math
\begin{bmatrix}
1 & 2 & 3 & 4 & 5 \\
11 & 12 & 13 & 14 & 15\\
21 & 22 & 23 & 24 & 25\\
31 & 32 & 33 & 34 & 35
\end{bmatrix}
\times
\begin{bmatrix}
0 & 0 \\
1 & 0 \\
0 & 0 \\
0 & 0 \\
0 & 1
\end{bmatrix}
=
\begin{bmatrix}
2 & 5 \\
12 & 15 \\
22 & 25 \\
32 & 35 
\end{bmatrix}
```
ということで実装したのが以下です。keras.layers.Lambda()内のxはバッチ数×入力次元のテンソルになってますので、列選択用の行列の次元は入力次元×選択する列数になります。バッチ数を学習中に変更したりしないならこの方法で大丈夫だと思います。

```python:jupyter_notebook
%%time
n = 10
batch_size = 32
data_width = 784
select_cols = np.sort(np.random.permutation(np.arange(data_width))[:n])

# 選択するための行列
select_matrix = np.zeros(data_width*len(select_cols)).reshape(data_width, len(select_cols))
for j, col in enumerate(select_cols):
    select_matrix[col-1, j] = 1
tf_select_matrix = keras.backend.variable(value=select_matrix, dtype='float32', name='example_var')
print(tf_select_matrix)

# mnistを読みこみます
mnist = keras.datasets.mnist
(x_train, y_train), (x_test, y_test) = mnist.load_data()

# 0-255の値が入っているので、0-1に収まるよう正規化します
x_train, x_test = x_train / 255.0, x_test / 255.0

# データを確認
print(x_train.shape, x_test.shape); print()

# モデル構造を定義します
inputs = keras.layers.Input(shape=(28, 28))
x = keras.layers.Flatten()(inputs)
x = keras.layers.Lambda(lambda x: tf.tensordot(x, tf_select_matrix, 1))(x)
x = keras.layers.Dense(128, activation='relu', input_shape=(len(select_cols), ))(x)
x = keras.layers.Dropout(0.2)(x)
x = keras.layers.Dense(10, activation=None)(x)
predictions = keras.layers.Activation('softmax')(x)

# 入出力を定義します
model = keras.Model(inputs=inputs, outputs=predictions)

# モデルをcompileします
model.compile(optimizer='adam',
              loss='sparse_categorical_crossentropy',
              metrics=['accuracy'])

# 学習して結果を書き出し
hist = model.fit(x_train, y_train, validation_split=0.1, epochs=10, verbose=0)
print(model.evaluate(x_train, y_train, verbose=0))
print(model.evaluate(x_test, y_test, verbose=0))
```

これを実行すると特徴量10個だと59秒、300個で62秒でした。ほとんど差がなく実装できましたのでこれで良さそうです。

# まとめ
- keras.layers.Densefeatureが上手く使えなかった
- 分けてからconcatenateだと遅い
- 行列積すれば速い
- やっぱ行列計算は最高だぜ