递归GAN时间序列缺失值插补:TensorFlow 2工程实战

发布时间:2026/11/4 2:46:17
递归GAN时间序列缺失值插补:TensorFlow 2工程实战

简介:这份项目面向需要处理时间序列缺失值的数据科学学习者与算法工程师,核心是用递归卷积Seq2seq网络完成插补,并在TensorFlow 2中实现循环卷积seq2seq、GAN以及部分对抗三种配置。项目在Kaggle维基百科网页流量预测数据上训练,涉及生成器与判别器对抗、真实误差损失与对抗损失组合等设计,适合希望理解时间序列插补、GAN训练流程和TF2工程组织的中高级读者。压缩包约3.01MB,文件总数标注为0,类型明细暂未提供;从描述可见包含config.yaml配置参数,以及main_processing.py等预处理管道入口,便于按配置复现数据处理、训练与测试流程。已有1428人学习或下载。读者可据此梳理从.npy数据准备、模型配置到训练评估的完整链路,并参考三种模型的损失设计与目录组织,形成可迁移的缺失值插补实验方案和排错思路。

1. 时间序列缺失值插补中的递归GAN:一份可直接跑的 TF2 工程

时间序列里的缺失值,绝大多数团队的处理方式是删掉或者填均值。删掉会丢上下文,填均值会让下游预测模型学到一个虚假的平滑结构——模型在训练集上看着还行,一上线遇到真实波动就垮。这份 GAN-RNN_Timeseries-imputation 工程走的是第三条路:把 RNN 当作生成器和判别器,用对抗训练去学整段序列的分布,再在缺失位置上把值“生成”出来。整套代码在 TensorFlow 2 下用 Jupyter Notebook 分步组织,数据集选的是 Kaggle 的 Wikipedia Web Traffic,天然带缺失、带长尾、带多序列并发,比人工造的数据更接近真实场景。适合已经会用 Keras 搭 LSTM、但一碰到缺失值就只能fillna(0)的从业者,也适合想把 GAN 从图像领域迁移到时序的人。

2. 数据与掩码工程:把 Wikipedia Web Traffic 切成可训练的窗口

在搭建模型之前,先把缺失值插补问题的输入输出格式固定下来,否则后面每一处张量形状都要重新对齐一遍。这一段针对的是 Web 流量数据:横轴是按天的访问量,纵轴是几百上千个页面序列,每个页面一条曲线,量级从个位数到百万级不等。

2.1 从宽表到 (T, N) 矩阵,以及两次必要的数值变换

原始 CSV 是“一个页面一行、后面几百个日期列”的宽表结构,第一列是页面名,其余列是逐日访问量。直接按行读进来会得到一个极扁的矩阵,RNN 没法用。需要先转置成时间在主轴的 (T, N) 形状,T 是日期数,N 是页面序列数。

import numpy as np import pandas as pd # 只读需要的列,dtype 显式指定 float32,几百列宽表用默认 float64 会白吃一倍内存 df = pd.read_csv("train_1.csv", dtype={0: "string"}) date_cols = df.columns[1:] mat = df[date_cols].to_numpy(dtype=np.float32).T # (T, N) # 访问量是长尾计数,先压量级再归一化,否则少数百万级页面会主导损失 mat = np.log1p(np.clip(mat, 0, None)) # 关键:切分在前,统计量在后。min/max 只能用训练段算,否则验证段信息泄漏进 scaler split = int(mat.shape[0] * 0.8) train_part = mat[:split] lo = np.nanmin(train_part, axis=0) hi = np.nanmax(train_part, axis=0) span = np.where(hi - lo < 1e-6, 1.0, hi - lo) # 常数序列防除零 mat_norm = (mat - lo) / span # 缺失位置仍是 NaN # 缺失位置先填 0,真正的“是否观测到”由后面的 mask 表达 mat_filled = np.nan_to_num(mat_norm, nan=0.0)

这段代码做三件事:转置、压缩量级、按训练段归一化。log1p不是可选项,Web 流量是典型的长尾分布,同一批序列里既有日访问几十的冷门页面,也有日访问几十万的头部页面,不做对数变换的话,MSE 类损失基本只对头部序列敏感,长尾序列的插补质量会烂得没法看。np.clip(x, 0, None)放在log1p之前是防止原始数据里出现极小负值(部分统计口径下会出现),一旦log1p吃到小于 -1 的输入就变 NaN,而且这个 NaN 会一路传染到 loss 里,报错信息还不会告诉你源头在哪。

split必须是按时间顺序切,不能随机切。时间序列随机切分会让窗口之间交叉污染,验证集指标虚高。span那行是给常数序列兜底的:某个页面如果整段都是同一个值,hi - lo为 0,后面一除就是 inf,训练时表现为 loss 突然变 NaN,排查起来相当费时间。

2.2 滑动窗口与掩码构造:让模型只对“洞”负责

GAN 训练需要成批的定长样本,所以要把长序列切成固定窗口。窗口长度和步长决定了样本量与上下文长度之间的取舍:窗口太短,模型看不到足够的历史去判断该填什么;窗口太长,GRU 的梯度传播和显存都吃不消。实践中 48 到 96 步是个比较稳的起点,步长取窗口的一半可以保证相邻窗口有重叠,边界处的插补不会因为“刚好切在洞中间”而失真。

def make_windows(mat, window=48, stride=24): starts = range(0, mat.shape[0] - window + 1, stride) return np.stack([mat[s:s + window] for s in starts]).astype(np.float32) # 返回 (S, T, N),S 是窗口数 def build_mask(shape, mr=0.2, block_len=(3, 12), seed=42): """mr: 总缺失比例;block_len: 连续缺失段长度范围""" rng = np.random.default_rng(seed) B, T, N = shape mask = np.ones(shape, dtype=np.float32) # 1=观测到,0=缺失 # 连续块缺失:模拟“采集中断”,按特征独立施加,更贴近真实故障 n_block = max(1, int(mr * T / np.mean(block_len))) for b in range(B): for n in range(N): for _ in range(n_block): L = int(rng.integers(block_len[0], block_len[1] + 1)) s = int(rng.integers(0, T - L)) mask[b, s:s + L, n] = 0.0 # 随机点缺失:模拟偶发丢点,比例约为总缺失的一半 mask[rng.random(shape) < mr * 0.5] = 0.0 return mask X = make_windows(mat_filled) M = build_mask(X.shape, mr=0.2)

make_windows里没有做 padding,末尾不足一个窗口的尾部直接丢掉,这是有意的——补零窗口会引入一批“整段都是 0”的假样本,判别器很容易学会“看到全零就判假”,然后生成器被迫去生成非零值,插补结果整体偏移。

build_mask的返回值是插补任务的真正监督信号。注意它是按特征独立打洞的,同一个时间点上,A 页面缺失不代表 B 页面也缺失,这符合“不同页面的统计链路各自出问题”的现实。块长度参数block_len=(3, 12)决定了模型要能补多长的连续空洞;如果你的业务里断线是以天为单位、动辄一两周,这个范围要相应放大,否则训练时的缺失模式和上线时对不上,指标会掉得莫名其妙。

提示:验证集和测试集的 mask 必须固定 seed 生成一次然后复用。每次评估重新随机打洞,指标会在几个点之间来回跳,你会误以为模型训练不稳定。

2.3 三种缺失模式,以及为什么不能直接用原始 NaN 当掩码

原始数据里本来就有空值,看起来可以直接拿isnan当 mask 用,省掉模拟这一步。这个想法在实际跑的时候会翻车:这些空值不是随机产生的,长尾页面的空值往往成段出现,甚至整段为 0,模型很快学会“看到一片空就输出 0”,在验证集上的 RMSE 好看得离谱,但那是它在抄近路,不是真的会插补。

所以训练用的 mask 必须自己造。常见的做法是三种模式混合:

缺失模式构造方式模拟的现实场景对模型的要求
随机点缺失逐元素按概率置 0单点采集失败、上报丢失依靠前后邻点插值
连续块缺失随机起点 + 固定长度段置 0采集链路中断、设备离线依靠长程依赖和序列分布
整段尾部缺失窗口末尾连续置 0数据回补延迟外推能力,最难

混合比例一般让块缺失占大头(六到七成),点缺失占小头。全部用点缺失的话,模型只要学会线性插值就能拿高分,GAN 的对抗部分基本不起作用,等于白训。全部用长块缺失又会让训练初期生成器完全找不到方向,重构损失居高不下,训练曲线一片混沌。我在自己的实验里习惯先用mr=0.1跑通全流程,确认 loss 正常下降之后再拉到 0.2 到 0.3 做正式训练,这个顺序能省掉大量“到底是模型不行还是任务太难”的纠结。

掩码准备好之后,喂给模型的输入是x_tilde = x * mask,也就是观测位置保留真值、缺失位置置 0,同时把 mask 本身也作为特征拼进去。这个拼接动作是整套结构的关键,下一章展开。

3. 生成器与判别器:RNN 结构怎么搭、张量怎么流

递归 GAN 和图像 GAN 最大的区别在于:图像生成器输出一整张图,判别器看整体;时间序列的生成器要逐时刻输出,判别器必须能感知“哪一段是补的、补得连不连贯”。这一章把两边的网络结构、张量形状和损失配比讲清楚,形状对不上是新手最常卡住的地方。

3.1 生成器:两层 GRU + 掩码拼接 + 输出合成

生成器的输入不是单纯的序列,而是“带洞序列 + 掩码”在特征维上的拼接。原序列有 N 个特征,拼接后每个时间步是 2N 维,RNN 在时间轴上递归,输出仍然是每个时间步 N 维的插补值。

import tensorflow as tf class ImputeGenerator(tf.keras.Model): def __init__(self, n_features, hidden=64, dropout=0.1): super().__init__() self.gru1 = tf.keras.layers.GRU(hidden, return_sequences=True, dropout=dropout) self.gru2 = tf.keras.layers.GRU(hidden, return_sequences=True) self.out = tf.keras.layers.Dense(n_features, activation="sigmoid") def call(self, x_tilde, mask, training=False): # x_tilde: (B, T, N) 缺失位置已置 0;mask: (B, T, N) h = tf.concat([x_tilde, mask], axis=-1) # (B, T, 2N) h = self.gru1(h, training=training) h = self.gru2(h, training=training) y = self.out(h) # (B, T, N) 候选插补值 # 合成:观测位置用原值,缺失位置用生成值 return mask * x_tilde + (1.0 - mask) * y

结构本身不复杂,重点在最后那行合成。如果没有这行,生成器要同时负责“保留观测点”和“补全缺失点”,两个目标互相拉扯,训练早期会出现观测位置被改得面目全非、重构损失怎么都降不下去的情况。加上这行之后,观测位置的误差恒为 0,梯度只会从缺失位置回传,任务边界非常清晰。

hidden=64对 N 在几百量级、T 在 48 到 96 的场景够用;再往上加收益递减,显存倒是涨得很快。dropout只加在第一层 GRU 上,第二层保持干净,因为第二层的输出直接进 Dense 出结果,加 dropout 会让插补值带着噪声,评估时 RMSE 会莫名偏高。输出层用sigmoid是因为前面把数据归一化到了 [0, 1];如果你的预处理用的是 [-1, 1],这里要换成tanh,混用会导致生成值被硬压在一侧,插补曲线整体偏低或偏高,是非常隐蔽的一类 bug。

3.2 判别器:为什么要看掩码,以及它可能走的捷径

判别器负责判断“这段序列是真实的还是生成器补出来的”。最朴素的实现是直接把序列喂进 GRU 再出一个标量,但这种做法在时序插补任务上有明显缺陷:生成器输出里只有缺失段是它自己造的,观测段是原样搬运的,判别器只要检测“哪一段数值上不连续、哪一段过于平滑”就能分辨真假,根本不需要学序列的整体分布。

把 mask 一起喂给判别器,等于告诉它“这些位置是补的,你重点看这些位置补得合不合理”。这是 GAIN 那一类工作的核心思路,在时序上同样成立。

class SeqDiscriminator(tf.keras.Model): def __init__(self, hidden=64): super().__init__() self.gru = tf.keras.layers.GRU(hidden) # 只取最后一步的隐状态 self.fc = tf.keras.layers.Dense(1) # 注意:不接 sigmoid def call(self, x, mask, training=False): h = self.gru(tf.concat([x, mask], axis=-1), training=training) return self.fc(h) # (B, 1) 未归一化分数

末端不接sigmoid是因为后面用的是 WGAN 形式的损失,判别器输出的是 Wasserstein 距离的打分而不是概率。如果这里误加了sigmoid,配上 WGAN 的损失函数,梯度会在饱和区被压成接近 0,表现为判别器损失几乎不动、生成器完全不更新,而且不会报任何错。

但给判别器看 mask 也带来了新的捷径风险:它可以只统计 mask 里 0 的比例来判断真假——真样本的 mask 也是随机造的,比例分布和假样本可能不同。解法是训练判别器时对真样本施加同样的 mask 逻辑,也就是真样本也走一遍“遮住再喂”的流程,两边 mask 分布一致,捷径就没了。

D 的输入方案判别器能学到什么风险
只喂 x局部平滑度、数值跳变生成器学会“补得平滑”而非“补得对”
喂 x + mask缺失位置的合理性可能靠 mask 比例走捷径
喂 x + mask + 真样本同分布 mask缺失位置的合理性实现稍复杂,但最稳

第三种是实际训练时要落到的形态,代码上只是多一行:真样本分支也调用同一个build_mask生成新掩码,而不是复用假样本的掩码。

3.3 损失配比:对抗项、重构项、一致性项各管什么

生成器的总损失由三块组成,各自解决一个问题。对抗项让生成值落在真实分布内,重构项让生成值逼近真值,一致性项防止生成器在观测位置乱改。三项的权重配比决定了模型最终是“补得像”还是“补得真”。

def generator_loss(d_fake, x_real, x_gen, mask, lam_rec=10.0, lam_con=1.0): adv = -tf.reduce_mean(d_fake) # 对抗项:骗过判别器 # 重构项:只在缺失位置算,分母用缺失点数做归一化,避免不同 mask 比例下量纲漂移 miss = 1.0 - mask rec = tf.reduce_sum(tf.abs(x_gen - x_real) * miss) / (tf.reduce_sum(miss) + 1e-8) # 一致性项:观测位置理论上误差为 0,这里作为数值兜底 con = tf.reduce_sum(tf.abs(x_gen - x_real) * mask) / (tf.reduce_sum(mask) + 1e-8) return adv + lam_rec * rec + lam_con * con def discriminator_loss(d_real, d_fake): return tf.reduce_mean(d_fake) - tf.reduce_mean(d_real) # WGAN 形式

lam_rec=10.0这个量级不是随手写的。对抗项的数值范围通常在个位数以内,重构项如果不放大,前几个 epoch 里对抗梯度会直接压过重构梯度,生成器迅速塌陷成“输出全 0.5”之类的退化解——因为那样判别器最难分辨,但插补结果毫无意义。把重构项放大一个量级,等于强制生成器优先保证“补得接近真值”,对抗项只负责在接近真值的候选里挑更像真实分布的。

lam_con=1.0看着多余,因为合成公式已经保证了观测位置不变。留着它是为了兜住一种情况:如果你后续改结构,把合成那行去掉或者改成软合成(用学出来的门控权重),观测位置就会开始漂,这时候一致性项是唯一能拦住它的东西。分母里的+1e-8是防全观测或全缺失的极端窗口除零,这类窗口在数据末尾很常见。

4. 训练循环实写:两阶段预热、WGAN 裁剪与检查点

结构与损失定好之后,真正决定能不能训出来的是训练流程。递归 GAN 直接端到端对抗训练几乎必然失败,必须先把生成器预热到一个“能补个大概”的水平,再引入判别器。这一章给出可直接跑的两阶段循环。

4.1 第一阶段:只用重构损失预热生成器

预热阶段判别器不参与,生成器只优化缺失位置上的 L1 误差。目标不是补得准,而是让它脱离随机初始化,输出一个大致在合理范围内的值。

opt_g = tf.keras.optimizers.Adam(1e-3) @tf.function def pretrain_step(x_real, mask): x_tilde = x_real * mask with tf.GradientTape() as tape: x_gen = G(x_tilde, mask, training=True) miss = 1.0 - mask loss = tf.reduce_sum(tf.abs(x_gen - x_real) * miss) / (tf.reduce_sum(miss) + 1e-8) grads = tape.gradient(loss, G.trainable_variables) grads = [tf.clip_by_norm(g, 1.0) for g in grads] # RNN 上必加 opt_g.apply_gradients(zip(grads, G.trainable_variables)) return loss

tf.clip_by_norm(g, 1.0)在纯 L1 阶段就已经很必要。GRU 的梯度在长窗口上容易爆,尤其是序列里有连续大值时,不加裁剪会出现 loss 突然从 0.0x 跳到几百,然后一路 NaN。加了裁剪之后曲线会平缓很多,代价是收敛略慢,值得换。

预热轮数不用多,五到十个 epoch 足够,判断标准是验证集上缺失位置的 L1 不再明显下降。继续训下去意义不大,因为 L1 单独优化的最优解就是条件中位数,生成器会朝着“所有缺失都填中位数”收敛,那正是我们要靠对抗项打破的东西。

4.2 第二阶段:交替对抗,判别器裁剪与更新节奏

进入对抗阶段后,每一步先更新判别器,再更新生成器。判别器用 WGAN 的权重裁剪而非梯度惩罚,主要原因是 RNN 上算梯度惩罚要对每个样本求梯度的梯度,显存和时间都吃不消,裁剪虽然糙但够用。

opt_d = tf.keras.optimizers.Adam(1e-4, beta_1=0.5, beta_2=0.9) @tf.function def train_step(x_real, mask): # ---- 1. 判别器更新 ---- with tf.GradientTape() as tape_d: x_gen_d = G(x_real * mask, mask, training=False) # 不记录 G 的梯度 d_real = D(x_real, mask, training=True) d_fake = D(tf.stop_gradient(x_gen_d), mask, training=True) loss_d = discriminator_loss(d_real, d_fake) grads_d = tape_d.gradient(loss_d, D.trainable_variables) opt_d.apply_gradients(zip(grads_d, D.trainable_variables)) for w in D.trainable_variables: # WGAN 权重裁剪 w.assign(tf.clip_by_value(w, -0.01, 0.01)) # ---- 2. 生成器更新 ---- with tf.GradientTape() as tape_g: x_gen = G(x_real * mask, mask, training=True) d_fake_g = D(x_gen, mask, training=True) loss_g = generator_loss(d_fake_g, x_real, x_gen, mask) grads_g = tape_g.gradient(loss_g, G.trainable_variables) grads_g = [tf.clip_by_norm(g, 1.0) for g in grads_g] opt_g.apply_gradients(zip(grads_g, G.trainable_variables)) return loss_d, loss_g

判别器分支里那句G(..., training=False)配合tf.stop_gradient是必须的。少了stop_gradient,判别器的梯度会顺着x_gen回传到生成器的变量上,虽然apply_gradients只更新 D 的参数,但计算图会多算一遍 G 的反向,显存直接翻倍,而且这一步的 G 梯度是脏的,会干扰后续统计。这个坑不会报错,只会表现为显存莫名其妙不够用。

beta_1=0.5是 GAN 训练的常见设定,一阶动量调低能让判别器对生成器分布的变化反应更快。裁剪阈值0.01是 WGAN 原论文的量级,如果你的损失尺度差得远,可以试0.05,但不要超过0.1,否则判别器表达能力过强,生成器又被压死。更新节奏上一开始用 1:1 就够,如果发现判别器损失迅速跑到一个很大的负值且不再变化,说明 D 太强,把 D 的学习率降到 5e-5 或者改成每两步更新一次 G。

几个关键超参放在一起对照:

参数取值作用与调整方向
window48窗口越长上下文越足,显存越紧
batch_size32~64太小判别器噪声大,太大显存吃紧
hidden64G 和 D 可以不同,D 通常可以更小
lr_g1e-4比预热阶段降一个量级
lr_d1e-4震荡时降到 5e-5
n_critic1RNN 上不建议升到 5,太慢
clip_value0.01D 太强就调小,太弱调大
lam_rec10插补偏离真值时加大

4.3 评估:只在缺失位置算误差,并做一次下游验证

评估方式直接决定你会不会被自己的指标骗。全窗口 RMSE 是没有意义的,因为观测位置被合成公式原样保留,误差恒为 0,算出来的数值只反映缺失比例。必须只在mask == 0的位置上算。

def evaluate(G, X, M, batch=64): mae, rmse, cnt = 0.0, 0.0, 0 for i in range(0, len(X), batch): xb, mb = X[i:i + batch], M[i:i + batch] pred = G(xb * mb, mb, training=False) diff = (pred - xb) * (1.0 - mb) # 只保留缺失位置 mae += tf.reduce_sum(tf.abs(diff)).numpy() rmse += tf.reduce_sum(tf.square(diff)).numpy() cnt += tf.reduce_sum(1.0 - mb).numpy() return mae / cnt, np.sqrt(rmse / cnt)

除了 RMSE 和 MAE,还要跟几个基线对比:均值填充、前向填充、线性插值。递归 GAN 如果在某个缺失率下打不过线性插值,说明对抗部分没起作用,或者预训练过头把生成器锁死在中位数解上,这时候回去调lam_rec比继续加训练轮数有用。

更硬的一道验证是下游任务。把插补后的完整序列拿去做下一步预测,用一个小 LSTM 训几轮,比较不同插补方式下的预测 MAE。插补指标好看但下游崩掉的情况非常常见,通常意味着模型把缺失段补成了“统计上合理但动力学上错误”的形状——比如把一个下降趋势补成了水平线。这一步多花二十分钟,能省掉上线后返工的两周。

5. 避坑与排查:五条把模型训崩的血泪记录

递归 GAN 的训练过程没有任何一步会明确告诉你“这里错了”,所有问题都表现为 loss 曲线难看或者指标不动。这一章把我自己踩过、也在别人代码里见过的五类问题按“现象 → 原因 → 解决”整理出来,对着查能省不少时间。

5.1 生成器输出全是一个值

现象:训练几十步之后,随便取一个窗口看插补结果,缺失段全被填成 0.47 之类的同一个数,重构损失停在一个平台上不再下降。

原因:判别器在早期太强,生成器的对抗梯度方向始终指向“输出分布中心”,因为那样判别器最难打分。重构项的权重不足以把它拉回来,生成器就退化成了条件中位数解。

解决:三步一起上。先把lam_rec从 10 提到 30,让重构项重新占主导;再把判别器学习率降到生成器的一半;最后检查预热阶段是不是训太久,如果预热后生成器已经收敛到中位数,对抗阶段很难再把它拽出来,把预热轮数砍到三轮重跑。判断是否恢复的标准是看插补结果的方差,正常应该在训练中缓慢上升而不是贴着 0。

5.2 判别器损失一路狂跌到负值后不动

现象:loss_d从 0 附近迅速掉到 -0.5 以下并且横盘,loss_g同步飙升,插补结果开始出现极端值。

原因:判别器过强。WGAN 的判别器损失理论上可以无限小,说明真样本和假样本已经被完全分开,生成器拿到的梯度信号失去区分度。常见诱因是判别器 hidden 比生成器大、或者学习率配比失衡。

解决:把判别器的 hidden 降到 32,clip_value从 0.01 收到 0.005,n_critic保持 1 不要加。另外确认真样本分支用的是新生成的 mask 而不是复用假样本的 mask——复用会让判别器直接通过 mask 分布区分真假,属于典型的信息泄漏,表现出来和“判别器太强”一模一样。

5.3 验证集 RMSE 很好,下游预测却崩了

现象:插补指标比线性插值好一大截,但拿插补后的序列去训预测模型,MAE 反而比直接前向填充更差。

原因:模型学会了在缺失段生成“平滑但方向错误”的值。L1 重构损失的最优解偏向中位数,连续缺失块越长,补出来的段越平,整段趋势被抹掉。指标上因为逐点误差不大,看不出来。

解决:在重构项里加入一阶差分惩罚,让生成段的变化率贴近真实序列的变化率。

def smooth_penalty(x_gen, mask): # 只惩罚缺失区域内部相邻点的一阶差分,避免把观测段也拉平 d = tf.abs(x_gen[:, 1:, :] - x_gen[:, :-1, :]) w = (1.0 - mask[:, 1:, :]) * (1.0 - mask[:, :-1, :]) return tf.reduce_sum(d * w) / (tf.reduce_sum(w) + 1e-8)

权重从 0.1 起调。加了这个惩罚之后,逐点 RMSE 通常会略微变差,但下游预测指标会明显改善,这个取舍是值得的。

5.4 归一化统计量用错了范围

现象:训练集指标正常,验证集指标突然很差,而且差得很均匀,不像过拟合。

原因:lo和hi是在全量数据上算的,包含了验证段的信息。更隐蔽的一种情况是窗口切分在归一化之前做,窗口跨越了训练段和验证段的边界,导致同一批窗口里混着两个时期的数据。

解决:严格按顺序来——先按时间切分原始矩阵,再在训练段上算 min/max,再对全量做变换,最后切窗口。另外检查make_windows的调用位置,它必须作用在已经切分好的train_part和valid_part上,而不是作用在整条序列上之后再切。

5.5 显存爆炸与训练速度异常

现象:跑到第二个 epoch 显存突然涨满,或者每步耗时从 0.2 秒变成 2 秒。

原因:多半是@tf.function的图重复构建。当输入形状在批次之间变化(最后一个 batch 不足 batch_size)时,tf.function会为每个新形状重新 trace 一遍计算图,显存一路累积。另一个常见原因是判别器分支里忘了stop_gradient,反向图里多存了一份生成器的中间激活。

解决:把drop_remainder=True加到tf.data的 batch 上,保证形状恒定;判别器分支里的tf.stop_gradient逐行检查一遍;tf.data流水线上加.prefetch(tf.data.AUTOTUNE)和.cache()(数据能装进内存的前提下)。这三处改完,同样的 batch 大小下显存通常能降三到四成。

6. 进阶验证:用缺失率扫描和二次插补检查模型的自洽性

单点指标只能告诉你“这个模型在这个缺失率下表现如何”,没法告诉你“它能扛到多高的缺失率”。我的习惯是做一个缺失率扫描:固定验证集窗口,mr从 0.1 扫到 0.5,每个档位重新生成一次固定 seed 的 mask,记录 RMSE 和下游预测 MAE。

mr插补 RMSE下游预测 MAE备注
0.1基准基准与线性插值差距应明显
0.2略升略升训练配置对应的档位
0.3明显上升明显上升关注拐点位置
0.4陡升陡升超过此档通常需要重训
0.5接近失效接近失效序列上下文已被破坏

数值本身随数据和 seed 变化,看的是曲线形状。健康的模型在 0.1 到 0.3 之间曲线平缓,0.3 之后开始加速上翘;如果 0.15 就开始陡升,说明模型对上下文长度依赖过强,窗口要加长或者训练时的block_len上限要放大。

另一个更便宜的检查是二次插补自洽性:

def consistency_check(G, X, M, seed=7): # 先用原 mask 插补一次,得到完整序列 full = G(X * M, M, training=False) # 再对插补后的序列重新随机打洞,用同一模型补第二次 M2 = build_mask(X.shape, mr=0.2, seed=seed) full2 = G(full * M2, M2, training=False) # 两次结果在共同缺失位置上的差异,越小说明模型越稳定 both = (1.0 - M) * (1.0 - M2) diff = tf.reduce_sum(tf.abs(full - full2) * both) / (tf.reduce_sum(both) + 1e-8) return diff.numpy()

这个值没有绝对标准,但它对模型状态非常敏感。我见过一次训练中途 loss 一切正常、验证 RMSE 也在缓慢改善,但二次插补差异突然从 0.03 跳到 0.2,回去查发现是判别器裁剪阈值设太大导致生成器开始震荡。从那以后我每次保存 checkpoint 前都强制跑一遍这个检查,差异超过前一个 epoch 的两倍就不保存,宁可回滚。这套流程救过我两次,一次是误改了学习率,一次是数据管道里混进了 NaN 窗口。

这套 notebook 按数据准备、结构定义、训练、评估的顺序拆开,改参数的地方都集中在单元格顶部,跑通一遍再动手改比直接读代码快得多。希望帮到你。

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