Problemy z autoenkoderem LSTM
TLDR:
Autoencoder niedopuszcza rekonstrukcji timeeries i po prostu przewiduje średnią wartość.
Konfiguracja pytania:
Oto podsumowanie mojej próby stworzenia autoenkodera sekwencyjnego. To zdjęcie pochodzi z tego artykułu:https://arxiv.org/pdf/1607.00148.pdf

Enkoder: standardowa warstwa LSTM. Sekwencja wejściowa jest kodowana w ostatecznym stanie ukrytym.
Dekoder: LSTM Cell (chyba!). Rekonstruuj sekwencję po jednym elemencie na raz, zaczynając od ostatniego elementu x[N]
.
Algorytm dekodera jest następujący dla sekwencji długości N
:
- Uzyskaj początkowy stan ukryty dekodera
hs[N]
: Po prostu użyj końcowego stanu ukrytego kodera. - Zrekonstruować ostatni element w sekwencji:
x[N]= w.dot(hs[N]) + b
. - Ten sam wzór dla innych elementów:
x[i]= w.dot(hs[i]) + b
- używać
x[i]
ihs[i]
jako danych wejściowych,LSTMCell
aby uzyskaćx[i-1]
ihs[i-1]
Minimalny przykład roboczy:
Oto moja implementacja, zaczynając od kodera:
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
Klasa dekodera:
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
Połączenie tych dwóch elementów:
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)
A oto moja funkcja treningowa:
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
Dane:
Duży zbiór danych o wydarzeniach pobrany z wiadomości (ICEWS). Istnieją różne kategorie, które opisują każde wydarzenie. Początkowo zakodowałem te zmienne na gorąco, rozszerzając dane do 274 wymiarów. Jednak aby debugować model, ograniczyłem go do pojedynczej sekwencji, która ma 14 kroków czasowych i zawiera tylko 5 zmiennych. Oto sekwencja, którą próbuję przesadzić:
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)
A oto Dataset
klasa niestandardowa :
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.")
Problem:
Model uczy się tylko średniej, bez względu na to, jak skomplikowany jest model, czy teraz długo go trenuję.
Przewidywane / Rekonstrukcja:

Rzeczywisty:

Moje badania:
Ten problem jest taki sam, jak ten omówiony w tym pytaniu: autoenkoder LSTM zawsze zwraca średnią z sekwencji wejściowej
Problem w tym przypadku polegał na tym, że funkcja celu uśredniała docelowe szeregi czasowe przed obliczeniem straty. Było to spowodowane pewnymi błędami w transmisji, ponieważ autor nie miał odpowiednich danych wejściowych do funkcji celu.
W moim przypadku nie widzę tego problemu. Sprawdziłem i dwukrotnie sprawdziłem, czy wszystkie moje wymiary / rozmiary są zgodne. Jestem w rozterce.
Inne rzeczy, których próbowałem
- Próbowałem tego z różnymi długościami sekwencji od 7 do 100 kroków czasowych.
- Próbowałem z różną liczbą zmiennych w szeregach czasowych. Próbowałem z jedną zmienną aż do wszystkich 274 zmiennych, które zawierają dane.
- Próbowałem z różnymi
reduction
parametrami wnn.MSELoss
module. Papier wzywasum
, ale próbowałem obusum
imean
. Bez różnicy. - W artykule wezwano do odtworzenia sekwencji w odwrotnej kolejności (patrz grafika powyżej). Wypróbowałem tę metodę, używając
flipud
oryginalnego wejścia (po treningu, ale przed obliczeniem strat). To nie ma znaczenia. - Próbowałem uczynić model bardziej złożonym, dodając dodatkową warstwę LSTM w koderze.
- Próbowałem bawić się ukrytą przestrzenią. Próbowałem od 50% wejściowej liczby funkcji do 150%.
- Próbowałem overfitting pojedynczej sekwencji (podanej w sekcji Dane powyżej).
Pytanie:
Co powoduje, że mój model przewiduje średnią i jak to naprawić?
Odpowiedzi
Okej, po pewnym debugowaniu myślę, że znam powody.
TLDR
- Próbujesz przewidzieć następny krok czasu zamiast różnicy między bieżącym krokiem a poprzednim
- Twój
hidden_features
numer jest za mały, przez co model nie mieści się nawet w jednej próbce
Analiza
Używany kod
Zacznijmy od kodu (model jest taki sam):
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()
Co to robi:
get_data
albo działa na danych, które podałeś, jeślisubtract=False
lub (jeślisubtract=True
) odejmuje wartość poprzedniego kroku czasu od bieżącego kroku- Reszta kodu optymalizuje model aż do
1e-4
osiągnięcia straty (dzięki czemu możemy porównać, w jaki sposób pojemność modelu i jej wzrost pomaga i co się dzieje, gdy używamy różnicy kroków czasu zamiast kroków czasu)
Zmienimy tylko HIDDEN_SIZE
i SUBTRACT
parametry!
BRAK ODEJMOWANIA, MAŁY MODEL
HIDDEN_SIZE=5
SUBTRACT=False
W tym przypadku otrzymujemy linię prostą. Model nie jest w stanie dopasować i uchwycić zjawisk przedstawionych w danych (stąd wspomniane płaskie linie).

Osiągnięto limit 1000 iteracji
ODEJMOWANIE, MAŁY MODEL
HIDDEN_SIZE=5
SUBTRACT=True
Cele są teraz daleko od płaskich linii , ale model nie może się zmieścić z powodu zbyt małej pojemności.

Osiągnięto limit 1000 iteracji
BEZ ODEJMOWANIA, WIĘKSZY MODEL
HIDDEN_SIZE=100
SUBTRACT=False
Zrobiło się dużo lepiej i nasz cel został trafiony po 942
krokach. Nigdy więcej płaskich linii, pojemność modelu wydaje się całkiem dobra (w tym jednym przykładzie!)

ODEJMOWANIE, WIĘKSZY MODEL
HIDDEN_SIZE=100
SUBTRACT=True
Chociaż wykres nie wygląda tak ładnie, po 215
kilku iteracjach doszliśmy do pożądanej straty .

Wreszcie
- Zwykle używaj różnicy kroków czasu zamiast kroków czasu (lub innej transformacji, zobacz tutaj, aby uzyskać więcej informacji na ten temat). W innych przypadkach sieć neuronowa spróbuje po prostu ... skopiować dane wyjściowe z poprzedniego kroku (ponieważ jest to najłatwiejsza do zrobienia). W ten sposób zostaną znalezione pewne minima, a wyjście z niego będzie wymagało większej pojemności.
- Kiedy używasz różnicy między krokami czasu, nie ma możliwości "ekstrapolacji" trendu z poprzedniego kroku; sieć neuronowa musi nauczyć się, jak faktycznie zmienia się funkcja
- Użyj większego modelu (dla całego zbioru danych powinieneś spróbować czegoś takiego jak
300
myślę), ale możesz go po prostu dostroić. - Nie używaj
flipud
. Używaj dwukierunkowych LSTM, w ten sposób możesz uzyskać informacje z przejścia LSTM do przodu i do tyłu (nie mylić z backprop!). To również powinno zwiększyć Twój wynik
pytania
OK, pytanie 1: Mówisz, że dla zmiennej x w szeregu czasowym powinienem nauczyć model uczenia się x [i] - x [i-1] zamiast wartości x [i]? Czy poprawnie tłumaczę?
Tak, dokładnie. Różnica eliminuje potrzebę sieci neuronowej do opierania swoich przewidywań na przeszłym kroku czasowym (po prostu uzyskując ostatnią wartość i być może trochę ją zmieniając)
Pytanie 2: Powiedziałeś, że moje obliczenia dotyczące braku wąskiego gardła były nieprawidłowe. Ale na przykład, powiedzmy, że używam prostej gęstej sieci jako automatycznego kodera. Znalezienie odpowiedniego wąskiego gardła rzeczywiście zależy od danych. Ale jeśli sprawisz, że wąskie gardło będzie miało taki sam rozmiar jak dane wejściowe, otrzymasz funkcję tożsamości.
Tak, zakładając, że nie ma nieliniowości, która utrudnia sprawę (zobacz tutaj podobny przypadek). W przypadku LSTM istnieją nieliniowości, to jeden punkt.
Innym jest to, że gromadzimy się timesteps
w stanie pojedynczego kodera. Więc zasadniczo musielibyśmy gromadzić timesteps
tożsamości w jednym ukrytym i komórkowym stanie, co jest wysoce nieprawdopodobne.
Ostatni punkt, w zależności od długości sekwencji, LSTM mają skłonność do zapominania niektórych najmniej istotnych informacji (do tego zostały zaprojektowane, a nie tylko do zapamiętania wszystkiego), przez co jest jeszcze bardziej nieprawdopodobne.
Czy num_features * num_timesteps nie jest szyjką butelki tego samego rozmiaru co dane wejściowe, a zatem czy nie powinno to ułatwiać modelowi poznania tożsamości?
Tak, ale zakłada, że masz num_timesteps
dla każdego punktu danych, co rzadko się zdarza, może tu być. O tożsamości i dlaczego trudno jest zrobić z nieliniowościami sieci, na którą odpowiedziano powyżej.
Ostatnia uwaga, dotycząca funkcji tożsamości; gdyby faktycznie były łatwe do nauczenia, ResNet
architektury nie odniosłyby sukcesu. Sieć mogłaby zbiegać się w tożsamość i bez niej wprowadzać „drobne poprawki” do wyjścia, co nie ma miejsca.
Ciekawi mnie stwierdzenie: „zawsze używaj różnicy kroków zamiast kroków czasowych” Wydaje się, że ma jakiś normalizujący efekt, zbliżając wszystkie funkcje do siebie, ale nie rozumiem, dlaczego to jest kluczowe? Wydawało się, że rozwiązaniem jest większy model, a odejmowanie tylko pomaga.
Kluczowe było tutaj rzeczywiście zwiększenie pojemności modelu. Sztuczka odejmowania naprawdę zależy od danych. Wyobraźmy sobie ekstremalną sytuację:
- Mamy
100
odcinki czasu, jedną funkcję - Początkowa wartość kroku czasowego to
10000
- Inne wartości czasu różnią się
1
co najwyżej
Co zrobiłaby sieć neuronowa (co jest tutaj najłatwiejsze)? Prawdopodobnie odrzuciłby tę 1
lub mniejszą zmianę jako szum i po prostu przewidział 1000
dla nich wszystkich (zwłaszcza jeśli istnieje jakaś regularyzacja), ponieważ oddalenie się o 1/1000
to niewiele.
A co jeśli odejmiemy? Utrata całej sieci neuronowej jest na [0, 1]
marginesie dla każdego kroku, a nie z [0, 1001]
, dlatego błąd jest poważniejszy.
I tak, w pewnym sensie jest to związane z normalizacją, jeśli się nad tym zastanowić.