简介:面向时间序列数据缺失值插补需求,压缩包基于Kaggle Wikipedia网页流量预测数据,提供TensorFlow 2实现的递归卷积Seq2seq GAN完备方案,适合深度学习、时序预测领域的中高级开发者,尤其是关注生成对抗网络与时序建模结合的实践者研究使用。资源采用zip压缩包格式,压缩包体积约3.01MB,其中包含配置参数文件与Python处理/训练脚本,清晰覆盖数据预处理、训练与测试各环节,并针对普通seq2seq、对抗式GAN及部分对抗模型三种典型配置分别给出可直接运行的具体实现。目前平台已有1428人学习浏览,可作为复现时序插补实验、对比生成对抗网络效果的参考代码库。即便数据缺失比例较高,也能借助循环对抗结构捕捉长期时序依赖并完成插补;读者可借助配置与脚本灵活调整参数、启动流程,进一步迁移至工业或其他时序填补场景,满足后续研究复用需求。
1. 时间序列缺失不是“填个平均值”那么简单:GAN-RNN插补方法为什么值得认真评估
我做页面访问量类监控时遇到过这样的场景:某天日志管道中断,连续 8 小时的流量曲线直接消失。如果按老办法用均值或线性插值补上,那一段曲线会被拉平,下游的周期检测会误认为“当天没有夜间低谷”,后续预测全部被带偏。时间序列插补的本质不是把空填上,而是让填补后的序列在局部走势、波动幅度和依赖关系上尽量接近原有分布。这正是标题里 GAN-RNN 这条技术路线在做的事:用一个递归结构的生成器去“画”缺失段,同时让一个判别器去区分哪些位置是真实观测、哪些位置是生成出来的。整个方案在 TensorFlow 2 中实现,并用 Kaggle 的 Wikipedia Web 流量预测数据集做验证,那些按小时统计的页面浏览量序列,形态上和日常运维里遇到的监控流量极为接近。适合正在做时序数据清洗、流量预测、异常检测预处理的工程师和数据方向从业者。
2. 递归GAN的工作原理与选型理由:为什么用RNN而不是普通GAN
2.1 时间序列缺失的三种机制与插补的核心矛盾
缺失机制大致分三类:完全随机缺失、随机缺失和非随机缺失。对插补来说,最难处理的是第三类——数据缺失本身和值的大小有关。比如监控系统在峰值时因过载丢点,缺失的往往是高值段,如果只按已有数据做回归,补出来的结果会系统性偏低。传统插补方法的通病是压缩方差:均值填充让序列变得过平,线性插值只能照顾到缺失段两端,对中间的突变和周期特征无能为力。时间序列插补的核心矛盾就在这里:既要让补出来的值在统计分布上贴近真实,又不能把方差抹平。GAN-RNN 把插补当成生成问题,让判别器对“补出来的完整序列”和“真实观测序列”做对抗区分,天然比回归式的逐点预测更接近“生成分布”这个目标。
2.2 为什么生成器和判别器都需要递归结构
普通 GAN 在图像上表现好,是因为卷积结构能捕捉空间局部性;时间序列的局部性在时间轴上,体现为连续步之间的依赖、周期和趋势。RNN 的隐状态会把前文信息一路携带到当前步,这正是插补缺失段时最需要的能力:补第 20 个点时,得知道前 19 个点长什么样。生成器读入的是“带掩码的观测序列”,输出完整序列;判别器对完整序列逐时间步打分,判断每一步是真实观测还是生成结果。这种逐点判别方式比“整条序列给一个真伪标签”更严格,因为它强制判别器关注局部跳变是否自然。相比 Transformer,RNN 在中小规模序列数据上的归纳偏置更稳,训练所需样本量更少,调参也不那么敏感。所以标题里选递归结构做生成器和判别器,是这个项目最常见也最可靠的做法。
2.3 对抗训练里两类损失的拆分
GAN-RNN 的生成器损失不是单一对抗损失,而是按掩码拆成两块:缺失位置用对抗损失,让补出来的部分“像真的”;观测位置用重建损失,让已有真值不被破坏。如果只在观测位置做回归,模型会收敛到均值填充;如果只做对抗,模型可能编造出和上下文无关的连续片段。训练循环里最核心的就是把这两个损失按掩码加权合并。
# 伪代码:训练循环中生成器损失的两种拆法 g_loss = 0.0 # 缺失位置用对抗损失:让补出来的东西“像真的” g_loss += adversarial_loss(d_fake, latent_mask) # 观测位置用重建损失:让已有的真值不被破坏 g_loss += reconstruction_loss(x_fake, x_real, observed_mask)这里的关键是 latent_mask 和 observed_mask 严格互斥。注意判别器不能只凭“缺失位置本身”来判断——如果生成结果在缺失位置永远是另一套分布,判别器只要学会看掩码就能赢,根本不需要看序列内容。比较稳妥的做法是把掩码也拼进判别器输入,或者至少让重建损失的权重压住对抗损失的震荡。下表给出几种常见插补方法的对比,方便选型时定位 GAN-RNN 的位置。
| 方法 | 优点 | 主要局限 | 适合场景 |
|---|---|---|---|
| 均值/中位数填充 | 简单、快 | 压缩方差,破坏时序依赖 | 缺失率极低的数据清洗 |
| 线性插值 | 保持局部平滑 | 无法处理突变和周期 | 短缺口、变化缓慢序列 |
| EM/卡尔曼滤波 | 有统计模型支撑 | 对非线性周期建模困难 | 信号处理、控制领域 |
| GAN-RNN 插补 | 拟合分布、保留波动 | 训练不稳定、需调参 | 中度以上缺失、下游任务敏感 |
实际项目中不要把 GAN-RNN 当作默认首选。缺失率低于 5% 时,线性插值通常够用;只有当缺失段连续、长度可观、且下游分析对波形敏感时,才值得上对抗生成这套方案。
3. 在TensorFlow 2里搭GAN-RNN插补模型:生成器、判别器与训练循环
3.1 生成器:双向GRU编码观测片段,单向GRU解码完整序列
生成器的输入是一个带掩码的序列:观测位置保留原始值,缺失位置填 0,同时附上一个 0/1 掩码告诉模型哪些位置是缺失的。我不用tf.keras.layers.Masking,因为 Masking 层会把 padding 位置从损失计算里整体剔除,这不符合插补任务——缺失位置恰恰是需要计算损失的。把掩码拼接进输入特征,模型天然能区分“真 0”和“填充 0”。
import tensorflow as tf class Generator(tf.keras.Model): """把带缺失的序列 x 和缺失标记 mask 映射成补全后的完整序列。""" def __init__(self, hidden_dim=128): super().__init__() self.encoder = tf.keras.layers.Bidirectional( tf.keras.layers.GRU(hidden_dim, return_sequences=True) ) self.decoder = tf.keras.layers.GRU(hidden_dim, return_sequences=True) self.out = tf.keras.layers.Dense(1, activation='tanh') def call(self, x, mask): # 把缺失标记拼进输入,保证模型能区分填充的0和真实观测 inp = tf.concat([x, mask], axis=-1) h = self.encoder(inp) # 双向编码:能看到缺失段两侧语境 h = self.decoder(h) # 单向解码:按时间顺序生成完整序列 return self.out(h)hidden_dim 控制 GRU 隐状态维度。对小时级流量数据,128 起步即可;如果序列更长或模式更复杂,可以加到 256。编码器用双向 GRU,是因为缺失段左侧和右侧的信息都重要;解码器保持单向,避免生成时“偷看”未来信息。输出层用 tanh,要求输入数据归一化到 [-1, 1],这样生成器的输出范围和数据分布一致。
3.2 判别器:逐时间步判断真伪的循环判别器
判别器也要用 GRU,但结构可以比生成器小一些。它的任务是读入一条完整序列,对每个时间步输出一个真伪 logit。逐点输出比“整条序列一个分数”信息量大,也更适合定位缺失段的位置。
class Discriminator(tf.keras.Model): """输入完整序列,对每个时间步输出一个真伪 logit。""" def __init__(self, hidden_dim=64): super().__init__() self.gru = tf.keras.layers.GRU(hidden_dim, return_sequences=True) self.dense = tf.keras.layers.Dense(1) def call(self, x): h = self.gru(x) # [batch, time, hidden_dim] logits = self.dense(h) # [batch, time, 1] return logitshidden_dim 取 64,目的是让判别器不要“过强”。对抗训练里如果判别器太强,生成器梯度会很快消失;如果太弱,生成器又得不到有效反馈。按我的经验,判别器参数总量控制在生成器的一半左右,是相对省心的起点。判别器本身不区分观测点和缺失点,它只判断每个时间步的值是否自然;真正的区分由训练损失里的掩码完成。
3.3 训练循环:GradientTape、梯度惩罚和更新频率
训练循环是整套实现里最容易抄错的地方。常见误区是把判别器的标签设置成简单二分类,忽略掩码结构。正确的做法是:判别器见到观测位置时,标签是“真”;见到生成序列的缺失位置时,标签是“假”。生成器希望让缺失位置也被判成“真”。
def train_step(x_real, mask, g, d, g_opt, d_opt): # mask=1表示有观测,mask=0表示缺失 observed = mask latent = 1.0 - mask with tf.GradientTape() as d_tape: x_fake = g(x_real, mask, training=True) d_real = d(x_real, training=True) d_fake = d(x_fake, training=True) # 判别器损失:观测位置标真,缺失位置标假 d_loss_real = tf.reduce_mean( tf.nn.sigmoid_cross_entropy_with_logits( logits=d_real, labels=observed)) d_loss_fake = tf.reduce_mean( tf.nn.sigmoid_cross_entropy_with_logits( logits=d_fake, labels=latent)) d_loss = d_loss_real + d_loss_fake with tf.GradientTape() as g_tape: x_fake = g(x_real, mask, training=True) d_fake = d(x_fake, training=True) # 生成器:缺失位置对抗损失 + 观测位置重建损失 g_adv = tf.reduce_mean( tf.nn.sigmoid_cross_entropy_with_logits( logits=d_fake, labels=observed)) g_rec = tf.reduce_mean(tf.abs(x_fake - x_real) * observed) g_loss = g_adv + 2.0 * g_rec grads_d = d_tape.gradient(d_loss, d.trainable_variables) d_opt.apply_gradients(zip(grads_d, d.trainable_variables)) grads_g = g_tape.gradient(g_loss, g.trainable_variables) g_opt.apply_gradients(zip(grads_g, g.trainable_variables)) return {'d_loss': d_loss, 'g_loss': g_loss}这段代码有两点需要仔细说明。第一,判别器损失里的标签是observed和latent,而不是 0/1 标量,意思是“真实观测位置要判真、缺失生成位置要判假”。第二,生成器重建损失用 MAE(tf.abs)而不是 MSE,因为 MAE 对缺失段中的极端峰值更宽容,不容易把序列往平均值方向拉。2.0 * g_rec是重建损失权重,如果训练时缺失率低于 30%,这个权重可以提高到 3 到 5。
提示:训练早期如果 g_loss 震荡明显,先把重建权重降到 0.5,确认对抗部分能收敛,再慢慢调回正常值。
优化器建议都使用 Adam,学习率统一从 0.0001 起。生成器和判别器的更新频率可以设为 1:1,如果发现判别器 loss 长期贴近 0,把判别器学习率降到生成器的 1/4,或者每更新 2 次生成器才更新 1 次判别器。整个训练循环外面可以包一层@tf.function提升速度,但要注意序列长度不一致时会引入动态 shape 问题,第 5 章会专门讲这个坑。
4. 在Kaggle的Wikipedia Web流量数据上验证:预处理、评估指标与基准实验
4.1 从页面浏览量序列切窗口,按比例挖洞构造训练样本
Wikipedia Web 流量预测数据集里,每一行是一个页面的访问量序列。直接用整条长序列训练 RNN 太长,常见做法是先切固定长度窗口,再按比例挖洞。挖洞时要随机确定缺失起始位置,不能每次都挖固定偏移,否则模型会学会“固定位置补全”,换到真实缺失场景直接失效。
import numpy as np def build_windows(series, window=72, missing_len=12): data = np.lib.stride_tricks.sliding_window_view( series, window ).astype(np.float32) # [n_windows, window] mask = np.ones_like(data) for i in range(len(data)): start = np.random.randint(0, window - missing_len + 1) mask[i, start:start + missing_len] = 0.0 x = data * mask # 缺失位置清零 return x, data, maskwindow 取 72 代表 72 个小时,正好覆盖三天周期,能保留明显的日内波动。missing_len 取 12 表示连续挖掉 12 个小时;实际场景中缺失长度不固定,可以改为np.random.randint(4, 16)让模型适应不同缺口长度。缺失位置清零的前提是数据归一化后 0 不是合法观测,或者掩码足够可靠;最稳妥的做法是用 NaN 填充缺失位置,在输入模型前手动替换为 0,同时保留掩码。
这个数据集里原始序列本身就可能带 NaN,比如某天完全没有页面访问记录。这些 NaN 是真实缺失,不能和人工挖洞混在一起处理。常见做法是先把自带 NaN 的位置也计入掩码,让模型学习“真实缺失”的分布;如果自带缺失比例太高,需要先做一次简单插补再进模型,否则训练信号会被大量噪声主导。
4.2 评估指标的坑:MASE比RMSE更适合插补场景
插补质量不能只看训练 loss,必须在保留的验证集上算缺失段误差。指标选择有讲究:RMSE 对极端值敏感,而流量序列本身有长尾,缺失段里偶尔出现大峰值时 RMSE 会虚高;MAE 稳健但无法反映分布形状;MASE 能把误差和朴素预测做比值,跨序列可比性更好。
| 指标 | 计算方式 | 适合场景 | 主要注意点 |
|---|---|---|---|
| MAE | 缺失段绝对误差平均 | 快速看量级 | 对偶尔的大误差不敏感 |
| RMSE | 缺失段平方误差平均后开方 | 想惩罚大偏差 | 极端峰值会让数值偏大 |
| MASE | 与朴素预测误差的比值 | 跨序列横向对比 | 分母较小时结果不稳 |
| CRPS | 预测分布与真实分布的累计误差 | 概率式插补 | 生成器需输出分布参数 |
我习惯同时报告 MAE 和 MASE。只报 RMSE 的插补论文实际上弱化了长尾影响,落地时容易误判。另外要特别注意评估时掩码的来源:如果用训练时的同一批掩码,模型等于“见过”缺失位置周边的生成结果,指标会偏乐观;正确做法是验证集单独生成掩码,且掩码生成时用独立随机种子。
4.3 训练/验证/测试切分顺序和归一化顺序
时序任务最忌讳先归一化、后切分。MinMaxScaler 如果是在整条序列上 fit 的,它的min和max已经偷看了验证集和测试集的信息,插补模型等于提前知道了真实值的量级范围。正确顺序是先按时间切开,只在训练段上 fit scaler,再对验证段和测试段做 transform。
from sklearn.preprocessing import MinMaxScaler train_series, val_series, test_series = split_by_time(series) # 按时间顺序切 scaler = MinMaxScaler(feature_range=(-1, 1)).fit(train_series) train_norm = scaler.transform(train_series) val_norm = scaler.transform(val_series) test_norm = scaler.transform(test_series) # 对每一段分别调用 build_windows,掩码 seed 各自独立 train_x, train_y, train_mask = build_windows(train_norm) val_x, val_y, val_mask = build_windows(val_norm)split_by_time表示按时间块切,而不是随机切样本。流量序列有强自相关性,随机切分会导致训练集和验证集里出现互相重叠的上下文窗口,评估结果虚高。按时间块切分会牺牲一部分训练样本,但换来的是可信的泛化结果。如果数据量充足,可以按“前 70% 训练、中间 15% 验证、最后 15% 测试”的比例切。
5. GAN-RNN插补训练避坑:五个高频翻车现场与排查顺序
5.1 判别器loss快速归零,生成器输出全是噪声
现象:训练几十步后判别器 loss 就掉到 0.01 以下,生成器补出来的序列忽高忽低,完全不像原始数据。原因:判别器学习率过高或者结构太简单,它轻松识破了生成器的破绽,梯度方向失去引导作用。解决:先把判别器学习率降到生成器的 1/4,再看 d_loss 是否回落到 0.7 左右;如果不行,在判别器 GRU 后面加一层Dropout(0.2),强行给判别器制造一点“不确定感”。按这个顺序排查,多数情况能在前两步解决。
5.2 生成器收敛到“均值填充”模式
现象:g_loss 很低,训练曲线也很平稳,但把缺失段画出来是一条接近水平的线,原有的波峰波谷全被抹平。原因:重建损失在总损失里权重过高,对抗损失压不住回归的“平滑倾向”;判别器也没能从平滑曲线里挑出毛病。解决:提高对抗损失权重,把g_adv的系数从 1.0 提到 3.0 左右;同时给生成器加一阶差分正则,惩罚相邻步之间的突变太小。代码如下:
# 在生成器损失里追加一阶差分惩罚,防止补出太平的序列 smoothness_penalty = tf.reduce_mean( tf.abs(x_fake[:, 1:, :] - x_fake[:, :-1, :]) ) g_loss = g_loss + 0.1 * smoothness_penalty系数 0.1 不要调太大,否则会走向另一个极端——序列抖动剧烈。
5.3 训练集指标漂亮,验证集插补一塌糊涂
现象:训练集上 MAE 很低,拿到验证集上比线性插值还差。原因:最常出现在归一化上——如果先在全量数据上 fit scaler 再切分,验证集的取值范围已经泄漏到训练阶段;其次是训练和验证用了同一种随机掩码,模型记住了掩码的位置模式。解决:严格按第 4 章的顺序切分和归一化;每次采样掩码时用不同随机种子;建立“假缺失回归”指标来监控泛化能力,而不要只看生成器 loss。
5.4 序列长度不一致导致TensorShape报错
现象:训练时正常,一到验证阶段就报InvalidArgumentError,或者模型在tf.function下无法跑通。原因:RNN 对序列长度敏感,padding 后的无效位置被当成了真实观测;或者构建 Dataset 时 batch 内序列长度不一致。解决:把掩码也作为模型输入,让 GRU 明确知道哪些位置是 padding;更彻底的办法是按长度分桶,把长度相近的样本分到同一个 batch。如果使用tf.function加速,可以考虑关闭动态 shape 相关检查,或统一用固定长度窗口训练,推理时再做长度适配。
5.5 梯度惩罚拖着训练不收敛
现象:用 WGAN-GP 训练时,d_loss 稳定在 1.0 附近,g_loss 也稳定,但插补结果几乎不更新。原因:梯度惩罚系数设置过大,判别器每步更新都被强力约束,梯度信号弱到生成器学不动;或者学习率与惩罚系数不匹配。解决:把梯度惩罚系数从默认的 10 降到 1 甚至 0.5,观察 d_loss 是否开始出现波动;同时把学习率降到 0.00005 重新跑。时间序列数据不像图像那样维度高,WGAN 的默认系数在时序任务上普遍偏大,这是我多次调参后最确定的一条经验。
6. 插补结果不是终点:三个验证技巧帮助判断GAN-RNN是否真的可用
6.1 用“假缺失”回归验证插补质量
训练结束后最常见的错误是直接看可视化效果,觉得“补得挺顺”就收工。更可靠的做法是把完整样本重新挖洞,让模型在已知真值的位置做插补,再计算误差。这相当于给插补模块做一次带标签的回归测试。
def fake_missing_eval(generator, x_complete, missing_len=12, seed=0): rng = np.random.default_rng(seed) x = x_complete.copy() mask = np.ones_like(x) for i in range(len(x)): start = rng.integers(0, x.shape[1] - missing_len + 1) x[i, start:start + missing_len] = 0.0 mask[i, start:start + missing_len] = 0.0 pred = generator(x, mask, training=False) return np.abs(pred.numpy() - x_complete)[mask == 0].mean()这个指标比训练 loss 可靠,因为它只在“从未参与训练的缺失位置”上评估。如果假缺失回归的 MAE 比线性插值还高,说明模型只是记住了训练数据,没有学到可泛化的插补逻辑。
6.2 用下游预测任务判断插补值是否真的“有用”
插补值最终要喂给下游预测或异常检测模块。直接对比插补前后的下游指标更直观:把原始完整序列、均值填充序列、GAN-RNN 补全序列分别送进同一个简单 GRU 预测器,比较它们的预测误差。如果 GAN-RNN 补全后的下游预测误差没有明显低于均值填充,说明插补只改变了波形,没有改变信息量。这一步可以和业务指标直接挂钩,也最容易向团队解释这个方案的价值。
6.3 检查高频细节是否被抹平
生成模型有个通病:补出来的序列整体趋势对,但高频细节丢失。对比插补段和真实段的一阶差分方差,如果补完后的方差明显偏低,说明模型在大面积平滑。更好的做法是直接在频谱上对比缺失段的低频和高频能量分布,低频吻合只能说明趋势对,高频丢失则意味着峰值检测类下游任务会失效。
我最早做这类模型时最常犯的错,是只盯着生成器 loss 下降就觉得成了。后来发现对抗生成模型的插补质量必须回到“假缺失回归、下游预测、细节频谱”三个层面一起看才可靠。训练 GAN-RNN 确实有些玄学,但只要按上面的顺序排查,多数问题都能压住。希望帮到你。
本文还有配套的精品资源,点击获取