Проблемы LSTM Autoencoder
TL; DR:
Автоэнкодер не подходит для реконструкции таймсерий и просто предсказывает среднее значение.
Постановка вопроса:
Вот краткое изложение моей попытки создания автокодировщика от последовательности к последовательности. Это изображение было взято из этой статьи:https://arxiv.org/pdf/1607.00148.pdf

Кодировщик: стандартный уровень LSTM. Входная последовательность кодируется в конечном скрытом состоянии.
Декодер: LSTM Cell (думаю!). Реконструируйте последовательность по одному элементу за раз, начиная с последнего элемента x[N]
.
Алгоритм декодера следующий для последовательности длины N
:
- Получить начальное скрытое состояние декодера
hs[N]
: просто используйте окончательное скрытое состояние кодировщика. - Реконструировать последний элемент в последовательности:
x[N]= w.dot(hs[N]) + b
. - Такой же шаблон для других элементов:
x[i]= w.dot(hs[i]) + b
- использовать
x[i]
и вhs[i]
качестве входных данныхLSTMCell
для полученияx[i-1]
иhs[i-1]
Минимальный рабочий пример:
Вот моя реализация, начиная с кодировщика:
class SeqEncoderLSTM(nn.Module):
def __init__(self, n_features, latent_size):
super(SeqEncoderLSTM, self).__init__()
self.lstm = nn.LSTM(
n_features,
latent_size,
batch_first=True)
def forward(self, x):
_, hs = self.lstm(x)
return hs
Класс декодера:
class SeqDecoderLSTM(nn.Module):
def __init__(self, emb_size, n_features):
super(SeqDecoderLSTM, self).__init__()
self.cell = nn.LSTMCell(n_features, emb_size)
self.dense = nn.Linear(emb_size, n_features)
def forward(self, hs_0, seq_len):
x = torch.tensor([])
# Final hidden and cell state from encoder
hs_i, cs_i = hs_0
# reconstruct first element with encoder output
x_i = self.dense(hs_i)
x = torch.cat([x, x_i])
# reconstruct remaining elements
for i in range(1, seq_len):
hs_i, cs_i = self.cell(x_i, (hs_i, cs_i))
x_i = self.dense(hs_i)
x = torch.cat([x, x_i])
return x
Объединение двух:
class LSTMEncoderDecoder(nn.Module):
def __init__(self, n_features, emb_size):
super(LSTMEncoderDecoder, self).__init__()
self.n_features = n_features
self.hidden_size = emb_size
self.encoder = SeqEncoderLSTM(n_features, emb_size)
self.decoder = SeqDecoderLSTM(emb_size, n_features)
def forward(self, x):
seq_len = x.shape[1]
hs = self.encoder(x)
hs = tuple([h.squeeze(0) for h in hs])
out = self.decoder(hs, seq_len)
return out.unsqueeze(0)
А вот и моя тренировочная функция:
def train_encoder(model, epochs, trainload, testload=None, criterion=nn.MSELoss(), optimizer=optim.Adam, lr=1e-6, reverse=False):
device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f'Training model on {device}')
model = model.to(device)
opt = optimizer(model.parameters(), lr)
train_loss = []
valid_loss = []
for e in tqdm(range(epochs)):
running_tl = 0
running_vl = 0
for x in trainload:
x = x.to(device).float()
opt.zero_grad()
x_hat = model(x)
if reverse:
x = torch.flip(x, [1])
loss = criterion(x_hat, x)
loss.backward()
opt.step()
running_tl += loss.item()
if testload is not None:
model.eval()
with torch.no_grad():
for x in testload:
x = x.to(device).float()
loss = criterion(model(x), x)
running_vl += loss.item()
valid_loss.append(running_vl / len(testload))
model.train()
train_loss.append(running_tl / len(trainload))
return train_loss, valid_loss
Данные:
Большой набор данных о событиях, извлеченных из новостей (ICEWS). Существуют различные категории, описывающие каждое событие. Сначала я закодировал эти переменные без промедления, расширив данные до 274 измерений. Однако, чтобы отладить модель, я сократил ее до одной последовательности, которая имеет длину 14 временных шагов и содержит только 5 переменных. Вот последовательность, которую я пытаюсь переобучить:
tensor([[0.5122, 0.0360, 0.7027, 0.0721, 0.1892],
[0.5177, 0.0833, 0.6574, 0.1204, 0.1389],
[0.4643, 0.0364, 0.6242, 0.1576, 0.1818],
[0.4375, 0.0133, 0.5733, 0.1867, 0.2267],
[0.4838, 0.0625, 0.6042, 0.1771, 0.1562],
[0.4804, 0.0175, 0.6798, 0.1053, 0.1974],
[0.5030, 0.0445, 0.6712, 0.1438, 0.1404],
[0.4987, 0.0490, 0.6699, 0.1536, 0.1275],
[0.4898, 0.0388, 0.6704, 0.1330, 0.1579],
[0.4711, 0.0390, 0.5877, 0.1532, 0.2201],
[0.4627, 0.0484, 0.5269, 0.1882, 0.2366],
[0.5043, 0.0807, 0.6646, 0.1429, 0.1118],
[0.4852, 0.0606, 0.6364, 0.1515, 0.1515],
[0.5279, 0.0629, 0.6886, 0.1514, 0.0971]], dtype=torch.float64)
А вот кастомный Dataset
класс:
class TimeseriesDataSet(Dataset):
def __init__(self, data, window, n_features, overlap=0):
super().__init__()
if isinstance(data, (np.ndarray)):
data = torch.tensor(data)
elif isinstance(data, (pd.Series, pd.DataFrame)):
data = torch.tensor(data.copy().to_numpy())
else:
raise TypeError(f"Data should be ndarray, series or dataframe. Found {type(data)}.")
self.n_features = n_features
self.seqs = torch.split(data, window)
def __len__(self):
return len(self.seqs)
def __getitem__(self, idx):
try:
return self.seqs[idx].view(-1, self.n_features)
except TypeError:
raise TypeError("Dataset only accepts integer index/slices, not lists/arrays.")
Проблема:
Модель изучает только среднее значение, независимо от того, насколько сложной я создаю модель или как долго я ее тренирую.
Прогноз / Реконструкция:

Актуально:

Мое исследование:
Эта проблема идентична проблеме, обсуждаемой в этом вопросе: автокодер LSTM всегда возвращает среднее значение входной последовательности.
Проблема в этом случае заключалась в том, что целевая функция усредняла целевые временные ряды перед вычислением потерь. Это произошло из-за некоторых ошибок трансляции, потому что у автора не было входных данных нужного размера для целевой функции.
В моем случае я не вижу в этом проблемы. Я проверил и дважды проверил, все ли мои размеры / размеры совпадают. Я в недоумении.
Другие вещи, которые я пробовал
- Я пробовал это с различной длиной последовательности от 7 временных шагов до 100 временных шагов.
- Я пробовал с различным количеством переменных во временном ряду. Я пробовал использовать одномерные для всех 274 переменных, содержащихся в данных.
- Я пробовал с различными
reduction
параметрамиnn.MSELoss
модуля. Газета требуетsum
, но я пробовал и то,sum
и другоеmean
. Нет разницы. - В документе предлагается реконструировать последовательность в обратном порядке (см. Рисунок выше). Я пробовал этот метод, используя
flipud
исходный ввод (после обучения, но до расчета потерь). Это не имеет значения. - Я попытался усложнить модель, добавив дополнительный слой LSTM в кодировщик.
- Я пробовал играть со скрытым пространством. Я пробовал от 50% вводимого количества функций до 150%.
- Я пробовал переоснащать одну последовательность (приведенную выше в разделе « Данные »).
Вопрос:
Что заставляет мою модель предсказывать среднее значение и как это исправить?
Ответы
Хорошо, после некоторой отладки я думаю, что знаю причины.
TL; DR
- Вы пытаетесь предсказать значение следующего временного шага вместо разницы между текущим временным шагом и предыдущим
- Ваше
hidden_features
число слишком мало, поэтому модель не вмещается даже в один образец
Анализ
Используемый код
Начнем с кода (модель та же):
import seaborn as sns
import matplotlib.pyplot as plt
def get_data(subtract: bool = False):
# (1, 14, 5)
input_tensor = torch.tensor(
[
[0.5122, 0.0360, 0.7027, 0.0721, 0.1892],
[0.5177, 0.0833, 0.6574, 0.1204, 0.1389],
[0.4643, 0.0364, 0.6242, 0.1576, 0.1818],
[0.4375, 0.0133, 0.5733, 0.1867, 0.2267],
[0.4838, 0.0625, 0.6042, 0.1771, 0.1562],
[0.4804, 0.0175, 0.6798, 0.1053, 0.1974],
[0.5030, 0.0445, 0.6712, 0.1438, 0.1404],
[0.4987, 0.0490, 0.6699, 0.1536, 0.1275],
[0.4898, 0.0388, 0.6704, 0.1330, 0.1579],
[0.4711, 0.0390, 0.5877, 0.1532, 0.2201],
[0.4627, 0.0484, 0.5269, 0.1882, 0.2366],
[0.5043, 0.0807, 0.6646, 0.1429, 0.1118],
[0.4852, 0.0606, 0.6364, 0.1515, 0.1515],
[0.5279, 0.0629, 0.6886, 0.1514, 0.0971],
]
).unsqueeze(0)
if subtract:
initial_values = input_tensor[:, 0, :]
input_tensor -= torch.roll(input_tensor, 1, 1)
input_tensor[:, 0, :] = initial_values
return input_tensor
if __name__ == "__main__":
torch.manual_seed(0)
HIDDEN_SIZE = 10
SUBTRACT = False
input_tensor = get_data(SUBTRACT)
model = LSTMEncoderDecoder(input_tensor.shape[-1], HIDDEN_SIZE)
optimizer = torch.optim.Adam(model.parameters())
criterion = torch.nn.MSELoss()
for i in range(1000):
outputs = model(input_tensor)
loss = criterion(outputs, input_tensor)
loss.backward()
optimizer.step()
optimizer.zero_grad()
print(f"{i}: {loss}")
if loss < 1e-4:
break
# Plotting
sns.lineplot(data=outputs.detach().numpy().squeeze())
sns.lineplot(data=input_tensor.detach().numpy().squeeze())
plt.show()
Что оно делает:
get_data
либо работает с предоставленными вами данными,subtract=False
либо (еслиsubtract=True
) вычитает значение предыдущего временного шага из текущего временного шага- Остальная часть кода оптимизирует модель до тех пор, пока не будет
1e-4
достигнута потеря (чтобы мы могли сравнить, как емкость модели и ее увеличение помогает и что происходит, когда мы используем разницу временных шагов вместо временных шагов)
Мы будем варьировать только HIDDEN_SIZE
и SUBTRACT
параметры!
БЕЗ ВЫЧЕТА, МАЛАЯ МОДЕЛЬ
HIDDEN_SIZE=5
SUBTRACT=False
В этом случае мы получаем прямую линию. Модель не может соответствовать и уловить явления, представленные в данных (отсюда плоские линии, о которых вы упомянули).

Достигнуто ограничение в 1000 итераций
ВЫЧИСЛЕНИЕ, МАЛАЯ МОДЕЛЬ
HIDDEN_SIZE=5
SUBTRACT=True
Цели теперь далеки от плоских линий , но модель не подходит из-за слишком маленькой емкости.

Достигнуто ограничение в 1000 итераций
БЕЗ ВЫЧЕТА, БОЛЬШАЯ МОДЕЛЬ
HIDDEN_SIZE=100
SUBTRACT=False
Стало намного лучше, и наша цель была достигнута после 942
шагов. Нет больше плоских линий, емкость модели кажется вполне нормальной (для этого единственного примера!)

ВЫЧИСЛЕНИЕ, БОЛЬШАЯ МОДЕЛЬ
HIDDEN_SIZE=100
SUBTRACT=True
Хотя график выглядит не очень красиво, мы получили желаемую потерю только после 215
итераций.

в заключение
- Обычно используйте разницу временных шагов вместо временных шагов (или какое-либо другое преобразование, см. Здесь для получения дополнительной информации об этом). В других случаях нейронная сеть попытается просто ... скопировать вывод с предыдущего шага (поскольку это самый простой способ). Таким образом будут найдены некоторые минимумы, и выход за них потребует большей мощности.
- Когда вы используете разницу между временными шагами, нет возможности «экстраполировать» тенденцию из предыдущего временного шага; нейронная сеть должна узнать, как на самом деле изменяется функция
- Используйте более крупную модель (для всего набора данных вы должны попробовать что-то вроде, как
300
я думаю), но вы можете просто настроить ее. - Не используйте
flipud
. Используйте двунаправленные LSTM, таким образом вы можете получать информацию о прямом и обратном проходе LSTM (не путать с обратным распространением!). Это также должно повысить ваш счет
Вопросов
Хорошо, вопрос 1: Вы говорите, что для переменной x во временном ряду я должен обучить модель изучать x [i] - x [i-1], а не значение x [i]? Я правильно интерпретирую?
Да, точно. Разница устраняет потребность нейронной сети слишком сильно основывать свои прогнозы на прошлом шаге времени (просто получая последнее значение и, возможно, немного его изменяя)
Вопрос 2: Вы сказали, что мои расчеты нулевого узкого места неверны. Но, например, допустим, я использую простую плотную сеть в качестве автокодировщика. Правильное определение узкого места действительно зависит от данных. Но если вы сделаете узкое место того же размера, что и вход, вы получите функцию идентификации.
Да, если предположить, что здесь нет нелинейности, которая усложняет задачу (см. Здесь аналогичный случай). В случае LSTM есть нелинейности, это один момент.
Другой заключается в том, что мы накапливаемся timesteps
в состоянии единственного кодировщика. Таким образом, по сути, нам пришлось бы аккумулировать timesteps
личности в одном скрытом и ячеистом состояниях, что маловероятно.
И последнее: в зависимости от длины последовательности LSTM склонны забывать часть наименее релевантной информации (именно для этого они были созданы, а не только для запоминания всего), что еще более маловероятно.
Разве num_features * num_timesteps не является узким местом того же размера, что и входные данные, и, следовательно, не должно ли это способствовать изучению идентичности модели?
Это так, но предполагается, что num_timesteps
для каждой точки данных, что бывает редко, может быть здесь. По поводу идентичности и почему это сложно сделать с нелинейностями для сети, было сказано выше.
И последнее, о функциях идентичности; если бы их действительно было легко изучить, ResNet
вряд ли бы они достигли успеха. Сеть могла бы сходиться к идентичности и вносить «небольшие исправления» в выходные данные без нее, что не так.
Мне любопытно утверждение: «всегда используйте разницу временного шага вместо временного шага». Кажется, он имеет некоторый нормализующий эффект, сближая все функции, но я не понимаю, почему это важно? Решением было иметь более крупную модель, а вычитание просто помогает.
Ключевым моментом здесь было увеличение мощности модели. Трюк с вычитанием действительно зависит от данных. Представим себе экстремальную ситуацию:
- У нас есть
100
временные интервалы, одна функция - Начальное значение временного шага
10000
- Другие значения временного шага варьируются не более чем
1
на
Что будет делать нейронная сеть (что здесь проще всего)? Он, вероятно, отбросил бы это 1
или меньшее изменение как шум и просто предсказал бы 1000
для всех из них (особенно, если есть некоторая регуляризация), так как отклонение - 1/1000
это не так много.
Что, если мы вычтем? Потери всей нейронной сети находятся в [0, 1]
пределе для каждого временного шага, а не [0, 1001]
, поэтому ошибиться гораздо серьезнее .
И да, это связано с нормализацией в некотором смысле, если задуматься.