Problemas con el Autoencoder LSTM
TLDR:
Autoencoder no se adapta a la reconstrucción de series temporales y solo predice el valor promedio.
Configuración de preguntas:
Aquí hay un resumen de mi intento de un codificador automático secuencia a secuencia. Esta imagen fue tomada de este artículo:https://arxiv.org/pdf/1607.00148.pdf

Codificador: capa estándar LSTM. La secuencia de entrada se codifica en el estado oculto final.
Decodificador: LSTM Cell (¡creo!). Reconstruya la secuencia un elemento a la vez, comenzando con el último elemento x[N]
.
El algoritmo del decodificador es el siguiente para una secuencia de longitud N
:
- Obtener el estado oculto inicial del decodificador
hs[N]
: simplemente use el estado oculto final del codificador. - Reconstruir último elemento de la secuencia:
x[N]= w.dot(hs[N]) + b
. - Mismo patrón para otros elementos:
x[i]= w.dot(hs[i]) + b
- utilizar
x[i]
yhs[i]
como entradasLSTMCell
para obtenerx[i-1]
yhs[i-1]
Ejemplo de trabajo mínimo:
Aquí está mi implementación, comenzando con el codificador:
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
Clase de decodificador:
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
Uniendo los dos:
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)
Y aquí está mi función de entrenamiento:
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
Datos:
Gran conjunto de datos de eventos extraídos de las noticias (ICEWS). Existen varias categorías que describen cada evento. Inicialmente codifiqué en caliente estas variables, expandiendo los datos a 274 dimensiones. Sin embargo, para depurar el modelo, lo reduje a una sola secuencia que tiene 14 pasos de tiempo y solo contiene 5 variables. Aquí está la secuencia que estoy tratando de sobreajustar:
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)
Y aquí está la Dataset
clase personalizada :
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.")
Problema:
El modelo solo aprende el promedio, no importa cuán complejo haga el modelo o cuánto tiempo lo entreno.
Reconstrucción prevista:

Real:

Mi investigación:
Este problema es idéntico al discutido en esta pregunta: el autoencoder LSTM siempre devuelve el promedio de la secuencia de entrada
El problema en ese caso terminó siendo que la función objetivo promediaba las series temporales objetivo antes de calcular la pérdida. Esto se debió a algunos errores de transmisión porque el autor no tenía las entradas del tamaño correcto para la función objetivo.
En mi caso, no veo que este sea el problema. He comprobado y comprobado que todas mis dimensiones / tamaños están alineados. Estoy en una pérdida.
Otras cosas que he probado
- He intentado esto con diferentes longitudes de secuencia, desde 7 pasos de tiempo hasta 100 pasos de tiempo.
- Lo he intentado con un número variado de variables en la serie temporal. Probé con univariante hasta las 274 variables que contienen los datos.
- Lo intenté con varios
reduction
parámetros en elnn.MSELoss
módulo. El periódico lo pidesum
, pero he probado ambossum
ymean
. Ninguna diferencia. - El documento requiere reconstruir la secuencia en orden inverso (ver gráfico arriba). Probé este método usando la
flipud
entrada original (después del entrenamiento pero antes de calcular la pérdida). Esto no hace ninguna diferencia. - Intenté hacer el modelo más complejo agregando una capa LSTM adicional en el codificador.
- Intenté jugar con el espacio latente. Probé desde el 50% del número de funciones de entrada hasta el 150%.
- Intenté sobreajustar una sola secuencia (proporcionada en la sección de Datos anterior).
Pregunta:
¿Qué hace que mi modelo prediga el promedio y cómo lo soluciono?
Respuestas
Bien, después de algunas depuraciones, creo que conozco las razones.
TLDR
- Intenta predecir el siguiente valor del paso de tiempo en lugar de la diferencia entre el paso de tiempo actual y el anterior
- Su
hidden_features
número es demasiado pequeño, por lo que el modelo no puede ajustarse ni siquiera a una sola muestra.
Análisis
Código utilizado
Comencemos con el código (el modelo es el mismo):
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()
Que hace:
get_data
o funciona con los datos que proporcionó sisubtract=False
o (sisubtract=True
) resta el valor del paso de tiempo anterior del paso de tiempo actual- El resto del código optimiza el modelo hasta que se
1e-4
alcanza la pérdida (para que podamos comparar cómo ayuda la capacidad del modelo y su aumento y qué sucede cuando usamos la diferencia de pasos de tiempo en lugar de pasos de tiempo)
¡Solo variaremos HIDDEN_SIZE
y SUBTRACT
parámetros!
SIN RESTA, MODELO PEQUEÑO
HIDDEN_SIZE=5
SUBTRACT=False
En este caso obtenemos una línea recta. El modelo no puede ajustarse y comprender los fenómenos presentados en los datos (por lo tanto, las líneas planas que mencionó).

Se alcanzó el límite de 1000 iteraciones
RESTA, MODELO PEQUEÑO
HIDDEN_SIZE=5
SUBTRACT=True
Los objetivos ahora están lejos de las líneas planas , pero el modelo no puede ajustarse debido a una capacidad demasiado pequeña.

Se alcanzó el límite de 1000 iteraciones
SIN RESTA, MODELO MÁS GRANDE
HIDDEN_SIZE=100
SUBTRACT=False
Se puso mucho mejor y nuestro objetivo fue alcanzado después de los 942
pasos. No más líneas planas, la capacidad del modelo parece bastante fina (¡para este único ejemplo!)

RESTA, MODELO MÁS GRANDE
HIDDEN_SIZE=100
SUBTRACT=True
Aunque el gráfico no se ve tan bonito, llegamos a la pérdida deseada después de solo 215
iteraciones.

Finalmente
- Por lo general, use la diferencia de pasos de tiempo en lugar de pasos de tiempo (o alguna otra transformación, consulte aquí para obtener más información al respecto). En otros casos, la red neuronal intentará simplemente ... copiar la salida del paso anterior (ya que eso es lo más fácil de hacer). De esta manera se encontrarán algunos mínimos y salir de él requerirá más capacidad.
- Cuando utiliza la diferencia entre pasos de tiempo, no hay forma de "extrapolar" la tendencia del paso de tiempo anterior; La red neuronal tiene que aprender cómo varía realmente la función.
- Use un modelo más grande (para todo el conjunto de datos, debería probar algo como
300
creo), pero simplemente puede ajustar ese. - No lo use
flipud
. Use LSTM bidireccionales, de esta manera puede obtener información de la pasada hacia adelante y hacia atrás de LSTM (¡no confundir con backprop!). Esto también debería aumentar tu puntuación
Preguntas
Bien, pregunta 1: ¿Estás diciendo que para la variable x en la serie de tiempo, debería entrenar el modelo para aprender x [i] - x [i-1] en lugar del valor de x [i]? ¿Estoy interpretando correctamente?
Sí exactamente. La diferencia elimina el impulso de la red neuronal de basar demasiado sus predicciones en el paso de tiempo pasado (simplemente obteniendo el último valor y tal vez cambiándolo un poco)
Pregunta 2: Dijo que mis cálculos para el cuello de botella cero eran incorrectos. Pero, por ejemplo, digamos que estoy usando una red densa simple como codificador automático. Conseguir el cuello de botella correcto depende de los datos. Pero si hace que el cuello de botella tenga el mismo tamaño que la entrada, obtiene la función de identidad.
Sí, asumiendo que no hay una no linealidad involucrada que lo haga más difícil (ver aquí un caso similar). En el caso de los LSTM, hay no lineales, ese es un punto.
Otro es que nos estamos acumulando timesteps
en un estado de codificador único. Entonces, esencialmente, tendríamos que acumular timesteps
identidades en un solo estado oculto y celular, lo cual es muy poco probable.
Un último punto, dependiendo de la longitud de la secuencia, los LSTM son propensos a olvidar parte de la información menos relevante (para eso fueron diseñados, no solo para recordar todo), por lo que es aún más improbable.
¿Num_features * num_timesteps no es un cuello de botella del mismo tamaño que la entrada y, por lo tanto, no debería facilitar que el modelo aprenda la identidad?
Lo es, pero asume que tiene num_timesteps
para cada punto de datos, lo que rara vez es el caso, podría estar aquí. Sobre la identidad y por qué es difícil hacerlo con las no linealidades para la red, se respondió anteriormente.
Un último punto, sobre las funciones de identidad; si fueran realmente fáciles de aprender, ResNet
sería poco probable que las arquitecturas tuvieran éxito. La red podría converger hacia la identidad y realizar "pequeños arreglos" en la salida sin ella, lo que no es el caso.
Tengo curiosidad por la afirmación: "siempre use la diferencia de pasos de tiempo en lugar de pasos de tiempo". Parece tener algún efecto de normalización al acercar todas las características, pero no entiendo por qué esto es clave. Tener un modelo más grande parecía ser la solución y la sustracción solo está ayudando.
La clave aquí fue, de hecho, aumentar la capacidad del modelo. El truco de resta depende realmente de los datos. Imaginemos una situación extrema:
- Tenemos
100
intervalos de tiempo, función única - El valor del paso de tiempo inicial es
10000
- Otros valores de intervalo de tiempo varían
1
como máximo
¿Qué haría la red neuronal (qué es lo más fácil aquí)? Probablemente, descartaría este 1
o un cambio más pequeño como ruido y solo predeciría 1000
para todos ellos (especialmente si existe alguna regularización), ya que estar fuera de lugar 1/1000
no es mucho.
¿Y si restamos? La pérdida total de la red neuronal está en el [0, 1]
margen para cada paso de tiempo en lugar de [0, 1001]
, por lo tanto, es más grave estar equivocado.
Y sí, está conectado con la normalización en cierto sentido, ahora que lo pienso.