Estimator APIの自分用チュートリアル

Estimator APIを使い始めたはいいもののチュートリアルにTF2.0でdeprecatedになってしまうものが多く含まれておりヽ（`Д´）ﾉ ｳﾜｧｧｧﾝ となってしまった自分への解説メモ。

なおチュートリアルということでデータセットにはみんな大好きタイタニックを使う。 
Notebook全体は[こちらのColaboratory](https://colab.research.google.com/drive/1D9oFbDAmea9RQl051njfo3PhStOEGYJh)にて公開している。

## TL;DR
- この記事はTensorFlow Estimator APIをとりあえず使いたい自分のためのチュートリアル
- feature_columnとかtf.estimatorとかの詳しいことは書いてない（長さの都合）
- tf.feature_columnを使って特徴量のtensorを作ろう
- tf.dataを使ってfeatureとtargetをgenerateするinput_fnを作ろう
- tf.estimatorを用いてモデル作成しよう
- 予測するときのinput_fnの作り方は模索中

## 今回使用するデータセット

[Titanic](https://www.kaggle.com/c/titanic/overview)データセットを用いる。
が、今回はチュートリアルという性格上使用するカラムを以下のように制限している。


```python
# ライブラリインポート
import os
import itertools
from time import strftime, gmtime
import numpy as np
import pandas as pd
import tensorflow as tf

df = pd.read_csv('./data/preprocessed/train.csv')
df.head()
```

| Survived | Sex | Age | Fare | Pclass | Cabin |
|:--|:--|:--|:--|:--|:--|:--|
| 0 | female | NaN | 15.2458 | 3 | NaN |
| 0 | male | 31.0 | 10.5000 | 2 | NaN |
| 0 | male | 31.0 | 37.0042 | 2 | NaN |
| 0 | male | 20.0 | 4.0125 | 3 | NaN |
| 0 | male | 21.0 | 7.2500 | 4 | NaN |

\* ここで使用しているtrain.csvは元々のtrain.csvではなくカラム制限しtrain/eval用にsplitした後のcsv

## 特徴量の作成

### 使用カラムの定義

使用するcsvファイルにどういうカラムがありそれぞれのカラムのデフォルト値が何であるかを定義する。
ここのHEADER_DEFAULTSでの型は後々重要になってくるのできちんと書くこと。

```python
HEADER = df.columns.to_list()
# ['Survived', 'Sex', 'Age', 'Fare', 'Pclass','Cabin']
HEADER_DEFAULTS = [['0'], ['female'], [30.00], [32.00], [1], ['NaN']] 
# AgeとFareのデフォルト値は平均としている
```

上記HEADERのうちSurvivedを目的変数（TARGET）として指定。
二値分類の場合stringとして渡す必要があるため変換してからリスト化する。

```python
TARGET = 'Survived'
TARGET_LABEL = list(df.Survived.map(str).unique())
# ['0','1']
```

特徴量として使用するカラムの指定。
今回連続変数として使用するカラムとカテゴリカル変数として使用するカラムの二種類があるためカラムリストを分けて定義したものも用意。
こうしておくと後々便利になる（と自分は思っている）。

CATEGORICAL_FEATURESではそれぞれのカラムをキーとして付与している値は後ほど使用する。

```python
NUMERICAL_FEATURE_NAMES = ['Age', 'Fare']

# カテゴリカル変数として使用するカラムたち
CATEGORICAL_FEATURES = {
    'Sex': list(df.Sex.unique()), # ['male', 'female']
    'Pclass': len(df.Parch.unique()), # [1,2,3]
    'Cabin': 30 # 一旦適当な数字をいれている
}
CATEGORICAL_FEATURE_NAMES = list(CATEGORICAL_FEATURES.keys())

# 特徴量として使用するカラム名のリスト
FEATURE_NAMES = NUMERICAL_FEATURE_NAMES + CATEGORICAL_FEATURE_NAMES
```

### 特徴量のデータ型定義
先ほどのカラム名リストを用いて特徴量のデータ型を定義していく
ここではそれぞれのfeature_columnに対する説明は割愛（長くなるため／[参考リンク](https://www.tensorflow.org/api_docs/python/tf/feature_column)）

#### 1. 連続変数

数値型のカラム定義は以下のようにtf.feature_column.numeric_columnを用いて行う。

```python
tmp_num_feature = tf.feature_column.numeric_column('Age')
```

tf.feature_column.numeric_columnに渡しているのはカラム名。
今回連続変数として使用するカラムは2つあるためそれぞれ作成しtensorをリスト化する。

```python
numerical_features = []
for column in NUMERICAL_FEATURE_NAMES:
    numerical_features.append(tf.feature_column.numeric_column(column))

print(numerical_features)
>>> [NumericColumn(key='Age', shape=(1,), default_value=None, dtype=tf.float32, normalizer_fn=None),
>>> NumericColumn(key='Fare', shape=(1,), default_value=None, dtype=tf.float32, normalizer_fn=None)]
```

本当はこの段階で正規化できるししなくてはいけないがごちゃごちゃするので今回は割愛。

#### 2. カテゴリカル変数

カテゴリカル変数の場合は特徴量作成の方法に応じて以下の4つのうち一つを選択する。

- tf.feature_column.categorical_column_with_hash_bucket
- tf.feature_column.categorical_column_with_identity
- tf.feature_column.categorical_column_with_vocabulary_list
- tf.feature_column.categorical_column_with_vocabulary_file

また入力するtensorはdenseである必要があるため、それぞれのカラムの定義に加え

- tf.feature_columns.indicator_column   
もしくは
- tf.feature_columns.embedding_column   

でwrapする必要がある。

##### 2.1 categorical_column_with_vocabulary_listを使う

カラムSexの定義にcategorical_column_with_vocabulary_listを用いる。
categorical_column_with_vocabulary_listではカテゴリカル変数のカテゴリをlistで渡しtensorを作成するメソッド。
先ほど作成したCATEGORICAL_FEATURESのdictはここで使用する。

```python
categorical_sex_raw = tf.feature_column.categorical_column_with_vocabulary_list('Sex', CATEGORICAL_FEATURES['Sex'], num_oov_buckets=1)
# CATEGORICAL_FEATURES['Sex'] = ['male','female']

categorical_sex = tf.feature_column.indicator_column(categorical_sex_raw)
```

ここで例えば

```
male
female
```
という内容のファイルvocab_sex.txtが存在する場合、tf.feature_column.categorical_column_with_vocabulary_fileを用いて以下のように書くこともできる。[^1]

```python
categorical_sex = \
   tf.feature_column.categorical_column_with_vocabulary_file('Sex', vocabulary_file='vocab_sex.txt', vocabulary_size=2, num_oov_buckets=1)
```

##### 2.2 categorical_column_with_identityを使う

カラムPclassは[1,2,3]というIDから成るカテゴリカル変数。
このようにIDを連続変数でなくカテゴリカル変数として扱いたいときにcategorical_column_with_identityを用いて以下のようにtensorを作成する。

```python
categorical_pclass_raw = tf.feature_column.categorical_column_with_identity(key='Pclass', num_buckets=CATEGORICAL_FEATURES['Pclass'], default_value=1)
categorical_pclass = tf.feature_column.indicator_column(categorical_pclass_raw)
```

##### 2.3 categorical_column_with_hash_bucketを用いる

カラムCabinは184種類の値のあるカテゴリカル変数。
今回はある程度のざっくりとしたカラムにするためtf.feature_column.categorical_column_with_hash_bucketを用いてカテゴリ化する。
またカテゴリ化したものを今度はembedding_columnでwrapする。


```python
categorical_embedding_dim = 128

categorical_cabin_raw = tf.feature_column.categorical_column_with_hash_bucket('Cabin', CATEGORICAL_FEATURES['Cabin'])
categorical_cabin = tf.feature_column.embedding_column(categorical_column=categorical_cabin_raw, dimension=categorical_embedding_dim)
```


最後に作成した変数を全てリストにする。

```python

# カテゴリカル変数はまとめておく
categorical_features.append(categorical_sex)
categorical_features.append(categorical_pclass)
categorical_features.append(categorical_cabin)

# 全ての特徴量をまとめる
input_features = numerical_features + categorical_features
```


## 入力データの処理

次にcsvから入力されたデータをバッチ分だけ持ってくるgenerator的なものを作成する。
Estimator APIにおけるgeneratorはinput_fnと呼ばれているらしくここではその名称に合わせて変数・関数を作成している。

まずはここの核となるcsv_input_fnの全体像を示す。

```python
def csv_input_fn(config, phase, mode=tf.estimator.ModeKeys.EVAL):
    file_names = tf.matching_files(config[phase]['filename_pattern'])
    dataset = tf.data.TextLineDataset(filenames=file_names)
    dataset = dataset.skip(config[phase]['skip_header_lines'])

    shuffle = True if mode == tf.estimator.ModeKeys.TRAIN else False
    if shuffle:
        dataset = dataset.shuffle(config[phase]['batch_size'] * 2,
                                  seed=0,
                                  reshuffle_each_iteration=True)    

    dataset = dataset.batch(config[phase]['batch_size'])
    dataset = dataset.map(lambda csv_row: parse_csv_row(csv_row)) # parse_csv_rowは自作関数（後述）
    dataset = dataset.repeat(config[phase]['num_epochs'])
    iterator = dataset.make_one_shot_iterator()

    features, target = iterator.get_next()

    return features, target
```

以下それぞれのブロックについて順を追って説明する。
なおここでは以下のようなdictをconfigとして設定している。

```
# 次のセルで使用する設定
# 個人的にはconfigparserを使っているがここでは説明のため専用のdictを作成する

config = {
    "train": {
        "filename_pattern" : "./data/preprocessed/train*",
        "batch_size" : 128,
        "max_steps" : 100000,
        "num_epochs" : 2000,
        "skip_header_lines" : 1
    },
    "eval": {
        "filename_pattern" : "./data/preprocessed/eval*",
        "batch_size" : 128,
        "max_steps" : None,
        "num_epochs" : 1,
        "skip_header_lines" : 1
    },
    "test": {
        "filename_pattern" : "./data/preprocessed/test.csv",
        "batch_size" : 128,
        "max_steps" : None,
        "num_epochs" : 1,
        "skip_header_lines" : 1
    }
}
```

#### データの読み込み

まずはcsvファイルを読み込む。
train-0.csv, train-1.csvのようにファイルが分かれていてもパターンに一致すれば読み込むようtf.matching_filesを用いる。
ヘッダがある場合はその分だけスキップするように指定する。

```python
file_names = tf.matching_files(config[phase]['filename_pattern']) # trainの場合は'/home/Titanic/data/preprocessed/train*'
dataset = tf.data.TextLineDataset(filenames=file_names)
dataset = dataset.skip(config[phase]['skip_header_lines'])
```

#### 学習データのシャッフル

学習時データの取り出し方が同じにならないようシャッフルする。
ここでは学習／評価／予測かどうかがtf.estimator.ModeKeysで指定されているのでそれがtf.estimator.ModeKeys.TRAINのときのみシャッフルするよう指定

```python
shuffle = True if mode == tf.estimator.ModeKeys.TRAIN else False
if shuffle:
    dataset = dataset.shuffle(config[phase]['batch_size'] * 2,
                              seed=0,
                              reshuffle_each_iteration=True)
```

#### データの取り出し

config内で指定したbatch_sizeの数だけデータをgenerateする。
取り出しが一回で終わらないようnum_epochs分だけ繰り返すよう引数を与える。

```python
dataset = dataset.batch(config[phase]['batch_size'])
dataset = dataset.map(lambda csv_row: parse_csv_row(csv_row))
dataset = dataset.repeat(config[phase]['num_epochs'])
iterator = dataset.make_one_shot_iterator()

features, target = iterator.get_next()
```

なおここで使用しているparse_csv_rowは以下のような関数
入力されるcsv_rowとHEADER_DEFAULTSのデータ型が合ってないとコケるので注意。[^2]

```python
def parse_csv_row(csv_row):
    columns = tf.decode_csv(csv_row, record_defaults=HEADER_DEFAULTS)
    features = dict(zip(HEADER, columns))

    target = features.pop(TARGET)
    return features, target
```

### TrainSpec / EvalSpecの作成

TrainSpecおよびEvalSpecは入力されたinput_fnやiterationの回数、さらにはhookまで管理してくれる便利なもの（[参考リンク](https://www.tensorflow.org/api_docs/python/tf/estimator/TrainSpec)）。[^3]
実際に学習を回すときはこのTrainSpecとEvalSpecを用いる。

#### TrainSpecの作成

まずはTrainSpecの作成。
train用のinput_fnを作成しそれをtf.estimator.TrainSpecに渡す。
Estimator全般そうだがepochという単位ではなくstep（iteration）という単位で学習の回数を指定することが多い（と自分は感じている）。

```python
# train用のinput_fnを作成
train_input_fn = lambda: csv_input_fn(config=config, 
                                      phase='train', 
                                      mode=tf.estimator.ModeKeys.TRAIN)

train_spec = tf.estimator.TrainSpec(input_fn=train_input_fn,
                  max_steps=int(config['train']['max_steps'])
                  )
```


#### EvalSpecの作成

TrainSpecの作成方法と大まかには同じだがモデルファイル出力用のexportersという引数や評価のタイミングに関するthrottle_secsといった引数が追加されている（いずれも詳細は割愛）。

```python
eval_input_fn = lambda: csv_input_fn(config=config,
                                     phase='eval', 
                                     mode=tf.estimator.ModeKeys.EVAL)

eval_spec = tf.estimator.EvalSpec(input_fn=eval_input_fn,
                exporters=[tf.estimator.LatestExporter(name="estimate",  
                                                       serving_input_receiver_fn=json_serving_input_fn)],
                steps=None,
                throttle_secs = 15,
                )
```

なおexporters内で使用しているjson_serving_input_fnという関数の中身は以下の通り。

```python
def json_serving_input_fn():
    receiver_tensor = {}
    for feature_name in FEATURE_NAMES:
        # 変数のデータ型をカラムに応じて指定
        if feature_name in NUMERICAL_FEATURE_NAMES:
            dtype = tf.float32
        elif feature_name == 'Pclass':
            dtype = tf.int32
        else:
            dtype = tf.string
        receiver_tensor[feature_name] = tf.placeholder(shape=[None], dtype=dtype)

    return tf.estimator.export.ServingInputReceiver(receiver_tensor, receiver_tensor)
```

### estimatorの作成

いわゆるモデル部分の作成。
モデルファイルの出力先や分散学習有無の設定用変数をあらかじめ作成する。
RunConfigも色々書くべきことあるような気がするが今回は割愛（[参考リンク](https://www.tensorflow.org/api_docs/python/tf/estimator/RunConfig)）。

```python
# モデルファイルの出力先を指定し設定ファイルを作成
raw_execute_time = gmtime()
execute_time = strftime("%Y%m%d_%H%M%S", raw_execute_time )
model_dir = os.path.join('./models/', execute_time)

# 設定ファイル（RunConfig）の作成
run_config = tf.estimator.RunConfig().replace(model_dir=model_dir)
```

なおmodel_dirを固定してしまうと毎回同じところからモデルを呼び出し→書き込みしてしまうため実行時間ごとにディレクトリを作成するようにしている。
（雑に言うと存在するmodel_dirを指定すればそれまでの学習経過を呼び出すことができる）

tf.estimatorにはPremade Estimatorsと呼ばれる出来合いのものとCustom Estimatorsという自作のものの2つが存在している。
今回は単なるNNなのでPremade Estimatorを使用するが、複雑なモデルにする場合はCustom Estimatorを自分で作る必要がある（[参考リンク](https://www.tensorflow.org/guide/premade_estimators)）。

```python
# estimatorの作成。今回はDropoutつきでDense2層+出力層という構成
dropout_prob = 0.3
hidden_units = [64, 32]

estimator = tf.estimator.DNNClassifier(
                        hidden_units = hidden_units,
                        feature_columns = input_features,
                        model_dir = model_dir,
                        n_classes = len(TARGET_LABEL),
                        label_vocabulary = TARGET_LABEL,
                        optimizer = tf.train.AdamOptimizer(),
                        activation_fn = tf.nn.relu,
                        dropout = dropout_prob,
                        config = run_config
                    )
```

### 学習

これまで作成したestimator, train_spec, eval_specをtf.estimator.train_and_evaluateに渡すだけ。

```python
tf.estimator.train_and_evaluate(estimator, train_spec, eval_spec)
```

### 予測

予測の場合もこれまで同様input_fnを作る。
今回はすでにestimatorが学習を終えているためそのestimatorを用いてpredictする。[^4]

```python
predict_input_fn = lambda: csv_input_fn(config=config, 
                                      phase='test', 
                                      mode=tf.estimator.ModeKeys.PREDICT)
predictions = estimator.predict(input_fn=predict_input_fn)
````

自分の場合予測結果の出力は以下のようにitertoolsを用いて行なっている

```python
values = list(map(lambda item: item["logistic"][0],list(itertools.islice(predictions, test_size))))
```

あとはSubmission用のcsvを作成して提出するだけ。
カラム適当に選んでモデルも適当なので当たり前だがスコアはよくなかった。

今回長さの都合で割愛しているがそれぞれのAPIもそこそこ設定すべき部分が多い。
それぞれのAPIについては誰か親切な方がまとめてくれるのだと思う（適当）。

お粗末。


[^1]: おそらく実際上はこっちの方が使いやすい。 
[^2]: 例えばstr型で['0']と渡したいのにデフォルトが[0]となっているとエラーになってしまう。
[^3]: ここには書いていないがhookにEarlyStopping用の関数を入れることもできる。
[^4]: csv_input_fn作ったからそれをそのまま使っているのだけれども、実際の予測用ファイルには目的変数の部分がないのでここをどう工夫するかはいまだ検討中（今は元データに全ての値がゼロとなるようなカラムをわざわざ追加している）。