ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

GAN-RNN时间序列缺失值填补:递归对抗与TensorFlow 2实战

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_hat

X_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_len=64, stride=16, missing_ratio=0.1, seed=0): """series: [N, T] 原始访问量,缺失处为 nan""" rng = np.random.default_rng(seed) x = np.log1p(np.nan_to_num(series, nan=0.0)) mask = (~np.isnan(series)).astype(np.float32) # 1=观测,0=缺失 # 按序列归一化:用观测位的 90 分位数,抗尖峰 scale = np.nanpercentile(np.where(mask > 0, series, np.nan), 90, axis=1) 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 与 D

import tensorflow as tf class RecursiveGenerator(tf.keras.Model): """逐时刻递归生成器。输出整段预测,缺失位置由外层算子替换。""" def __init__(self, hidden=64, noise_dim=8): super().__init__() self.noise_proj = tf.keras.layers.Dense(noise_dim) self.lstm = tf.keras.layers.LSTM(hidden, return_sequences=True, return_state=True) self.out = tf.keras.layers.Dense(1) def call(self, x_obs, mask, z, training=False): # x_obs: [B,T,1] 缺失处已置 0;mask: [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, training=training) return self.out(h) # [B,T,1] class RecursiveDiscriminator(tf.keras.Model): """逐步打分判别器:每个时刻一个 logit,梯度更稠密。""" def __init__(self, hidden=64): super().__init__() self.lstm = tf.keras.layers.LSTM(hidden, return_sequences=True) self.logit = tf.keras.layers.Dense(1) def call(self, x, mask, training=False): feat = tf.concat([x, mask], axis=-1) # 掩码作为提示通道 return self.logit(self.lstm(feat, training=training))

RecursiveGenerator.call的参数顺序决定了调用时的可读性,把观测值放第一位,是因为推理时只有它和掩码是真实的,噪声是外挂的。noise_proj把[B,1,noise_dim]投影后沿时间轴广播,等价于每个时刻喂同一段噪声——这样隐状态仍然要负责生成变化,而不是让噪声直接把答案念出来。

判别器保留return_sequences=True是关键,它决定了损失是逐步算的。如果把这里改成return_sequences=False,整个模型会退化成整段判别,前面的设计就白搭了。

4.2 一个 train_step 里的两个 tape 与更新顺序

def make_train_step(G, D, g_opt, d_opt, lambda_rec=10.0, clip=5.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, training=True) x_fill = mask * x_obs + (1.0 - mask) * x_hat # D 的两路前向:一路不回流到 G,一路要回流 d_real = D(x_real, tf.ones_like(mask), training=True) d_fake_d = D(tf.stop_gradient(x_fill), mask, training=True) d_fake_g = D(x_fill, mask, training=True) 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 次(k=2 或 3)、生成器更新一次,但 k 越大判别器越强,越容易把生成器按死。

优化器用 Adam,学习率 2e-4、beta_1=0.5,这是序列 GAN 里比较通用的配置,低beta_1能减少动量带来的震荡。

4.3 推理阶段:从噪声到完整序列的递归推演与填充评估

推理不能只采样一次。同一个观测上下文,不同噪声会给出不同的合理补全,单次采样的方差很大。做法是采多条再取中位数:

def impute(model, x_obs, mask, noise_dim=8, samples=32): """多次采样取逐点中位数,压住单次采样的方差""" 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, training=False) outs.append(mask * x_obs + (1.0 - mask) * x_hat) stack = tf.stack(outs, axis=0) # [S,B,T,1] mid = tf.gather(tf.sort(stack, axis=0), samples // 2) return tf.squeeze(mid, axis=0)

取中位数而不是均值,是因为对抗生成出来的样本偶尔会跑飞(某次采样补出一个量级不对的段),均值会被这种离群样本拽偏,中位数稳得多。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 换成 L1(L1 的最优解是条件中位数,天然鼓励非平滑输出);把 λ 从 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 曲线也不算数。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进