基于GAN-RNN与TensorFlow 2的时间序列缺失值插补实战
简介面向时间序列缺失值插补的学习者与数据科学从业者这份项目代码围绕递归卷积Seq2seq与生成对抗网络展开提供三种可对照的TensorFlow 2实现纯循环卷积Seq2seq、以Imputer欺骗判别器的GAN以及将重建误差与对抗损失合并的部分对抗模型。项目在Kaggle Wikipedia Web流量预测数据上训练适合已有RNN、CNN与seq2seq基础、希望理解GAN式插补损失设计与训练流程的中高级读者。压缩包约3.01MB上游未标注文件总数与类型明细按项目说明配置参数、预处理管道与.npy数据产物构成主要学习材料配置集中管理预处理、训练和测试参数预处理脚本负责生成可训练数据集便于按模块复现实验。已有1428人学习或下载。读者可据此梳理生成器与判别器的职责边界、对比三种损失结构的差异并借助配置与预处理流程搭建自己的缺失值插补实验理解从数据准备到模型训练测试的完整链路。1. 用 GAN 补缺失值这套 GAN-RNN 时间序列插补到底解决什么问题做时序预测的人大多撞过这种场面采集端断了几小时导出的 CSV 里有一列整段是空的用前向填充草草补上模型照样能训验证集误差却压不下去。GAN-RNN 时间序列插补这套东西就是冲这个场景来的整套实现在 TensorFlow 2 里完成生成器用递归网络把缺失段补出来判别器逐个时刻判断这段数据是补的还是真的训练素材取自公开的 Web 流量预测数据集里成千上万条真实访问曲线。它适合两类人手上有多传感器或埋点数据、要在残缺序列上继续做预测和异常检测的开发者以及想找一个结构完整、能直接跑通的对抗式插补代码拿来对照自己理解的人。工程不大但数据构造、模型定义、训练、评估这条链路都摆齐了改一改就能接自己的数据。2. 工程结构与技术栈从目录看 TensorFlow 2 的落地方式在动手跑之前先把目录翻一遍。这类小型研究工程最怕的就是文件命名随意等你要换数据集时找不到入口。这套项目的组织方式是比较典型的「脚本驱动 Notebook 观察」结构训练逻辑沉在 Python 脚本里Notebook 只负责看数据、调参和出图。2.1 目录结构与模块职责GAN-RNN_Timeseries-imputation/ ├── data/ # 原始与处理后的序列数据放这里不入版本库 ├── notebooks/ │ ├── 01_data_explore.ipynb # 看曲线形态、缺失分布、归一化前后的差异 │ └── 02_eval_visual.ipynb # 训练完加载权重画插补前后对比图 ├── gan/ │ ├── generator.py # 递归生成器RNN 主干 提示向量 │ ├── discriminator.py # 判别器含 CNN 变体 │ └── losses.py # 对抗项、重建项、权重配置 ├── utils/ │ ├── data_utils.py # 滑窗切分、归一化、缺失注入 │ └── metrics.py # MAE / RMSE 与基线对比 ├── train.py # 训练主入口参数走命令行 ├── config.py # 窗口长度、隐层维度、学习率等常量 └── requirements.txtgan/下面按网络角色拆文件这一点值得学。很多人写 GAN 喜欢把生成器、判别器和损失全塞进一个model.py等要做消融实验、对比 RNN 判别器和 CNN 判别器的时候就非常难受。拆开之后train.py只需要从配置里挑一个判别器类实例化换结构只改一行。utils/data_utils.py是另一个关键位置。时间序列插补和普通生成任务最大的区别就在数据构造上——你得人为把数据破坏掉再让模型去还原。缺失注入的策略、掩码的生成方式全在这个文件里后面调效果基本都在调它。2.2 技术栈与选型理由核心依赖就三样TensorFlow 2.x、NumPy/Pandas、Matplotlib。没有 PyTorch也没有额外的 GAN 库对抗训练是自己手写GradientTape完成的。为什么用 TF 2 的 Keras 子类化而不是Sequential因为生成器的前向不是线性堆叠输入要先拼接提示向量RNN 输出之后还要和原始观测做一次掩码融合才能得到最终插补结果。这种带条件分支和中间张量操作的结构用函数式 API 写会很别扭子类化tf.keras.Model并重写call最直接。训练循环用tf.function包一层。TF 2 默认 eager 执行写起来像 NumPy但对抗训练每步要算两次梯度图模式下能省不少时间。常见的坑是第一次调用时会触发 tracing日志里耗时特别长别以为是卡住了。优化器一般用 Adambeta_1设成 0.5 而不是默认的 0.9。这是 GAN 训练的常见做法——动量小一点判别器和生成器互相追的时候不容易震荡。2.3 环境准备与依赖安装Python 版本建议 3.8 到 3.10太新的版本有时和 TF 的 wheel 对不上。虚拟环境建好之后python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate pip install tensorflow2.12.0 # 有 GPU 就装 tensorflow别装 tensorflow-cpu pip install numpy pandas matplotlib jupyter pip install scikit-learn # 只用到它的归一化和指标不涉及模型装完跑一句自检确认 GPU 是否被识别import tensorflow as tf print(tf.__version__) print(tf.config.list_physical_devices(GPU))如果第二行输出空列表说明走的是 CPU。这套模型参数量不大CPU 也能训只是 64 长度窗口、64 批大小的配置下一个 epoch 大约要慢一个数量级。显存吃紧的时候先把 batch 降到 32再考虑减窗口长度——窗口变短会直接削弱 RNN 对周期性的建模能力能不降就不降。3. 核心模块拆解生成器、判别器与提示向量的对抗设计对抗式插补和普通 GAN 生图的差别在于生成器不是从噪声造出整段数据而是拿到一段「残缺但有结构」的输入只在缺失位置做决策。这就要求网络必须知道哪些位置是可信的观测、哪些位置是要它填的否则它会把已知数据也一起改掉。提示向量hint vector就是干这个的。3.1 生成器单向 RNN 加提示向量怎么补值生成器的主干是一个 GRULSTM 也行GRU 参数少、在中等长度序列上收敛更快。输入是两路拼接一路是掩码后的序列缺失位置填 0另一路是提示向量1 表示该时刻是真实观测0 表示需要生成。import tensorflow as tf from tensorflow.keras import layers class RecurrentGenerator(tf.keras.Model): def __init__(self, hidden_dim64, dropout0.2): super().__init__() # return_sequencesTrue每个时刻都要出一个值不是只出最后一步 self.rnn layers.GRU(hidden_dim, return_sequencesTrue, dropoutdropout) # 数据归一化到 [0, 1]所以输出层用 sigmoid 卡住范围 self.out layers.Dense(1, activationsigmoid) def call(self, x_masked, hint, trainingFalse): # x_masked: [B, T, 1] 缺失处已被置 0 # hint: [B, T, 1] 1真实观测0缺失待补 inp tf.concat([x_masked, hint], axis-1) # [B, T, 2] h self.rnn(inp, trainingtraining) x_hat self.out(h) # [B, T, 1] 纯生成值 # 关键一步已知位置保留原值只让模型决定缺失位置 x_imp hint * x_masked (1.0 - hint) * x_hat return x_imp, x_hatx_imp和x_hat都要返回因为它们在不同地方用。x_imp是「观测 补值」拼起来的完整序列送进判别器x_hat是模型对每个位置给出的原始输出算重建损失时用它否则已知位置的误差会被x_masked直接抹掉重建项就失去意义了。sigmoid这个选择跟数据预处理强绑定。如果归一化方式换成 z-score均值 0 方差 1输出层必须去掉激活函数否则负值全被截断模型会学出一个恒为正的偏置。这一点在换数据集的时候最容易翻车。参数上hidden_dim64是这套工程的默认值。窗口长度 64、数据只有单变量时64 维隐状态足够表达日周期和周周期如果你的序列是多通道的比如同时有流量、响应时间、错误率隐层提到 128 更稳妥但要注意判别器也得同步加宽不然两边容量不对等训练会立刻失衡。3.2 判别器逐时刻判别与 CNN 变体判别器的任务是判断传入的完整序列「像不像真的」。这里有个设计选择是整段出一个标量还是每个时刻各出一个判断项目里走的是后者——逐时刻输出 logit。原因是插补任务的监督信号本来就稀疏一段 64 步的窗口可能只有十几步是缺失的整段打一个分梯度传回生成器时会被大量正常位置稀释。class RecurrentDiscriminator(tf.keras.Model): def __init__(self, hidden_dim64, dropout0.2): super().__init__() self.rnn layers.GRU(hidden_dim, return_sequencesTrue, dropoutdropout) self.out layers.Dense(1) # 不加激活输出 logit配合 from_logitsTrue def call(self, x, hint, trainingFalse): inp tf.concat([x, hint], axis-1) # [B, T, 2] h self.rnn(inp, trainingtraining) return self.out(h) # [B, T, 1]判别器同样要吃 hint。这个细节常被忽略如果判别器只看数值不看掩码它会去区分「这段是不是被改动过」而不是「这段像不像真实分布」训练方向就跑偏了。工程里还带了一个 CNN 变体用一维卷积在时间轴上滑窗捕获局部形态比如流量曲线的尖峰和回落class ConvolutionalDiscriminator(tf.keras.Model): def __init__(self, filters32, kernel_size3, dropout0.2): super().__init__() self.conv1 layers.Conv1D(filters, kernel_size, paddingsame, activationrelu) self.conv2 layers.Conv1D(filters * 2, kernel_size, paddingsame, activationrelu) self.drop layers.Dropout(dropout) self.out layers.Conv1D(1, 1, paddingsame) # 1x1 卷积输出逐时刻 logit def call(self, x, hint, trainingFalse): inp tf.concat([x, hint], axis-1) h self.drop(self.conv1(inp), trainingtraining) h self.drop(self.conv2(h), trainingtraining) return self.out(h)paddingsame保证输出长度和输入一致这是逐时刻判别的前提。kernel_size3对应三个连续时刻的感受野对日周期数据来说偏小真正跑起来可以试 5 或 7让卷积看到更长的局部模式。CNN 版本训练更快但对长程依赖的建模不如 RNN——如果你的缺失段是连续几十步的整段断裂RNN 判别器给的回传信号更靠谱。3.3 损失函数对抗项、重建项与权重配比生成器的损失由两块组成骗过判别器的对抗项以及逼近真值的重建项。判别器则是标准的二分类损失。bce tf.keras.losses.BinaryCrossentropy(from_logitsTrue) def discriminator_loss(real_logits, fake_logits): real_loss bce(tf.ones_like(real_logits), real_logits) fake_loss bce(tf.zeros_like(fake_logits), fake_logits) return 0.5 * (real_loss fake_loss) def generator_loss(fake_logits, x_real, x_hat, hint, alpha10.0, miss_weight2.0): adv bce(tf.ones_like(fake_logits), fake_logits) # 希望判别器判成 1 # 缺失位置给更高权重已知位置权重为 1 w hint (1.0 - hint) * miss_weight rec tf.reduce_mean(w * tf.abs(x_real - x_hat)) return adv alpha * recfrom_logitsTrue是必须的因为判别器输出层没加 sigmoid。如果这里忘了改损失会一直卡在 0.69 附近——那是二分类在随机猜测时的理论值是个很典型的黑匣子现象。alpha控制对抗项和重建项的比重默认 10。这个值偏大意味着模型更偏向「忠实还原」对抗项主要起正则作用防止生成值塌缩到均值。想更激进地追求分布真实性可以降到 1 到 5但降太狠会出现插补值抖动、不合常理的锯齿。miss_weight2.0是缺失位置相对已知位置的误差权重。训练时我们其实知道缺失位置的真值是人为挖掉的所以重建项可以在全序列上算加权是为了让梯度集中在真正需要学的位置上。4. 训练与评估从缺失构造到 RMSE 复核模型定义只是骨架插补效果好坏八成取决于数据怎么被破坏、训练时两边怎么交替。这一章把可执行的步骤过一遍。4.1 缺失模式构造与数据流水线原始数据通常是一张宽表每行一条序列每列一个时间点。第一步是把长序列切成定长窗口第二步是注入缺失。import numpy as np def make_windows(series, window64, stride8): # series: [N, T] 多行多列的原始矩阵 windows [] for s in series: for i in range(0, len(s) - window 1, stride): windows.append(s[i:i window]) return np.asarray(windows, dtypenp.float32)[..., None] # [M, T, 1] def inject_missing(batch, miss_ratio0.2, moderandom): hint np.ones_like(batch) if mode random: # 随机点缺失模拟偶发丢包 mask np.random.rand(*batch.shape) miss_ratio elif mode block: # 连续段缺失模拟采集端停机 mask np.zeros_like(batch, dtypebool) L max(1, int(batch.shape[1] * miss_ratio)) for i in range(batch.shape[0]): start np.random.randint(0, batch.shape[1] - L) mask[i, start:start L] True hint[mask] 0.0 x_masked batch * hint return x_masked.astype(np.float32), batch, hint.astype(np.float32)stride8意味着相邻窗口重叠 56 步。重叠是刻意的插补任务样本量本来就不如分类任务充足重叠采样能显著增加有效样本。代价是评估时不能简单按窗口平均重叠部分会被重复计入后面算指标要按位置去重。mode参数决定训练难度。只用random训出来的模型碰上整段断裂会表现得很差因为它没见过连续缺失的形态。比较稳的做法是训练时两种模式各占一半比例通过 batch 级随机切换。归一化要放在切窗之前并且统计量只能从训练集算def normalize(train, valid, eps1e-6): # 先 log1p 压长尾再按训练集最大值缩放到 [0,1] tr np.log1p(train) va np.log1p(valid) scale np.percentile(tr, 99) eps # 用分位数而不是最大值抗离群点 return np.clip(tr / scale, 0, 1), np.clip(va / scale, 0, 1), scale用 99 分位而不是最大值是为了避免某一条异常爆量的序列把整体尺度拉飞导致绝大多数数据被压到 0.05 以下模型学不到东西。反归一化时记得把scale存下来评估阶段要用。4.2 交替训练循环两个优化器、两条梯度带这是对抗训练的标配写法BATCH 64 g_opt tf.keras.optimizers.Adam(2e-4, beta_10.5) d_opt tf.keras.optimizers.Adam(1e-4, beta_10.5) # 判别器学习率压低一档 tf.function def train_step(x_real, x_masked, hint): with tf.GradientTape() as g_tape, tf.GradientTape() as d_tape: x_imp, x_hat generator(x_masked, hint, trainingTrue) real_logits discriminator(x_real, hint, trainingTrue) fake_logits discriminator(x_imp, hint, trainingTrue) g_loss generator_loss(fake_logits, x_real, x_hat, hint) d_loss discriminator_loss(real_logits, fake_logits) # 注意两条 tape 分开求导不要合并 g_grads g_tape.gradient(g_loss, generator.trainable_variables) d_grads d_tape.gradient(d_loss, discriminator.trainable_variables) g_opt.apply_gradients(zip(g_grads, generator.trainable_variables)) d_opt.apply_gradients(zip(d_grads, discriminator.trainable_variables)) return g_loss, d_loss外层循环按 epoch 走tf.data.Dataset每 10 个 epoch 存一次权重。判别器学习率设为生成器的一半是应对「判别器太强」最直接的手段原理在下一章的排查里展开。有一点必须说明train_step里discriminator(x_imp, ...)这次调用是在d_tape的作用域里跑的但x_imp由生成器产生。这样写判别器能拿到梯度生成器那边也通过g_tape拿到了穿过判别器的梯度两边互不干扰是正确写法。有人会额外加tf.stop_gradient(x_imp)再喂给判别器那是另一种选择效果差别不大但两种写法不要混着用。4.3 插补质量评估与基线对比评估只看整体 MAE 是没有意义的——已知位置占了 80%模型就算什么都不学、原样输出整体误差也很低。指标必须只在真正注入缺失的位置上算。def imputation_metrics(x_true, x_hat, hint, eps1e-8): miss 1.0 - hint n tf.reduce_sum(miss) eps mae tf.reduce_sum(tf.abs(x_true - x_hat) * miss) / n rmse tf.sqrt(tf.reduce_sum(tf.square(x_true - x_hat) * miss) / n) return mae.numpy(), rmse.numpy() def baseline_forward_fill(x_masked, hint): # 用前一个有效观测填充最朴素的基线 out tf.identity(x_masked) last tf.zeros_like(x_masked[:, 0]) steps [] for t in range(x_masked.shape[1]): h hint[:, t] last h * x_masked[:, t] (1.0 - h) * last steps.append(last) return tf.stack(steps, axis1)每次改完模型先跑前向填充基线再跑模型两个数放一起看。如果模型只比基线好一点点多半是训练没到位或者归一化出了问题而不是模型结构不行。这套项目在随机点缺失比例 20% 的设置下RNN 版本的 MAE 一般能比前向填充低三到五成如果差距很小回头查 3.3 里的alpha和 4.1 里的缺失模式比例是否匹配。可视化也别省。取一条验证集序列把原始曲线、人为挖掉后的残缺曲线、模型插补曲线画在同一张图上缺失段用竖线标出来。数值指标看不出「趋势对了但幅度偏小」这种问题图上一眼就能发现。5. 避坑与常见问题排查这几处我确实翻过车对抗训练本身就带点玄学加上时序插补特有的掩码逻辑出问题的点比普通 GAN 更多。下面这几条是按出现频率排的。5.1 判别器损失掉到接近 0生成器完全不动现象训练日志里d_loss头几十步就掉到 0.01 以下并一直贴着g_loss不涨不跌插补结果全是接近均值的平线。原因判别器容量或学习率相对生成器过强两边失去平衡。判别器几乎完美区分真假之后生成器收到的梯度趋近于零等于没在训练。解决把判别器学习率降到生成器的一半工程里 2e-4 / 1e-4 的配置就是为此判别器隐层维度不要超过生成器每步只更新一次判别器必要时让生成器更新两次。另外可以把逐时刻判别器输出的 logit 做一点标签平滑tf.ones_like(real_logits) * 0.9替代全 1能明显减缓判别器收敛速度。5.2 生成器输出全为均值曲线变得很平现象插补出来的段在图上是一条几乎水平的线位置大致在缺失段两侧值的平均附近缺失段越长越明显。原因重建损失权重过大模型发现输出均值能让 L1 损失最小干脆放弃对抗项。这在alpha设得太高或者缺失比例很低时特别容易发生。解决把alpha从 10 往下调先试 5 再到 2同时观察训练曲线——g_loss里的对抗项应该有一定波动一直平稳说明没起作用。另一个办法是给判别器加逐时刻信号密度用 3.2 里的逐时刻判别器而非整段判别梯度回传更细模型不容易塌到均值。5.3 训练一切正常推理结果却和输入对不上现象训练时损失曲线漂亮加载权重跑推理已知位置的数值居然被改了插补结果和原始观测完全错位。原因掩码约定在训练和推理两处不一致。训练里 hint1 表示观测推理时如果传成了 0生成器会把已知值当成缺失值重新生成或者拼接顺序写反先 hint 后数据模型学到的是错位的输入。解决写一个单元级的自检函数传入全 1 的 hint断言输出必须严格等于输入def sanity_check(gen, x): hint tf.ones_like(x) x_imp, _ gen(x, hint, trainingFalse) tf.debugging.assert_near(x_imp, x, atol1e-6)这个检查花不了几秒钟但能挡掉一大类低级错误。我现在的习惯是每次改完call方法都先跑一遍再开训练。5.4 用 0 表示缺失但数据里本来就有 0现象某些序列插补结果全是 0或者明明有观测的位置被当成缺失补掉了。原因拿数值 0 当缺失占位符。Web 流量类数据在深夜低峰确实会出现 0模型没法区分「真的没访问」和「没采到」。解决缺失状态必须单独用一个布尔数组或 hint 数组承载绝不能用数值代替。inject_missing里x_masked batch * hint这一步本质上也把缺失位置变成了 0所以 hint 必须同步保存并一路传下去任何时候都不能靠「判断值是不是 0」来还原掩码。5.5 评估指标看着不错但缺失段一长就崩现象整体 MAE 很低可是单独挑出连续缺失 20 步以上的样本看插补曲线形状完全不对。原因评估集和训练集的缺失模式分布不一致。训练只用了随机点缺失评估里混进了整段断裂模型没见过这种形态。或者指标是在全序列上算的被大量已知位置稀释了。解决训练时随机点缺失和连续段缺失各占一半评估时按缺失长度分桶统计把短缺失和长缺失的指标分开报。这一步做完往往能立刻看出模型的能力边界在哪里。6. 二次开发换数据集、换结构、换损失的改造路径拿到手第一件事我一般不是跑默认配置而是先确认「换成我自己的数据要改哪几处」。这套工程的可改点集中得比较好主要有三层。6.1 接入自有数据的三处改动第一处是utils/data_utils.py里的加载函数。把宽表读成[N, T]的浮点矩阵NaN 先按列用中位数填一遍只是为了让归一化能算真正训练时这些位置会被重新挖掉然后走normalize和make_windows。第二处是config.py里的窗口长度。日周期数据 64 步够用但如果是分钟级采样、需要捕捉周内节律窗口得拉到几百。窗口一变hidden_dim也要跟着调经验比例是窗口长度的 1 到 2 倍左右但别超过 256否则小数据集上会过拟合。第三处是输出激活。归一化方式如果从 min-max 换成 z-score3.1 里生成器输出层的sigmoid必须去掉同时x_imp的融合逻辑不变。改完立刻跑 5.3 里那个自检函数。6.2 结构层面从单向 RNN 到 Seq2Seq 与 CNN 混合想提升长缺失段的插补质量方向之一是把生成器换成编码器-解码器结构编码器把整段观测压成隐状态解码器在缺失区间上逐步生成。这样后段的生成能用到前段的全部信息比单向 RNN 边读边写更合理。改动量主要在generator.py损失函数和训练循环完全不用动。另一个方向是混合结构——用 CNN 抽局部形态特征再接 RNN 建模长程依赖。实现上就是在 GRU 前面加一层Conv1D(filters32, kernel_size5, paddingsame)然后接layers.GRU(hidden_dim, return_sequencesTrue)。这个组合在中长窗口上通常比纯 RNN 稳定收敛也快一些。6.3 怎么确认改造没白改每次改动我只认三个对照同一个随机种子下与前向填充基线的 MAE 对比、按缺失长度分桶的误差曲线、以及至少三条真实序列的可视化。前两个看数值第三个看形态。消融实验要控制变量。换了生成器结构判别器和损失权重就别动调了alpha结构保持不变。一次改两处结果变好变坏都说不清原因这是血泪经验。import numpy as np def fixed_seed(seed42): np.random.seed(seed) tf.random.set_seed(seed) # 每组实验开跑前固定一次保证缺失注入的随机掩码可复现 fixed_seed()掩码是随机的不固定种子的话两次跑的评估集都不一样指标差异可能比模型改动本身还大。我现在的习惯是每次开新实验前先钉死种子跑完把配置和三项指标一起记到实验表里隔一周回头看才知道当时到底改了什么。这套流程看着笨但省下来的返工时间远超记录成本。希望帮到你。本文还有配套的精品资源点击获取