05 - 生成模型轨道 - 创造的艺术 🎨

欢迎来到生成模型的世界!这条轨道探索如何让机器创造新内容——图像、音乐、文本…

核心问题: 如何从噪声或潜在空间生成逼真的数据?


🎯 本轨道内容

算法核心思想优势劣势难度
VAE编码→潜在空间→解码可解释性,平滑插值生成模糊⭐⭐⭐
GAN生成器vs判别器对抗生成清晰训练不稳定⭐⭐⭐
Diffusion逐步去噪质量最高,稳定生成慢⭐⭐⭐⭐

学习路径: VAE(理解潜在空间) → GAN(理解对抗训练) → Diffusion(理解扩散过程)


🔮 算法一: VAE - 变分自编码器

什么是VAE?

VAE (Variational Auto-Encoder, Kingma & Welling, 2013) 学习数据的压缩表示,并能从这个表示重建数据。

结构:

数据x → Encoder → 潜在变量z ~ N(μ,σ) → Decoder → 重建x'

关键创新: Reparameterization Trick (重参数化技巧)

传统采样: z ~ N(μ,σ)  (不可微!)
重参数化: z = μ + σ·ε, 其中 ε ~ N(0,1)  (可微!)

对话1: VAE和普通AutoEncoder有什么区别?

🤔 学生: AutoEncoder也是Encoder+Decoder,VAE的”变分”体现在哪里?

💡 导师: 关键区别: VAE的潜在空间是概率分布,不是确定的点!

普通AutoEncoder:

数据x → Encoder → z(确定值) → Decoder → x'
Loss = ||x - x'||²  (重建误差)

问题:
1. 潜在空间不连续 — 两个点z1,z2之间可能是"垃圾"
2. 不能生成 — 随机采样z可能解码出无意义图像

VAE:

数据x → Encoder → μ,σ → z=μ+σ·ε → Decoder → x'
Loss = 重建误差 + KL(q(z|x) || p(z))

强制:
1. q(z|x)接近标准正态分布N(0,I)
2. 潜在空间连续、平滑 — 任意采样z都能解码

直观理解:

AutoEncoder学到的潜在空间:

[数字0] → z=0.1
[数字1] → z=0.5
[数字2] → z=2.3  (跳跃!)

采样 z=1.5 → ??? (未定义)

VAE学到的潜在空间:

[数字0] → μ=0.0, σ=0.1
[数字1] → μ=1.0, σ=0.1
[数字2] → μ=2.0, σ=0.1

采样 z=1.5 → 类似0和1的混合体 (有效!)

KL散度的作用:

KL(q(z|x) || N(0,I)) 惩罚:
  - μ离0太远 (中心化)
  - σ过大或过小 (标准化)
  
结果: 所有数据的z分布重叠在N(0,I)附近
→ 平滑的潜在空间

✨ 关键洞察: VAE用KL散度”强制”潜在空间规整——代价是重建质量稍差,收益是可以随意采样生成新数据。


对话2: 重参数化技巧为什么重要?

🤔 学生: 为什么 z=μ+σ·ε 就能让采样可微?

💡 导师: 因为把随机性分离到ε,梯度只流经μ和σ!

问题: 直接采样z~N(μ,σ)不可微

# 不可微的采样
z = sample_from_normal(μ, σ)  
 
# 反向传播时:
∂Loss/∂μ = ???  (采样操作没有梯度!)

解决: 重参数化

# 可微的采样
ε = sample_from_normal(0, 1)  # 固定分布,不依赖参数
z = μ + σ * ε
 
# 反向传播:
∂z/∂μ = 1
∂z/∂σ = ε
 
∂Loss/∂μ = ∂Loss/∂z · ∂z/∂μ = ∂Loss/∂z  (梯度流通!)
∂Loss/∂σ = ∂Loss/∂z · ε

图示:

直接采样(不可微):
  μ,σ → [采样黑盒] → z → Decoder → x' → Loss
          ↑
      梯度断裂!

重参数化(可微):
  μ,σ → [μ + σ·ε] ← ε ~ N(0,1) (外部噪声)
         ↓
         z → Decoder → x' → Loss
         ↑
     梯度流通!

数学证明:

期望梯度等价性:
E[∂Loss/∂μ | z~N(μ,σ)] = E[∂Loss/∂μ | z=μ+σ·ε, ε~N(0,1)]

但后者可以用链式法则计算!

✨ 关键洞察: 重参数化是个”梯度路由技巧”——把不可微操作(随机采样)移到计算图外,只留可微操作(加法、乘法)在图内。


代码片段: VAE核心

class VAE:
    def __init__(self, input_dim=784, latent_dim=20, hidden_dim=400):
        # Encoder
        self.encoder_fc1 = nn.Linear(input_dim, hidden_dim)
        self.encoder_mu = nn.Linear(hidden_dim, latent_dim)  # 均值头
        self.encoder_logvar = nn.Linear(hidden_dim, latent_dim)  # log方差头
        
        # Decoder
        self.decoder_fc1 = nn.Linear(latent_dim, hidden_dim)
        self.decoder_fc2 = nn.Linear(hidden_dim, input_dim)
    
    def encode(self, x):
        """编码器: x → μ, log(σ²)"""
        h = F.relu(self.encoder_fc1(x))
        mu = self.encoder_mu(h)
        logvar = self.encoder_logvar(h)  # 用log(σ²)而不是σ,确保正数
        return mu, logvar
    
    def reparameterize(self, mu, logvar):
        """重参数化技巧: z = μ + σ·ε"""
        std = torch.exp(0.5 * logvar)  # σ = sqrt(σ²) = exp(0.5·log(σ²))
        eps = torch.randn_like(std)    # ε ~ N(0,1)
        z = mu + std * eps             # z ~ N(μ,σ)
        return z
    
    def decode(self, z):
        """解码器: z → x'"""
        h = F.relu(self.decoder_fc1(z))
        x_recon = torch.sigmoid(self.decoder_fc2(h))  # [0,1]像素
        return x_recon
    
    def forward(self, x):
        mu, logvar = self.encode(x)
        z = self.reparameterize(mu, logvar)
        x_recon = self.decode(z)
        return x_recon, mu, logvar
 
def vae_loss(x, x_recon, mu, logvar):
    """VAE损失 = 重建误差 + KL散度"""
    # 重建误差(Binary Cross-Entropy for pixel values)
    recon_loss = F.binary_cross_entropy(x_recon, x, reduction='sum')
    
    # KL散度: KL(N(μ,σ) || N(0,1))
    # 解析解: -0.5 * sum(1 + log(σ²) - μ² - σ²)
    kl_loss = -0.5 * torch.sum(1 + logvar - mu.pow(2) - logvar.exp())
    
    return recon_loss + kl_loss
 
# 训练循环
for epoch in range(epochs):
    for batch_x in dataloader:
        x_recon, mu, logvar = vae(batch_x)
        loss = vae_loss(batch_x, x_recon, mu, logvar)
        
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
 
# 生成新样本
z = torch.randn(100, latent_dim)  # 从N(0,1)采样
x_generated = vae.decode(z)

关键点:

  1. 两个输出头: μ和logvar(不是直接σ)
  2. KL散度解析解: 避免采样估计,训练稳定
  3. 生成: 直接从N(0,I)采样z,解码即可

⚔️ 算法二: GAN - 生成对抗网络

什么是GAN?

GAN (Generative Adversarial Network, Goodfellow et al., 2014) 通过对抗博弈训练生成器。

两个玩家:

  • 生成器G: 噪声z → 假数据x_fake (骗过判别器)
  • 判别器D: 真数据/假数据 → 真假概率 (识破生成器)

Minimax博弈:

min_G max_D E[log D(x_real)] + E[log(1 - D(G(z)))]

翻译: G想让D(G(z))→1(假数据被当真),D想让D(x_real)→1,D(G(z))→0


对话3: GAN的训练为什么不稳定?

🤔 学生: GAN听起来很聪明,为什么说训练困难?

💡 导师: 因为两个网络相互追赶,容易失衡!

问题1: Mode Collapse (模式坍缩)

数据分布有10种模式(比如10个数字)
生成器发现: 生成"7"总能骗过判别器
策略: 只生成"7" → 100%骗过D!

结果: 多样性丢失,只会生成单一模式

为什么D不抵抗?

  • 当G只生成”7”,D学会识破”7” → 惩罚G
  • G切换到生成”3” → 骗过D → D学会识破”3”
  • G又切换到”7”…
  • 循环振荡,永远不收敛到生成全部模式

问题2: 梯度消失

判别器太强 → D(G(z)) ≈ 0(完全识破假数据)
生成器损失: -log D(G(z)) ≈ -log(0) = +∞
但梯度: ∂(-log D(G(z)))/∂G ≈ 0  (D饱和,G收不到信号!)

G停止学习,训练崩溃。

问题3: 非收敛动力学

标准优化:   Loss(θ) 有全局最小值 → 梯度下降收敛
GAN "优化": 两个玩家互相博弈 → Nash均衡难达到

类比: 两人石头剪刀布
  - 一方总出石头 → 另一方出布
  - 发现后改出剪刀 → 对方改石头
  - 无穷循环!

解决方案:

  1. Non-saturating GAN: 改G的损失为 -log D(G(z)) → log D(G(z)) (反转)
  2. Wasserstein GAN: 用Earth Mover距离替代JS散度
  3. Spectral Normalization: 约束D的Lipschitz常数
  4. Tricks: label smoothing, feature matching, minibatch discrimination
  5. StyleGAN: 渐进式训练 + 自适应实例归一化

✨ 关键洞察: GAN是个”动态博弈”而非”静态优化”——稳定性来自精心设计的损失函数和训练技巧,不是算法本身。


对话4: GAN vs VAE,什么时候用谁?

🤔 学生: GAN生成更清晰,为什么还要用VAE?

💡 导师: 因为它们优化目标不同!

维度VAEGAN
优化目标最大化似然P(x)最小化真假分布距离
生成质量模糊(Gaussian假设)清晰(对抗逼真)
训练稳定性稳定(凸优化)不稳定(博弈)
多样性高(覆盖所有模式)低(易mode collapse)
潜在空间有(Encoder)无(单向生成)
生成速度快(1次前向)快(1次前向)
应用异常检测、插值图像合成、超分辨率

VAE适合:

  • 需要潜在空间(数据压缩、特征提取)
  • 需要稳定训练(工业界)
  • 需要密度估计(P(x)有意义)

GAN适合:

  • 追求生成质量(艺术创作、超分)
  • 不需要Encoder(只生成,不编码)
  • 有足够资源调参(学术研究)

最新趋势: 混合模型

  • VAE-GAN: VAE的Decoder加GAN判别器 → 清晰度提升
  • α-GAN: GAN的生成器加VAE的Encoder → 潜在空间可用

✨ 关键洞察: VAE和GAN是”不同哲学”——VAE是贝叶斯推断(最大化似然),GAN是博弈论(极小化极大)。没有绝对优劣,只有任务适配。


代码片段: GAN核心

class Generator(nn.Module):
    def __init__(self, noise_dim=100, output_dim=784):
        super().__init__()
        self.fc = nn.Sequential(
            nn.Linear(noise_dim, 256),
            nn.ReLU(),
            nn.Linear(256, 512),
            nn.ReLU(),
            nn.Linear(512, output_dim),
            nn.Tanh()  # 输出[-1,1]
        )
    
    def forward(self, z):
        return self.fc(z)
 
class Discriminator(nn.Module):
    def __init__(self, input_dim=784):
        super().__init__()
        self.fc = nn.Sequential(
            nn.Linear(input_dim, 512),
            nn.LeakyReLU(0.2),
            nn.Linear(512, 256),
            nn.LeakyReLU(0.2),
            nn.Linear(256, 1),
            nn.Sigmoid()  # 输出[0,1]概率
        )
    
    def forward(self, x):
        return self.fc(x)
 
# 训练循环
G = Generator()
D = Discriminator()
opt_G = torch.optim.Adam(G.parameters(), lr=0.0002, betas=(0.5, 0.999))
opt_D = torch.optim.Adam(D.parameters(), lr=0.0002, betas=(0.5, 0.999))
 
for epoch in range(epochs):
    for real_data in dataloader:
        batch_size = real_data.size(0)
        
        # === 训练判别器D ===
        # 真数据
        real_labels = torch.ones(batch_size, 1)
        real_pred = D(real_data)
        d_loss_real = F.binary_cross_entropy(real_pred, real_labels)
        
        # 假数据
        z = torch.randn(batch_size, noise_dim)
        fake_data = G(z).detach()  # 不更新G!
        fake_labels = torch.zeros(batch_size, 1)
        fake_pred = D(fake_data)
        d_loss_fake = F.binary_cross_entropy(fake_pred, fake_labels)
        
        # D总损失
        d_loss = d_loss_real + d_loss_fake
        opt_D.zero_grad()
        d_loss.backward()
        opt_D.step()
        
        # === 训练生成器G ===
        z = torch.randn(batch_size, noise_dim)
        fake_data = G(z)
        fake_pred = D(fake_data)  # 不detach,G要通过D反向传播!
        
        # G的目标: 让D(G(z))→1 (骗过D)
        g_loss = F.binary_cross_entropy(fake_pred, real_labels)
        
        opt_G.zero_grad()
        g_loss.backward()
        opt_G.step()
 
# 生成新样本
z = torch.randn(64, noise_dim)
fake_images = G(z)

关键点:

  1. 交替训练: 每步先更新D,再更新G
  2. detach: 训练D时,G不更新(梯度阻断)
  3. 标签反转: G训练时用real_labels(骗D)

🌊 算法三: Diffusion - 扩散模型

什么是Diffusion?

Diffusion Model (DDPM, Ho et al., 2020) 通过逐步去噪生成数据,是Stable Diffusion、DALL-E 2的核心。

核心思想:

  1. 前向过程(固定): 逐步加噪声 x → x₁ → … → xₜ ~ N(0,I)
  2. 反向过程(学习): 逐步去噪 xₜ → … → x₁ → x₀ ≈ 数据分布

数学:

前向: q(x_t | x_0) = N(x_t; √ᾱ_t·x_0, (1-ᾱ_t)·I)
      ᾱ_t = ∏(1-β_i),  β_t是噪声schedule

反向: p_θ(x_{t-1} | x_t) ≈ N(x_{t-1}; μ_θ(x_t,t), Σ_θ(x_t,t))
      训练ε_θ预测噪声: L = ||ε - ε_θ(x_t, t)||²

对话5: Diffusion为什么生成质量高?

🤔 学生: Diffusion慢这么多,为什么还比GAN好?

💡 导师: 因为分解了难问题!

GAN的难点: 一步到位

噪声z → 图像x  (巨大跳跃,判别器容易崩溃)

Diffusion的策略: 分T步

纯噪声 → 稍清晰 → 更清晰 → ... → 完美图像
每步只需: "去除一点噪声" (简单!)

类比:

  • GAN: 一笔画完整幅画 (难,容易出错)
  • Diffusion: 素描→上色→细节→完成 (每步简单,累积精细)

训练稳定性:

GAN:
  Loss = min_G max_D ...  (博弈,振荡)
  
Diffusion:
  Loss = E[||ε - ε_θ(x_t,t)||²]  (标准回归,凸优化!)

模式覆盖:

GAN: 容易mode collapse,丢失多样性
Diffusion: T步渐进,每步只调整小幅 → 自然覆盖所有模式

可控性:

Classifier Guidance: p(x_t | y) ∝ p(x_t) · p(y | x_t)
  用分类器p(y|x_t)引导去噪方向 → 可控生成!
  
GAN: 潜在空间难解释,可控性差

缺点: 慢!

  • GAN/VAE: 1次前向 → 图像
  • Diffusion: 100-1000次前向 → 图像 (每步去一点噪)

加速方案:

  1. DDIM: 确定性采样,跳步(1000步→50步)
  2. Latent Diffusion (Stable Diffusion): 在VAE的潜在空间做扩散 → 8x加速
  3. 一致性模型: 单步生成,保持质量

✨ 关键洞察: Diffusion是”时间换质量”——用更多步数换更高质量和稳定性。本质是把”生成”分解为”逐步精炼”。


对话6: Diffusion的噪声schedule怎么设计?

🤔 学生: 为什么要设计β_t的schedule,直接均匀加噪不行吗?

💡 导师: 因为前期和后期需要不同噪声强度!

线性Schedule(最简单):

β_t = β_start + (β_end - β_start) · t/T
例如: β_1=0.0001, β_T=0.02

问题: 中间步噪声过大,信息丢失太快

Cosine Schedule (更好):

ᾱ_t = cos²((t/T + s) / (1+s) · π/2)
s是小偏移(防止t=0时ᾱ=1)

特点:
  - 前期(t小): ᾱ接近1,慢慢加噪
  - 中期: 平滑过渡
  - 后期(t→T): ᾱ→0,完全噪声

直觉:

图像信息 vs 噪声:
  t=0:   100%信号, 0%噪声   (原图)
  t=T/4: 80%信号,  20%噪声 (稍模糊)
  t=T/2: 40%信号,  60%噪声 (很模糊)
  t=T:   0%信号,   100%噪声 (纯噪声)

Cosine schedule: 前期保护信息,后期快速破坏
Linear schedule: 匀速破坏,中期信息丢失过快

实验结果:

ScheduleFID (越低越好)
Linear12.5
Cosine8.2
Quadratic10.1

✨ 关键洞察: 噪声schedule是个”信息破坏策略”——前期温和(保留结构),后期激进(彻底随机化)。


代码片段: DDPM核心

def noise_schedule(T, schedule_type='linear'):
    """计算噪声schedule"""
    if schedule_type == 'linear':
        betas = torch.linspace(0.0001, 0.02, T)
    elif schedule_type == 'cosine':
        s = 0.008
        steps = torch.arange(T + 1, dtype=torch.float64) / T
        alphas_cumprod = torch.cos((steps + s) / (1 + s) * torch.pi * 0.5) ** 2
        alphas_cumprod = alphas_cumprod / alphas_cumprod[0]
        betas = 1 - (alphas_cumprod[1:] / alphas_cumprod[:-1])
        betas = torch.clip(betas, 0.0001, 0.9999)
    
    alphas = 1 - betas
    alphas_cumprod = torch.cumprod(alphas, dim=0)
    return betas, alphas_cumprod
 
class DiffusionModel:
    def __init__(self, noise_model, T=1000):
        self.model = noise_model  # ε_θ(x_t, t)
        self.T = T
        self.betas, self.alphas_cumprod = noise_schedule(T)
    
    def q_sample(self, x_0, t, noise=None):
        """前向加噪: x_0 → x_t"""
        if noise is None:
            noise = torch.randn_like(x_0)
        
        sqrt_alphas_cumprod_t = torch.sqrt(self.alphas_cumprod[t])
        sqrt_one_minus_alphas_cumprod_t = torch.sqrt(1 - self.alphas_cumprod[t])
        
        # x_t = √ᾱ_t · x_0 + √(1-ᾱ_t) · ε
        return sqrt_alphas_cumprod_t * x_0 + sqrt_one_minus_alphas_cumprod_t * noise
    
    def train_step(self, x_0):
        """训练: 预测噪声"""
        # 随机采样时间步
        t = torch.randint(0, self.T, (x_0.shape[0],))
        
        # 加噪
        noise = torch.randn_like(x_0)
        x_t = self.q_sample(x_0, t, noise)
        
        # 预测噪声
        pred_noise = self.model(x_t, t)
        
        # 损失
        loss = F.mse_loss(pred_noise, noise)
        return loss
    
    @torch.no_grad()
    def p_sample(self, x_t, t):
        """单步去噪: x_t → x_{t-1}"""
        # 预测噪声
        pred_noise = self.model(x_t, t)
        
        # 计算均值
        alpha_t = 1 - self.betas[t]
        alpha_cumprod_t = self.alphas_cumprod[t]
        
        # μ_θ = (1/√α_t) · (x_t - (β_t/√(1-ᾱ_t))·ε_θ)
        mean = (x_t - (self.betas[t] / torch.sqrt(1 - alpha_cumprod_t)) * pred_noise) / torch.sqrt(alpha_t)
        
        if t > 0:
            noise = torch.randn_like(x_t)
            variance = self.betas[t]
            x_t_minus_1 = mean + torch.sqrt(variance) * noise
        else:
            x_t_minus_1 = mean
        
        return x_t_minus_1
    
    @torch.no_grad()
    def sample(self, shape):
        """完整采样: x_T ~ N(0,I) → x_0"""
        x_t = torch.randn(shape)
        
        for t in reversed(range(self.T)):
            x_t = self.p_sample(x_t, t)
        
        return x_t
 
# 训练
diffusion = DiffusionModel(UNet(), T=1000)
for epoch in range(epochs):
    for x_0 in dataloader:
        loss = diffusion.train_step(x_0)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
 
# 生成
generated = diffusion.sample(shape=(64, 3, 32, 32))

关键点:

  1. 训练: 随机t,加噪,预测噪声
  2. 采样: 从T倒推到0,逐步去噪
  3. 重参数化: x_{t-1} = μ + √β · ε

📊 三大生成模型对比

维度VAEGANDiffusion
生成质量⭐⭐ (模糊)⭐⭐⭐⭐ (清晰)⭐⭐⭐⭐⭐ (最高)
训练稳定性⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
多样性⭐⭐⭐⭐⭐⭐ (mode collapse)⭐⭐⭐⭐⭐
生成速度⭐⭐⭐⭐⭐ (1步)⭐⭐⭐⭐⭐ (1步)⭐ (100-1000步)
潜在空间✅ 有Encoder❌ 无❌ 无
可控性⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐ (Guidance)
数学基础变分推断博弈论非平衡热力学

应用场景:

  • VAE: 异常检测、数据压缩、插值
  • GAN: 图像超分辨率、风格迁移、人脸生成
  • Diffusion: Text-to-Image(Stable Diffusion)、视频生成、音频合成

🎯 本章总结

生成模型的核心权衡:质量 vs 速度 vs 稳定性

演化路径:

VAE (2013) → 平滑潜在空间,但模糊
  ↓
GAN (2014) → 清晰生成,但不稳定
  ↓
Diffusion (2020) → 质量+稳定,但慢
  ↓
Latent Diffusion (2022) → 在VAE潜在空间做Diffusion → Stable Diffusion!

混合趋势:

  • VAE-GAN: 结合潜在空间+清晰度
  • Latent Diffusion: VAE压缩+Diffusion生成
  • 一致性模型: Diffusion的蒸馏→单步生成

核心原则:

  1. No Free Lunch: 质量、速度、稳定性不可兼得
  2. 任务适配: 实时应用(GAN)vs 离线渲染(Diffusion)
  3. 持续演化: 新架构不断出现(Transformer+Diffusion→DiT)

下一站: 06-智能体轨道 - 探索如何让AI自主规划和决策!