Problem
Most data has far fewer degrees of freedom than coordinates. A 28×28 digit has 784 pixels, but the set of images that look like digits is a thin sliver of that space: stroke thickness, slant, style. We often have plenty of unlabeled examples and want a short description of each one, to compress it, to remove noise, to spot an input that does not fit, or to give a later model better features than raw pixels.
There is no label to supervise that description. An autoencoder uses the input itself as the target, which makes this a self-supervised problem in the sense of learning paradigms: learn an encoder that maps x to a code z, a decoder that maps z back to x̂, and train both so that x̂ is close to x. The interesting part is everything that stops the network from cheating.
Intuition
Imagine describing a photo to a friend over a phone line that only carries sixteen numbers, and they have to redraw it. You cannot send pixels, so you agree on a vocabulary: where the object is, how big, what tilt. The encoder and decoder learn that vocabulary together, and because the line is narrow, the vocabulary must capture the directions in which the data actually varies. Noise and rare details are the first things dropped.
If the line were as wide as the image, the pair could agree to send the pixels unchanged and learn nothing. Every useful autoencoder therefore has some constraint: a narrow bottleneck (undercomplete), corrupted inputs it must clean up (denoising), or a penalty that keeps most code units silent (sparse). The constraint is what turns copying into learning.
The bottleneck forces a short code; the loss needs no label because the input is its own target.
Mechanics
The objective
An encoder fθ and a decoder gφ are trained to minimize the average reconstruction loss over the data:
z = fθ(x), x̂ = gφ(z), L(θ, φ) = (1/N) Σn ℓ(xn, gφ(fθ(xn)))
For real-valued data ℓ is squared error, PyTorch's MSELoss, which averages over every element by default. For pixels scaled to [0, 1] binary cross-entropy is common, with a sigmoid at the decoder's output or, more stably, BCEWithLogitsLoss on the raw outputs.
Both are the negative log-likelihood of the input under a Gaussian or Bernoulli decoder, the same correspondence as in loss functions. Training is an ordinary training loop in which the target is the batch itself.
A linear autoencoder is PCA
Take centred data, a linear encoder z = Ex with E of shape (k, d), a linear decoder x̂ = Dz with D of shape (d, k), k < d, and squared error. The reconstruction is x̂ = DEx, a matrix of rank at most k, and the best rank-k map in squared error is the orthogonal projection onto the top k eigenvectors of the data's second-moment matrix Σ = (1/N) Σn xnxnᵀ. That is exactly PCA. Baldi and Hornik showed that this loss has no local minima other than the global one: every other critical point is a saddle, so gradient descent reaches the PCA subspace.
The minimum loss is the variance the code throws away. With error averaged over d coordinates, it is (λk+1 + … + λd)/d. But the learned E and D are not the eigenvectors themselves: any invertible A gives the same product (DA⁻¹)(AE), so the encoder rows can be any basis of the subspace, scaled and mixed. The product DE is unique, the factors are not. Non-linear encoders and decoders can follow curved data that PCA cannot, which is the reason to use a neural autoencoder at all (Hinton and Salakhutdinov's 2006 deep autoencoders beat PCA on images and documents this way).
The one-unit code can only say "how far along u"; everything perpendicular to u is lost, and that loss is exactly PCA's.
Why a wide autoencoder learns nothing, and three fixes
If the code is as wide as the input, E = D = I is a perfect solution, and a large enough non-linear network can approximate it whatever the code size. Three standard constraints rule it out:
- Undercomplete. Make the code smaller than the input, as above. Simple, but the right size is a guess and too small loses detail.
- Denoising. Corrupt the input (Gaussian noise, or masking a fraction of entries) and train x̂ = g(f(x̃)) to match the clean x. The identity now scores badly, because it reproduces the noise. For a linear map and Gaussian noise of variance σ², the best map is Σ(Σ + σ²I)⁻¹: in each eigendirection it multiplies by λ/(λ + σ²), keeping directions with a lot of signal and shrinking the weak ones. Vincent et al. (2008) introduced the idea; masked language and image models are its descendants.
- Sparse. Allow a wide code but add a penalty, such as λ·Σ|zj| (an L1 penalty from regularization) or a KL divergence pulling each unit's average activation toward a small target, so each input uses only a few units. Sparse autoencoders on a model's activations are a current tool for finding interpretable features inside large language models.
Convolutional autoencoders and output_padding
For images the encoder is a stack of strided convolutions and the decoder mirrors it with transposed convolutions, which map each input position to a k×k patch of the output, summing where patches overlap.
A Conv2d has output size ⌊(H + 2p − d(k − 1) − 1)/s⌋ + 1, and a ConvTranspose2d with the same arguments has (H − 1)s − 2p + d(k − 1) + output_padding + 1, so they invert each other's shapes (not their values).
With stride 2 the floor makes Conv2d map both 13 and 14 to 7, so going back is ambiguous: k = 3, s = 2, p = 1 gives 13 from 7, and output_padding=1 picks 14.
PyTorch's docs stress that output_padding only selects the output shape and adds no zeros. Alternatively, pass output_size to forward. Transposed convolutions with stride that does not divide the kernel can leave checkerboard artifacts; upsampling then an ordinary convolution is a common alternative.
| Component | Shape/Params | Meaning | Source |
|---|---|---|---|
nn.Linear |
weight (out, in), bias (out,), U(−√k, √k), k = 1/in | y = xAᵀ + b: a linear encoder or decoder layer. | linear.py 53-80, 117-134 |
nn.Conv2d |
weight (Cout, Cin/groups, k0, k1); Hout = ⌊(Hin + 2p − d(k − 1) − 1)/s + 1⌋ | Strided convolution that downsamples in the encoder. | conv.py 468-490, 564-566 |
nn.ConvTranspose2d shape note |
output_padding: size added to one side of the output, default 0 |
Same arguments as a Conv2d invert its shapes; output_padding resolves stride ambiguity and adds no zeros. | conv.py 1053-1063, 778-830 |
nn.ConvTranspose2d shapes |
weight (Cin, Cout/groups, k0, k1); Hout = (Hin − 1)s − 2p + d(k − 1) + output_padding + 1 | Upsampling layer in the decoder; note in and out channels are swapped in the weight. | conv.py 1083-1100 |
ConvTranspose2d.forward |
forward(input, output_size=None) |
Computes output_padding from a requested output_size, then calls F.conv_transpose2d. |
conv.py 1169-1207 |
nn.Flatten, nn.Unflatten |
Flatten(start_dim=1); Unflatten(dim, sizes) | Bridge between the convolutional feature map and the dense code. | flatten.py 12-55, 64-130 |
nn.MSELoss |
reduction='mean' over every element | Gaussian reconstruction loss for real-valued inputs. | loss.py 567-630 |
nn.BCELoss, nn.BCEWithLogitsLoss |
targets in [0, 1]; the latter takes logits | Bernoulli reconstruction loss for pixels in [0, 1]; the logits version is the numerically stable one. | loss.py 633-716, 719-845 |
A linear autoencoder with a 1-unit code reaches the PCA loss. Will its encoder weights equal the top eigenvector u?
Not in general. Only the product DE = uuᵀ is determined: scaling E by a and D by 1/a gives the same reconstructions. In the worked example below the encoder is (0.800, 0.632) and the decoder (0.769, 0.608), different vectors, both parallel to u = (0.785, 0.620).
Worked example
Training a linear autoencoder. 40 centred points in 2-D, spread 2.0 along (0.8, 0.6) plus noise of standard deviation 0.4 (seed 7), shown in the figure above. Their second-moment matrix is Σ = [[2.239, 1.659], [1.659, 1.450]], with eigenvalues λ₁ = 3.5500 and λ₂ = 0.1392, and top eigenvector u = (0.7846, 0.6200).
The autoencoder has a 1-unit code: z = e·x, x̂ = d·z, with e and d both 2-vectors, loss = mean over points and both coordinates of (x − x̂)². It starts from e = (0.344, 0.258), d = (−0.079, −0.241) and runs full-batch gradient descent with learning rate 0.05.
| Step | Reconstruction MSE |
|---|---|
| 0 | 2.18899 |
| 5 | 1.68133 |
| 10 | 1.05158 |
| 25 | 0.06995 |
| 50 | 0.06964 |
| 100 | 0.06962 |
| 1000 | 0.06961 |
For scale, a code that says nothing (e = 0) scores the total variance per coordinate, (λ₁ + λ₂)/2 = 1.8446. Training ends at 0.06961, and PCA's minimum is λ₂/2 = 0.06961. The learned e = (0.8002, 0.6322) and d = (0.7694, 0.6079) differ, but d has cosine 1.000000 with u, and the product d·eᵀ = [[0.616, 0.486], [0.486, 0.384]] matches uuᵀ = [[0.616, 0.486], [0.486, 0.384]] to three decimals.
Denoising shrinks weak directions. If instead the 2-unit linear autoencoder sees x + noise and must output x, the best map is Σ(Σ + σ²I)⁻¹, which scales each eigendirection by λ/(λ + σ²):
| σ | Optimal map | Scale along u (λ₁) | Scale along the weak direction (λ₂) |
|---|---|---|---|
| 0.0 | [[1.000, 0.000], [0.000, 1.000]] | 1.000 | 1.000 |
| 0.5 | [[0.713, 0.280], [0.280, 0.579]] | 0.934 | 0.358 |
| 2.0 | [[0.302, 0.212], [0.212, 0.201]] | 0.470 | 0.034 |
With no noise the identity is optimal and nothing is learned. With σ = 0.5 the weak direction is already scaled to 0.358; at σ = 2 to 0.034, close to a projection onto u. The code is as wide as the input, yet noise alone made the map discard the direction that carries little signal.
Shapes through a convolutional autoencoder for 1×28×28 images with a 16-number code, as built in the implementation below:
| Layer | Output shape | Parameters |
|---|---|---|
| input | 1×28×28 | — |
| Conv2d(1, 16, 3, stride=2, padding=1) + ReLU | 16×14×14 | 160 |
| Conv2d(16, 32, 3, stride=2, padding=1) + ReLU | 32×7×7 | 4640 |
| Flatten | 1568 | — |
| Linear(1568, 16) | 16 | 25104 |
| Linear(16, 1568) + ReLU | 1568 | 26656 |
| Unflatten(1, (32, 7, 7)) | 32×7×7 | — |
| ConvTranspose2d(32, 16, 3, stride=2, padding=1, output_padding=1) + ReLU | 16×14×14 | 4624 |
| ConvTranspose2d(16, 1, 3, stride=2, padding=1, output_padding=1) + Sigmoid | 1×28×28 | 145 |
| Total | 61329 |
Without output_padding=1, the first transposed convolution would produce 13×13 instead of 14×14 and the output would be 25×25, not 28×28.
Gradient descent finds PCA's subspace but not PCA's basis; denoising finds a soft version of the same subspace without any bottleneck.
Implementation
import torch
import torch.nn as nn
class ConvAutoencoder(nn.Module):
def __init__(self, code=16):
super().__init__()
self.encoder = nn.Sequential(
nn.Conv2d(1, 16, 3, stride=2, padding=1), nn.ReLU(), # 16x14x14
nn.Conv2d(16, 32, 3, stride=2, padding=1), nn.ReLU(), # 32x7x7
nn.Flatten(),
nn.Linear(32 * 7 * 7, code),
)
self.decoder = nn.Sequential(
nn.Linear(code, 32 * 7 * 7), nn.ReLU(),
nn.Unflatten(1, (32, 7, 7)),
nn.ConvTranspose2d(32, 16, 3, stride=2, padding=1, output_padding=1), nn.ReLU(),
nn.ConvTranspose2d(16, 1, 3, stride=2, padding=1, output_padding=1), # logits
)
def forward(self, x):
return self.decoder(self.encoder(x))
model = ConvAutoencoder()
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
loss_fn = nn.BCEWithLogitsLoss() # pixels in [0, 1]
for x, _ in loader: # labels are ignored
noisy = (x + 0.3 * torch.randn_like(x)).clamp(0, 1) # drop for a plain autoencoder
loss = loss_fn(model(noisy), x) # the target is the clean input
opt.zero_grad()
loss.backward()
opt.step()
with torch.no_grad():
codes = model.encoder(x) # (N, 16) features for search, clustering, anomalies
images = torch.sigmoid(model(x))
The worked example's table counts a sigmoid output, so its parameter total equals this model's. For anomaly detection, train on normal data only and flag inputs whose reconstruction error is far above the training distribution; but check, because autoencoders sometimes reconstruct unfamiliar inputs well. To reuse the encoder as features for a classifier, freeze it or fine-tune it with a small head and keep the data splits clean.
Tradeoffs
- Autoencoder versus PCA. If the data is close to a linear subspace, PCA gives the same answer in closed form with ordered, orthogonal components. A neural autoencoder pays off only when the structure is curved, and costs training, tuning and an arbitrary basis.
- Code size. Too small and reconstructions blur; too large and, without another constraint, the network copies. Denoising and sparsity let you choose a generous size safely.
- Blurry outputs. Squared error is minimized by the average of plausible outputs, so fine detail is smoothed away. Perceptual or adversarial losses sharpen results at the cost of stability.
- Not a generator. A plain autoencoder's code space has holes: decoding a random z, or the midpoint of two codes, often gives garbage because nothing shaped the space between training codes. Variational autoencoders add exactly that shaping.
- Features without labels. Reconstruction is a cheap, label-free objective, but low reconstruction error does not guarantee codes that are useful for a downstream task; contrastive or masked objectives often give better features.
Why does adding noise to the input stop a wide linear autoencoder from learning the identity?
The identity reproduces the noise exactly, so its loss is the full noise variance. Scaling a direction by λ/(λ + σ²) trades a little bias for less noise, and wins whenever σ > 0. Weak directions have small λ, so they are shrunk most.
Connections
Read first
Read next
References
- Bourlard and Kamp. Auto-association by multilayer perceptrons and singular value decomposition. Biological Cybernetics, 1988.
- Baldi and Hornik. Neural networks and principal component analysis: Learning from examples without local minima. Neural Networks, 1989.
- Hinton and Salakhutdinov. Reducing the Dimensionality of Data with Neural Networks. Science, 2006.
- Vincent, Larochelle, Bengio and Manzagol. Extracting and Composing Robust Features with Denoising Autoencoders. ICML 2008.
- Ng. Sparse autoencoder. CS294A lecture notes, Stanford, 2011.
- Dumoulin and Visin. A guide to convolution arithmetic for deep learning. 2016. arXiv:1603.07285
- Odena, Dumoulin and Olah. Deconvolution and Checkerboard Artifacts. Distill, 2016. distill.pub/2016/deconv-checkerboard
- PyTorch v2.15.0:
torch/nn/modules/conv.py,torch/nn/modules/linear.py,torch/nn/modules/loss.py.