GAN-RNN时间序列缺失值填补:递归对抗与TensorFlow 2实战
简介面向时间序列缺失值插补场景的 TensorFlow 2 实战项目资料适合具备一定 Python、RNN/CNN 与对抗训练基础的数据科学学习者用于理解并复现循环卷积 Seq2seq 插补模型及 GAN 训练思路。内容围绕 Kaggle Wikipedia Web 流量预测数据集展开给出循环卷积 Seq2seq、基于同一架构的 GAN 插补器与部分对抗模型三种配置并包含数据预处理、训练和测试所需的参数文件与管道脚本便于对照实验设置、损失组合、判别器与插补器的博弈过程以及模型评估方式。压缩包约 3.01MB文件总数与主要文件类型明细上游暂未提供。已有 1428 人学习或下载可作为缺失值填补、时序生成建模与对抗损失设计的参考案例帮助读者梳理从数据准备、配置调整到训练复现的完整实验结构并理解不同损失结构对插补效果的影响。1. 结论先行时间序列缺失值填补补「最像的值」不如补「说得通的一段」先给一个反直觉的结论做时间序列缺失值填补时把空洞补成「条件期望」这种单点最优解在 RMSE 上通常赢但放到下游任务里经常拖后腿。原因不难想——一段真实流量曲线有它自己的起伏节奏你把中间二十天抹成一条平滑的均值线指标好看可后续的异常检测、容量预估一碰到这段就全瞎。GAN-RNN 这类递归对抗结构正是冲这个矛盾来的生成器逐时刻吐值判别器在整条时间轴上判真假逼着补出来的片段在联合分布上站得住脚而不只是逐点误差小。搞懂它等于搞懂「什么样的缺失值填补才算真的补对了」。2. 生成器与判别器都是 RNN对抗训练怎么在时间维度上展开把 GAN 搬到序列上最容易翻车的地方不是损失函数写错而是没想清楚「时间」这个维度在哪几处参与运算。图像 GAN 里一张图是一次性喂进去的序列不行长度可变、时刻之间有因果依赖、缺失位置还带掩码。这三件事决定了生成器必须是递归的、判别器必须能对时间轴打分、损失必须在掩码加权下计算。下面三节分别拆这三个决策。2.1 生成器的递归结构隐状态里装的到底是上下文还是噪声递归生成器在 t 时刻的输入通常是三路拼接上一时刻的值、该时刻的掩码、一段噪声。隐状态 h_t 负责承载「这段序列到目前为止是什么形态」——是缓慢爬升的流量还是周期性波动的访问量全靠 h_t 记住。噪声 z 则负责注入多样性让同一个观测上下文能对应多条合理补全路径。这里有个特别容易被忽略的细节值通道和掩码通道必须分开。很多人图省事把缺失位置直接填 0 就送进 LSTM结果网络根本分不清「这天访问量真的是 0」和「这天数据没采到」。网页流量里确实存在真实的零访问这个歧义会一路污染隐状态。正确做法是喂x_obs * mask和mask两路让网络自己学「这个 0 到底是不是缺失」。所谓「递归」体现在两个不同阶段训练阶段t 时刻的值通道喂的是真值teacher forcing并行度高一条序列一次前向就能算完推理阶段模型只能喂自己上一步的输出free running必须真的一个时刻一个时刻往前推。这两者的落差是后面第 5 章要专门讲的坑。现在先记住一点递归结构换来的是参数在时间上共享、能处理变长序列、且补出来的相邻点天然连贯——一次性用全连接层吐出整段相邻时刻之间没有任何约束补出来会是锯齿状的。2.2 判别器的两种粒度逐步打分与整段打分判别器输出一个标量整段真/假还是输出一串标量每个时刻真/假差别比想象中大。整段打分是标准 GAN 的做法简单但对长序列很吃亏一条 500 步的序列梯度要从最后一步一路回传到第一步判别器给出的信号被稀释得厉害生成器前面那些时刻基本收不到有效梯度。逐步打分则让判别器在每个时刻都吐一个 logit损失在时间轴上求平均。好处是梯度稠密某个位置补得离谱那个位置立刻挨罚不用等整段判完。代价是判别器可能「偷懒」。逐步判别时它很容易退化成一个局部平滑度检测器——真实流量曲线有抖动、有尖峰而生成器初期吐出来的东西偏平滑判别器光靠「这一段的高频能量偏低」就能把假样本挑出来压根没去学整体分布。表现出来就是判别器 loss 飞快掉到 0.1生成器却什么也没学到。还有一种中间路线只在被填补的区段上加权打分观测区段降低权重。这个在实操里挺管用因为观测段本来就是真的判别器在那儿打分属于送分题。提示判别器要不要把掩码当输入是个真取舍。带掩码判别器知道哪里是补的会更专注判「补得像不像」不带掩码它被迫学整体分布但可能退化成靠某处方差异常就能分辨的检测器。常见的折中是给判别器一个「提示向量」只暴露部分掩码信息而不是全给或全不给。2.3 对抗损失之外为什么必须加一条重建损失只用对抗损失训练填补模型会遇到一个根本性问题对抗信号只约束「像真的」不约束「和观测部分对得上」。生成器完全可以无视输入的观测片段凭空造一条看起来非常真实的流量曲线——判别器分辨不出来因为那条曲线确实符合真实数据分布只是跟这段序列的历史毫无关系。所以必须补一条重建损失在观测位置上算生成值与真值的偏差。它把生成器从「无条件生成器」拉回「条件生成器」你得先看懂前面三十天在涨还是在跌才能把后面补对。但这条损失的权重 λ 极其敏感属于玄学区间λ 太大生成器退化成普通回归器输出条件均值判别器形同虚设最后补出来的还是一条平线白折腾λ 太小生成器只顾讨好判别器忽略观测上下文补出来的片段和前后文接不上接缝处出现明显跳变。我一般会先把 λ 设成 10 左右跑一轮看重建损失和对抗损失的量级是否在同一个数量级附近差两个数量级以上说明有一方已经压制了另一方得调。还有一个很实用的技巧训练时在 batch 里人为再挖一批洞。这些新洞有真值因为是从完整序列里挖的可以拿来算监督损失。等于用同一批数据同时获得「真实缺失位置的对抗信号」和「人造缺失位置的监督信号」收敛快很多。3. 掩码与填补路径缺失值到底从哪一步进入网络掩码不是一个预处理完就扔掉的中间产物它在整条链路上出现四次。少做任何一次模型都不会报错只是悄悄变差——这是这类实现最典型的黑匣子特征。3.1 掩码向量与填补算子把缺失位置替换成生成值核心算子只有一行X_fill M ⊙ X_obs (1 - M) ⊙ X_hatX_obs是缺失位置填 0 的原始序列X_hat是生成器对整段的预测M是 1/0 掩码。这个式子的含义是观测位置保留真值缺失位置用生成值顶上。判别器看到的是X_fill不是X_hat——这个区别很关键。如果直接把X_hat喂给判别器判别器会同时评判观测段的重建质量而那部分本该是「已知条件」白白增加噪声。掩码的四处落点位置作用漏掉的后果生成器输入区分真 0 与缺失隐状态被歧义污染填补算子观测段保留真值判别器看到扭曲样本重建损失只在有真值处算误差用假值当真值训练发散判别器输入可选告知缺失位置判别器退化为模式检测器重建损失那一行最要命。如果写成reduce_mean(square(x_hat - x_real))而不乘掩码缺失位置的x_real是 0等于在逼生成器把缺失段补成 0。这个 bug 不会崩loss 还会稳定下降只是模型学了个寂寞——属于典型的需要后悔药但没处买的场景。3.2 递归方向单向自回归与双向一致性填补不是预测。预测只能用过去填补两边都有信息。一段十五天的空洞右边那十五天的数据对判断左边该补什么同样有价值。但递归 GAN 的生成器是自回归的天生只能往一个方向走。三种应对单向自回归最忠实于递归结构训练和推理完全一致缺点是浪费了未来的信息长空洞的中段基本靠猜。双向 LSTM 生成器直接用完整掩码做条件输入训练和推理都喂同一份掩码不存在不一致问题。代价是丢掉了「逐时刻递归生成」的采样能力噪声注入也得改成对整段一次性注入。前后两趟 融合正向生成一遍、反向生成一遍按到观测点的距离做加权。实现最啰嗦但对长空洞效果最好而且两趟都是标准递归结构改动最小。我一般会这么理解如果缺失段长度大多在序列长度的 10% 以内单向递归够用超过 30%单向的中段基本是瞎编这时候双向或者两趟就是必须的不是优化。3.3 预处理流水线log1p、按序列归一化与滑窗切分这一步看着最枯燥实则决定了模型能不能收敛。网页访问量是典型的重尾分布少数页面日访问量几十万大多数只有个位数。直接喂原始值损失会被大流量页面完全主导小页面等于没参与训练。所以先log1p压一下再按每条序列自己的分位数归一化——注意是每条序列各自归一化不是全局一个常数。全局归一化是另一个高频坑第 5 章细说。import numpy as np def preprocess(series, seq_len64, stride16, missing_ratio0.1, seed0): series: [N, T] 原始访问量缺失处为 nan rng np.random.default_rng(seed) x np.log1p(np.nan_to_num(series, nan0.0)) mask (~np.isnan(series)).astype(np.float32) # 1观测0缺失 # 按序列归一化用观测位的 90 分位数抗尖峰 scale np.nanpercentile(np.where(mask 0, series, np.nan), 90, axis1) scale np.where(np.isfinite(scale) (scale 0), scale, 1.0) x x / np.log1p(scale)[:, None] windows, masks [], [] for i in range(0, x.shape[1] - seq_len 1, stride): windows.append(x[:, i:i seq_len]) masks.append(mask[:, i:i seq_len]) return np.stack(windows, 1), np.stack(masks, 1)seq_len决定模型能看多长的上下文64 到 100 是比较稳的区间太长会让 BPTT 的梯度衰减问题变严重。stride控制窗口重叠度取seq_len // 4左右能在样本数和冗余度之间取得平衡。scale用 90 分位而不是最大值是为了防止个别暴涨的尖峰把整条序列压扁。注意训练/验证/测试必须按时间顺序切不能随机切窗口。同一段序列的相邻窗口高度相关随机切会让验证集里混进训练集的信息指标虚高得离谱。4. TensorFlow 2 源码级实现一个 train_step 里怎么同时伺候两个网络TF2 的即时执行让对抗训练好写很多代价是两个网络的梯度流必须自己理清楚。最常见的错误是判别器的梯度不小心穿过了生成器或者生成器的梯度在判别器参数上被重复累积。用两个GradientTape是标准解法但得知道每一行在挡什么。4.1 用 Keras 子类化定义 G 与 Dimport tensorflow as tf class RecursiveGenerator(tf.keras.Model): 逐时刻递归生成器。输出整段预测缺失位置由外层算子替换。 def __init__(self, hidden64, noise_dim8): super().__init__() self.noise_proj tf.keras.layers.Dense(noise_dim) self.lstm tf.keras.layers.LSTM(hidden, return_sequencesTrue, return_stateTrue) self.out tf.keras.layers.Dense(1) def call(self, x_obs, mask, z, trainingFalse): # x_obs: [B,T,1] 缺失处已置 0mask: [B,T,1]z: [B,1,noise_dim] z_seq self.noise_proj(tf.tile(z, [1, tf.shape(x_obs)[1], 1])) # 逐步广播 feat tf.concat([x_obs * mask, mask, z_seq], axis-1) # 值/掩码/噪声三路 h, _, _ self.lstm(feat, trainingtraining) return self.out(h) # [B,T,1] class RecursiveDiscriminator(tf.keras.Model): 逐步打分判别器每个时刻一个 logit梯度更稠密。 def __init__(self, hidden64): super().__init__() self.lstm tf.keras.layers.LSTM(hidden, return_sequencesTrue) self.logit tf.keras.layers.Dense(1) def call(self, x, mask, trainingFalse): feat tf.concat([x, mask], axis-1) # 掩码作为提示通道 return self.logit(self.lstm(feat, trainingtraining))RecursiveGenerator.call的参数顺序决定了调用时的可读性把观测值放第一位是因为推理时只有它和掩码是真实的噪声是外挂的。noise_proj把[B,1,noise_dim]投影后沿时间轴广播等价于每个时刻喂同一段噪声——这样隐状态仍然要负责生成变化而不是让噪声直接把答案念出来。判别器保留return_sequencesTrue是关键它决定了损失是逐步算的。如果把这里改成return_sequencesFalse整个模型会退化成整段判别前面的设计就白搭了。4.2 一个 train_step 里的两个 tape 与更新顺序def make_train_step(G, D, g_opt, d_opt, lambda_rec10.0, clip5.0): bce tf.nn.sigmoid_cross_entropy_with_logits tf.function def step(x_real, mask, z): x_obs x_real * mask with tf.GradientTape() as g_tape, tf.GradientTape() as d_tape: x_hat G(x_obs, mask, z, trainingTrue) x_fill mask * x_obs (1.0 - mask) * x_hat # D 的两路前向一路不回流到 G一路要回流 d_real D(x_real, tf.ones_like(mask), trainingTrue) d_fake_d D(tf.stop_gradient(x_fill), mask, trainingTrue) d_fake_g D(x_fill, mask, trainingTrue) d_loss (tf.reduce_mean(bce(tf.ones_like(d_real), d_real)) tf.reduce_mean(bce(tf.zeros_like(d_fake_d), d_fake_d))) g_adv tf.reduce_mean(bce(tf.ones_like(d_fake_g), d_fake_g)) # 重建只在观测位置算分母用观测点总数避免短序列权重失衡 rec tf.reduce_sum(tf.square(x_hat - x_real) * mask) / (tf.reduce_sum(mask) 1e-8) g_loss g_adv lambda_rec * rec d_grads d_tape.gradient(d_loss, D.trainable_variables) g_grads g_tape.gradient(g_loss, G.trainable_variables) d_grads, _ tf.clip_by_global_norm(d_grads, clip) g_grads, _ tf.clip_by_global_norm(g_grads, clip) d_opt.apply_gradients(zip(d_grads, D.trainable_variables)) g_opt.apply_gradients(zip(g_grads, G.trainable_variables)) return d_loss, g_loss, rec return step几处必须解释的地方tf.stop_gradient(x_fill)是关键的一刀。判别器的损失只应该更新判别器参数如果不停梯度d_tape会把整条生成路径也记录下来显存直接翻倍而且一旦有人顺手把G.trainable_variables传进去就会闹出双份梯度。停下梯度后d_fake_d这条路径对 G 完全透明。d_fake_g是另一路前向故意不 stop因为生成器的对抗损失必须穿过判别器回传到 G。代价是判别器前向跑了两遍批量小的时候无所谓序列长的时候能明显感觉到慢。想省这一步的话可以只跑一遍并在损失里用tf.stop_gradient做张量级切换但可读性会变差我倾向保留两路。rec的分母用reduce_sum(mask)而不是序列长度是为了让「一个 batch 里观测点总数」成为归一化基准。如果按序列长度平均掩码率高的批次重建损失会被稀释梯度尺度在不同 batch 之间跳来跳去。更新顺序上判别器先于生成器是有道理的这一轮判别器先看到「当前生成器能造出什么」再据此更新自己生成器拿到的是更新后的判别器反馈。如果反过来生成器是在追一个即将过时的靶子。实践里也可以让判别器每步更新 k 次k2 或 3、生成器更新一次但 k 越大判别器越强越容易把生成器按死。优化器用 Adam学习率 2e-4、beta_10.5这是序列 GAN 里比较通用的配置低beta_1能减少动量带来的震荡。4.3 推理阶段从噪声到完整序列的递归推演与填充评估推理不能只采样一次。同一个观测上下文不同噪声会给出不同的合理补全单次采样的方差很大。做法是采多条再取中位数def impute(model, x_obs, mask, noise_dim8, samples32): 多次采样取逐点中位数压住单次采样的方差 B, T, _ x_obs.shape outs [] for _ in range(samples): z tf.random.normal([B, 1, noise_dim]) x_hat model(x_obs, mask, z, trainingFalse) outs.append(mask * x_obs (1.0 - mask) * x_hat) stack tf.stack(outs, axis0) # [S,B,T,1] mid tf.gather(tf.sort(stack, axis0), samples // 2) return tf.squeeze(mid, axis0)取中位数而不是均值是因为对抗生成出来的样本偶尔会跑飞某次采样补出一个量级不对的段均值会被这种离群样本拽偏中位数稳得多。samples取 16 到 32 之间通常够再多收益递减。评估必须只在人造空洞上做因为只有那里有真值def masked_rmse(x_true, x_pred, hole_mask): err tf.square(x_true - x_pred) * hole_mask return tf.sqrt(tf.reduce_sum(err) / (tf.reduce_sum(hole_mask) 1e-8))hole_mask是「人造挖掉、且原本有真值」的位置和被填补的1-mask不是一回事——真实缺失位置没有真值算不进来。这两者搞混是评估环节最常见的错误会把指标算得毫无意义。5. 避坑与排查五个让对抗式填补悄悄失效的细节这些坑的共同特点是不报错、loss 曲线看着还挺正常、甚至验证指标也在降但模型实际学到的东西是错的。5.1 判别器 loss 掉到 0.1 以下生成器梯度先死现象训练几百步后判别器损失迅速逼近 0生成器损失纹丝不动补出来的值几乎不随输入变化。原因逐步判别的判别器太容易赢。它只需要学到「被填补区段的高频能量偏低」这一条特征就能把假样本全部挑出来压根不需要理解分布。判别器一强生成器的对抗梯度就趋近于零。解决先把判别器的学习率降到生成器的 1/2 到 1/4再给判别器的输入加一点高斯噪声标准差 0.05 量级还不行就把判别器改成每两步更新一次。三步走完仍不收敛说明任务本身不适合对抗回去用纯监督回归。5.2 补出来的曲线是一条平线重建损失把生成器拉向条件均值现象填补结果平滑得不像话方差明显低于真实数据长空洞的中间段几乎就是一条直线。原因λ 设太大或者重建损失用了 L2。L2 的最优解就是条件均值生成器发现「输出均值」能同时把重建损失压到最低、对抗损失也不会太差于是直接躺平。解决把重建损失从 L2 换成 L1L1 的最优解是条件中位数天然鼓励非平滑输出把 λ 从 10 降到 1 到 3再加一条方差匹配项强制生成段的方差接近真实段。5.3 训练指标漂亮上线填充离谱teacher forcing 埋的 exposure bias现象验证集 RMSE 很低实际部署时把一段连续观测全抹掉让它补补出来的东西越往后越飘。原因训练时每个时刻喂的都是真值模型从没见过自己犯的错。推理时一旦第一步偏了一点误差会顺着递归结构滚雪球。解决引入 scheduled sampling——训练前期全喂真值后期按概率 p 把输入替换成模型自己的输出p 从 0 线性升到 0.5 左右。或者干脆用两趟生成第 3.2 节让误差不至于单方向累积。5.4 大流量页面主导了 loss小页面等于没训练现象整体指标还行但抽样看小流量页面填补结果基本是常数完全不跟随波动。原因全局归一化或者归一化后没做 log 变换。不同页面量级差三四个数量级损失被头部页面吃光。解决log1p 按每条序列自身的分位数归一化见 3.3 的代码。做完之后抽样对比头部和尾部页面的填补曲线两者都应该有明显跟随。5.5 窗口里全是完整序列生成器根本没机会学填补现象重建损失降得很快但一测人造空洞就崩。原因真实数据里连续完整的窗口占多数随机切窗后一个 batch 里可能大半样本的掩码全是 1。这些样本上生成器的任务退化成恒等映射重建损失轻松归零对填补能力毫无贡献。解决在数据管道里显式控制掩码率——每个 batch 保证至少一半样本被人为挖洞挖洞比例在 5% 到 30% 之间随机。这一步加进去收敛曲线会明显变难看但泛化会好一大截。6. 设计取舍对抗换来的到底是什么以及怎么验证它真的换来了回到最开始那个问题。对抗训练给填补模型带来的本质上是分布层面的约束生成器不再只对每个点负责而是对整段片段的联合形态负责。代价是训练不稳定、超参敏感、调参周期长。这笔交易值不值取决于下游任务对分布的敏感程度。方法补出来的是什么适合的下游任务主要问题均值/中位数填充一个常数粗略聚合统计破坏方差时序结构全丢前向填充上一个观测值短空洞、稀疏事件制造平台误导变化率类特征线性插值两端连线平滑慢变信号抹掉周期与尖峰RNN 回归条件均值点估计、单步预测方差偏低长空洞趋平GAN-RNN一条合理的样本路径异常检测、容量预估、仿真训练不稳需多样本采样表格里最右边那列的「需多样本采样」不是小事推理成本是普通回归的几十倍。如果业务只关心「这段大概是多少」用 RNN 回归加 L1 损失两天能上线没必要上对抗。验证协议上我会坚持两套口径同时报一套是人造缺失——从完整序列里随机挖洞有真值可比指标干净另一套是真实缺失——挑历史上确实断采的区段虽然没真值但可以看填补结果是否与前后文连贯、是否引入不可能的负值或突变。只报第一套容易被「在合成缺失上过拟合」骗过去只报第二套则完全没有量化依据。还有个容易被跳过的步骤多种子重复。对抗训练对初始化极其敏感同一个配置跑三次RMSE 能差 15%。只跑一个种子就下结论等于拿运气当结论。我一般至少跑五个种子报均值和标准差标准差超过均值 10% 就说明这个配置本身不稳别急着调超参先降模型容量。进阶方向上有两条值得试一是把掩码当作条件做条件生成让同一个模型适配不同缺失模式而不是每个缺失率训一个二是用多步扩散替代单步对抗训练稳定性和样本质量都会好一截代价是推理步数更多。从那以后我每次搭填补模型的 baseline都会先把「只用观测位的 L1 回归」跑通、把指标钉死再看对抗损失到底有没有带来超过一个标准差的增益。带不来就是没带来再漂亮的 loss 曲线也不算数。希望帮到你。本文还有配套的精品资源点击获取