做无监督异常检测的人,基本都跟自编码器打过交道。数据不均衡、异常样本永远稀缺,很多人第一个想到的方案就是把正常图片送去训练一个自编码器,然后用重建误差来打分。但实际跑下来你会发现问题很明显:自编码器对异常图像往往也能“重建”个大概,因为压缩再放大的过程会把很多局部细节直接抹平,异常区域被当作噪声处理掉了,重建误差并没有想象中那么大。GANomaly这个工作,就是在2018年专门针对这个问题提出来的方案,核心是用生成对抗网络GAN去强化“压缩-重建”过程中的表征约束,让异常在潜在空间里就露出马脚。这篇文章我会把GANomaly的原理和源码一起拆开讲,从网络结构、三个损失函数,到PyTorch训练循环和异常分数计算,全部过一遍。
1. GANomaly到底在解决什么问题
1.1 纯自编码器做异常检测为什么不够
自编码器(AutoEncoder,AE)的训练目标很直接:输入一张图,经过编码器压成低维向量,再经过解码器还原,要求输出和输入尽量一致。正常样本和异常样本都喂进去之后,理论上异常样本重建误差会更大。问题在于,自编码器学习到的潜在向量通常只保留全局结构信息,而很多异常恰恰是局部细节层面的,比如划痕、污点、局部的形状畸变。
假如一张图片上有一个很小但很明显的黑点,编码器把这个图片压到低维空间时,黑点这个局部特征很可能就“溶”进了背景统计里,解码器自然也不会把它恢复出来。如果再用传统自编码器,重建误差可能只集中在那个黑点周围,但经过卷积和池化后这个误差会很分散,最后算MSE时反而不明显。说白了,纯自编码器做异常检测的核心问题,是它的潜在空间没有经过“对抗式”的约束,模型可以找到一个很平庸的编码方式,让正常和异常在重建误差上没那么容易区分。
有人会问,那直接把重建误差换成感知损失(Perceptual Loss)行不行?用预训练VGG提取特征再算L2距离,确实比像素MSE好一些,但还是解决不了关键问题:数据本身是高度不平衡的,模型仍然只需要“过得去”的重建效果就够了,不会刻意去区分哪些输入应该被判定为异常。这就像一个人练字,如果只要求“整体看起来差不多”,他永远不会发现某个笔画其实是错的。
1.2 GANomaly的核心机制:压缩再压缩
GANomaly的思路很巧妙,它不是直接去优化重建误差,而是把“输入图像”和“重建图像”分别压缩到潜在空间,然后比较这两个潜在向量的差异。换句话说,一张图进来,先被生成器内部的编码器压成潜在向量z,再从z解码回图像;接着把这个重建图像再喂给另一个编码器E,得到第二个潜在向量z_hat。如果输入是正常样本,z和z_hat应该很接近;如果输入是异常样本,压缩-重建-再压缩这条链路会在某个环节无法完整保留信息,导致z和z_hat产生明显的偏移。
这个机制的直观理解,可以类比成让一个秘书用一段话概括一份文件,然后让另一个秘书根据这段概括反推原文要点,再让记录员把两版要点对比。如果原文件内容都在正常范围内,两个秘书的工作结果会高度一致;一旦文件里混入了一个不合常规的条目,第一个秘书可能会忽略它,或者第二个秘书在“反推”时凭经验脑补成正常内容,那最后对比出的差异就会暴露问题。
关键的创新点在于,它用GAN的对抗训练把重建过程约束在训练集的流形上。生成器不仅仅要把图像重建得像,还要让生成的图像在判别器眼里“真”。这样异常区域想要在重建图中被悄悄抹掉,就不再那么容易了,因为判别器会把那种看起来“虚假”的重建结果打回去,逼着生成器保留更多输入中的真实细节。
1.3 与AnoGAN、EGBAD等方法的关键差别
在做异常检测的GAN方案里,GANomaly不是第一个。早些时候有AnoGAN,用训练好的GAN把测试图像反演到潜在空间,再计算重建误差;还有EGBAD,用双向GAN学习图像与潜在向量的联合分布。这些方法的问题在于推理阶段需要额外的优化迭代,测试一张图要跑多次梯度下降,又慢又不稳定。
GANomaly直接把推理过程设计成了前向传播,不需要反演优化。训练阶段学会了从图像到潜在向量的映射,测试时只需要把图像喂进去,算一下z和z_hat的差值就得到异常分数。这在实际场景里非常重要,尤其是工业质检这种可能需要实时处理的场景,AnoGAN这种每次测试都要迭代几百步的方法几乎没法落地。
| 方法 | 是否需要测试时优化 | 异常分数来源 | 主要短板 |
|---|---|---|---|
| AnoGAN | 是 | 重建图与原始图的残差 | 测试耗时大,训练流程复杂 |
| EGBAD | 否 | 重建误差 + 判别器特征误差 | 模型结构复杂,训练不稳定 |
| GANomaly | 否 | 潜在向量z与z_hat的距离 | 依赖训练数据的纯正性 |
从上面的对比能看出来,GANomaly在“检测效果”和“使用成本”之间找了一个很实际的平衡点。这也是它后续能成为无监督异常检测领域基线的直接原因。
2. 网络结构拆解:生成器、判别器与重编码器
2.1 生成器G的结构与潜在向量z
GANomaly里的生成器G不是一个单纯的Decoder,它内部其实包含了一个Encoder和一个Decoder。论文里把这个生成器写作G_E + G_D,G_E负责把输入图像压缩成潜在向量z,G_D负责把z还原成图像。如果以64x64的RGB图像输入为例,G_E通常是4个卷积层,每层stride为2,通道数从64一路翻到512;最后flatten成一个固定长度的特征向量,经过一个线性层输出100维的z。
解码器G_D则是对称的,先通过一个全连接层把z映射回特征图的大小,再用4个转置卷积逐级上采样,恢复成64x64x3的图像。注意这里输出层一般用Tanh激活,因为输入图像在预处理时要归一化到[-1, 1]区间,而不是0到1。
这个结构设计最关键的一点是,潜在向量z的维度只有100维,远小于原始图像的空间大小。编码器必须把最重要的结构信息和语义信息放进这100个维度里。如果输入是一张正常样本,编码器可以很从容地完成这个“摘要”任务;如果输入属于异常类别,某些局部特征在压缩过程中要么被忽略,要么被错误地当成其他正常模式,重建出来就会和原始输入产生偏差。
2.2 判别器D的双路输出
大多数GAN的判别器只输出一个真/假概率标量,GANomaly的判别器在此基础上多输出了一组特征向量。它提取的是判别器在判定真伪过程中学到的中间层特征,用这个特征向量来衡量“输入图像”和“重建图像”在判别器视角下的距离。
为什么需要这个特征?纯粹依赖潜在向量做异常评分有一个隐患:潜在向量z是高度抽象的结果,可能丢掉太多细节。如果判别器能提供一个更偏“感知层”的特征,那对于局部级别的异常,比如一条很细的裂纹,感知特征的差异可能会比潜在向量的差异更敏感。所以在源码实现里,判别器倒数第二层的特征会被单独拉出来,反向传播给生成器使用。
这个双路输出的设计也可以理解为:判别器既要告诉生成器“你重建的图像像不像真的”,又要告诉生成器“你的重建图在特征层面和原图差在哪”。这种双重约束让GANomaly在保留全局结构的同时,也能保留不少局部纹理信息。
2.3 重编码器E为什么不能省
这是我读源码时最想强调的一个点:为什么不能直接复用生成器里的编码器G_E,非要另起炉灶再定义一个E网络?
从几何意义上讲,G_E是生成器内部的组件,它的参数会随着生成器一起更新,目标是把输入图像编码成一个“方便解码器重建”的潜在向量。这个潜在向量的分布已经被训练过程塑造成了解码器最舒服的形状。如果测试时用同一个G_E来编码重建图,得到的z_hat和z很可能会天然接近,异常信号的区分度就被削弱了。
单独用一个E网络就不同了。E的目标是独立地把重建图编码成一个潜在表示,它不受解码器“舒适区”影响。虽然源码里E在训练阶段不更新参数,但它的随机初始化特征映射已经提供了一个相对独立的度量视角。可以这样想:G_E是“作者”视角的摘要,E是“读者”视角的摘要,对比两种视角的差异,远比只站在一个视角里来回打转更有信息量。
2.4 三个损失的数学表达与源码对应
GANomaly的总损失函数由三部分构成:
L = L_adv + λ_con * L_con + λ_enc * L_enc
其中论文设定的λ_con = 1,λ_enc = 50。这说明编码损失的权重最高,是模型优化的核心目标。
对抗损失L_adv的形式比较标准。判别器希望把真实图片判定为1,把生成器重建的图片判定为0;生成器则希望重建图片被判定为1。对应源码里,生成器的对抗损失是把G(x)送入判别器后,与全1标签计算BCELoss。这里用非饱和损失形式,而不是直接最小化log(1-D(G(x))),因为后者在判别器太强时会有严重的梯度消失问题。
上下文损失L_con就是重建图像和原始图像之间的L1距离:
L_con = E[||x - G(x)||_1]
为什么用L1不用L2?L2损失倾向于把误差平均分配到整张图片上,生成的图像会显得模糊;L1损失对局部区域的偏差保留了更大的梯度信号,重建出来的图像边缘更锐利。
编码损失L_enc是整个模型最具辨识度的部分:
L_enc = E[||G_E(x) - E(G(x))||_2]
这里用了L2距离,原因是潜在向量本身是一个连续且维度较低的空间,L2能更直接地度量两个点在欧氏空间中的偏移程度。如果某个输入图像是异常的,它的编码z和重建图再编码得到的z_hat在L2距离上会明显大于正常样本的情况。
3. 源码解析:从数据加载到训练全流程
3.1 项目目录与PyTorch基础版本说明
我用的是PyTorch版本的GANomaly实现,这是社区里比较常见的复现版本。项目结构很清晰,主要包含data目录、model目录和train/val脚本。数据部分的核心是自定义Dataset类,在初始化时读取所有训练图片的路径,并在__getitem__里做预处理。预处理包含三件事:Resize到64x64、ToTensor、Normalize到均值为0.5、标准差为0.5。这意味着图像像素值会落在-1到1之间。
验证集和测试集不需要单独打标签,异常检测场景是无监督的。你只需要提供一个“正常样本”列表用于训练,测试时把所有样本(正常+异常)都送进去,按照异常分数排序后计算AUC就行。
3.2 生成器与判别器核心代码
生成器类在PyTorch里定义得很直接。Encoder部分用卷积层逐步下采样,Decoder部分用ConvTranspose2d上采样。下面这段是核心结构:
class Generator(nn.Module): def __init__(self, nz=100, nc=3): super().__init__() self.encoder = nn.Sequential( nn.Conv2d(nc, 64, 4, 2, 1, bias=False), nn.BatchNorm2d(64), nn.ReLU(True), nn.Conv2d(64, 128, 4, 2, 1, bias=False), nn.BatchNorm2d(128), nn.ReLU(True), nn.Conv2d(128, 256, 4, 2, 1, bias=False), nn.BatchNorm2d(256), nn.ReLU(True), nn.Conv2d(256, 512, 4, 2, 1, bias=False), nn.BatchNorm2d(512), nn.ReLU(True), ) self.fc1 = nn.Linear(512 * 4 * 4, nz) self.decoder = nn.Sequential( nn.ConvTranspose2d(nz, 512, 4, 1, 0, bias=False), nn.BatchNorm2d(512), nn.ReLU(True), nn.ConvTranspose2d(512, 256, 4, 2, 1, bias=False), nn.BatchNorm2d(256), nn.ReLU(True), nn.ConvTranspose2d(256, 128, 4, 2, 1, bias=False), nn.BatchNorm2d(128), nn.ReLU(True), nn.ConvTranspose2d(128, 64, 4, 2, 1, bias=False), nn.BatchNorm2d(64), nn.ReLU(True), nn.ConvTranspose2d(64, nc, 4, 2, 1, bias=False), nn.Tanh() ) def forward(self, x): z = self.encode(x) x_hat = self.decode(z) return x_hat, z def encode(self, x): x = self.encoder(x) x = x.view(x.size(0), -1) return self.fc1(x) def decode(self, z): z = z.view(z.size(0), z.size(1), 1, 1) return self.decoder(z)判别器使用的卷积层相对更少,因为它只需要输出“真伪判断”和“特征向量”两样东西。倒数第二层得到一个256维的特征向量,这个特征会作为编码损失的一部分被反向传播。
class Discriminator(nn.Module): def __init__(self, nc=3): super().__init__() self.conv1 = nn.Conv2d(nc, 64, 4, 2, 1, bias=False) self.conv2 = nn.Conv2d(64, 128, 4, 2, 1, bias=False) self.conv3 = nn.Conv2d(128, 256, 4, 2, 1, bias=False) self.fc1 = nn.Linear(256 * 8 * 8, 1024) self.fc2 = nn.Linear(1024, 1) self.leaky = nn.LeakyReLU(0.2, True) def forward(self, x): x = self.leaky(self.conv1(x)) x = self.leaky(self.conv2(x)) x = self.leaky(self.conv3(x)) x = x.view(x.size(0), -1) x = self.leaky(self.fc1(x)) features = x out = torch.sigmoid(self.fc2(x)) return out, features3.3 训练循环和损失计算
训练循环的核心思路是:先更新判别器,再更新生成器。每一步都从同一个批次的数据出发,分别算出三个损失。下面这段代码是每轮epoch中训练生成器时的缩影:
# 输入x是归一化到[-1,1]的图片 x_hat, z = netg(x) # 生成重建图和潜在向量 f_x = netd(x) # 判别器对真实图片的特征 f_x_hat = netd(x_hat) # 判别器对重建图的特征 z_hat = nete(x_hat) # 外部编码器对重建图的编码 real_label = torch.ones(x.size(0), 1) fake_label = torch.zeros(x.size(0), 1) # 对抗损失:生成器要让重建图被判别为真 err_g_adv = criterion(netd(x_hat)[0], real_label) # 上下文损失:重建图和原图的L1距离 err_g_con = criterion_l1(x_hat, x) # 编码损失:潜在向量z和重编码z_hat之间的L2距离 err_g_enc = criterion_l2(z_hat, z) err_g = err_g_adv + lambda_con * err_g_con + lambda_enc * err_g_enc err_g.backward() optimizer_g.step()判别器的更新相对常规,只不过它需要同时处理真实图片和重建图片两组输出。真实图片的标签为1,重建图片的标签为0。判别器训练到位之后,生成器会越来越难骗过它,这会反向逼迫生成器提高重建质量。
3.4 训练时E到底做什么、不做什么
源码里最容易被忽略的就是E网络。很多人第一次读代码时会以为三个网络都参与了训练,但实际上E在训练过程中只被调用,没有优化器去更新它的参数。也就是说E网络从头到尾保持一个随机初始化状态,它作为一个固定的“度量器”存在。
这一点如果理解错,整个模型的设计逻辑就歪了。E不参与训练,意味着它不会去“讨好”生成器,也不会对正常样本产生偏好。它的随机投影提供了一种无偏的度量方式。测试阶段计算异常分数时,z_hat正是因为来自这个“独立视角”,才更能反映出重建过程中的信息损失。
4. 推理与评估:异常分数与AUC
4.1 测试流程与异常分数计算
训练完成后,测试阶段不再需要判别器,只保留生成器G和编码器E。对每一张测试图片,计算:
- z = G_E(x),即生成器内部编码器对原始图的压缩结果;
- x_hat = G(z),重建图像;
- z_hat = E(x_hat),外部编码器对重建图的压缩结果;
- 异常分数 A(x) = ||z - z_hat||_1。
分数越高,说明这张图片在“压缩-重建-再压缩”过程中丢失的信息越多,越可能是异常。这个打分逻辑在源码里实现起来非常短:
def anomaly_score(inputs): with torch.no_grad(): x_hat, z = netg(inputs) z_hat = nete(x_hat) scores = torch.mean(torch.abs(z - z_hat), dim=1) return scores我自己的经验是,计算分数时用L1还是L2差别不大,重要的是在同一个模型、同一个数据集内部保持一致性。不同训练轮次得到的分数尺度不一样,所以跨模型比较时要小心。
4.2 用scikit-learn计算AUC的实操
拿到所有测试图片的异常分数后,评估就变成了一个标准的二分类排序问题。正常样本和异常样本都有对应的真实标签,直接用sklearn的roc_auc_score计算即可:
from sklearn.metrics import roc_auc_score scores = [] labels = [] for img, label in test_loader: score = anomaly_score(img) scores.extend(score.cpu().numpy()) labels.extend(label.numpy()) auc = roc_auc_score(labels, scores)注意这里的标签不能用于训练,只能在评估环节使用。在实际工厂质检场景里,异常样本往往不会超过5%,AUC只反映排序能力,最终还得根据误检率和漏检率的业务要求去选择具体阈值。
4.3 一个可视化调试方法
只看AUC数字有时会掩盖问题。我习惯把正常样本和异常样本的异常分数分布画出来,看看两个分布的重叠程度。分数分布图比单一数值更直观。另一个更实用的可视化手段是把重建图x_hat和原图x做像素级差异热力图:
diff = torch.abs(x - x_hat).mean(dim=1, keepdim=True)把diff放大后叠加在原图上,就能直观看到异常区域集中在图片的哪个位置。GANomaly虽然本身不输出分割掩码,但重建差的分布往往能非常准确地指示异常位置。有一次我在一个表面划痕数据集上调试,AUC只有0.82,画了热力图才发现模型把图片角落的正常纹理误判成了异常,后来通过清理训练数据里的少量脏样本,AUC直接提到了0.95以上。
5. 复现与调参中的避坑清单
5.1 关键训练参数速查表
我整理了一份可以直接参考的参数配置,基于PyTorch实现和64x64图像输入。
| 参数 | 建议值 | 说明 |
|---|---|---|
| 输入尺寸 | 64x64 | 源码里线性层维度写死,换尺寸需同步改结构 |
| z维度 | 100 | 太小可能丢细节,太大会让异常判别变钝 |
| batch_size | 64 | 低于16容易训练崩溃,显存不够可降到32 |
| 优化器 | Adam | lr设为0.0002,betas设为(0.5, 0.999) |
| epoch | 15-30 | 数据量大时建议配合早停 |
| λ_con | 1 | 论文默认值 |
| λ_enc | 50 | 论文默认值,编码损失权重最大 |
这里要单独提醒一句:如果你换成了128x128或更高分辨率的输入,生成器里那些channel数和kernel size不一定直接适用。尤其是全连接层的输入维度,需要根据卷积输出的feature map大小重新计算。当时我改分辨率时没注意这个,直接报了一堆shape mismatch的错。
5.2 高频问题与排查建议
训练GANomaly最常见的故障是训练不收敛、生成器loss不降、重建图像全黑或全灰、AUC一直徘徊在0.5附近。下面这些是基于实际经验总结的排查路径:
| 问题现象 | 可能原因 | 处理建议 |
|---|---|---|
| 生成器loss震荡剧烈 | 判别器训练太快 | 降低学习率,或者每更新一次判别器后再更新两次生成器 |
| 重建图像模糊 | 上下文损失权重过低 | 适当调大λ_con,检查是否忘了用L1损失 |
| AUC接近0.5 | 训练数据里混入了少量异常样本 | 可视化训练集,剔除明显偏离正常分布的数据 |
| 测试分数整体偏高 | 输入预处理不一致 | 确认训练和测试都用[-1,1]归一化,不是[0,1] |
| 潜在向量z和z_hat始终接近 | E网络被意外更新 | 检查优化器列表,确认nete参数没有传进去 |
还有一个很多人容易踩的坑:BatchNorm在batch size为1时会直接崩掉。如果你用单张图片做推理,一定要把网络切到eval模式,关闭BatchNorm的更新逻辑。
5.3 让GANomaly落地更稳的几个改进
GANomaly本身是个基线方法,不等于不能改进。实际项目中,以下几个改动在保持简单性的同时,会带来比较明显的效果提升。
第一个是把E网络从“随机初始化且不训练”改成“使用ImageNet预训练的backbone”。实验下来,尤其在纹理类异常检测上,预训练E网络能让异常分数的稳定性提升不少。这是因为随机初始化的CNN特征分布不稳定,而预训练网络提取的特征本身就具备一定的通用语义能力。
第二个改动是在上下文损失里融合SSIM结构相似度。L1损失对像素绝对值差异敏感,但对纹理结构的变化不敏感;SSIM能捕捉局部结构中细微的变化,对划痕、微小形变这类异常的区分更有效。常见做法是让L1和SSIM损失各占一半权重。
第三个改动是把测试阶段的重建过程做成多次采样取平均。输入同一张图像,由于Dropout或数据增强的存在,重建结果会有轻微波动;算异常分数时跑3次取平均,可以压低噪声对分数的影响。这个办法在图像噪声较大的工业相机场景下效果很直观。
说回源码层面,如果你想把GANomaly用到自己的数据集上,我建议不要直接照搬训练20个epoch的做法。先用一个小子集跑通完整流程,确认重建效果肉眼可见地清晰、异常分数能区分开,再逐步放大数据量。我在实际使用中发现,GANomaly对训练数据的“纯正程度”要求比普通自编码器高很多,数据里一旦混入几条异常样本,判别器会很早学到取巧的重建策略,后续AUC怎么调都上不去。最后再分享一个小技巧:测试阶段千万不要忘了把模型切到eval模式,否则BatchNorm带来的统计量偏移会让异常分数出现系统性偏差,这个坑我踩过不止一次。