Para responder sucintamente a la pregunta titular: "Este autocodificador no puede alcanzar la pérdida 0 porque hay una mala correspondencia entre las entradas y la función de pérdida. Entrenando el mismo modelo en los mismos datos con una función de pérdida diferente, o entrenando un modelo ligeramente modificado en datos diferentes con la misma función de pérdida se alcanza la pérdida cero muy rápidamente."
Simplifique
Siempre que encuentro un comportamiento desconcertante, me resulta útil reducirlo al problema más básico y resolverlo. Tú has iniciado ese proceso con tu modelo de juguete, pero creo que el modelo puede simplificarse aún más.
La versión más sencilla de este problema es una monocapa red con activaciones de identidad; se trata de un modelo lineal. El codificador es una transformación lineal (matriz de pesos y vector de sesgo) y el decodificador es otra transformación lineal (matriz de pesos y vector de sesgo).
Sin embargo, todos estos modelos mantienen la propiedad de que no hay cuello de botella: la dimensión de incrustación es tan grande como la dimensión de entrada.
Modelo lineal, $x \sim \mathcal{U}(0,1)$
Este modelo tan sencillo tiene el siguiente aspecto
$$ \hat{x} = W_\text{dec}(W_\text{enc}x + b_\text{enc})+b_\text{dec} $$
Utilizando la siguiente configuración, este modelo converge a una pérdida de entrenamiento inferior a $10^{-5}$ en menos de 450 iteraciones:
- Optimizador Adam con tasa de aprendizaje $10^{-5}$
- 30.000 muestras de dos características
- tamaño de lote mínimo de 128
- Función de pérdida MSE.
Sigmoide, $x \sim \mathcal{U}(0,1)$
Utilizar una activación sigmoidea en la capa final y la pérdida BCE no parece funcionar tan bien. El modelo sigmoide tiene la forma $$ \hat{x} = \sigma\left(W_\text{dec}(W_\text{enc}x + b_\text{enc})+b_\text{dec}\right) $$
- Optimizador Adam con tasa de aprendizaje $10^{-4}$
- 30.000 muestras de dos características
- tamaño de lote mínimo de 128
- Función de pérdida BCE
- función de activación sigmoidea
Creo que este modelo no funciona bien con los datos de origen porque los objetivos son uniformes en $[0,1]$ en lugar de concentrarse en 0 y 1. (Recordemos que una forma de justificar el uso de la función de pérdida logarítmica es que surge naturalmente de la probabilidad de Bernoulli). He probado muchas variaciones en la tasa de aprendizaje y la complejidad del modelo, pero este modelo con estos datos no logra una pérdida por debajo de alrededor de 0,5.
Podemos probar mi hipótesis intentando estimar el mismo modelo utilizando entradas binarias .
Sigmoide, $x \sim \text{Bernoulli}(0.5)$
Sin embargo, si cambiamos la forma de construir los datos para que sean aleatorios binario a continuación, utilizando la pérdida BCE con la activación sigmoidea hace convergen.
- Optimizador Adam con tasa de aprendizaje $10^{-4}$
- 30.000 muestras de dos características
- tamaño del mini lote de 128
- Función de pérdida BCE.
- activación sigmoidea en la capa final
Con este modelo se consiguen bajas pérdidas muy rápidamente. Si desea presionar para extremadamente valores de pérdida pequeños, mi consejo es calcular la pérdida en la escala logit para evitar problemas de redondeo.
Complicar
Ahora que tenemos una hipótesis de cómo funciona el modelo cuando es muy simple y barato de estimar, podemos aumentar la complejidad del modelo simple y comprobar si la hipótesis que desarrollamos a partir de los modelos más simples sigue siendo válida cuando intentamos modelos más complejos.
He realizado experimentos con modelos más profundos, activaciones no lineales (leaky ReLU), pero repitiendo el mismo diseño experimental utilizado para entrenar los modelos simples: mezclar la elección de la función de pérdida y comparar distribuciones alternativas de los datos de entrada. En estos experimentos con modelos más grandes y no lineales, encuentro que es mejor ajustar el MSE a entradas de valor continuo y la pérdida logarítmica a entradas de valor binario.
Cuellos de botella
Si deseamos entrenar un modelo utilizando una estructura de codificador/decodificador de cuello de botella, es decir, un modelo en el que la salida del codificador tenga una dimensión menor que la dimensión de entrada, debemos considerar si nuestros datos de origen están estructurados de modo que sea posible dicha compresión. Todos nuestros experimentos hasta ahora han utilizado valores aleatorios iid, que son los menos compresibles porque los valores de una característica no tienen información sobre los valores de ninguna otra característica por construcción .
Alternativamente, supongamos que los datos de entrada fueran valores completamente redundantes, por lo que un ejemplo podría ser $[1,1,1,1]$ y otro ejemplo es $[2,2,2,2]$ y otra es $[-1.5, -1.5, -1.5, -1.5]$ . Una red de cuello de botella encajaría fácilmente, ya que tres columnas son totalmente redundantes. Este tipo de datos fuente se prestaría más a un autocodificador de cuello de botella.
from __future__ import division
import numpy as np
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset
class ToyAutoEncoder(nn.Module):
def __init__(self, n_features, encoding_dim):
super(ToyAutoEncoder, self).__init__()
self.encoder = nn.Sequential(
nn.Linear(n_features, n_features),
nn.LeakyReLU(0.1),
nn.Linear(n_features, encoding_dim),
)
self.decoder = nn.Sequential(
nn.Linear(encoding_dim, n_features),
nn.LeakyReLU(0.1),
nn.Linear(n_features, n_features),
# nn.Sigmoid()
)
def forward(self, x):
x_enc = self.encoder(x)
x_dec = self.decoder(x_enc)
return x_dec
def fit(model, data_queue, optimizer, n_epoch, print_freq, tol=1e-5):
# loss_fn = nn.BCELoss(reduction="mean")
loss_fn = nn.MSELoss(reduction="mean")
for epoch_ndx in range(n_epoch):
model.train()
loss_epoch = np.zeros(len(queue))
loss_recent = np.zeros(print_freq)
for batch_ndx, (x_batch,) in enumerate(data_queue):
optimizer.zero_grad()
x_dec = autoencoder(x_batch)
loss_tensor = loss_fn(input=x_dec, target=x_batch)
loss_tensor.backward()
optimizer.step()
loss_np = loss_tensor.detach().numpy()
loss_epoch[batch_ndx] = loss_np
loss_recent[batch_ndx % print_freq] = loss_np
if batch_ndx % print_freq == 0 and batch_ndx > 0:
msg_data = (epoch_ndx, batch_ndx, loss_recent.mean(), loss_epoch[:batch_ndx].mean())
print("Epoch %d Batch %d Recent loss %.4f Cumulative Loss %.4f" % msg_data)
if loss_epoch.mean() < tol:
print("Convergence achieved: %s" % loss_epoch.mean())
break
if __name__ == "__main__":
N_FEATURES = ENCODING_DIM = 10
N_EPOCH = 1024
PRINT_FREQ = 32
autoencoder = ToyAutoEncoder(n_features=N_FEATURES, encoding_dim=ENCODING_DIM, )
x_rand = torch.rand((30000, N_FEATURES))
# x_rand = (torch.rand((30000, N_FEATURES)) - 0.5).sign()
x_rand = x_rand.clamp(min=0.0)
optim = torch.optim.Adam(lr=1e-4, params=autoencoder.parameters(),
# weight_decay=1e-6,
)
# optim = torch.optim.SGD(lr=1e-3, params=autoencoder.parameters(), momentum=0.9)
queue = DataLoader(TensorDataset(x_rand), batch_size=128, shuffle=True)
fit(model=autoencoder, data_queue=queue, optimizer=optim, n_epoch=N_EPOCH, print_freq=PRINT_FREQ)