最近在尝试做时间序列数据的缺失值填补,由于数据的缺失率很高(80% 以上)且这一部分预计是研究的重点和亮点,想到了用深度学习方法来做填补。在网上搜索资料发现,现有的模型很多都使用了 GAN 框架,比如 GAIN (ICML 2018),E2GAN (NIPS 2018, IJCAI 2019),这些都是发表在计算机顶会上的成果。后者参考资料比较多,我打算用这个框架进行尝试,这也是我第一次正儿八经地做模型复现。幸运的是,我找到了论文一作的GitHub 项目,不至于从零开始。但这个项目基于 Python 2.7 和 Tensorflow 1.7,有些陈旧了。我参考源码和论文,按个人理解用 Python 3和Pytorch 复现了这一模型结构。
E2GAN总体框架
模型结构
和大部分 GAN 一样,E2GAN 也是由一个生成器(Generator)和一个判别器(Discriminator)组成。其中,生成器的部分是一个自动编码器(Auto-Encoder)结构,原始的不完整时间序列输入 \(x\) 会被加上一个随机噪声 \(\eta\),送入 RNN 网络进行编码,压缩为一个一维向量 \(z\),再经过 RNN 解码,逐个时间步地生成完整序列 \(x^{'}\)。判别器的结构相对简单,对输入的数据用同样的 RNN 结构提取特征,最后用全连接层映射到一个输出。

损失函数
E2GAN的损失函数参考了Wasserstein GAN(WGAN),但做了一些修改。具体来说,文章定义了以下几个损失:
遮盖重建损失(Masked Reconstruction Loss) \(L_r(z)=\Vert X \odot M - G(z) \odot M\Vert_2\)
判别损失(Discriminative Loss) \(L_d(z) = -D(G(z))\)
插补损失(Imputation Loss) \(L_{imputation}(z) = L_r(z) + \lambda L_d(z)\)
其中,插补损失即为生成器损失,相比 WGAN 多加了一项遮盖重建损失,并使用 \(\lambda\) 调节大小。整个模型需要优化的判别器与生成器损失为:
\[L_G = L_r(z) + \lambda L_d(z)\] \[L_D = D(x) - L_G\]
GRUI cell
可以看出,E2GAN 的生成器和判别器里都使用了同一种 RNN 网络,是整个模型的核心。这里使用的 RNN 是作者在 NIPS 的文章中提出的 GRUI(Gated Recurrent Unit for data Imputation),一种 GRU 的变体。
GRUI 的最大改变是引入了一个 0-1 之间的时间衰减向量 \(\beta\) 来控制历史时间步的影响。\(\beta\) 的表达式为 \(\beta_{t_i}=e^{-\max (0, W_\beta \delta_{t_i}+b_\beta)}\),这里的 \(\delta\) 是序列 \(x\) 对应时间步的滞后时长(time lag)。
1 | beta = self.beta_fc(delta) # Linear layer |
加入 \(\beta\) 后,GRUI 的计算过程与 GRU 大同小异: \[ h^{'}_{t_{i-1}} = \beta_{t_i} \odot h_{t_{i-1}} \\ \mu_{t_i} = \sigma(W_\mu [h^{'}_{t_{i-1}},x_{t_i}]+b_\mu) \\ r_{t_i} = \sigma(W_r [h^{'}_{t_{i-1}},x_{t_i}]+b_r) \\ \tilde h_{t_i} = \tanh(W_{\tilde h} [r_{t_i} \odot h^{'}_{t_{i-1}},x_{t_i}]+b_{\tilde h}) \\ h_{t_i} = (1 - \mu_{t_i}) \odot h^{'}_{t_{i-1}} + \mu_{t_i} \odot \tilde h_{t_i} \]
为了方便,我与作者源码保持一致,把 \(\beta\) 和每个时间步的输入 concat 在了一起,所以多了一个拆分的操作。此外,由于输入的序列不定长,传入的 batch size 是不固定的,而隐状态的大小固定,因此将已经不需要更新的隐状态(已经超过实际序列长度)拆出来,最后再合并,代码实现如下:
1 | class GRUI(nn.Module): |
Encoder
Encoder 的作用是在生成器中把原始输入压缩为向量 \(z\),具体步骤包括添加噪声 \(\eta\)、计算 \(\beta\)、输入 GRUI
网络,将最后的隐状态通过线性层映射到 \(z\)。pack_padded_sequence
操作也写在了这里来处理不定长序列。
1 | class Encoder(nn.Module): |
Generator
封装了 GRUI 和 Encoder 之后,生成器的结构就比较简单了。将输入压缩到 \(z\) 之后,输入另一个 GRUI 网络,从第一个时间步开始进行序列生成。需要注意的是这里 GRUI 使用的 \(\beta\) 与 Encoder 中不同,是通过完整数据的 \(\delta_{imputed}\) 计算而来的,即相邻元素的差等于时间戳间隔。
1 | class Generator(nn.Module): |
Discriminator
判别器的结构比较简单,和 Encoder 类似,最后的线性层把输入序列映射到 batch size*1,由于采用了 WGAN loss,输出不需要加入 sigmoid 层。
1 | class Discriminator(nn.Module): |
总结
有作者源码的参考,复现整个模型结构并没有花费很多时间,反倒是后续调整输入、改 bug 浪费了大量时间。但尽管投入了很多精力去调整模型,E2GAN 在我的数据集上效果也并不好,甚至可以说非常差,生成器对原始输入的拟合效果不佳,遮盖重建损失下降缓慢。这其中可能有部分原因是我对 Tensorflow 不熟悉,在代码迁移的过程中有隐藏的 bug 没有发现,但整个模型也存在很多我觉得不合理的地方。例如,Encoder 只取了最后而不是每个时间步的隐状态为输出,一个一维的向量 \(z\) 可能很难提取出整个序列的特征;生成器逐个时间步生成序列,每个时间步过一次 GRUI 和同一个线性层,线性层的权重更新混乱,后续时间步也缺少 \(z\) 的信息等等。事实上,我在其他的模型中,用作者提出的 GRUI 代替原本的 GRU 和 LSTM 确实得到了更好的结果,因此,至少 Github 上这个版本的源码的模型结构可能存在问题。