xbe.at

Deep learning / Autoencoders and generative

Variational Lossy Autoencoder in Python:

11 min read, 15 sections

Contents
  1. Introduction to Variational Lossy Autoencoders
  2. VLAE Architecture
  3. Loss Function
  4. Training Process
  5. Latent Space Visualization
  6. Image Generation
  7. Image Reconstruction
  8. Lossy Compression
  9. Interpolation in Latent Space
  10. Conditional VLAE
  11. Disentangled Representations
  12. Real-life Example: Image Denoising
  13. Real-life Example: Anomaly Detection
  14. Limitations and Considerations
  15. Additional Resources

Introduction to Variational Lossy Autoencoders

Variational Lossy Autoencoders (VLAEs) are an extension of traditional autoencoders, designed to learn efficient data representations while allowing for controlled information loss. They combine the principles of variational autoencoders and lossy compression, making them particularly useful for tasks such as image compression and generation.

import torch
import torch.nn as nn
import torch.optim as optim
import torchvision.transforms as transforms
from torch.utils.data import DataLoader
from torchvision.datasets import MNIST

# Set up the device
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

VLAE Architecture

The VLAE architecture consists of an encoder, a latent space, and a decoder. The encoder compresses the input data into a lower-dimensional latent representation, while the decoder reconstructs the data from this representation. The "lossy" aspect comes from the intentional discarding of some information during the encoding process.

class VLAE(nn.Module):
    def __init__(self, input_dim, latent_dim):
        super(VLAE, self).__init__()
        self.encoder = nn.Sequential(
            nn.Linear(input_dim, 256),
            nn.ReLU(),
            nn.Linear(256, 128),
            nn.ReLU()
        )
        self.fc_mu = nn.Linear(128, latent_dim)
        self.fc_logvar = nn.Linear(128, latent_dim)
        self.decoder = nn.Sequential(
            nn.Linear(latent_dim, 128),
            nn.ReLU(),
            nn.Linear(128, 256),
            nn.ReLU(),
            nn.Linear(256, input_dim),
            nn.Sigmoid()
        )

    def encode(self, x):
        h = self.encoder(x)
        return self.fc_mu(h), self.fc_logvar(h)

    def reparameterize(self, mu, logvar):
        std = torch.exp(0.5 * logvar)
        eps = torch.randn_like(std)
        return mu + eps * std

    def decode(self, z):
        return self.decoder(z)

    def forward(self, x):
        mu, logvar = self.encode(x.view(-1, 784))
        z = self.reparameterize(mu, logvar)
        return self.decode(z), mu, logvar

Loss Function

The VLAE loss function combines reconstruction loss and KL divergence. The reconstruction loss measures how well the decoder can reconstruct the input from the latent representation. The KL divergence term regularizes the latent space, encouraging it to follow a standard normal distribution.

def loss_function(recon_x, x, mu, logvar):
    BCE = nn.functional.binary_cross_entropy(recon_x, x.view(-1, 784), reduction='sum')
    KLD = -0.5 * torch.sum(1 + logvar - mu.pow(2) - logvar.exp())
    return BCE + KLD

Training Process

Training a VLAE involves iterating through the dataset, passing the data through the model, computing the loss, and updating the model parameters using backpropagation. We use an optimizer like Adam to adjust the weights and biases of the neural network.

def train(model, dataloader, optimizer, epochs):
    model.train()
    for epoch in range(epochs):
        train_loss = 0
        for batch_idx, (data, _) in enumerate(dataloader):
            data = data.to(device)
            optimizer.zero_grad()
            recon_batch, mu, logvar = model(data)
            loss = loss_function(recon_batch, data, mu, logvar)
            loss.backward()
            train_loss += loss.item()
            optimizer.step()
        
        print(f'Epoch {epoch+1}, Loss: {train_loss / len(dataloader.dataset):.4f}')

# Initialize model, optimizer, and dataloader
model = VLAE(784, 20).to(device)
optimizer = optim.Adam(model.parameters(), lr=1e-3)
train_loader = DataLoader(MNIST('.', train=True, download=True, 
                                transform=transforms.ToTensor()), 
                          batch_size=128, shuffle=True)

# Train the model
train(model, train_loader, optimizer, epochs=10)

Latent Space Visualization

One of the advantages of VLAEs is the ability to visualize and manipulate the latent space. We can project high-dimensional data into a 2D or 3D space for visualization, or generate new samples by sampling from the latent space.

import matplotlib.pyplot as plt
from sklearn.manifold import TSNE

def visualize_latent_space(model, dataloader):
    model.eval()
    latent_vectors = []
    labels = []
    
    with torch.no_grad():
        for data, label in dataloader:
            data = data.to(device)
            mu, _ = model.encode(data.view(-1, 784))
            latent_vectors.append(mu.cpu().numpy())
            labels.append(label.numpy())
    
    latent_vectors = np.concatenate(latent_vectors, axis=0)
    labels = np.concatenate(labels, axis=0)
    
    tsne = TSNE(n_components=2, random_state=42)
    latent_tsne = tsne.fit_transform(latent_vectors)
    
    plt.figure(figsize=(10, 8))
    scatter = plt.scatter(latent_tsne[:, 0], latent_tsne[:, 1], c=labels, cmap='tab10')
    plt.colorbar(scatter)
    plt.title("t-SNE visualization of VLAE latent space")
    plt.show()

# Visualize the latent space
visualize_latent_space(model, train_loader)

Image Generation

VLAEs can generate new images by sampling from the latent space and passing the samples through the decoder. This process allows us to create novel images that resemble the training data.

def generate_images(model, num_images=10):
    model.eval()
    with torch.no_grad():
        z = torch.randn(num_images, 20).to(device)
        generated = model.decode(z)
    
    fig, axes = plt.subplots(1, num_images, figsize=(20, 2))
    for i, ax in enumerate(axes):
        ax.imshow(generated[i].view(28, 28).cpu().numpy(), cmap='gray')
        ax.axis('off')
    plt.show()

# Generate new images
generate_images(model)

Image Reconstruction

We can evaluate the quality of our VLAE by comparing original images with their reconstructed versions. This helps us understand how much information is preserved during the encoding-decoding process.

def reconstruct_images(model, dataloader):
    model.eval()
    dataiter = iter(dataloader)
    images, _ = next(dataiter)
    
    with torch.no_grad():
        images = images.to(device)
        reconstructed, _, _ = model(images)
    
    fig, axes = plt.subplots(2, 10, figsize=(20, 4))
    for i in range(10):
        axes[0, i].imshow(images[i].cpu().view(28, 28), cmap='gray')
        axes[0, i].axis('off')
        axes[1, i].imshow(reconstructed[i].cpu().view(28, 28), cmap='gray')
        axes[1, i].axis('off')
    plt.show()

# Reconstruct images
reconstruct_images(model, train_loader)

Lossy Compression

The "lossy" aspect of VLAEs allows for controlled information loss, which can be useful in compression tasks. By adjusting the size and structure of the latent space, we can trade off between reconstruction quality and compression ratio.

def compress_image(model, image):
    model.eval()
    with torch.no_grad():
        image = image.to(device)
        mu, _ = model.encode(image.view(-1, 784))
    return mu

def decompress_image(model, compressed):
    model.eval()
    with torch.no_grad():
        decompressed = model.decode(compressed)
    return decompressed

# Example usage
dataiter = iter(train_loader)
images, _ = next(dataiter)
original_image = images[0]

compressed = compress_image(model, original_image)
decompressed = decompress_image(model, compressed)

plt.figure(figsize=(10, 5))
plt.subplot(1, 2, 1)
plt.imshow(original_image.cpu().view(28, 28), cmap='gray')
plt.title("Original")
plt.subplot(1, 2, 2)
plt.imshow(decompressed.cpu().view(28, 28), cmap='gray')
plt.title("Decompressed")
plt.show()

print(f"Compression ratio: {784 / compressed.numel():.2f}")

Interpolation in Latent Space

One interesting property of VLAEs is the ability to perform smooth interpolation between different images in the latent space. This can reveal interesting transitions and relationships between different data points.

def interpolate_images(model, img1, img2, steps=10):
    model.eval()
    with torch.no_grad():
        z1, _ = model.encode(img1.view(-1, 784))
        z2, _ = model.encode(img2.view(-1, 784))
        
        alphas = torch.linspace(0, 1, steps)
        interpolated_z = torch.stack([z1 * (1 - alpha) + z2 * alpha for alpha in alphas])
        interpolated_images = model.decode(interpolated_z)
    
    fig, axes = plt.subplots(1, steps, figsize=(20, 2))
    for i, ax in enumerate(axes):
        ax.imshow(interpolated_images[i].view(28, 28).cpu().numpy(), cmap='gray')
        ax.axis('off')
    plt.show()

# Example usage
dataiter = iter(train_loader)
images, _ = next(dataiter)
interpolate_images(model, images[0], images[1])

Conditional VLAE

We can extend the VLAE to incorporate conditional information, allowing us to generate or reconstruct images based on specific attributes or labels. This is useful for tasks like generating images of specific digit classes in MNIST.

class ConditionalVLAE(nn.Module):
    def __init__(self, input_dim, latent_dim, num_classes):
        super(ConditionalVLAE, self).__init__()
        self.encoder = nn.Sequential(
            nn.Linear(input_dim + num_classes, 256),
            nn.ReLU(),
            nn.Linear(256, 128),
            nn.ReLU()
        )
        self.fc_mu = nn.Linear(128, latent_dim)
        self.fc_logvar = nn.Linear(128, latent_dim)
        self.decoder = nn.Sequential(
            nn.Linear(latent_dim + num_classes, 128),
            nn.ReLU(),
            nn.Linear(128, 256),
            nn.ReLU(),
            nn.Linear(256, input_dim),
            nn.Sigmoid()
        )
        self.num_classes = num_classes

    def encode(self, x, c):
        inputs = torch.cat([x, c], dim=1)
        h = self.encoder(inputs)
        return self.fc_mu(h), self.fc_logvar(h)

    def decode(self, z, c):
        inputs = torch.cat([z, c], dim=1)
        return self.decoder(inputs)

    def forward(self, x, c):
        mu, logvar = self.encode(x.view(-1, 784), c)
        z = self.reparameterize(mu, logvar)
        return self.decode(z, c), mu, logvar

    def reparameterize(self, mu, logvar):
        std = torch.exp(0.5 * logvar)
        eps = torch.randn_like(std)
        return mu + eps * std

# Initialize and train the conditional VLAE (code omitted for brevity)

Disentangled Representations

VLAEs can be designed to learn disentangled representations, where different dimensions of the latent space correspond to independent factors of variation in the data. This can be achieved by modifying the loss function to encourage independence between latent dimensions.

def disentangled_loss(recon_x, x, mu, logvar, beta=4.0):
    BCE = nn.functional.binary_cross_entropy(recon_x, x.view(-1, 784), reduction='sum')
    KLD = -0.5 * torch.sum(1 + logvar - mu.pow(2) - logvar.exp())
    return BCE + beta * KLD

class DisentangledVLAE(VLAE):
    def __init__(self, input_dim, latent_dim):
        super(DisentangledVLAE, self).__init__(input_dim, latent_dim)

    def forward(self, x):
        mu, logvar = self.encode(x.view(-1, 784))
        z = self.reparameterize(mu, logvar)
        return self.decode(z), mu, logvar

# Train the disentangled VLAE using the modified loss function
model = DisentangledVLAE(784, 10).to(device)
optimizer = optim.Adam(model.parameters(), lr=1e-3)

for epoch in range(50):
    for batch_idx, (data, _) in enumerate(train_loader):
        data = data.to(device)
        optimizer.zero_grad()
        recon_batch, mu, logvar = model(data)
        loss = disentangled_loss(recon_batch, data, mu, logvar)
        loss.backward()
        optimizer.step()
    print(f'Epoch {epoch+1}, Loss: {loss.item() / len(data):.4f}')

Real-life Example: Image Denoising

VLAEs can be used for image denoising by training the model on pairs of noisy and clean images. The model learns to map noisy inputs to their clean counterparts in the latent space.

import numpy as np

def add_noise(image, noise_factor=0.5):
    noisy_image = image + noise_factor * torch.randn(*image.shape)
    return torch.clamp(noisy_image, 0., 1.)

class DenoisingVLAE(VLAE):
    def __init__(self, input_dim, latent_dim):
        super(DenoisingVLAE, self).__init__(input_dim, latent_dim)

    def forward(self, x_noisy, x_clean):
        mu, logvar = self.encode(x_noisy.view(-1, 784))
        z = self.reparameterize(mu, logvar)
        x_recon = self.decode(z)
        return x_recon, mu, logvar

# Train the denoising VLAE
model = DenoisingVLAE(784, 20).to(device)
optimizer = optim.Adam(model.parameters(), lr=1e-3)

for epoch in range(50):
    for batch_idx, (data, _) in enumerate(train_loader):
        clean_data = data.to(device)
        noisy_data = add_noise(clean_data).to(device)
        optimizer.zero_grad()
        recon_batch, mu, logvar = model(noisy_data, clean_data)
        loss = loss_function(recon_batch, clean_data, mu, logvar)
        loss.backward()
        optimizer.step()
    print(f'Epoch {epoch+1}, Loss: {loss.item() / len(data):.4f}')

# Denoise an image
def denoise_image(model, noisy_image):
    model.eval()
    with torch.no_grad():
        denoised, _, _ = model(noisy_image, None)
    return denoised.view(28, 28)

# Example usage
test_image = next(iter(test_loader))[0][0].to(device)
noisy_image = add_noise(test_image)
denoised_image = denoise_image(model, noisy_image)

plt.figure(figsize=(15, 5))
plt.subplot(1, 3, 1)
plt.imshow(test_image.cpu().numpy().squeeze(), cmap='gray')
plt.title("Original Image")
plt.subplot(1, 3, 2)
plt.imshow(noisy_image.cpu().numpy().squeeze(), cmap='gray')
plt.title("Noisy Image")
plt.subplot(1, 3, 3)
plt.imshow(denoised_image.cpu().numpy().squeeze(), cmap='gray')
plt.title("Denoised Image")
plt.show()

Real-life Example: Anomaly Detection

VLAEs can be employed for anomaly detection in various domains, such as manufacturing or healthcare. By training the model on normal data, it learns to reconstruct normal patterns accurately. Anomalies can then be detected by measuring the reconstruction error.

def anomaly_score(model, x):
    model.eval()
    with torch.no_grad():
        x_recon, mu, logvar = model(x)
        recon_error = nn.functional.mse_loss(x_recon, x.view(-1, 784), reduction='none').sum(dim=1)
        kl_div = -0.5 * torch.sum(1 + logvar - mu.pow(2) - logvar.exp(), dim=1)
    return recon_error + kl_div

# Train VLAE on normal data (assuming '0' is the normal class)
normal_data = [(img, label) for img, label in train_loader.dataset if label == 0]
normal_loader = DataLoader(normal_data, batch_size=128, shuffle=True)

model = VLAE(784, 20).to(device)
optimizer = optim.Adam(model.parameters(), lr=1e-3)

for epoch in range(50):
    for batch_idx, (data, _) in enumerate(normal_loader):
        data = data.to(device)
        optimizer.zero_grad()
        recon_batch, mu, logvar = model(data)
        loss = loss_function(recon_batch, data, mu, logvar)
        loss.backward()
        optimizer.step()

# Detect anomalies
def detect_anomalies(model, dataloader, threshold):
    anomalies = []
    for data, labels in dataloader:
        data = data.to(device)
        scores = anomaly_score(model, data)
        anomalies.extend([(img, label, score.item()) for img, label, score in zip(data, labels, scores) if score > threshold])
    return anomalies

# Example usage
threshold = 100  # This should be tuned based on the specific use case
anomalies = detect_anomalies(model, test_loader, threshold)

print(f"Detected {len(anomalies)} anomalies")
for img, label, score in anomalies[:5]:
    plt.figure(figsize=(3, 3))
    plt.imshow(img.cpu().numpy().squeeze(), cmap='gray')
    plt.title(f"Label: {label}, Score: {score:.2f}")
    plt.show()

Limitations and Considerations

While VLAEs are powerful tools for various tasks, they have some limitations to consider:

  1. Training complexity: VLAEs can be challenging to train, especially for high-dimensional data or complex distributions.
  2. Hyperparameter sensitivity: The performance of VLAEs can be sensitive to hyperparameters like the latent space dimension and the balance between reconstruction and KL divergence losses.
  3. Interpretability: Although VLAEs can learn meaningful latent representations, interpreting these representations can still be challenging, especially for complex data.
  4. Scalability: For very large datasets or high-resolution images, training VLAEs can be computationally expensive and time-consuming.
  5. Mode collapse: In some cases, VLAEs might suffer from mode collapse, where they fail to capture the full diversity of the input distribution.
# Pseudocode for addressing some limitations

# 1. Use learning rate scheduling to help with training stability
scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, 'min')

# 2. Implement beta-VAE for better disentanglement
def beta_vae_loss(recon_x, x, mu, logvar, beta):
    BCE = F.binary_cross_entropy(recon_x, x.view(-1, 784), reduction='sum')
    KLD = -0.5 * torch.sum(1 + logvar - mu.pow(2) - logvar.exp())
    return BCE + beta * KLD

# 3. Visualize latent space traversals for interpretability
def latent_traversal(model, data, dim, range_):
    # Implementation details omitted for brevity

# 4. Use data parallelism for better scalability
model = nn.DataParallel(model)

# 5. Implement techniques like batch normalization or weight normalization
class ImprovedVLAE(nn.Module):
    def __init__(self, input_dim, latent_dim):
        super(ImprovedVLAE, self).__init__()
        # Add batch normalization layers
        # Implementation details omitted for brevity

Additional Resources

For those interested in diving deeper into Variational Lossy Autoencoders and related topics, here are some valuable resources:

  1. "Auto-Encoding Variational Bayes" by Kingma and Welling (2013) ArXiv: https://arxiv.org/abs/1312.6114
  2. "β-VAE: Learning Basic Visual Concepts with a Constrained Variational Framework" by Higgins et al. (2017) ICLR 2017
  3. "Understanding disentangling in β-VAE" by Burgess et al. (2018) ArXiv: https://arxiv.org/abs/1804.03599
  4. "Variational Lossy Autoencoder" by Chen et al. (2016) ArXiv: https://arxiv.org/abs/1611.02731
  5. "An Introduction to Variational Autoencoders" by Kingma and Welling (2019) ArXiv: https://arxiv.org/abs/1906.02691

These papers provide in-depth explanations of the theoretical foundations and practical applications of VLAEs and related models.

More in Autoencoders and generative

  1. Variational Autoencoders Generative Models and Applications7 min

    A Variational Autoencoder combines probabilistic inference with neural networks, creating a powerful generative model. The architecture consists of an...

  2. Variational Autoencoders A Python-Powered Generative Modeling Approach8 min

    Variational Autoencoders (VAEs) are powerful generative models that combine ideas from deep learning and probabilistic inference. They learn to encode data...

  3. Unexpected Benefits of Self-Modeling in Neural Networks14 min

    Self-modeling in neural systems refers to the ability of neural networks to create internal representations of their own structure and functionality. This...

  4. Uncertainty Quantification for Neural Networks with Python and PyTorch Lightning5 min

    Uncertainty quantification (UQ) is the process of quantifying and characterizing the uncertainties associated with computational models, input data, and...

  5. Temporal Quantum-Inspired Neural Networks in Python10 min

    Temporal Quantum-Inspired Neural Networks (TQINNs) are an innovative approach to neural network architecture that draws inspiration from quantum mechanics...

  6. Popular Deep Learning Architectures for Recommender Systems14 min

    The Wide and Deep architecture combines a wide linear model with a deep neural network to capture both memorization and generalization in recommender...