3、单层线性自编码器:最简单的降维入门
咱们直接动手吧。这一章的目标很明确——用最最原始的线性自编码器,把 MNIST 手写数字从 784 维压到 2 维,然后画出来看看效果。你可能会问:这么简单的网络能行吗?嗯,我当年第一次跑出来时也半信半疑,但结果确实能看出数字聚成了不同的簇。
3.1 自编码器是个什么东西
说白了,自编码器就是一种“自己把自己复现出来”的神经网络。它分两段:
- 编码器:把高维输入压缩到低维的隐藏层(瓶颈)。
- 解码器:从低维表示再还原回原始维度。
训练的目标就是让输出尽可能接近输入。如果只用线性层(没有激活函数),那它本质上就等价于 PCA(主成分分析)。我刚开始接触这玩意儿的时候,心想这不就是换个壳的 PCA 吗?后来加了非线性才发现,原来差别大着呢。不过这一章咱们先走线性版本,把原理摸清楚。
3.2 网络结构:就两层
结构超级简单:
- 输入层:784 个神经元(28×28 拉平)
- 隐藏层:2 个神经元(这就是降维后的表征)
- 输出层:784 个神经元(重建原图)
中间只有一次线性变换。公式长这样:
h = W1 * x + b1 (编码)
x_hat = W2 * h + b2 (解码)
没有 ReLU,没有 sigmoid,就是纯纯的矩阵乘法。你想想看,这么简单的网络,它的学习能力上限就是 PCA 的线性投影。
3.3 代码实现(PyTorch + 可视化)
我直接贴核心代码了。完整训练脚本大概 80 行,这里挑关键部分说。
3.3.1 定义模型
import torch
import torch.nn as nn
class LinearAutoencoder(nn.Module):
def __init__(self):
super().__init__()
self.encoder = nn.Linear(784, 2) # 编码层
self.decoder = nn.Linear(2, 784) # 解码层
def forward(self, x):
h = self.encoder(x)
x_hat = self.decoder(h)
return x_hat, h # 返回重建和潜在编码
3.3.2 数据准备与训练
MNIST 下载下来,像素值归一化到 [0,1],batch size 用 128。损失函数用均方误差(MSE),优化器选 Adam,学习率 1e-3。训练 50 个 epoch 足够收敛。
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
transform = transforms.Compose([transforms.ToTensor()])
train_set = datasets.MNIST('./data', train=True, download=True, transform=transform)
train_loader = DataLoader(train_set, batch_size=128, shuffle=True)
model = LinearAutoencoder()
criterion = nn.MSELoss()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
for epoch in range(50):
for batch, (img, _) in enumerate(train_loader):
img = img.view(img.size(0), -1) # 拉平
x_hat, h = model(img)
loss = criterion(x_hat, img)
optimizer.zero_grad()
loss.backward()
optimizer.step()
if epoch % 10 == 0:
print(f'Epoch {epoch}, Loss {loss.item():.4f}')
3.3.3 提取潜在编码并可视化
训练完成后,把所有训练集图片过一遍编码器,拿到 2 维的 h 向量。然后按数字标签染色画散点图。
import matplotlib.pyplot as plt
model.eval()
all_h = []
all_labels = []
with torch.no_grad():
for img, label in train_loader:
img = img.view(img.size(0), -1)
_, h = model(img)
all_h.append(h)
all_labels.append(label)
all_h = torch.cat(all_h).numpy() # shape (60000, 2)
all_labels = torch.cat(all_labels).numpy()
plt.figure(figsize=(8,6))
for digit in range(10):
mask = all_labels == digit
plt.scatter(all_h[mask, 0], all_h[mask, 1], s=1, label=str(digit))
plt.legend(fontsize=10)
plt.xlabel('Latent dim 1')
plt.ylabel('Latent dim 2')
plt.title('Linear Autoencoder on MNIST (2D latent)')
plt.show()
3.4 结构总结图
把上面的网络结构画出来,更直观:
3.5 结果讨论
训练完后,你看到的散点图大致是这样:不同数字聚成了 10 个团,但团之间边界模糊,有些簇(比如 4 和 9、7 和 2)会重叠。这很合理——线性变换只能做全局平移和旋转,没法把弯曲的流形拉开。
| 特性 | 线性自编码器 | PCA |
|---|---|---|
| 本质 | 两层线性网络 | 特征值分解协方差矩阵 |
| 降维结果 | 潜在向量 h | 前 k 个主成分得分 |
| 计算方式 | 梯度下降优化 | 矩阵分解(闭式解) |
| 非线性能力 | ❌ 无 | ❌ 无 |
| 可解释性 | 中等(权重可看) | 好(载荷矩阵) |
| 大尺度数据 | 可批量训练 | 需要全部数据进内存 |
一个重要认识:线性自编码器学到的权重 W1 的转置,就是 PCA 的前两个主成分方向。你验证一下就会发现,两者投影后的散点图几乎一模一样。这就是“线性 + MSE 损失”的数学等价性。
3.6 一点改进建议
如果觉得 2 维压缩太粗暴,可以试试把隐藏层设成 32 或 64 维,然后用 t-SNE 或者 UMAP 再降到 2 维做可视化。不过那是后话了。这一章的核心是让你亲手跑通一个最简的自编码器,亲眼看看线性瓶颈到底能保留多少信息。相信我,这会帮你理解后面所有复杂结构的基础。