[TensorFlow] OptimizerにもWeightがあるなんて

# はじめに

TensorFlow (Keras) のLayerオブジェクトは重み（Weight）を持っています。全結合層 ${\bf y} = {\bf W} {\bf x} + {\bf b}$ の行列 ${\bf W}$ とバイアスのベクトル ${\bf b}$ が代表的ですね。

実はOptimizerにもWeightがあります。学習を中断して続きから再開するとか、Fine-Tuningを行うときなど、「すでに途中まで学習が進んでいる」状態では、このWeightの存在を意識しないと痛い目に遭うという話です。何を今更と言われるかもしれませんが。

# 検証環境

- Ubuntu 18.04
- Python 3.6.9
- TensorFlow 2.1.0 (CPU)

# 現象

まずはハマった内容をご紹介しましょう。
3エポック学習させた後、Optimizerを最初と同じ設定で再作成し、モデルを再コンパイルした後、同じデータで再度3エポック回します。実行している本人は、全部で6エポック分学習させた気になっていますが…？
（分かりやすいように例を単純化しています）

```python
import numpy as np
import tensorflow as tf
from tensorflow.keras.datasets import mnist
from tensorflow.keras.layers import Dense, Input
from tensorflow.keras.optimizers import Adam
from tensorflow.keras.models import Model

# 乱数シード固定
tf.random.set_seed(1)

# データ作成：現象が分かりやすいようにSubsetで試す
(x_train, y_train), (x_test, y_test) = mnist.load_data()
x_train = x_train[0:1000, :]
y_train = y_train[0:1000]
x_test = x_test[0:100, :]
y_test = y_test[0:100]

feature_dim = 28*28
num_classes = 10

# 前処理をする
# 画素は[0, 1]のfloat32型に変換する
x_train = x_train.reshape((-1, feature_dim)).astype("float32") / 255.0
x_test  = x_test.reshape((-1, feature_dim)).astype("float32") / 255.0
# ラベルもfloat32型にする
y_train = y_train.reshape((-1, 1)).astype("float32")
y_test  = y_test.reshape((-1, 1)).astype("float32")

# モデル定義
layer_input = Input(shape=(feature_dim,))
fc1 = Dense(512, activation="relu")(layer_input)
layer_output = Dense(num_classes, activation="softmax")(fc1)
model = Model(layer_input, layer_output)
model.summary()

optimizer = Adam(lr=0.003)
model.compile(
    loss="sparse_categorical_crossentropy",
    optimizer=optimizer,
    metrics=["accuracy"])

# まずは普通に3epoch学習する
epochs = 3
batch_size = 32
model.fit(
    x=x_train,
    y=y_train,
    batch_size=batch_size,
    epochs=epochs,
    verbose=1,
    validation_data=(x_test, y_test))

# Layerの重みを引き継いで続きから学習するつもりで
# Modelを再コンパイル
optimizer = Adam(lr=0.003)
model.compile(
    loss="sparse_categorical_crossentropy",
    optimizer=optimizer,
    metrics=["accuracy"])
# もう3epoch学習
model.fit(
    x=x_train,
    y=y_train,
    batch_size=batch_size,
    epochs=epochs,
    verbose=1,
    validation_data=(x_test, y_test))
```

結果、このようなことが起きます。

```
Train on 1000 samples, validate on 100 samples
Epoch 1/3
1000/1000 [==============================] - 1s 919us/sample - loss: 0.8494 - accuracy: 0.7360 - val_loss: 0.3652 - val_accuracy: 0.9000
Epoch 2/3
1000/1000 [==============================] - 0s 225us/sample - loss: 0.2397 - accuracy: 0.9340 - val_loss: 0.3003 - val_accuracy: 0.9000
Epoch 3/3
1000/1000 [==============================] - 0s 209us/sample - loss: 0.1146 - accuracy: 0.9700 - val_loss: 0.2830 - val_accuracy: 0.9000
Train on 1000 samples, validate on 100 samples
Epoch 1/3
1000/1000 [==============================] - 1s 787us/sample - loss: 0.1515 - accuracy: 0.9560 - val_loss: 0.3035 - val_accuracy: 0.9200
Epoch 2/3
1000/1000 [==============================] - 1s 575us/sample - loss: 0.0608 - accuracy: 0.9820 - val_loss: 0.2220 - val_accuracy: 0.9300
Epoch 3/3
1000/1000 [==============================] - 0s 384us/sample - loss: 0.0190 - accuracy: 0.9980 - val_loss: 0.2319 - val_accuracy: 0.9100
```

注目していただきたいのは、**最初の3エポック目のTraining Lossよりも、次の1エポック目のTraining Lossのほうが大きくなってしまっている (0.1146 → 0.1515)** 点です。

一度に6エポック学習させた場合は、以下のように綺麗にTraining Lossが落ちていきます。
（乱数シードを固定しているため、最初の3エポック分の結果は全く同じです）

```python
epochs = 6
batch_size = 32
model.fit(
    x=x_train,
    y=y_train,
    batch_size=batch_size,
    epochs=epochs,
    verbose=1,
    validation_data=(x_test, y_test))
```

```
Train on 1000 samples, validate on 100 samples
Epoch 1/6
1000/1000 [==============================] - 1s 911us/sample - loss: 0.8494 - accuracy: 0.7360 - val_loss: 0.3652 - val_accuracy: 0.9000
Epoch 2/6
1000/1000 [==============================] - 0s 291us/sample - loss: 0.2397 - accuracy: 0.9340 - val_loss: 0.3003 - val_accuracy: 0.9000
Epoch 3/6
1000/1000 [==============================] - 0s 427us/sample - loss: 0.1146 - accuracy: 0.9700 - val_loss: 0.2830 - val_accuracy: 0.9000
Epoch 4/6
1000/1000 [==============================] - 0s 279us/sample - loss: 0.1008 - accuracy: 0.9710 - val_loss: 0.2584 - val_accuracy: 0.9200
Epoch 5/6
1000/1000 [==============================] - 0s 266us/sample - loss: 0.0420 - accuracy: 0.9950 - val_loss: 0.2393 - val_accuracy: 0.9300
Epoch 6/6
1000/1000 [==============================] - 0s 305us/sample - loss: 0.0188 - accuracy: 0.9990 - val_loss: 0.2104 - val_accuracy: 0.9200
```

今回は簡単な例なのですぐにまたTraining Lossが小さくなっていきますが、複雑な問題だとせっかく時間を掛けて途中まで学習させた成果が台無しになってしまいます。

## 何が起きたの？

ここで冒頭の「OptimizerにもWeightがあります」という話につながります。
Layerの重みを更新する場合、古典的な手法（SGD: 確率的勾配降下法）では、重みから「損失関数の偏微分係数に学習率を掛けたもの」を引きます。しかし、収束に時間が掛かるので、過去の情報に基づいて重みの変化量をうまく調整し、速く収束させる工夫が考えられてきました。

[Optimizer : 深層学習における勾配法について - Qiita](https://qiita.com/tokkuman/items/1944c00415d129ca0ee9)

ところが、Optimizerオブジェクトを何も考えずに再作成してしまうと、この**「過去の情報」が消えてしまい、重みの調整に悪影響を及ぼしてしまいます。この「過去の情報」こそがOptimizerのWeightです。**
上の参考ページの数式で言えば、$h_t, m_t, v_t$ のように、Layerの重み ${\bf w}_t$ 以外の情報を使って重みの更新を行っているアルゴリズムが存在しますね。もし学習を一旦中断し、続きから実行したい場合には、これらの情報を保存しておかなければなりません。

以下のようにすると、Optimizerオブジェクトに含まれるWeightの値を確認できます。

```python
print(optimizer.get_weights())
print(model.optimizer.get_weights()) # ModelのプロパティでもOptimizerを取得可能
```

Optimizerの再作成とモデルの再コンパイルを行わない場合を試しましょう。以下のコードのうち2回目の方をコメントアウトします。

```python
# optimizer = Adam(lr=0.003)
# model.compile(
#     loss="sparse_categorical_crossentropy",
#     optimizer=optimizer,
#     metrics=["accuracy"])
```

すると、2回目の学習を始めたときもTraining Lossはちゃんと減少しています。

```
Train on 1000 samples, validate on 100 samples
Epoch 1/3
1000/1000 [==============================] - 1s 1ms/sample - loss: 0.8494 - accuracy: 0.7360 - val_loss: 0.3652 - val_accuracy: 0.9000
Epoch 2/3
1000/1000 [==============================] - 0s 278us/sample - loss: 0.2397 - accuracy: 0.9340 - val_loss: 0.3003 - val_accuracy: 0.9000
Epoch 3/3
1000/1000 [==============================] - 0s 307us/sample - loss: 0.1146 - accuracy: 0.9700 - val_loss: 0.2830 - val_accuracy: 0.9000
Train on 1000 samples, validate on 100 samples
Epoch 1/3
1000/1000 [==============================] - 0s 243us/sample - loss: 0.1010 - accuracy: 0.9710 - val_loss: 0.2640 - val_accuracy: 0.9200
Epoch 2/3
1000/1000 [==============================] - 0s 263us/sample - loss: 0.0355 - accuracy: 0.9960 - val_loss: 0.2206 - val_accuracy: 0.9100
Epoch 3/3
1000/1000 [==============================] - 0s 235us/sample - loss: 0.0132 - accuracy: 1.0000 - val_loss: 0.2102 - val_accuracy: 0.9200
```

## 古典的なOptimizer (SGD) の場合

SGDを使う場合、時間によって変化する量はLayerの重み ${\bf w}_t$ だけであり、それ以外の内部状態を持たないので、この影響を受けないと思われます。試しに ``Adam`` の代わりに ``SGD`` を使ってみます。

```python
import numpy as np
import tensorflow as tf
from tensorflow.keras.datasets import mnist
from tensorflow.keras.layers import Dense, Input
#from tensorflow.keras.optimizers import Adam
from tensorflow.keras.optimizers import SGD
from tensorflow.keras.models import Model

# (中略)

optimizer = SGD(lr=0.003)
model.compile(
    loss="sparse_categorical_crossentropy",
    optimizer=optimizer,
    metrics=["accuracy"])

# まずは普通に3epoch学習する
epochs = 3
batch_size = 32
model.fit(
    x=x_train,
    y=y_train,
    batch_size=batch_size,
    epochs=epochs,
    verbose=1,
    validation_data=(x_test, y_test))

# Layerの重みを引き継いで続きから学習するつもりで
# Modelを再コンパイル
optimizer = SGD(lr=0.003)
model.compile(
    loss="sparse_categorical_crossentropy",
    optimizer=optimizer,
    metrics=["accuracy"])
# もう3epoch学習
model.fit(
    x=x_train,
    y=y_train,
    batch_size=batch_size,
    epochs=epochs,
    verbose=1,
    validation_data=(x_test, y_test))
```

以下のように、Training Lossは減少し続けています。もっとも、``Adam`` より収束がかなり遅いのですが。

```
Train on 1000 samples, validate on 100 samples
Epoch 1/3
1000/1000 [==============================] - 1s 848us/sample - loss: 2.2205 - accuracy: 0.2150 - val_loss: 2.1171 - val_accuracy: 0.4200
Epoch 2/3
1000/1000 [==============================] - 0s 227us/sample - loss: 2.0913 - accuracy: 0.3970 - val_loss: 2.0141 - val_accuracy: 0.5700
Epoch 3/3
1000/1000 [==============================] - 0s 226us/sample - loss: 1.9763 - accuracy: 0.5210 - val_loss: 1.9227 - val_accuracy: 0.5900
Train on 1000 samples, validate on 100 samples
Epoch 1/3
1000/1000 [==============================] - 1s 738us/sample - loss: 1.8722 - accuracy: 0.5890 - val_loss: 1.8374 - val_accuracy: 0.6700
Epoch 2/3
1000/1000 [==============================] - 0s 211us/sample - loss: 1.7773 - accuracy: 0.6350 - val_loss: 1.7565 - val_accuracy: 0.7000
Epoch 3/3
1000/1000 [==============================] - 0s 277us/sample - loss: 1.6879 - accuracy: 0.6660 - val_loss: 1.6832 - val_accuracy: 0.7100
```

先ほどと同様に、6エポック一気に学習した場合はこんな感じです。2回に分けた場合とほぼ変わりません。
（4エポック目以降の値が微妙に異なりますが、この誤差はどこから来るのでしょうね？）

```
Epoch 1/6
1000/1000 [==============================] - 1s 802us/sample - loss: 2.2205 - accuracy: 0.2150 - val_loss: 2.1171 - val_accuracy: 0.4200
Epoch 2/6
1000/1000 [==============================] - 0s 233us/sample - loss: 2.0913 - accuracy: 0.3970 - val_loss: 2.0141 - val_accuracy: 0.5700
Epoch 3/6
1000/1000 [==============================] - 0s 259us/sample - loss: 1.9763 - accuracy: 0.5210 - val_loss: 1.9227 - val_accuracy: 0.5900
Epoch 4/6
1000/1000 [==============================] - 0s 266us/sample - loss: 1.8719 - accuracy: 0.5920 - val_loss: 1.8376 - val_accuracy: 0.6500
Epoch 5/6
1000/1000 [==============================] - 0s 315us/sample - loss: 1.7764 - accuracy: 0.6410 - val_loss: 1.7552 - val_accuracy: 0.6900
Epoch 6/6
1000/1000 [==============================] - 0s 273us/sample - loss: 1.6873 - accuracy: 0.6680 - val_loss: 1.6806 - val_accuracy: 0.7100
```

``SGD`` にはWeightはありません…と思いきや、謎の値が出てきます。

```python
print(optimizer.get_weights())
# Result: [192]
```

いろいろ試したところ、どうも学習した累計バッチ数が入っているようです。先ほどの参考ページの記法でいえば $t$ ですね。ただSGDの場合、$t$ の値そのものは（手法の原理上）重みの更新に影響を及ぼさないはずですので、これ以上は深く立ち入りません（例えばAdamは $t$ の値も使っていますね）。

# 対処法

今回のような面倒事にならないようにする方法としては

1. 数エポック学習させた後、同じデータで続きから学習させる場合は、OptimizerのWeightを保存しておく
2. Fine-Tuningなど、最初とは別のデータで続きから学習させる場合は、Optimizerとして内部状態を持たないSGDを使う (Momentum-SGDは使っても大丈夫のはず)

のが良いのではと思われます（特に2点目は自信なし…）。

## OptimizerのWeightを保存する方法

Modelオブジェクトに対し ``save()`` すれば、LayerのWeightだけでなく、設定したOptimizerの情報やWeightの値も含めてファイルに保存できます。

```python
model.save("model.h5", save_format="h5")
exit()
```

``tf.keras.models.load_model()`` でモデルを読み込むと、前の情報を引き継いだ状態で学習できます。
独自のLayerやCallbackを指定して作成したモデルの場合は ``custom_objects`` 引数を指定する必要がありますが、ここでは触れません。
[tf.keras.models.load_model  |  TensorFlow Core v2.1.0](https://www.tensorflow.org/api_docs/python/tf/keras/models/load_model)

```python
import tensorflow as tf
from tensorflow.keras.datasets import mnist

feature_dim = 28*28
num_classes = 10

# データ作成
(x_train, y_train), (x_test, y_test) = mnist.load_data()
x_train = x_train[0:1000, :]
y_train = y_train[0:1000]
x_test = x_test[0:100, :]
y_test = y_test[0:100]

# 前処理をする
# 画素は[0, 1]のfloat32型に変換する
x_train = x_train.reshape((-1, feature_dim)).astype("float32") / 255.0
x_test  = x_test.reshape((-1, feature_dim)).astype("float32") / 255.0
# ラベルもfloat32型にする
y_train = y_train.reshape((-1, 1)).astype("float32")
y_test  = y_test.reshape((-1, 1)).astype("float32")

# モデル読み込み
model = tf.keras.models.load_model("model.h5")
# print(model.optimizer.get_weights())

# 続きから学習
epochs = 3
batch_size = 32
model.fit(
    x=x_train,
    y=y_train,
    batch_size=batch_size,
    epochs=epochs,
    verbose=1,
    validation_data=(x_test, y_test))
```

# その他

[Fine-tuningを使って少ない画像データから効率よく学習モデルを作成する方法 | AI coordinator](https://ai-coordinator.jp/fine-tuning)

> \# TODO: ここでAdamを使うとうまくいかない
> \# Fine-tuningのときは学習率を小さくしたSGDの方がよい？

とコメントが書かれていますが、おそらく同じ現象が起きているかもしれません？