初めまして,減衰振動を見かけて居てもたってもいられず解答する形になります.参考になれば幸いです.
アプローチの良かった点と悪かった点,謎な点を述べたうえで,改善案を示したいと思います.
- 良かったと思う点
- MSEを使ったこと
- 悪かったと思う点
- Binary CrossEntropyを使ったこと
- ReLUを使ったこと
- Sigmoidを使ったこと
- 謎な点
- BCEからMSEへの変更,標準化によるデータの加工で改善しなかったこと
異常検知の原理からの観点
オートエンコーダを用いた波形の異常検知では,オートエンコーダに特徴量抽出を行わせて,元の波形を復元するという手法をとっています.
そこで問題になるのがReLU関数を用いた入出力層です.この関数は
$$
\mathrm{ReLU}(x) = \mathrm{max}(0, x)
$$
であることからもわかる通り,負の値を0に置き換えて無視します.その上,負の値を出力することはありません.
今回対象としている波形は負の値をとることもある減衰振動となっています.
ReLUを入力層に用いた学習では,負の値を使わずに学習が進むことになってしまい,不適切な選択だと考えられます.また,負の値を出力することもないため,元の波形を復元するに至りません.
次に実践してみますので,そちらを参考に話を進めたいと思います.
データの生成
減衰振動の関数と,異常な波形の例としてただの三角関数を用意しました.
次のコードで生成を行います.
import csv
import numpy as np
from numpy.random import randn
from matplotlib import pyplot as plt
def DampedVibration(x):
return (1 + randn(1) * 0.125) * np.exp(-(1 + randn(1) * 0.125) * x) * np.cos(8 * x + randn(1) * 0.25) + randn(len(x)) * 0.03125
def Trigonometric(x):
return (1 + randn(1) * 0.125) * np.cos(8 * x + randn(1) * np.pi) + randn(len(x)) * 0.03125
class DataGenerator:
def __init__(self):
self.x = np.linspace(0, 2 * np.pi, 512)
self.y = list()
def append(self, f, num):
for _ in range(num):
self.y.append(f(self.x))
def plot(self):
for y in self.y:
plt.plot(self.x, y)
plt.grid()
plt.show()
plt.close()
def save(self, file_name):
with open(file_name, mode = "w", encoding = "utf-8", newline="") as f:
writer = csv.writer(f)
writer.writerow(self.x)
writer.writerows(self.y)
if __name__ == "__main__":
for func, file_name in zip([DampedVibration, Trigonometric], ["train.csv", "trigonometric.csv"]):
datagen = DataGenerator()
datagen.append(func, 30)
datagen.plot()
datagen.save(file_name)
得られる波形は次のようになります.各波形につき30個出力しています.(nonameさんのコードでは教師データが30とありますが300の間違いでしょうか.一応信じて30個にしています.)
実験
今回の問題は異常検知で,元の波形をオートエンコーダで出力させることを目的としているので,二値分類に用いるBinary CrossEntropyは不適です.損失関数をMSEにして話を進めます.
import csv
import numpy as np
from keras.layers import Dense, Input
from keras.models import Model
from matplotlib import pyplot as plt
def read_csv(file_name):
ret = list()
with open(file_name, "r") as f:
reader = csv.reader(f)
header = list(map(float, next(reader)))
for row in reader:
ret.append(np.array(list(map(float, row))))
return header, np.array(ret)
header, x_train = read_csv("train.csv")
def AutoEncoder():
kwargs = {
"activation": "relu",
"kernel_initializer": "he_normal"
}
inputs = Input(shape = (512,))
encoded = Dense(256, **kwargs)(inputs)
encoded = Dense(128, **kwargs)(encoded)
encoded = Dense(64, **kwargs)(encoded)
decoded = Dense(128, **kwargs)(encoded)
decoded = Dense(256, **kwargs)(decoded)
outputs = Dense(512, **kwargs)(decoded)
return Model(inputs = inputs, outputs = outputs)
model = AutoEncoder()
model.compile(
loss = 'mse',
optimizer = 'adam',
metrics = ['mae', 'mse']
)
epochs = 128
batch_size = 4
history = model.fit(
x_train, x_train,
epochs = epochs,
batch_size = batch_size,
validation_split = 0.2
)
epoch_arr = range(epochs)
plt.figure(figsize = (16, 5))
for i, param in enumerate(["loss", "mae", "mse"]):
plt.subplot(1, 3, i + 1)
plt.plot(epoch_arr, history.history[param], label = param)
plt.plot(epoch_arr, history.history["val_" + param], label = "val_" + param)
plt.ylim(bottom = 0)
plt.legend()
plt.grid()
plt.show()
_, x_test = read_csv("trigonometric.csv")
x_train_flipped = np.fliplr(x_train).copy()
err = list()
plt.figure(figsize = (16, 5))
titles = ["DampedVibration", "Trigonometric", "DV-Flipped"]
for i, (x, t) in enumerate(zip([x_train, x_test, x_train_flipped], titles)):
x_pred = model.predict(x, batch_size = batch_size)
err.append(np.sum((np.array(x) - np.array(x_pred)) ** 2, axis = 1))
plt.subplot(1, 3, i + 1)
plt.plot(header, x[0], label = "Input")
plt.plot(header, x_pred[0], label = "Output")
plt.title(t)
plt.legend()
plt.grid()
plt.show()
plt.figure(figsize = (7, 4))
plt.hist(err, bins = 64, label = titles, stacked = True)
plt.legend()
plt.grid()
plt.show()
上記のコードを用いて得られた結果が次になります.
損失関数は収束しており,
予想した通り,波形は正の部分しか出力できておりません.
残差平方和からは,減衰振動と発散振動の区別が難しいようです.
Sigmoid関数に変更してみる
以下のようにオートエンコーダの定義だけ変更してみます.
def AutoEncoder():
kwargs = {
"activation": "sigmoid",
"kernel_initializer": "he_normal"
}
inputs = Input(shape = (512,))
encoded = Dense(256, **kwargs)(inputs)
encoded = Dense(128, **kwargs)(encoded)
encoded = Dense(64, **kwargs)(encoded)
decoded = Dense(128, **kwargs)(encoded)
decoded = Dense(256, **kwargs)(decoded)
outputs = Dense(512, **kwargs)(decoded)
return Model(inputs = inputs, outputs = outputs)
次の結果が得られました.
先ほどと同様,正の値しか出力できていません.
しかし,少しだけ減衰振動と発散振動の区別がつきやすくなったように感じます.
tanh関数に変更してみる
以下のようにオートエンコーダの定義だけ変更してみます.
def AutoEncoder():
kwargs = {
"activation": "tanh",
"kernel_initializer": "he_normal"
}
inputs = Input(shape = (512,))
encoded = Dense(256, **kwargs)(inputs)
encoded = Dense(128, **kwargs)(encoded)
encoded = Dense(64, **kwargs)(encoded)
decoded = Dense(128, **kwargs)(encoded)
decoded = Dense(256, **kwargs)(decoded)
outputs = Dense(512, **kwargs)(decoded)
return Model(inputs = inputs, outputs = outputs)
次の結果が得られました.
損失は先ほどの2パターンと比べて0に収束しています.
元の波形が減衰振動でない場合は,ただのノイズのような波形が出力されることがわかりました.
残差平方和からも,先ほどと比べて区別がつきやすくなっております.
出力層のみLinear,他はすべてtanh
tanhのみの場合と比較して良い結果になりました.
def AutoEncoder():
kwargs = {
"activation": "tanh",
"kernel_initializer": "he_normal"
}
inputs = Input(shape = (512,))
encoded = Dense(256, **kwargs)(inputs)
encoded = Dense(128, **kwargs)(encoded)
encoded = Dense(64, **kwargs)(encoded)
decoded = Dense(128, **kwargs)(encoded)
decoded = Dense(256, **kwargs)(decoded)
kwargs["activation"] = "linear"
outputs = Dense(512, **kwargs)(decoded)
return Model(inputs = inputs, outputs = outputs)
tanhは$\pm 1$の値を出力するのが限界ですので,それを上回る教師データもあることも鑑みた結果,こちらの方がうまくいったと考えられます.
結論
おそらく,nonameさんが「うまくいかなかった」というのは,設定した閾値thresholderがうまく分離するように働かなかった,すなわちReLU関数やSigmoid関数のときのような負値を出力していない状態であったことや,標準化前ではSigmoidを使った場合において$\pm 1$の範囲でしか出力できないデコーダの状態で異常検知をしようとしていたからなのではないでしょうか.
問題設定に応じて,適切な活性化関数や損失関数を選ぶべきだと考えます.
今回の問題であれば,出力層は負の値を出力できるtanhが適していると考えることができます.また標準化を行わないで学習するのであれば入出力層はLinearにするべきだと考えます.
本解答では示していませんが,中間層にReLUファミリーの活性化関数を用いたケースを検討しましたが,あまりうまくいきませんでした.中間層のサイズを増大させた場合に,tanhほどではありませんがある程度の分離ができましたこと,報告いたします.