资讯详情

资讯详情

PyTorch实现Deep Dream:CNN特征可视化与梯度上升实战

Deep Dream 这个名词第一次听到的人多半以为是某种“梦境生成器”实际上它是 CNN 特征可视化里最有视觉冲击力的一类玩法。核心就一句话把一张普通图片喂给卷积神经网络然后反着用梯度故意去放大网络“看到”的某种模式最后输出一张充满怪诞花纹、眼睛、羽毛、建筑纹理的图。用 PyTorch 实现它其实只需要几十行核心代码哪怕你对反向传播只有最基础的认知也能跑起来并玩出自己的效果。这篇文章会从原理、代码、调参到踩坑完整拆解一遍我自己的实现过程保证你读完能独立写出一个属于自己的 Deep Dream 生成器。1. Deep Dream 的核心思路梯度上升与特征可视化1.1 从 CNN 特征图说起想搞懂 Deep Dream先得理解卷积神经网络里的“特征”是什么。训练好的 CNN比如 VGG、GoogLeNet、ResNet在每一层卷积核的眼里图片不是一张图而是一组“特征响应图”。浅层网络关注边缘、颜色、纹理这些低级模式中层开始组合出轮廓、部件、局部形状深层则能抽象出眼睛、嘴、动物头甚至整个物体的概念。每一层的输出张量形状通常是[N, C, H, W]其中 C 代表这一层有多少个卷积核也就是多少个“特征通道”。每个通道激活值大的地方说明网络在该位置“看到”了它想要的那种模式。Deep Dream 的本质就是人为地去“放大”这种响应。你给网络一张偏暗、偏平的图它本来不觉得哪里有眼睛但梯度告诉你可以往哪里加“眼睛纹理”能让某个通道的激活更大。你顺着这个方向改图像网络就一步一步地把隐藏的模式“脑补”出来。这也是为什么 Deep Dream 出的图总有一种“过度解读”的感觉——它看到了人类看不出的事物。1.2 最大化激活的数学表示用一句话概括 Deep Dream 的优化目标不断调整输入图像 x让某一个或几个卷积层 l 的激活值 A_l 的某个范数尽可能变大。写成公式就是[ x \leftarrow x \lambda \cdot \frac{\partial |A_l|}{\partial x} ]这里的 (\lambda) 是学习率决定每次更新图像的幅度。(|A_l|) 通常是 L2 范数也就是所有元素平方和的均值。之所以用 L2 而不是直接求和是因为 L2 会对较大激活值产生更强的梯度让网络重点强化那些已经“有点想法”的区域这样出来的纹理更集中、更夸张。整个更新过程跟训练神经网络恰好相反。训练网络时我们固定图像、更新权重用梯度下降让损失变小。Deep Dream 固定权重、更新图像用梯度上升让激活值变大。所以它叫“反向”使用神经网络对象从“网络的权重”换成了“输入图像”。1.3 梯度方向为什么是“上升”很多人第一次写代码时容易搞混PyTorch 里loss.backward()之后默认是往梯度负方向更新参数。但 Deep Dream 要的是“最大化激活”所以更新方向必须是梯度的正方向。实现时有两个选择一是直接用x.data.add_加上x.grad的缩放值二是在计算 loss 之后手动对x.data做x.data lr * x.grad。这里有个非常容易踩的坑如果你把 loss 写成-loss然后再做optimizer.step()虽然表面上也是最大化但因为 optimizer 本身默认是做梯度下降等于往负梯度方向走效果完全相反。所以最稳妥的办法就是绕过 optimizer直接操作x.data别让 PyTorch 的自动优化机制干扰你的梯度方向。2. 动手前的准备环境、模型与工具选型2.1 PyTorch 环境配置要点Deep Dream 代码量不大但对 PyTorch 版本有基本要求最好在 1.8 以上因为旧版对张量原地操作的钩子处理有些奇怪容易在多次迭代后报错。另外不要用太老版本的 torchvision预训练模型的下载接口在 0.9 之后有若干调整否则你可能要手动去缓存目录找权重文件。如果你的机器没有 GPUCPU 也能跑只是速度慢很多。Deep Dream 通常要迭代几十到上百轮而且要做多尺度金字塔CPU 上一张 512 大小的图可能要跑十几分钟。我建议先在小尺寸224 或 256上调通流程再上大图和高分辨率。显存方面8GB 左右的卡可以轻松跑 VGG19 的浅层和中层但跑深层同时加金字塔最好用 12GB 以上的显存或者把 batch_size 的概念完全去掉只处理单张图。2.2 预训练模型怎么选模型的选择直接决定了生成图像的“画风”。VGG19最经典的选择。它的卷积层结构均匀层与层之间特征差异明显浅层能出迷幻的波纹和彩纹深层能出眼睛、狗、建筑物。缺点是参数多计算量大。GoogLeNetInceptionV3有多个 Inception 模块特征通道非常丰富生成的纹理更细碎、更密集容易出大量小图案。ResNet残差连接会让梯度传播更平滑Deep Dream 效果相对“温和”不太容易出现极端过拟合的密集纹理适合希望图不那么“炸”的场景。我用得最多的是 VGG19因为它的features子模块是顺序的方便直接按索引取某一层输出不需要像 ResNet 那样拼接 hook。torchvision 里加载 VGG19 的方式很简单但需要注意要设置pretrainedTrue然后取它的.features部分。2.3 图像预处理与反预处理你可能觉得“把图片转成 tensor 喂给模型就行”但实际没那么简单。预训练模型在 ImageNet 上训练时输入数据都做了标准化每个通道减均值再除标准差。PyTorch 的torchvision.transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225])就是在做这件事。Deep Dream 过程中我们一直是在“标准化后的张量”上做梯度更新但最终可视化时要把标准化操作反过来否则图像会严重偏色、对比度怪异。我习惯自己封装一对函数def preprocess(img): # img: PIL Image 或 numpy array0-255 x torch.from_numpy(img).float() / 255.0 x x.permute(2, 0, 1).unsqueeze(0) mean torch.tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1) std torch.tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1) return (x - mean) / std def deprocess(x): # x: tensor shape [1, 3, H, W]normalized mean torch.tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1) std torch.tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1) x x * std mean x x.clamp(0, 1) * 255 x x.permute(0, 2, 3, 1)[0].detach().cpu().numpy() return x.astype(np.uint8)注意一点反预处理时一定要clamp(0, 1)因为经过多轮梯度更新后像素值可能超出 [0,1] 范围。clamp 能防止输出图像出现大面积过曝的死白区域。3. 从零写一个 Deep DreamPyTorch 代码拆解3.1 总体流程整个 Deep Dream 可以拆成五个阶段加载预训练模型并冻结所有参数requires_gradFalse。读取并预处理输入图像得到标准化的输入张量。定义目标层——指定我们想要放大哪一层的特征。设计梯度上升迭代循环每一轮前向传播拿到目标层输出计算 L2 范数作为 loss反向传播得到输入图像的梯度然后更新x.data。多尺度金字塔处理将图像缩小再放大分多次梯度上升让网络在不同尺度下“做梦”避免只出现单一频率的纹理。我建议先写一个单尺度版本确认能跑通再加多尺度。3.2 定义目标层与特征提取VGG19 的 features 模块是 Sequential里面每个 Conv2d 和 ReLU 都有索引。我们可以选择某个 ReLU 的输出作为“梦境层”。通常选择features[26]对应第 4 个卷积块后的激活层效果比较典型。代码上我们不需要修改模型结构只需要在 forward 时截断import torch import torch.nn as nn import torchvision.models as models device torch.device(cuda if torch.cuda.is_available() else cpu) model models.vgg19(pretrainedTrue).features.to(device).eval() for p in model.parameters(): p.requires_grad_(False) layer_index 26 # 可以根据需要调整 def get_feature(x): # 只前向到目标层 for i, layer in enumerate(model): x layer(x) if i layer_index: return x这个get_feature函数每次前向都遍历到目标层效率不算最高但胜在简单直观。如果你想更快可以用 hook 注册到目标层但 hook 的保存和清理比较麻烦容易在多次迭代后积累内存。我更推荐这种“截断前向”的写法代码清晰不容易出隐藏 bug。3.3 梯度上升循环体核心更新代码如下def dream_step(x, lr): x.requires_grad_(True) loss get_feature(x).norm(2) # L2范数 model.zero_grad() loss.backward() with torch.no_grad(): x.data lr * x.grad x.requires_grad_(False) return x这里有个细节每次循环开头要x.requires_grad_(True)更新完再requires_grad_(False)。如果不这样做下次循环再 backward 时会遇到“张量已经参与过一次梯度计算试图再次通过它反向传播”的报错。因为我直接用x.data更新等同于创建了一个新的数据但 PyTorch 的自动求导图可能还记着旧的历史所以手动切换 requires_grad 是必要的。单次迭代就是for i in range(iterations): x dream_step(x, lr)如果你想加一点“噪声控制”可以在每次更新后调整全图方差这个后面再讲。3.4 多尺度金字塔octave实现单尺度跑出来的图通常纹理比较单一要么全是高频小碎花要么全是低频大块结构。原因很简单卷积核的感受野是固定的一次只在一个尺度上放大特征。多尺度金字塔的做法是先把原图缩小在缩小图上做梯度上升然后放大再加细节。我实现的金字塔流程是这样的设定金字塔层数num_octaves以及每层缩放比例octave_scale一般取 1.4即每层比上一层放大 1.4 倍。把原图缩放到最小尺寸然后逐层放大并做迭代。伪代码def deep_dream(img_tensor, num_octaves5, octave_scale1.4, iterations20, lr1.5): # img_tensor: [1,3,H,W] normalized # 先准备各层尺寸 octaves [] cur img_tensor for _ in range(num_octaves - 1): h, w cur.shape[2], cur.shape[3] h_new, w_new int(h / octave_scale), int(w / octave_scale) cur F.interpolate(cur, size(h_new, w_new), modebilinear, align_cornersFalse) octaves.append(cur) octaves.append(img_tensor) # 从最小开始 x octaves[-1] for octave_idx in range(len(octaves) - 1, -1, -1): if octave_idx len(octaves) - 1: # 放大上一层的输出再加上当前层的原始图 x F.interpolate(x, size(octaves[octave_idx].shape[2], octaves[octave_idx].shape[3]), modebilinear, align_cornersFalse) x x octaves[octave_idx] # 这里把原始图加了回来 for _ in range(iterations): x dream_step(x, lr) return x注意里面那个x x octaves[octave_idx]非常关键。如果不把当前层的原始图加回来网络可能在放大的过程中丢失原始图像的结构最后只剩一团乱纹。加回原图相当于给优化过程一个“锚点”让它既保留原始内容又叠加幻觉特征。3.5 完整代码汇总上面几节拼起来就是一份可以运行的完整代码。我贴一下核心部分import numpy as np import torch import torch.nn.functional as F import torchvision.transforms as T import torchvision.models as models from PIL import Image def load_image(path, sizeNone): img Image.open(path).convert(RGB) if size is not None: img img.resize((size, size), Image.BILINEAR) img np.array(img).astype(np.float32) return preprocess(img) def dream(image_path, out_path, layer26, iterations20, lr1.5, num_octaves5, octave_scale1.4, sizeNone): x load_image(image_path, size).to(device) x deep_dream(x, num_octaves, octave_scale, iterations, lr) out deprocess(x) Image.fromarray(out).save(out_path)实际使用时输入图建议不要太大比如长边 512 以内否则金字塔每一层都会很耗时。如果原图很大先缩到 512 跑梦再叠加回原图是更高效的方式。4. 调参经验与效果控制4.1 参数速查表不同的参数组合出来的图差异极大。我根据自己的实测经验整理了一张速查表参数取值范围经验值效果影响lr0.5 ~ 3.01.5越大纹理越夸张但太大容易崩坏成噪点iterations10 ~ 10020~30越大“脑补”越强烈但过大会失去原图轮廓num_octaves3 ~ 85层数越多感受野越丰富但耗时成倍增加octave_scale1.2 ~ 1.61.4越大各层尺寸差异越大容易出现跨尺度大纹理layerVGG19 中 10~3026浅层出纹路深层出物体见下文详解4.2 选对“做梦层”才是灵魂VGG19 的 features 是 0~36 共 37 个层包含卷积和 ReLU 等。我实测下来layer10第三个卷积后的 ReLU输出的是非常细密的边角纹理图上看有点像彩色噪点的藤蔓适合做“迷幻壁纸”但看不出任何物体。layer20第三个卷积块末尾开始出现一些曲线和局部形状有轻微“蠕虫”感。layer26第四个卷积块中间的 ReLU非常经典的层既保留原图的轮廓又能生成大量的眼睛、羽毛类图案Deep Dream 最经典的“狗化”效果基本都是这个层附近出来的。layer30第四个卷积块末尾比 26 更激进物体感更强但很容易过度图会变得密集恐怖。layer34接近输出会生成大面积的完整物体构象比如整张脸的轮廓但也更容易崩成彩色马赛克。如果你想要那种“半梦半醒”的微妙效果我建议先用layer22左右测试。如果想让图彻底放飞直接用 28~30。4.3 加噪声与归一化两个关键技巧直接在原图上做梯度上升有时会很快收敛到局部最优图像的纹理出现明显的“块状感”。有一个常用技巧每次迭代更新后给图像注入少量高斯噪声。噪声能打破局部平衡让网络跳到更有意思的特征模式上。注意噪声幅度要小比如标准差 0.05 左右太大就是纯随机噪声了。归一化技巧则更实用。多轮迭代后张量的数值范围会越来越大导致梯度爆炸或图像过曝。一个稳妥的做法是每轮更新后对 x 做一次“标准差归一化”mean x.mean() std x.std() x (x - mean) / std * previous_std previous_mean其中previous_std是你初始图像的全局标准差。这样能让整体对比度保持大致稳定不会越跑越极端。我实际测试加了归一化后可以允许更大的 lr收敛也更平滑。4.4 早期停止别把梦做成噩梦Deep Dream 迭代次数过多时图像会逐渐失去原始结构变成一团高频密集的彩色噪点——圈内俗称“过度烹饪”overcooked。怎么判断该停了我总结了一个经验每迭代 5 次保存一次中间图肉眼观察。如果发现原图的主体轮廓开始被大量新增纹理覆盖但细节还在增加这时候还可以再来 5 次如果边缘已经开始像融化的蜡烛一样扭曲就要果断停止。另外金字塔模式下每一层的迭代次数不必相同。我自己的做法是最小尺寸层迭代 25 次越往后的层对应更大图迭代 15 次左右。因为大图迭代更耗时且小图上已经学到的大尺度特征放大到大图后会自然补充细节不需要太多轮次。5. 常见问题与排查技巧实录5.1 梯度全是 0图像完全不更新多半是模型参数和输入同时只读导致的。确认在调用backward()之前x 的requires_grad是True并且model的参数requires_grad是False。如果二者都是 False梯度图根本建立不起来。还有一个冷门原因你选的目标层如果本身是 ReLU输入是负值那么梯度会全部为 0ReLU 负半轴导数为 0。解决办法是先看一眼特征图输出如果负值占绝大多数换成其他层或者对输入做一次正偏移。5.2 图像全黑或全白全黑通常是反预处理时忘了做std * x mean导致被还原成接近标准化的值。全白则是更新幅度过大像素值溢出到很大clamp 后全部变成 255。把 lr 调小同时在更新后加归一化基本能解决。5.3 显存不足OOM单张 224 的图跑 VGG19 一般不会有问题但 512 以上就吃紧了。最有效的办法是减少num_octaves。金字塔的每一层会保存一份缩小的图像张量同时梯度图还在占用的显存比想象大。把octave_scale从 1.4 改成 1.2能减少每层尺寸跳变也能显著降显存。如果还不行就在dream_step里用torch.no_grad()包住前向中不需要梯度的部分实际上前向必须建立图所以只能减少迭代轮次或用更小的模型。5.4 输出出现网格状伪影这是F.interpolate的align_cornersFalse与上采样时产生的格子效应。尤其在金字塔放大时如果尺寸是整数倍缩小再放大伪影会被放大。解决方法是每次缩放时不要用整数倍用float(h / octave_scale)转换为 int但偏差可能会重复。更稳妥的方法是在每次放大后对图像做一个轻微的高斯模糊例如T.GaussianBlur(kernel_size3, sigma0.5)把网格纹理抹平。注意模糊强度不能太大否则会模糊掉细节。5.5 特征图维度对不上当你用的层是 VGG19 中只有卷积没有 ReLU 时输出形状是[1, C, H, W]没有问题。但如果你选的层是 MaxPool那么输出会正常减半。Deep Dream 的 loss 不依赖于具体 H 和 W所以维度大小不影响代码运行。只有当你试图把特征图拼接或做尺寸匹配时才需要注意。6. 进阶扩展不只是“做梦”6.1 特征可视化的其他玩法Deep Dream 是特征可视化的一种直观玩法但它不是唯一。你可以把梯度上升的目标改一下变成“最大化某个特定通道的激活”这样你能单独看到某一个卷积核学到了什么。方法就是把 loss 改成feat[0, channel].norm()。还可以把多个通道的激活加权求和控制生成图的某类图案占比。另外还有个有趣的方向是“倒着画画”你先定义一个目标特征向量然后优化图像让特征匹配这就是后来风格迁移和对抗样本的雏形。6.2 把它和风格迁移结合Deep Dream 和神经风格迁移共享同一个核心通过梯度优化输入图像。区别只在于损失函数不同。Deep Dream 用激活绝对值风格迁移用 Gram 矩阵。你可以把 Deep Dream 的激活 loss 和风格 loss 叠加起来生成“既梦幻又模仿某幅画笔触”的图。做法很简单新增一个风格分支计算风格层的 Gram 矩阵差然后两个 loss 按权重相加。这其实是很多创意滤镜工具的底层原理。6.3 从 Deep Dream 到对抗样本的灵感理解 Deep Dream 之后再看对抗样本就很容易对抗样本是最小化某个损失迷惑网络Deep Dream 是最大化特征响应。两者的代码框架几乎一样都是“在输入上做梯度”区别在于目标函数和方向。所以我一直建议想研究对抗攻击的同学先写一个 Deep Dream因为这是最温和的上手练习能帮你彻底理解输入空间的梯度优化到底是什么感觉。结尾我踩过的坑和最后的建议我自己第一次跑通 Deep Dream 时犯过最可笑的一个错误把所有层的参数requires_gradTrue然后输入x也requires_gradTrue倒是没报错但每次 backward 都会计算权重梯度显存直接爆掉。后来把所有模型参数冻结只留输入可导整整省了一大半显存。还有一次我把 lr 设成 5迭代 50 次出来的图简直像抽象派砸碎了的调色盘但也意外地好看。所以调参这件事有时候“错误”也能带来惊喜。如果你想真正掌握 Deep Dream我的建议是先不要用别人的封装库老老实实把这段代码自己敲一遍然后把layer从 0 到 36 全部跑一遍保存每一层的输出图。这个过程不仅能让你彻底搞懂 CNN 的不同层到底在干什么还能形成一套自己的“层号直觉”——以后做任何特征可视化任务你都会比那些只调别人工具的人快得多。最后跑大图时记得保存中间过程别一口气跑到底否则一旦崩了你连“崩溃前长什么样”都不知道。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →