最近在研究两个方向时,发现不少同学会把 LSTM 和 Diffusion Model 当成两条完全不相干的技术线:一条是处理“序列到序列”的时序建模,一条是“从噪声生成图像”的生成模型。但实际上,这两者正在越来越多地被组合进同一个跨模态系统里。比如用 LSTM 提取一段人体动作序列、传感器时序或股价走势的特征,再用 Diffusion Model 生成对应的图像、姿态或未来场景。这种组合方式在学术论文里已经很常见,工程落地的需求也越来越多。
这篇文章我会从两个模型的核心原理讲起,给出 LSTM 的时序预测完整示例,再手写一个最小可运行的 Diffusion 图像生成模型,最后拆解“LSTM 编码时序条件 + Diffusion 生成图像”的组合思路。代码会按文件拆分,尽量做到复制后能直接跑起来。无论你是刚开始接触深度学习,还是已经在做多模态项目,都可以把它当作一份系统笔记来用。
1. 为什么把 LSTM 和 Diffusion 放在一起学
1.1 两个技术的定位完全不同,但天然互补
LSTM 是循环神经网络的一种改进结构,它解决的问题是“如何建模序列数据中的长期依赖”。比如一句话里后面的词依赖前面的词,一段股价走势依赖过去几十天的变化,一段人体动作依赖前几帧的姿态。LSTM 通过门控机制决定哪些历史信息要保留,哪些要遗忘,从而在时间维度上进行建模。
Diffusion Model(扩散模型)则是生成模型的一种。它解决的问题是“如何从随机噪声中生成一张真实图像”。它的思路是:先把图像逐步加噪,直到变成纯噪声;再训练一个神经网络学会反过来去噪,从而把噪声还原为图像。Stable Diffusion 就是基于这种思路的大规模应用。
所以两者听起来毫无关系:LSTM 处理时间维,Diffusion 处理空间维。但放到一个更大的框架里看,它们正好互补:LSTM 负责把“时序信号”压缩成一个语义特征向量,Diffusion 负责把这个语义特征向量“解码”成可感知的图像内容。这正是跨模态生成的基础结构。
1.2 跨模态搭配的真实应用场景
这类组合在工业界和学术界的应用越来越多,常见的有:
- 人体动作序列生成姿态图:输入一段骨架关键点序列,用 LSTM 编码时序信息,再用条件 Diffusion 生成对应动作的视频帧或姿态热力图。
- 医学图像跨模态生成:例如从 MRI 序列生成 CT 图像,或者用零样本互信息引导扩散,完成跨模态医学图像转换。
- 金融时序可视化:基于 LSTM 与注意力机制做股价预测,同时用生成模型把预测结果渲染成 K 线或趋势图像。
- 机器人决策规划:Diffusion Policy 这类方法利用扩散模型生成动作轨迹,而动作轨迹本身是时序数据,可以和 LSTM 类编码器协同使用。
- 文本/语音驱动的图像生成:文本或语音先被编码为时序特征,再作为扩散模型的条件,控制图像内容。
这些场景都有一个共同点:输入侧的原始数据是序列,输出侧的目标是图像或视觉内容。靠单一模型很难同时建模两种模态,所以组合是更合理的技术路线。
1.3 本文你能带走什么
学完本文,你会得到以下收获:
- 理解 LSTM 的门控机制和计算公式,能独立搭建一个时序预测模型。
- 理解扩散模型的前向加噪、反向去噪、训练目标,能用手写代码在 MNIST 上训练一个最小扩散生成模型。
- 掌握 LSTM 与 Diffusion 组合的条件生成思路,知道在代码层面要改哪些地方。
- 了解训练过程中的常见坑以及工程落地的建议。
下面我们按“先原理,再环境,再代码,再组合”的顺序展开。
2. LSTM 时序建模核心原理
2.1 RNN 的短板与 LSTM 的引入
传统 RNN(循环神经网络)在设计上允许信息沿着时间步传递,但实际训练时容易遇到梯度消失和梯度爆炸问题。原因是反向传播时梯度需要沿着时间步连乘,步数一多,梯度要么趋近于零,要么爆炸。也就是说,RNN 很难真正记住很久以前的信息。
LSTM(Long Short-Term Memory,长短期记忆网络)通过引入“记忆单元”和“门控机制”来解决这个问题。它不像 RNN 那样把每个时间步的信息直接覆盖,而是用一个独立的细胞状态(cell state)来存储长时间信息,并通过门来控制信息的流入、流出和遗忘。
简单理解:LSTM 在序列处理过程中,有三个“开关”。
- 遗忘门:决定过去的信息要保留多少。
- 输入门:决定新的输入要写入多少。
- 输出门:决定当前时刻要输出多少。
2.2 LSTM 的门结构
LSTM 在时间步 t 的输入是当前输入 x_t 和上一个时间步的隐状态 h_{t-1},输出是当前隐状态 h_t,同时更新细胞状态 c_t。
三个门以及候选记忆的计算方式如下:
遗忘门(forget gate): [ f_t = \sigma(W_f \cdot [h_{t-1}, x_t] + b_f) ] 输出值在 0 到 1 之间,0 表示完全遗忘,1 表示完全保留。
输入门(input gate): [ i_t = \sigma(W_i \cdot [h_{t-1}, x_t] + b_i) ] 决定新信息写入细胞状态的比例。
候选记忆: [ \tilde{c}t = \tanh(W_c \cdot [h{t-1}, x_t] + b_c) ] 这是当前时刻产生的候选信息,取值范围在 -1 到 1 之间。
细胞状态更新: [ c_t = f_t \odot c_{t-1} + i_t \odot \tilde{c}_t ] 旧记忆先被遗忘门缩放,再加入新的候选记忆。
输出门(output gate): [ o_t = \sigma(W_o \cdot [h_{t-1}, x_t] + b_o) ]
最终隐状态: [ h_t = o_t \odot \tanh(c_t) ]
这里的 (\odot) 表示逐元素相乘,(\sigma) 是 sigmoid 激活函数。
2.3 单向 LSTM 与双向 LSTM
单向 LSTM 只从前往后读取序列,每个时间步只依赖过去的信息。适合股票预测、传感器读数预测这类“未来不可见”的任务。
双向 LSTM(BiLSTM)会增加一个从后往前的方向,每个时间步同时看到过去和未来的信息。适合自然语言理解、命名实体识别、语音识别等离线任务,因为整个序列都已知。
从 PyTorch 代码来看,nn.LSTM中设置bidirectional=True就能得到双向 LSTM,但要注意输出维度会翻倍。比如隐层大小是hidden_size,双向后每个时间步的输出维度是hidden_size * 2。后续接全连接层时,输入维度要对应调整。
3. Diffusion Model 图像生成核心原理
3.1 生成模型家族里的 Diffusion 处在什么位置
目前主流生成模型大致分为四类:
- GAN(生成对抗网络):生成器与判别器对抗训练,生成速度快,但训练不稳定,容易模式崩塌。
- VAE(变分自编码器):通过编码器把图像压缩到隐空间,再解码重建,训练稳定,但生成图像容易模糊。
- 自回归模型:逐像素预测,效果精细,但生成速度慢。
- Diffusion Model:模拟从噪声逐步去噪的过程,训练稳定,生成质量高,代价是采样速度较慢。
Diffusion 模型有两个关键过程:前向扩散过程和反向去噪过程。
3.2 前向扩散过程:逐步加噪
前向过程是固定的,不需要训练。给定一张真实图像 (x_0),我们按照一个预设的噪声调度表,逐步加入高斯噪声,经过 T 步后,图像变成接近标准正态分布的纯噪声。
每一步可以表示为: [ x_t = \sqrt{1 - \beta_t} \cdot x_{t-1} + \sqrt{\beta_t} \cdot \epsilon ] 其中 (\epsilon \sim \mathcal{N}(0, I)),(\beta_t) 是预先设定的噪声系数,通常从小到大线性增长或按余弦曲线变化。
由于高斯分布的叠加性质,我们可以直接从 (x_0) 跳到任意第 t 步: [ x_t = \sqrt{\bar{\alpha}_t} \cdot x_0 + \sqrt{1 - \bar{\alpha}_t} \cdot \epsilon ] 其中 (\bar{\alpha}t = \prod{i=1}^{t} (1 - \beta_i))。
这个公式非常实用。训练时不需要把 T 步全部走一遍,只需要随机采一个时间步 t,加一次噪声,就能作为训练样本。
3.3 反向去噪过程:学习逆转换
如果我们可以学习到前向过程的逆过程,就能从纯噪声逐步还原出图像。反向过程也是一个马尔可夫链,每一步要根据当前的 (x_t) 和时间步 t 去预测 (x_{t-1})。
实际中,网络并不直接预测 (x_{t-1}),而是预测加入的噪声 (\epsilon),或者预测 (x_0) 再换算。最经典的 DDPM(Denoising Diffusion Probabilistic Models)采用预测噪声的方式。
训练目标可以写成: [ L = \mathbb{E}{x_0, t, \epsilon} \left[ | \epsilon - \epsilon\theta(x_t, t) |^2 \right] ] 其中 (\epsilon_\theta) 是带时间步条件的神经网络,通常用 U-Net 结构。
3.4 U-Net、CLIP 与 Classifier-Free Guidance 在实践里的角色
在 Stable Diffusion 这类大规模模型里,去噪网络的核心是 U-Net。U-Net 的结构类似编码器-解码器:编码器逐步下采样提取高层特征,解码器逐步上采样恢复空间分辨率,中间通过跳连(skip connection)保留细节。
由于扩散模型需要知道“当前是第几步”,U-Net 需要接收时间步 t 的嵌入向量。这个嵌入向量通常用正弦位置编码生成,然后通过 MLP 映射到与特征图相同的维度。
如果想要控制生成内容,还需要条件信息。Stable Diffusion 使用 CLIP 文本编码器把文字转换为向量,再通过交叉注意力机制注入 U-Net。此外,Classifier-Free Guidance(CFG)是常用的采样技巧:训练时随机丢弃条件,采样时同时预测条件输出和无条件输出,再外推两者差值,从而增强条件对生成结果的控制力。CFG 的参数越高,图像越贴近提示词,但过高的值会导致颜色过饱和或伪影。
近期的研究也在不断改进 CFG,例如基于控制信号的 CFG-Ctrl 等方法,目的都是让条件控制更精准、更稳定。
3.5 为什么生成阶段比训练阶段更讲究
训练阶段,网络只需要学会“去噪”这一件事。但生成阶段,从随机噪声出发,我们需要反复执行 T 步去噪。每一步计算量都不小,所以 Diffusion 生成速度比 GAN 慢。
为了加速采样,业界有很多优化方案:
- DDIM 采样:减少采样步数,从 1000 步降到 20~50 步。
- DPM-Solver:基于微分方程求解器减少步数。
- LCM/LCM-LoRA:一致性模型蒸馏,实现几步采样。
理解 DDPM 基础原理后,再接触这些加速方案会容易得多。
4. 环境准备与版本说明
4.1 本机环境
本文示例以常见深度学习环境为例,重点演示代码思路。实际版本需要根据你的项目调整。
推荐环境:
- 操作系统:Windows 10/11、Ubuntu 20.04 或 macOS
- Python:3.10 或更高版本
- 深度学习框架:PyTorch 2.x
- CUDA:如果有 NVIDIA 显卡,建议安装对应版本的 CUDA 和 cuDNN;没有显卡也能跑 CPU 版本,只是训练更慢
- 开发工具:VS Code、Jupyter Notebook 或 PyCharm 均可
4.2 安装 PyTorch 与依赖库
建议先创建虚拟环境:
conda create -n cross_modal python=3.10 -y conda activate cross_modal安装 PyTorch 时,可以根据自己的显卡情况选择官网命令。如果没有特殊需求,先安装 CPU 版本也能完成本文的示例。
接着安装其他依赖:
pip install numpy matplotlib torchvision4.3 项目目录结构
为了让代码更有条理,我们按下面的目录组织项目:
cross_modal_ai/ ├── lstm_demo/ │ ├── data_utils.py │ ├── model.py │ └── train.py ├── diffusion_demo/ │ ├── ddpm.py │ ├── model.py │ ├── train.py │ └── sample.py └── cross_modal_demo/ └── README.mdLSTM 部分我们先单独跑通,Diffusion 部分单独训练一个最小模型,最后在跨模态部分给出组合思路和关键代码改动。
5. 源码拆解:先跑通一个 LSTM 股票预测示例
时序建模的方向很多,这里选一个直观的任务:使用过去 24 个时间步的数据预测下一点。为了让示例可复现且不需要外部数据,我们用正弦函数叠加随机噪声生成模拟数据。如果你想用真实股票数据,只需要把数据集替换成 CSV 中的价格序列。
5.1 生成模拟时间序列数据
# 文件路径:lstm_demo/data_utils.py import numpy as np def create_sine_dataset(seq_len=24, sample_num=1000): np.random.seed(42) t = np.linspace(0, 8 * np.pi, sample_num) data = np.sin(t) + 0.05 * np.random.randn(sample_num) # Min-Max 归一化,帮助模型更快收敛 min_val, max_val = data.min(), data.max() data = (data - min_val) / (max_val - min_val) X, y = [], [] for i in range(len(data) - seq_len): X.append(data[i:i + seq_len]) y.append(data[i + seq_len]) X = np.array(X).reshape(-1, seq_len, 1) y = np.array(y).reshape(-1, 1) return X, y, min_val, max_val这里有一个关键点:LSTM 的输入形状是(batch_size, seq_len, input_size)。我们设置seq_len=24,表示用 24 个历史点预测下一点。
为什么需要归一化?因为 LSTM 内部使用 tanh 和 sigmoid 激活函数,对输入取值范围比较敏感。把数据缩放到 0 到 1 之间,可以避免梯度计算出现极端值。
5.2 定义 LSTM 预测模型
# 文件路径:lstm_demo/model.py import torch import torch.nn as nn class LSTMPredictor(nn.Module): def __init__(self, input_size=1, hidden_size=32, num_layers=1): super().__init__() self.lstm = nn.LSTM( input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, batch_first=True ) self.fc = nn.Linear(hidden_size, 1) def forward(self, x): # x 形状: (batch_size, seq_len, input_size) out, (h_n, c_n) = self.lstm(x) # 取最后一个时间步的输出 last_out = out[:, -1, :] y_pred = self.fc(last_out) return y_pred关键点在于out[:, -1, :]。out包含所有时间步的隐状态,但我们做的是单点预测,所以只需要最后一个时间步的信息。nn.LSTM返回的h_n, c_n是最后时刻的隐状态和细胞状态,也可以用来替代out[:, -1, :],效果相似。
batch_first=True表示输入的第一个维度是 batch。这是 PyTorch 中非常容易忽略的细节,不设置的话输入形状是(seq_len, batch_size, input_size)。
5.3 训练并观察损失和预测效果
训练代码如下:
# 文件路径:lstm_demo/train.py import torch import torch.nn as nn import numpy as np from data_utils import create_sine_dataset from model import LSTMPredictor X, y, min_val, max_val = create_sine_dataset(seq_len=24, sample_num=1000) # 划分训练集和测试集 train_len = int(len(X) * 0.8) X_train, y_train = X[:train_len], y[:train_len] X_test, y_test = X[train_len:], y[train_len:] X_train = torch.tensor(X_train, dtype=torch.float32) y_train = torch.tensor(y_train, dtype=torch.float32) X_test = torch.tensor(X_test, dtype=torch.float32) y_test = torch.tensor(y_test, dtype=torch.float32) model = LSTMPredictor(input_size=1, hidden_size=32, num_layers=1) loss_fn = nn.MSELoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) batch_size = 64 epochs = 100 for epoch in range(epochs): model.train() total_loss = 0 for i in range(0, len(X_train), batch_size): x_batch = X_train[i:i + batch_size] y_batch = y_train[i:i + batch_size] pred = model(x_batch) loss = loss_fn(pred, y_batch) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() if (epoch + 1) % 20 == 0: print(f"Epoch {epoch + 1}, Loss: {total_loss / (len(X_train) // batch_size):.6f}") # 测试集评估 model.eval() with torch.no_grad(): test_pred = model(X_test) test_loss = loss_fn(test_pred, y_test) print(f"Test Loss: {test_loss.item():.6f}")这个示例输出会显示一个不断下降的 MSE Loss。测试 Loss 在 0.001 左右说明模型基本学到了序列的变化规律。如果你想用真实股票数据,只要把create_sine_dataset替换为自己的数据加载函数,并保持输出的形状符合(batch_size, seq_len, input_size)即可。
6. 源码拆解:从零实现一个最小 Diffusion 图像生成模型
理解了扩散模型的原理后,我们直接动手实现一个可以在 MNIST 上训练出效果的最小 DDPM 示例。为了保证代码简洁,我们不会写完整的 Stable Diffusion,而是保留最核心的加噪、U-Net 去噪、训练和采样逻辑。
MNIST 是 28x28 的灰度手写数字数据集,类别为 0-9。用它训练扩散模型,CPU 上也能较快跑出效果。
6.1 数据准备
# 文件路径:diffusion_demo/data_utils.py import torch from torchvision import datasets, transforms def load_mnist(batch_size=128): transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ]) train_dataset = datasets.MNIST( root='./data', train=True, transform=transform, download=True ) train_loader = torch.utils.data.DataLoader( train_dataset, batch_size=batch_size, shuffle=True ) return train_loaderNormalize((0.5,), (0.5,))会把像素值从 [0,1] 映射到 [-1,1],这是因为扩散模型假设数据分布是标准正态分布附近的分布,输入范围更匹配。
6.2 时间步嵌入
扩散模型必须知道当前处于哪个时间步。我们使用 Transformer 中常见的正弦位置编码:
# 文件路径:diffusion_demo/model.py import torch import torch.nn as nn import math class SinusoidalPositionEmbeddings(nn.Module): def __init__(self, dim): super().__init__() self.dim = dim def forward(self, time): device = time.device half_dim = self.dim // 2 embeddings = math.log(10000) / (half_dim - 1) embeddings = torch.exp(torch.arange(half_dim, device=device) * -embeddings) embeddings = time[:, None] * embeddings[None, :] embeddings = torch.cat((embeddings.sin(), embeddings.cos()), dim=-1) return embeddingstime是形状为(batch_size,)的时间步张量。输出形状是(batch_size, dim)。
6.3 简化的 U-Net 去噪网络
严格意义上的 U-Net 包含下采样、上采样和中间层。为了在教程里控制代码篇幅,我们写一个“简化版 U-Net”,它保留了核心思路:先用卷积提取特征,再通过几个卷积块处理,最后输出与输入同形状的噪声预测图。
# 文件路径:diffusion_demo/model.py(续) class SimpleUNet(nn.Module): def __init__(self, in_channels=1, time_dim=128): super().__init__() self.time_mlp = nn.Sequential( SinusoidalPositionEmbeddings(time_dim), nn.Linear(time_dim, time_dim), nn.ReLU(), nn.Linear(time_dim, time_dim) ) self.conv1 = nn.Conv2d(in_channels, 64, kernel_size=3, padding=1) self.conv2 = nn.Conv2d(64, 128, kernel_size=3, padding=1) self.conv3 = nn.Conv2d(128, 256, kernel_size=3, padding=1) self.conv4 = nn.Conv2d(256, 128, kernel_size=3, padding=1) self.conv5 = nn.Conv2d(128, 64, kernel_size=3, padding=1) self.out = nn.Conv2d(64, in_channels, kernel_size=1) self.act = nn.ReLU() def forward(self, x, t): # x: (batch, in_channels, 28, 28) t_emb = self.time_mlp(t) # (batch, time_dim) h = self.act(self.conv1(x)) h = self.act(self.conv2(h)) h = self.act(self.conv3(h)) # 把时间嵌入通过全连接映射到通道数,然后加到特征图上 t_emb = t_emb[:, :, None, None] t_emb = t_emb.expand(-1, -1, h.shape[2], h.shape[3]) # 这里为了演示,直接把时间嵌入 broadcast 后加到 h 上 # 更严谨的做法是用 FiLM 或注意力机制 h = h + t_emb[:, :h.shape[1], :, :] h = self.act(self.conv4(h)) h = self.act(self.conv5(h)) return self.out(h)这段代码是教学演示的核心片段,目的是让你理解“时间步影响生成过程”这一关键机制。工业级的 U-Net 要复杂得多,包括残差连接、注意力模块、上采样层等。
6.4 前向加噪与训练循环
DDPM 的训练比 GAN 简单,因为它不需要对抗训练。我们只需要随机采样时间步,加噪,并让网络预测噪声。
# 文件路径:diffusion_demo/ddpm.py import torch def linear_beta_schedule(timesteps=1000, beta_start=1e-4, beta_end=0.02): return torch.linspace(beta_start, beta_end, timesteps) class DDPM: def __init__(self, timesteps=1000): self.timesteps = timesteps self.betas = linear_beta_schedule(timesteps) self.alphas = 1.0 - self.betas self.alpha_bars = torch.cumprod(self.alphas, dim=0) def q_sample(self, x_0, t, noise=None): """前向加噪:x_t = sqrt(alpha_bar_t) * x_0 + sqrt(1 - alpha_bar_t) * noise""" if noise is None: noise = torch.randn_like(x_0) sqrt_alpha_bar = torch.sqrt(self.alpha_bars[t])[:, None, None, None] sqrt_one_minus_alpha_bar = torch.sqrt(1 - self.alpha_bars[t])[:, None, None, None] return sqrt_alpha_bar * x_0 + sqrt_one_minus_alpha_bar * noise, noise训练循环:
# 文件路径:diffusion_demo/train.py import torch import torch.nn as nn from data_utils import load_mnist from model import SimpleUNet from ddpm import DDPM device = torch.device("cuda" if torch.cuda.is_available() else "cpu") train_loader = load_mnist(batch_size=128) ddpm = DDPM(timesteps=1000) model = SimpleUNet(in_channels=1).to(device) optimizer = torch.optim.Adam(model.parameters(), lr=2e-4) loss_fn = nn.MSELoss() epochs = 20 model.train() for epoch in range(epochs): total_loss = 0 for x_0, _ in train_loader: x_0 = x_0.to(device) batch_size_now = x_0.shape[0] t = torch.randint(0, ddpm.timesteps, (batch_size_now,), device=device) noise = torch.randn_like(x_0) x_t, noise = ddpm.q_sample(x_0, t, noise) predicted_noise = model(x_t, t) loss = loss_fn(predicted_noise, noise) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() print(f"Epoch {epoch + 1}, Loss: {total_loss / len(train_loader):.6f}")t是随机采样的,所以每个 batch 会有不同的噪声强度。网络在同一个模型里要处理不同程度的噪声,这是扩散模型训练最重要的设计。
6.5 采样生成
训练完成后,我们要从纯噪声出发,逐步去噪生成图像。下面实现 DDPM 的采样过程:
# 文件路径:diffusion_demo/sample.py import torch from model import SimpleUNet from ddpm import DDPM from torchvision.utils import save_image @torch.no_grad() def sample(model, ddpm, num_images=16, img_size=28, device="cpu"): model.eval() x_t = torch.randn(num_images, 1, img_size, img_size).to(device) for t in reversed(range(ddpm.timesteps)): t_tensor = torch.full((num_images,), t, device=device, dtype=torch.long) predicted_noise = model(x_t, t_tensor) alpha = ddpm.alphas[t][:, None, None, None] alpha_bar = ddpm.alpha_bars[t][:, None, None, None] beta = ddpm.betas[t][:, None, None, None] # 计算均值 mean = (1 / torch.sqrt(alpha)) * ( x_t - (beta / torch.sqrt(1 - alpha_bar)) * predicted_noise ) if t > 0: noise = torch.randn_like(x_t) sigma = torch.sqrt(beta) x_t = mean + sigma * noise else: x_t = mean # 把 [-1,1] 映射到 [0,1] x_t = (x_t.clamp(-1, 1) + 1) / 2 save_image(x_t, "generated.png", nrow=4) print("Saved generated.png") if __name__ == "__main__": device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = SimpleUNet().to(device) # 这里需要加载训练好的权重 model.load_state_dict(torch.load("ddpm_model.pth", map_location=device)) sample(model, DDPM(timesteps=1000), device=device)训练多轮后,generated.png里会出现类似手写数字的模糊图案。如果只是训练 1-2 轮,效果会比较噪;训练 20 轮左右,MNIST 上就能出现可辨识的数字轮廓。
7. 跨模态组合:LSTM 编码时序条件,Diffusion 生成对应图像
7.1 为什么需要条件生成
无条件的 Diffusion 生成结果是随机的,你无法控制它生成“数字 3”还是“数字 7”。为了让生成内容可控,我们必须在去噪过程中加入条件信息。
条件可以是类别标签、文本向量、图像特征,也可以是时序特征。当我们把 LSTM 编码得到的向量作为条件注入扩散模型时,就实现了“输入一段序列,生成一张对应的图像”的跨模态能力。
7.2 用 LSTM 提取时序特征的两种方式
第一种方式是直接使用 LSTM 最后一个时间步的隐状态作为整体序列特征。这种方式适合“整段序列对应一张图”的场景,比如根据一段心电图信号生成心脏图像。
第二种方式是使用注意力机制,把 LSTM 所有时间步的输出加权求和。这种方式适合“序列中不同位置对生成结果的影响不同”的场景,比如生成人体动作图像时,某些关键帧更重要。
代码层面的差异如下:
# 直接取最后一步 seq_feat = lstm_out[:, -1, :] # (batch, hidden_size) # 使用注意力加权 attn_weights = torch.softmax(attn_linear(lstm_out), dim=1) seq_feat = torch.sum(attn_weights * lstm_out, dim=1) # (batch, hidden_size)7.3 一个可行的跨模态训练流程
假设我们要实现的任务是:输入一段长度为 24 的传感器序列,生成一张表示系统状态的 28x28 灰度图像。
整体流程如下:
- LSTM 编码阶段:输入序列
(batch, 24, 1),得到特征seq_feat = (batch, hidden_size)。 - 条件映射阶段:用一个全连接层把
seq_feat映射到与时间嵌入相同的维度。 - 扩散生成阶段:把条件向量与时间步嵌入合并,作为 U-Net 的输入条件。
- 损失函数:预测噪声的 MSE Loss,与无条件扩散模型一致。
7.4 代码层面的关键改动
在之前的SimpleUNet中,我们只是简单地把时间嵌入加到特征图上。要加入 LSTM 条件,需要把条件向量拼接到时间嵌入后,或者用 FiLM 方式对特征图进行缩放和平移。
这里给出一个关键改动示例,需要注意这是核心片段,需要放入对应文件中调整:
class ConditionalUNet(nn.Module): def __init__(self, cond_dim=32, time_dim=128): super().__init__() self.time_mlp = nn.Sequential( SinusoidalPositionEmbeddings(time_dim), nn.Linear(time_dim, time_dim), nn.ReLU(), nn.Linear(time_dim, time_dim), ) # 把 LSTM 特征映射到 time_dim self.cond_proj = nn.Linear(cond_dim, time_dim) # 你的卷积层定义... def forward(self, x, t, cond): # cond 来自 LSTM: (batch, cond_dim) t_emb = self.time_mlp(t) c_emb = self.cond_proj(cond) # 按元素相加,让条件影响整个去噪过程 cond_emb = t_emb + c_emb # 后续网络使用 cond_emb 代替原来的 t_emb # ...训练时,把每段序列先过 LSTM 得到cond,再过扩散模型:
seq = torch.randn(batch_size, 24, 1) # 示例输入序列 cond = lstm_encoder(seq) # (batch, cond_dim) x_t, noise = ddpm.q_sample(x_0, t) pred_noise = conditional_unet(x_t, t, cond) loss = loss_fn(pred_noise, noise)这个结构虽然简化,但你已经掌握了跨模态生成的核心思路:先把时序信号压缩成条件向量,再把条件向量注入扩散模型的去噪网络。至于条件注入方式,可以用相加、FiLM、交叉注意力等更复杂的机制,这是工程调优的后续方向。
8. 常见问题与排查思路
训练这类模型时,大家容易遇到下面几个问题。我整理了一个排查表:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| LSTM Loss 不下降 | 学习率过高/过低,或数据未归一化 | 检查学习率,确认输入数据已归一化,尝试减小 batch size |
| LSTM 预测结果滞后真实值一拍 | 模型偏向于复制上一步值,窗口太短 | 增大seq_len,或使用注意力机制强化关键时间步 |
| Diffusion 训练 Loss 下降很慢 | 时间步嵌入维度不足,或网络太浅 | 增大time_dim,增加卷积层和通道数 |
| 生成图像全是噪声 | 训练轮数不足,或采样步数太少 | 增加训练 epoch,或提高采样步数,检查加噪公式是否正确 |
| 生成图像模糊但有轮廓 | U-Net 表达能力不够,或没有使用跳连 | 换成标准 U-Net 结构,添加上采样层和跳连 |
| 显存不足 | batch size 太大,或图像分辨率太高 | 降低 batch size,使用梯度累积,降采样图像 |
| 梯度爆炸 | 模型结构深或学习率过高 | 使用梯度裁剪,如clip_grad_norm_(model.parameters(), 1.0) |
| 组合模型训练时梯度无法回传到 LSTM | 扩散模型直接使用cond.detach() | 去掉detach(),让梯度可以反向传播到 LSTM |
9. 最佳实践与工程建议
9.1 数据与预处理
- 时序数据标准化:LSTM 对输入尺度敏感,建议先做 Min-Max 归一化或 Z-score 标准化。
- 训练集/测试集划分不能随机洗牌:时序数据有先后顺序,随机洗牌会引入未来信息泄漏,导致评估失真。
- 图像数据要归一化到 [-1,1]:扩散模型通常在这个范围内效果最好。
9.2 模型训练稳定性
- 设置固定随机种子,保证结果可复现:
import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed)- 使用梯度裁剪防止 RNN 梯度爆炸。
- Diffusion 训练建议使用学习率调度器,例如余弦退火。
- 有条件时使用 EMA(指数移动平均)保存模型权重,通常能提升生成质量。
9.3 工程化相关
- 配置外置:把序列长度、batch size、学习率、扩散步数等都写到 YAML 配置文件里,方便实验对比。
- 日志记录:记录每个 epoch 的 Loss、学习率和显卡占用,推荐使用 TensorBoard。
- 模型保存与恢复:保存
state_dict时要同时保存配置和归一化参数,否则推理时容易出问题。 - 设备管理:代码里统一使用
device变量,避免在 CPU/GPU 之间出现张量设备不一致的报错。
9.4 合规与安全
- 下载预训练权重时,请使用官方或可信来源,不要使用破解版、非官方整合包,避免引入恶意代码和版权风险。
- 生成图像前,注意版权与平台规定。不要用生成模型制作虚假信息、冒充他人或绕过平台水印。很多平台对生成内容有明确标识要求,生产环境落地时务必遵守。
10. 总结与下一步学习路线
到这里,我们完整梳理了 LSTM 与 Diffusion Model 的核心原理,并跑通了两个最小示例:一个用于时序预测的 LSTM 模型,一个用于图像生成的 DDPM。最后的跨模态组合演示,展示了如何把 LSTM 编码的序列特征作为条件注入去噪网络,实现“序列 + 图像”的跨模态生成。
如果你从零开始接触这部分内容,建议按下面的路线继续深入:
- 把本文的 LSTM 示例换到真实数据集上,比如股票、电力负荷或传感器数据,理解窗口长度、批大小和归一化对结果的影响。
- 把本文的简单 U-Net 替换为标准 U-Net,加入残差连接、注意力机制和上采样层,在 CIFAR-10 上训练一个更完整的 DDPM。
- 阅读 DDPM 原论文(Denoising Diffusion Probabilistic Models),重点理解公式推导和采样过程。
- 研究 DDIM 和 DPM-Solver,掌握加速采样的方法。
- 阅读 Classifier-Free Guidance 原论文,理解条件控制的细节。
- 参考 Stable Diffusion 系列论文,理解 CLIP 文本编码器、VAE 和 U-Net 是如何协同工作的。
- 最后再回头做跨模态组合,可以参考人体动作生成、医学图像跨模态生成、Diffusion Policy 等方向的最新论文。
动手永远比只看书有效。建议你先跑通本文的两个独立示例,再进行组合改造。遇到报错不要慌,先确认版本、数据形状和设备是否一致,再逐层排查。
如果本文对你有帮助,可以收藏备用,也可以分享给正在学习时序建模和扩散模型的朋友。下一篇我打算拆解一个更完整的 Stable Diffusion 系列源码,包括文本编码、VAE 和采样器的工程实现,欢迎持续关注。