Autoencoders and VAEs
Two people show a robot how to get past an obstacle. One goes round the left, the other round the right. Both are right.
A network that copies them with the usual loss goes straight into the obstacle. How can a model represent the same task done in different ways?
Overview
The running example: a robot starts near the near edge of a 1 m table and must reach the far edge. A round obstacle sits in the middle.
A policy sees two numbers, its start x and a cue (−1 or +1, say a gap that is open on one side), and outputs a chunk of 10 waypoints at once, as ACT outputs a chunk of actions. Every demonstration goes round the obstacle, on the left or on the right.
The mental model: give the network an extra number that says which way the demonstrator went, so it never has to average two ways into one.
The average
First, a plain policy: observation in, 20 numbers out, trained with the squared error against each demonstration, exactly as the imitation workshop trained its policy.
Take the first world, where the cue means nothing: half the demonstrations go left, half right, whatever it shows.
Demonstrations and the plain policy
How to read the map:
- The grey disc is the obstacle; the robot starts on the bottom edge and goes up.
- Thin blue paths are demonstrations that went left, thin magenta ones went right.
- The thick orange path is the plain policy’s chunk for this cue.
Squared error is smallest for the mean of the targets. With half the demonstrations at about −0.3 m and half at +0.3 m beside the obstacle, the mean is 0: straight through it.
Over 1,000 new starts and seeds 0–9, the plain policy hits the obstacle in of starts in this world.
In the second world, demonstrators follow the cue 70% of the time. The average then leans towards the cue’s side, but not far enough: of starts still hit.
Autoencoder
What the plain policy lacks is the one fact that decides each demonstration: which way it went. That fact is in the demonstration itself.
An autoencoder is trained to squeeze its input through a narrow middle and rebuild it. Ours squeezes a demonstration’s 20 numbers, plus the observation, into a single number \(z\), then rebuilds the 20 numbers from \(z\) and the observation.
- Encoder · decoder
- the part that squeezes the input into the code · the part that rebuilds the input from it.
- Latent variable
- a number the model invents to describe its input, here \(z\): nobody labels it.
To rebuild the paths with only one number, the encoder learns to use \(z\) for what the observation cannot tell: left or right.
VAE
At test time there is no demonstration to encode, so \(z\) must come from somewhere else. A plain autoencoder puts its codes wherever it likes, so there is no telling which values are safe to use.
A variational autoencoder (VAE) fixes that in two changes:
- The encoder outputs a distribution, a mean and a spread, and training uses a random \(z\) drawn from it. Nearby codes must then decode to similar paths.
- A KL term in the loss pulls every encoded distribution towards the standard bell curve \(N(0, 1)\). So at test time, \(z\) can be drawn from \(N(0, 1)\).
def forward(self, observation, chunk):
mean, log_var = self.encoder(torch.cat([observation, chunk], 1)).chunk(2, dim=1)
z = mean + torch.randn_like(mean) * (0.5 * log_var).exp() # the reparameterisation trick
return self.decoder(torch.cat([observation, z], 1)), mean, log_varFull file · pytorch_vae.py · 119 lines
"""The same task, done in different ways: why copying the average fails, and how a conditional VAE picks one way.
Run: python3 site/ml/foundations/examples/pytorch_vae.py
A robot starts near (0, 0) and must reach the far side of a 1 m table, past a round obstacle at (0, 0.5).
Each demonstration goes round it on the left or on the right. A policy sees two numbers, its start x and a
cue (−1 or +1, say a gap that is open on one side), and outputs a chunk of 10 waypoints: 20 numbers.
"""
import torch
from torch import nn
STEPS = torch.linspace(0.1, 1.0, 10) # forward progress of the 10 waypoints
OBSTACLE, RADIUS = torch.tensor([0.0, 0.5]), 0.12
def demonstrations(count, generator, follow=0.5):
"""Observations (count, 2) and demonstrated chunks (count, 20). With probability `follow` the demonstrator
goes round on the cue's side; otherwise on the other side. follow=0.5: the cue means nothing."""
start = (torch.rand(count, generator=generator) - 0.5) * 0.2
cue = torch.where(torch.rand(count, generator=generator) < 0.5, -1.0, 1.0)
side = torch.where(torch.rand(count, generator=generator) < follow, cue, -cue)
width = 0.25 + 0.1 * torch.rand(count, generator=generator) # how wide this person swings
x = start[:, None] * (1 - STEPS) + side[:, None] * width[:, None] * torch.sin(torch.pi * STEPS)
chunk = torch.stack([x, STEPS.expand(count, 10)], dim=2).flatten(1)
return torch.stack([start, cue], dim=1), chunk, side
def collides(chunks, observations):
"""Does the path from the start through the 10 waypoints come within the obstacle's radius anywhere?"""
points = torch.cat([torch.stack([observations[:, 0], torch.zeros(len(chunks))], 1)[:, None], chunks.view(-1, 10, 2)], 1)
s = torch.linspace(0, 1, 20)[None, None, :, None]
along = (points[:, :-1, None] * (1 - s) + points[:, 1:, None] * s).reshape(len(chunks), -1, 2)
return ((along - OBSTACLE).norm(dim=2) < RADIUS).any(dim=1)
def mlp(inputs, outputs):
return nn.Sequential(nn.Linear(inputs, 64), nn.ReLU(), nn.Linear(64, 64), nn.ReLU(), nn.Linear(64, outputs))
class Plain(nn.Module):
"""Plain regression: observation in, chunk out, trained to match the demonstration."""
def __init__(self):
super().__init__()
self.net = mlp(2, 20)
def forward(self, observation, chunk=None):
return self.net(observation), None, None
class CVAE(nn.Module):
"""A conditional VAE: the encoder reads observation and demonstrated chunk and says which 'style' z it was;
the decoder turns observation and z back into a chunk."""
def __init__(self, latent=1):
super().__init__()
self.latent = latent
self.encoder = nn.Sequential(nn.Linear(2 + 20, 64), nn.ReLU(), nn.Linear(64, 2 * latent))
self.decoder = mlp(2 + latent, 20)
def forward(self, observation, chunk):
mean, log_var = self.encoder(torch.cat([observation, chunk], 1)).chunk(2, dim=1)
z = mean + torch.randn_like(mean) * (0.5 * log_var).exp() # the reparameterisation trick
return self.decoder(torch.cat([observation, z], 1)), mean, log_var
def act(self, observation, z):
"""At test time there is no demonstration: z is chosen, then decoded."""
return self.decoder(torch.cat([observation, z], 1))
def kl(mean, log_var):
"""How far each encoded distribution N(mean, var) is from N(0, 1), summed over z's numbers."""
return (-0.5 * (1 + log_var - mean ** 2 - log_var.exp())).sum(dim=1).mean()
def train(kind, follow=0.5, beta=0.001, seed=0, epochs=200): # beta chosen on validation starts
torch.set_num_threads(1)
torch.manual_seed(seed)
generator = torch.Generator().manual_seed(seed)
observations, chunks, _ = demonstrations(4000, generator, follow)
policy = Plain() if kind == "plain" else CVAE()
optimizer = torch.optim.Adam(policy.parameters(), lr=1e-3)
for epoch in range(epochs):
for batch in torch.randperm(4000, generator=generator).split(128):
guess, mean, log_var = policy(observations[batch], chunks[batch])
loss = ((guess - chunks[batch]) ** 2).mean() # reconstruction: match the demonstration
if kind == "cvae":
loss = loss + beta * kl(mean, log_var) # keep the styles near N(0, 1)
optimizer.zero_grad()
loss.backward()
optimizer.step()
return policy
def test(policy, follow, seed=9):
"""Collision rate over 1,000 new starts: plain, and for a CVAE with z = 0 and with z sampled from N(0, 1)."""
generator = torch.Generator().manual_seed(seed)
observations, chunks, side = demonstrations(1000, generator, follow)
with torch.no_grad():
if isinstance(policy, Plain):
return {"plain": collides(policy(observations)[0], observations).float().mean().item()}
zero = policy.act(observations, torch.zeros(1000, 1))
sampled = policy.act(observations, torch.randn(1000, 1, generator=generator))
mean, _ = policy.encoder(torch.cat([observations, chunks], 1)).chunk(2, dim=1)
return {"z0": collides(zero, observations).float().mean().item(), "sampled": collides(sampled, observations).float().mean().item(),
"z0_follows_cue": ((zero.view(-1, 10, 2)[:, 4, 0].sign()) == observations[:, 1]).float().mean().item(),
"sampled_left": (sampled.view(-1, 10, 2)[:, 4, 0] < 0).float().mean().item(),
"sampled_follows_cue": ((sampled.view(-1, 10, 2)[:, 4, 0].sign()) == observations[:, 1]).float().mean().item(),
"z_left": mean[side < 0, 0].mean().item(), "z_right": mean[side > 0, 0].mean().item(),
"z_with_cue": mean[side == observations[:, 1], 0].mean().item(), "z_against_cue": mean[side != observations[:, 1], 0].mean().item()}
if __name__ == "__main__":
for follow in (0.5, 0.7):
plain, cvae = train("plain", follow), train("cvae", follow)
p, c = test(plain, follow), test(cvae, follow)
print(f"cue followed {follow:.0%}: plain regression collides {p['plain']:.1%}; CVAE with z = 0 collides {c['z0']:.1%} "
f"(follows the cue {c['z0_follows_cue']:.1%}), with z sampled {c['sampled']:.1%} ({c['sampled_left']:.0%} go left, "
f"{c['sampled_follows_cue']:.0%} follow the cue); encoded z: left {c['z_left']:+.2f}, right {c['z_right']:+.2f}, "
f"with the cue {c['z_with_cue']:+.2f}, against it {c['z_against_cue']:+.2f}")
mean, log_var: the encoder’s two outputs, the centre of the distribution and the log of its spread squared.- The reparameterisation trick draws \(z\) as mean plus spread times a standard random number. The randomness sits outside the network, so
backward()can still reach the encoder. self.decodersees the observation and \(z\), never the demonstration.
The loss adds the two goals, with a weight \(\beta\) on the second:
\[\text{loss} = \text{reconstruction error} + \beta \times \text{KL}\big(N(\text{mean}, \text{spread}^2)\ \|\ N(0, 1)\big)\]- Variational autoencoder (VAE)
- an autoencoder whose encoder outputs a distribution, kept close to \(N(0,1)\) by a KL term.
- KL divergence
- a measure of how different two distributions are; 0 when they are the same.
- Conditional VAE (CVAE)
- a VAE whose encoder and decoder also see a condition, here the observation.
\(\beta\) was chosen on validation starts, by how often paths decoded from random \(z\) hit the obstacle (mean of seeds 0–2, first world):
- With \(\beta = 0\), a plain autoencoder, left and right sit far apart in \(z\), and many random \(z\) fall in places the decoder never saw.
- With \(\beta = 0.1\), the KL term wins: every demonstration gets the same \(z\), the code carries nothing, and the decoder is back to averaging.
A policy with a style
After training, the decoder alone is the policy: give it the observation and a \(z\), and it returns a chunk. Slide \(z\) and watch the path:
The decoder as a policy
How to read the lab:
- On the left, the thick orange path is the decoder’s chunk for the chosen \(z\); red when it hits the obstacle.
- On the right, the bars show where the encoder put 600 left and 600 right demonstrations on the \(z\) axis; the line is the chosen \(z\).
The encoder has given the two ways their own regions of \(z\): one side below zero, the other above (which is which depends on the seed). Nobody told it about left and right; one number had to rebuild the path, and the side is what it needed.
In the 70% world each region splits again into two groups, one per cue.
Choosing z
At test time there is no demonstration to encode. Two choices, measured over 1,000 new starts and seeds 0–9 (medians):
- Sampling \(z\) from \(N(0,1)\) picks a side, and reproduces the demonstrators’ own mix: follow the cue where they followed it 70% of the time. A few samples land between the two regions and still hit.
- \(z = 0\), the centre of \(N(0,1)\), gives the decoder’s most typical path. When the cue makes one side more common, that is the cue’s side every time, with no collisions. When both sides are equally common, it is the middle, and it crashes like the plain policy.
That is this lesson’s one caveat, demonstrated: \(z = 0\) works when the observation makes one way the usual one.
ACT’s CVAE
ACT is a conditional VAE of this kind. In LeRobot 0.6.1:
- Encoder (training only): a transformer reads a class token, the joint angles and the demonstrated chunk of 100 actions. From the class token it outputs a mean and a log-variance for a 32-number \(z\).
- Reparameterisation: \(z\) = mean + spread × a random number, as above.
- Decoder: the transformer of the previous lesson, with \(z\) as one of its encoder tokens beside the camera and joint-angle tokens.
- Loss: L1 error on the action chunk plus 10 × the KL term, summed over \(z\)’s 32 numbers.
- At test time: \(z\) is all zeros.
So ACT makes the second choice. It relies on the camera images to show which way is the usual one, as our cue did. The CVAE then soaks up how people differ while training, instead of averaging their differences into the policy.
Recap
demonstrations(count, generator, follow)makes paths that go round the obstacle on the left or right.Plainlearns the average path, and the average of left and right goes through the obstacle.CVAE.encoderreads a demonstration and outputs a distribution for \(z\); the reparameterisation trick samples from it.kl(mean, log_var), weighted by \(\beta = 0.001\), keeps the codes near \(N(0,1)\) without wiping them out.CVAE.act(observation, z)is the policy: sampled \(z\) picks a side; \(z = 0\) picks the usual one.
When one task can be done several ways, give the model a number for “which way”. Learn it from the demonstrations, keep it tidy with the KL term, and choose it at test time.
Key concepts
- Autoencoder
- A network trained to squeeze its input through a narrow code and rebuild it.
- Encoder · decoder
- Input to code · code to output.
- Latent variable
- A number the model invents to describe its input, with no label.
- VAE
- An autoencoder whose codes are distributions, kept near \(N(0,1)\).
- KL divergence
- How different two distributions are.
- Reparameterisation trick
- Sampling as mean + spread × a fixed random number, so gradients still flow.
- Conditional VAE
- A VAE whose parts also see a condition, such as the observation.
- Multimodal data
- Examples where the same input has several different right answers.