简介针对时间序列数据中缺失值插补这一常见难题这套资源提供了基于递归卷积序列到序列结构的生成对抗网络实现并聚焦 Kaggle 的 Wikipedia Web 流量预测数据展开训练适合具备 TensorFlow 基础并希望深入理解 RNN、Seq2seq 与 GAN 结合方式的数据科学学习者。压缩包以 zip 格式提供大小约 3.01MB内部包含数据预处理启动脚本、集中管理参数的 YAML 配置以及三种模型实现代码纯循环卷积序列到序列模型、标准生成对抗网络模型和部分对抗组合模型结构清晰便于逐模块阅读和对比实验。项目在 TensorFlow 2 环境下完成通过让插补器同时学习重构误差与对抗损失可帮助读者掌握如何在实际时间序列数据上优化缺失值预测效果也可作为课程设计或论文复现的参考。目前已有 1428 人学习下载兼具方法讲解与工程实现价值适合用于时间序列插补、生成对抗网络效果评估及相关实验扩展。1. 时间序列插补为什么难当缺失率超过 30%线性插值为什么会翻车拿到一份缺失率超过 30% 的网页访问量数据第一反应通常是对缺的日期做线性插值或前向填充结果补出来的曲线既没有形状也没有周期。这个基于递归生成对抗网络的时间序列估算项目把缺失值重建当成一个序列生成任务来做生成器使用 RNN 的 seq2seq 结构把缺失片段补出来判别器负责判断整段序列到底是真实观测还是模型补出来的。项目在 TensorFlow 2 中实现配套 Jupyter Notebook围绕一份公开的网页流量预测数据集完成切窗、掩码模拟和训练评估。适合做流量预测前置处理的工程师也适合刚把 GAN 概念看完、想找完整代码仓库复现的开发者。对入门者来说它最大的价值不是模型结构多新而是把掩码如何生成、训练时如何避免模式崩溃这些坑都留在了代码里。2. 生成器与判别器如何分工seq2seq 结构、掩码机制与对抗损失这类项目背后的判断是缺失值不是随机空点而是在时间轴上与其他时刻相关的片段。你无法从单一时间点推出合理取值只能依赖前后窗口甚至周期信息。因此核心结构通常是编码器-解码器编码器把含缺失的序列压缩成状态向量解码器从状态出发生成完整序列。在 GAN 框架下生成器负责重建判别器负责挑毛病。两者一对抗生成器就会被迫输出“分布层面可信”的序列而不是一个固定的平均形状。我一般把这种设计叫做条件生成式插补输入条件是观测值和掩码输出目标是完整序列。2.1 为什么用生成模型而不是直接插值直接插值补的是数值不是分布。常见做法是线性插值或样条插值它们只利用缺失区间两端少数点补出来的序列在视觉上异常平滑在高频流量数据里会把尖峰吃掉。更麻烦的是当你把插值后的序列交给下游预测模型模型学到的是被人为平滑过的分布预测值会系统性偏低。单看 MAE 指标插值法在缺失率不高时甚至能比 GAN 更小但一旦遇到连续缺失或周期性缺失插值法的误差会成倍放大。GAN-RNN 的路子是让生成器学一个条件概率给定可观测部分和掩码估算缺失部分的合理取值范围。合理取值范围不是唯一真值。缺失数据的真值不可观测你能做的只有让补出来的序列和真实序列落在同一个概率空间。判别器恰好干这个它把整个补全序列和真实序列放在一起比从全局分布里找破绽。逐点重建损失算的是逐时间步差异而判别器会注意到相邻步之间的模式比如流量数据的周期性、周末低峰、节假日尖峰。这一点是传统插值模型很难显式建模的。有人也会问变分自编码器不也能生成吗它能拟合分布但对序列局部细节的控制不如对抗训练直观。判别器以一个完整序列为输入能同时捕捉长期趋势和短期抖动给生成器的反馈比逐点 L2 损失更接近人的判断标准。当然这也意味着后面为了稳定训练要付出血泪代价。你会在第 5 章看到训练时序 GAN 的体验和修一台老设备差不多问题总在你想不到的地方冒出来。2.2 生成器双向 LSTM 编码与 seq2seq 重建生成器把输入整理成两个通道原值通道和掩码通道。原值里的缺失位置先用 0 占位掩码里对应位置记为 0有观测记 1。两个通道拼接后进入双向 LSTM。双向的价值在于缺失区间前后都有信息可用单向 RNN 只能看到过去补中间和开头会比较吃力。实际搭建时常见做法是先用 Concatenate 拼通道再把结果交给编码器。# 生成器输入原始序列和掩码输出完整重建序列 def build_generator(seq_len, feats, hidden_units64): seq tf.keras.Input(shape(seq_len, feats)) mask tf.keras.Input(shape(seq_len, 1)) # 把缺失值和观测状态拼在同一特征维里 x tf.keras.layers.Concatenate(axis-1)([seq, mask]) # 双向 LSTM 编码保持时间步输出 x tf.keras.layers.Bidirectional( tf.keras.layers.LSTM(hidden_units, return_sequencesTrue) )(x) # 解码逐时间步映射回原始特征维度 out tf.keras.layers.Dense(feats, activationlinear)(x) return tf.keras.Model([seq, mask], out, namegenerator)逻辑上编码器不输出单个状态向量而是保留每个时间步的隐状态方便后续损失做逐点计算。Dense 层不加激活函数因为流量数据需要无界的回归输出。hidden_units 建议先从 32 到 128 里选序列越长该值越大特征只有一条流量序列时64 足够。若你要同时处理多个页面feats 会变成多列Dense 层会自动映射回对应维度。2.3 判别器整序列判真和 CNN 与 RNN 的取舍判别器结构可以比生成器简单但必须有全序列视野。常见实现是把完整序列输入一个单向 LSTM取最后一个时间步的输出经过两层全连接后输出真假概率。另一种做法是叠两层一维卷积把时间维压缩成特征图再接全局平均池化。两种做法在类似项目里都能跑区别是 RNN 对长距离依赖更敏感CNN 计算更快。对 60 步以下的短序列CNN 反而更容易出现过拟合对 180 步以上的长序列LSTM 的耗时又会成为主要瓶颈。# 判别器输入整段序列输出真/假概率 def build_discriminator(seq_len, feats, hidden_units64): seq tf.keras.Input(shape(seq_len, feats)) # 用一个 LSTM 把全序列压缩成最后的隐状态 x tf.keras.layers.LSTM(hidden_units, return_sequencesFalse)(seq) # 接一个轻量 MLP避免判别器容量过大 x tf.keras.layers.Dense(16, activationrelu)(x) out tf.keras.layers.Dense(1, activationsigmoid)(x) return tf.keras.Model(seq, out, namediscriminator)这里的 LSTM 只返回最后状态后面接 16 维 MLP 再输出概率。这样做的目的是在捕捉时间依赖的同时压缩参数量避免判别器过强。判别器过强会直接压制生成器让生成器 loss 不下降过弱则给不出有效梯度。这个平衡是训练时序 GAN 最玄学的部分我通常用每训练生成器 5 步、判别器 2 步的比例做初始设置后续再根据 loss 曲线微调。2.4 占位值与重建损失的掩码约束生成器输出是整个序列的补全结果但训练时只惩罚缺失部分。如果对全部时间点都算重建误差本来就有观测的数值也会被模型改掉下游预测会看到无意义的抖动。因此损失层要乘上一个掩码用“观测到1、缺失0”的掩码把误差集中在缺失点上另一种做法是把掩码反过来让生成器在已有观测点上严格复制输入。常见做法是保留前一种生成器自由度更大不容易过拟合输入噪声。同时要注意输入里缺失位置用 0 占位只是占位不是真实值。如果数据本身有 0 值比如网页访问量的 0 表示无人访问这一步就要特别小心。你需要在预处理阶段先区分“真 0”和“缺失”否则生成器会去学习把缺失补成 0出现一种能把人骗过的低损失。这个点在第 5 章的坑里会展开讲。3. 数据准备与模型搭建从一页一行的原始表到批处理张量网页流量预测数据集的原始格式通常是一行一个页面、一列一个日期。RNN 需要的是 [batch, time_steps, features]所以第一步是把表格转置让时间变成行、页面变成列。这个环节有大量隐性坑有的页面缺失日期、有的页面访问量全为 0、有的页面只有短短几十天记录。在开始建模之前要把这些脏数据全部清洗成规整的三维张量。3.1 网页流量重构转置、对齐日期、切窗成 batch先看数据框的结构假设 df 的索引是页面标识列是日期。先用 pandas 转置让日期在上、页面在左随后把整表转成 float32并统一按日期排序。常见做法是import pandas as pd import numpy as np # 原始 df行是页面列是日期值为浏览数 pivot df.set_index(page).T.sort_index() pivot pivot.astype(np.float32) # 缺失日期先填 NaN再统一转为 0 占位 pivot pivot.replace(0, np.nan) # 0 视为流量缺失按需调整这段代码把“真 0”和“缺失”区分开先用 NaN 标记缺失后续在掩码生成时单独记录。为什么要这样做因为网页流量里很多页面长期无人访问算出来的 0 不是缺失是真值。如果不区分模型会把缺失点学成均值或 0把真实存在的 0 也改掉。切窗时我一般用非重叠窗口降低训练集内部相关性。每个窗口取 180 天然后把多个窗口堆叠成 batch。代码上可以写成def split_into_windows(pivot_values, seq_len180): total_time, total_pages pivot_values.shape windows [] for start in range(0, total_time - seq_len, seq_len): win pivot_values[start:start seq_len] # [seq_len, total_pages] windows.append(win) windows np.stack(windows) # 转成 [batch, seq_len, total_pages] return windows这里返回的 shape 是 [N_batches, seq_len, page_count]。如果页面超过 1000 个直接全量填充会让生成器的 feats 过大训练异常慢。常见做法是先按页面访问量的均值筛选出前 50~200 个页面再切成多列特征。这个项目示例里只取了流量最高的若干页面再压缩成单变量或多变量序列。3.2 掩码生成随机缺失与连续缺失的差别训练阶段必须人为挖掉一些数据才能构造监督信号。最简单的做法是随机挑位置把 mask 设为 0。但真实网页流量缺失更像网络故障或节假日往往连续缺好几天。因此掩码生成要混合两类随机缺失和连续缺失。我通常用 70% 随机缺失 30% 连续缺失连续缺失长度随机取 3~7 天。def apply_mixed_mask(x, missing_rate0.25, block_ratio0.3): seq_len, feats x.shape mask np.ones_like(x, dtypenp.float32) for f in range(feats): n_miss int(seq_len * missing_rate) n_block int(n_miss * block_ratio) # 随机缺失点 rand_idx np.random.choice(seq_len, n_miss - n_block, replaceFalse) # 随机连续块 block_start np.random.randint(0, seq_len - 8) block_idx np.arange(block_start, block_start 7) miss_idx np.concatenate([rand_idx, block_idx]).astype(int) # 缺失位置原值置 0掩码置 0 x[miss_idx % seq_len, f] 0 mask[miss_idx % seq_len, f] 0 return x, mask这里 missing_rate 设 0.25block_ratio 设 0.3意思是一段 180 天窗口里大约 45 天缺失其中约 13 天连成一块。连续块长度为 7模拟一周的网络故障。生成器看到连续缺失时需要利用前后更长范围的上下文这种掩码比纯随机缺失更难也更能反映真实场景。3.3 TF2 模型实例化与超参数清单把数据喂进模型之前先确定 seq_len、feats、hidden_units 和 batch size。一个能起步的组合如下表超参数建议值说明seq_len180看半年窗口兼顾上下文和训练速度feats1~20单页面为 1多页面并行则取页面数hidden_units64生成器/判别器的 LSTM 单元数batch_size32在内存和梯度稳定性之间折中missing_rate0.25模拟缺失比例太大会导致生成器学不到信号block_ratio0.3连续缺失占缺失总数的比例# 实例化两个模型和优化器 generator build_generator(seq_len180, feats1, hidden_units64) discriminator build_discriminator(seq_len180, feats1, hidden_units64) g_optimizer tf.keras.optimizers.Adam(learning_rate1e-4) d_optimizer tf.keras.optimizers.Adam(learning_rate2e-4)优化器分开配置是有原因的判别器通常需要略高的学习率否则收敛太慢生成器学习率若设得和判别器一样高容易在前期被推离稳定点。batch_size 在 180 天序列上一般不超过 64否则显存和梯度方差同时变大。第一次跑先按这个表格来后面再单独调 hidden_units 和 learning_rate。4. 训练循环与指标设定判别器先热身、生成器后更新的工程做法时序 GAN 的代码结构比图像 GAN 简单但训练节奏更敏感。原因是序列数据的局部噪声多判别器很容易从噪声里找到捷径。为了让生成器不至于一开始就被压死很多实现会让判别器先跑几步热身再进入交替更新。4.1 训练主循环tf.GradientTape 与两个优化器训练循环里最需要留意的是两个梯度带GradientTape写在同一域内还是在不同域内。常见做法是生成器和判别器的前向计算分开避免梯度被串扰。# 假设 dataset 每个 batch 返回 (x_real, x_observed, mask) for epoch in range(epochs): for x_real, x_batch, m_batch in dataset: # 第一步更新判别器 with tf.GradientTape() as d_tape: x_gen generator([x_batch, m_batch], trainingTrue) real_score discriminator(x_real, trainingTrue) fake_score discriminator(x_gen, trainingTrue) d_loss tf.reduce_mean(fake_score) - tf.reduce_mean(real_score) d_grads d_tape.gradient(d_loss, discriminator.trainable_variables) d_optimizer.apply_gradients(zip(d_grads, discriminator.trainable_variables)) # 第二步更新生成器 with tf.GradientTape() as g_tape: x_gen generator([x_batch, m_batch], trainingTrue) fake_score discriminator(x_gen, trainingFalse) recon_loss tf.reduce_mean(tf.abs(x_gen - x_real) * m_batch) g_loss -tf.reduce_mean(fake_score) 1.0 * recon_loss g_grads g_tape.gradient(g_loss, generator.trainable_variables) g_optimizer.apply_gradients(zip(g_grads, generator.trainable_variables))判别器更新时要同时看真实序列和生成序列生成器更新时只传假序列。上面代码里x_real来自没有挖掉掩码的原始完整序列x_batch是挖掉缺失后的输入m_batch是掩码。如果更新顺序写反比如先更新生成器再更新判别器判别器的 loss 会长期不降。常见的做法是先做 3 次判别器更新再做 1 次生成器更新或者每个 batch 内交替各更新一次。若你的 GPU 显存紧张可以每 5 个 batch 固定只更新一次生成器效果差别不大。4.2 损失组成对抗损失、重建损失与掩码约束为了让生成器既准又稳损失通常写成两项的加权。def generator_loss(x_real, x_gen, mask, fake_score, lam1.0): # 对抗损失希望生成序列骗过判别器 adv_loss -tf.reduce_mean(fake_score) # 重建损失只算缺失部分 recon_loss tf.reduce_mean(tf.abs(x_real - x_gen) * mask) return adv_loss lam * recon_loss def discriminator_loss(real_score, fake_score): # 判别器希望真实序列得分高生成序列得分低 return tf.reduce_mean(fake_score) - tf.reduce_mean(real_score)lam 控制对抗损失和重建损失的平衡。lam 太低生成器只顾骗判别器缺失点的数值会偏离真实范围太多lam 太高对抗损失被淹没模型退化成普通自编码器补出的序列失去整体分布一致性。我的习惯是把 lam 从 1.0 起调当重建误差下降但生成序列视觉上不自然时把 lam 降到 0.5当重建 MAE 偏高时把 lam 提到 2.0。4.3 训练观察mae、重建误差和 loss 曲线的使用习惯训练时不建议直接看 100 步报告而是盯三个指标判别器 loss、生成器重建 MAE、以及补全后全序列的均方误差。重建 MAE 是必须在缺失掩码对应的区域计算的不要把观测点的误差也统计进去否则会掩盖缺失区域的真实水平。# 评估掩码区域的 MAE def masked_mae(x_real, x_gen, mask): err tf.abs(x_real - x_gen) return tf.reduce_mean(err * (1 - mask) / tf.maximum(tf.reduce_sum(1 - mask), 1))正常情况下前 300 轮里判别器 loss 会上下震荡生成器重建 MAE 缓慢下降。如果判别器 loss 一路暴跌到接近于零说明判别器太强需要降低判别器学习率或者增加生成器更新频次如果重建 MAE 纹丝不动则要检查掩码是否把真实值也挖掉了比如全零掩码。把这三个指标的曲线保存下来是后续调参最有用的“黑匣子”记录。5. 避坑与常见问题排查NaN、模式崩溃与长序列欠拟合这一章记录的是我在复现这类递归 GAN 插补项目时踩过的几条典型路径。每条按“现象 → 原因 → 解决”写可以直接套到自己的训练日志上。5.1 训练到一半反复出现 NaN / Inf梯度惩罚与数值稳定性现象训练前几百步正常某个 batch 之后 loss 变成 NaN重跑几次还是会在相近位置爆炸。原因生成器输出的值没有边界遇到异常大的访问量时Dense 层输出可能达到 10^6 量级判别器判别异常值后回传梯度LSTM 的内部状态容易在长序列上累积形成梯度爆炸。另外如果输入的数据方差过大标准化没做好常见的 BatchNormalization 并不能替代输入标准化。解决第一步先把所有序列做缩放常见做法是把 log1p(访问量) 作为目标把量级压到 0~10 之间第二步在判别器上加梯度惩罚项第三步给生成器和判别器的梯度做 clip_by_norm。# 梯度裁剪的常见写法 g_grads [tf.clip_by_norm(grad, clip_norm5.0) for grad in g_grads] d_grads [tf.clip_by_norm(grad, clip_norm5.0) for grad in d_grads]把 clip_norm 设成 5.0 后我几乎没有再遇到过训练中断。注意裁剪值和学习率要一起看学习率太大时裁剪会把有效梯度压缩到很小训练进度变慢。5.2 生成器只在平均值附近摆动判别器太强和重建损失过重现象生成的缺失片段是一条近似水平线数据集整体均值是多少它就补多少没有任何波峰波谷。原因判别器容量远大于生成器轻松分辨出真实范围和生成范围导致生成器从对抗损失中得不到正向引导同时如果把重建损失权重 lam 调太高生成器也会倾向取一个在均值附近的小误差解也就是模式崩溃。解决把判别器的 hidden_units 从 64 减到 32再在生成器重建损失中使用加权模式即缺失区域中的位置权重设高观测区域权重设低迫使生成器聚焦于难补的位置。还可以在生成器中加一个 dropout让输出不完全确定间接绕过判别器的“捷径”。5.3 长序列训练半天 loss 不降截断 BPTT 与子序列下采样现象seq_len 取 365训练一个 epoch 要半个多小时跑了两个 epoch 重建 MAE 还在 0.8 附近没有下降趋势。原因LSTM 在 365 步上的时间反向传播计算量太大梯度沿时间维度衰减严重模型实际上没有学到足够久的依赖大部分信息在中间步就丢失了。解决先做截断 BPTT把 365 天的序列切成 3 个 120 天的子序列分别训练生成器每次只补其中一个子序列或者把所有序列先降到 180 步用一个轻量 GRU 替换 LSTM。建议最初版本跑seq_len180, hidden_units32, batch_size32把训练时间压到 10 分钟以内先把流程跑通再去处理长序列。5.4 真实缺失和模拟掩码的分布不一致加周期性 mask现象用随机掩码训练的模型在实际却有“每月初都缺 3 天”的数据上效果很差重建误差直接翻倍。原因模拟掩码和真实缺失模式不匹配。随机缺失不会产生周期性结构生成器学到的补全习惯只在随机丢失位置生效。解决训练时加入周期掩码按固定间隔挖掉一段数据比如每 30 天挖掉 3 天。这会让生成器学到“周期性缺失”的规律同时也会让判别器更容易抓住伪造样本的破绽。另一个通用做法是先生成一个带缺失模式的验证集判断模型在哪些位置系统性偏弱再回头调整模拟掩码的分布。6. 进阶用法双向外推、插补置信区间与三段式验证模型训练稳定后最后一步是把插补结果和实际业务衔接起来。单纯看重建 MAE 不够我习惯用“三段式验证”来判断生成器是不是真的有用第一段把真实完整序列的最后 20% 时间步全部遮掉第二段遮掉中间连续的 10% 时间步第三段遮掉随机 15% 的时间步。分别比较三种情况下的重建误差。随机缺失的 MAE 最低是正常连续缺失的 MAE 偏高二到三倍也正常如果随机缺失的 MAE 也降不下来说明模型没有学到序列分布。双向外推是一个容易被人忽略的进阶用法。生成器是 seq2seq 结构通过掩码也能处理序列开头的缺失把前面 30 天全部标记为缺失生成器会从后面的观测值推导出一个合理的前序趋势。实际实现很简单把待预测部分的 mask 全部置 0原值置 0直接调用生成器即可。def forecast_prefix(model, x_observed, future_mask_ratio): # 把开头 30 天当作缺失 seq_len x_observed.shape[1] x x_observed.copy() m np.ones_like(x) x[:, :30, :] 0 m[:, :30, :] 0 x_hat model([x, m], trainingFalse) return x_hat[:, :30, :]置信区间可以通过多次随机初始化生成器来近似。训练完成后保存 5 个不同随机种子下的权重对同一段缺失区域做 5 次插补计算每个时间点的均值和标准差。标准差大的位置说明模型对该点把握不足这些位置不应该直接进入下游预测模型建议再人工复核。这个做法虽然笨但在生产场景里比一个干巴巴的单值补全结果更可靠。我还习惯在模型收敛后做最后一趟“后悔药”实验把训练时见过的非缺失值也随机挖掉一部分重新训练观察重建分布是否稳定。如果每次重训的均值偏差不超过 0.2 个量级我就认为这个模型可以上线如果偏差太大我会回到第 4 章去把 lam 和训练比例再调一轮。从那以后我每次跑完这一类时序 GAN都会强制走一遍这个验证闭环先看随机缺失再看连续缺失最后看置信区间三个判断全过才敢把插补结果交给下游预测任务。希望帮到你。本文还有配套的精品资源点击获取