3、单层线性自编码器:最简单的降维入门

咱们直接动手吧。这一章的目标很明确——用最最原始的线性自编码器,把 MNIST 手写数字从 784 维压到 2 维,然后画出来看看效果。你可能会问:这么简单的网络能行吗?嗯,我当年第一次跑出来时也半信半疑,但结果确实能看出数字聚成了不同的簇。

3.1 自编码器是个什么东西

说白了,自编码器就是一种“自己把自己复现出来”的神经网络。它分两段:

  • 编码器:把高维输入压缩到低维的隐藏层(瓶颈)。
  • 解码器:从低维表示再还原回原始维度。

训练的目标就是让输出尽可能接近输入。如果只用线性层(没有激活函数),那它本质上就等价于 PCA(主成分分析)。我刚开始接触这玩意儿的时候,心想这不就是换个壳的 PCA 吗?后来加了非线性才发现,原来差别大着呢。不过这一章咱们先走线性版本,把原理摸清楚。

个人习惯:我建议新手先从线性自编码器开始调,参数少,容易 debug。等跑通了再往上加激活函数和深度。

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}')
踩过的一个坑:我曾经忘记把像素值归一化,直接用 0~255 的整数训练,结果 loss 爆炸,编码器的输出也全是 NaN。后来乖乖加 ToTensor() 把值压到 [0,1] 就解决了。

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 结构总结图

把上面的网络结构画出来,更直观:

单层线性自编码器结构 输入层 784 维 (28×28) W1, b1 隐藏层 2 维 (潜在编码 h) W2, b2 输出层 784 维 (重建 x_hat) 训练目标:让 x_hat 尽可能接近 x,从而迫使 2 维隐藏层保留关键信息

3.5 结果讨论

训练完后,你看到的散点图大致是这样:不同数字聚成了 10 个团,但团之间边界模糊,有些簇(比如 4 和 9、7 和 2)会重叠。这很合理——线性变换只能做全局平移和旋转,没法把弯曲的流形拉开。

线性自编码器 vs PCA 对比
特性线性自编码器PCA
本质两层线性网络特征值分解协方差矩阵
降维结果潜在向量 h前 k 个主成分得分
计算方式梯度下降优化矩阵分解(闭式解)
非线性能力❌ 无❌ 无
可解释性中等(权重可看)好(载荷矩阵)
大尺度数据可批量训练需要全部数据进内存

一个重要认识:线性自编码器学到的权重 W1 的转置,就是 PCA 的前两个主成分方向。你验证一下就会发现,两者投影后的散点图几乎一模一样。这就是“线性 + MSE 损失”的数学等价性。

3.6 一点改进建议

如果觉得 2 维压缩太粗暴,可以试试把隐藏层设成 32 或 64 维,然后用 t-SNE 或者 UMAP 再降到 2 维做可视化。不过那是后话了。这一章的核心是让你亲手跑通一个最简的自编码器,亲眼看看线性瓶颈到底能保留多少信息。相信我,这会帮你理解后面所有复杂结构的基础。