Проблемы LSTM Autoencoder

Dec 09 2020

TL; DR:

Автоэнкодер не подходит для реконструкции таймсерий и просто предсказывает среднее значение.

Постановка вопроса:

Вот краткое изложение моей попытки создания автокодировщика от последовательности к последовательности. Это изображение было взято из этой статьи:https://arxiv.org/pdf/1607.00148.pdf

Кодировщик: стандартный уровень LSTM. Входная последовательность кодируется в конечном скрытом состоянии.

Декодер: LSTM Cell (думаю!). Реконструируйте последовательность по одному элементу за раз, начиная с последнего элемента x[N].

Алгоритм декодера следующий для последовательности длины N:

  1. Получить начальное скрытое состояние декодера hs[N]: просто используйте окончательное скрытое состояние кодировщика.
  2. Реконструировать последний элемент в последовательности: x[N]= w.dot(hs[N]) + b.
  3. Такой же шаблон для других элементов: x[i]= w.dot(hs[i]) + b
  4. использовать 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 всегда возвращает среднее значение входной последовательности.

Проблема в этом случае заключалась в том, что целевая функция усредняла целевые временные ряды перед вычислением потерь. Это произошло из-за некоторых ошибок трансляции, потому что у автора не было входных данных нужного размера для целевой функции.

В моем случае я не вижу в этом проблемы. Я проверил и дважды проверил, все ли мои размеры / размеры совпадают. Я в недоумении.

Другие вещи, которые я пробовал

  1. Я пробовал это с различной длиной последовательности от 7 временных шагов до 100 временных шагов.
  2. Я пробовал с различным количеством переменных во временном ряду. Я пробовал использовать одномерные для всех 274 переменных, содержащихся в данных.
  3. Я пробовал с различными reductionпараметрами nn.MSELossмодуля. Газета требует sum, но я пробовал и то, sumи другое mean. Нет разницы.
  4. В документе предлагается реконструировать последовательность в обратном порядке (см. Рисунок выше). Я пробовал этот метод, используя flipudисходный ввод (после обучения, но до расчета потерь). Это не имеет значения.
  5. Я попытался усложнить модель, добавив дополнительный слой LSTM в кодировщик.
  6. Я пробовал играть со скрытым пространством. Я пробовал от 50% вводимого количества функций до 150%.
  7. Я пробовал переоснащать одну последовательность (приведенную выше в разделе « Данные »).

Вопрос:

Что заставляет мою модель предсказывать среднее значение и как это исправить?

Ответы

7 SzymonMaszke Dec 16 2020 at 05:04

Хорошо, после некоторой отладки я думаю, что знаю причины.

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], поэтому ошибиться гораздо серьезнее .

И да, это связано с нормализацией в некотором смысле, если задуматься.