CNN对着一张普通照片使劲看越看越上头最后把云看成狗、把树看成眼睛这就是Deep Dream给我的第一印象。它本质上是一种CNN特征可视化手段通过梯度上升把网络内部已经学会的抽象模式反向刻回输入图像让原本隐性的特征变成肉眼可见的图形。这篇文章我会用PyTorch从零搭一个最小可运行的Deep Dream把原理、代码、调参经验和踩坑记录全部展开适合已经会写基础PyTorch训练代码、但对模型内部机制好奇的读者。1. Deep Dream原理为什么“反向放大激活值”会产生梦境图1.1 同样的反向传播换一个更新对象先回忆一下正常的训练流程输入图像经过网络前向传播得到预测计算损失后反向传播得到每个权重参数的梯度然后用梯度下降更新权重。整个过程里图像是固定不变的“自变量”权重是不断被修改的“因变量”。Deep Dream把这件事倒过来了。模型权重完全冻结我们声明一个跟输入图像同形状的变量让它可导。前向传播不再关注最终的分类输出而是取中间某一层卷积输出的激活值计算它的某种“能量”通常用L2范数然后反向传播。因为权重都冻结了梯度只会流向我们声明的那个图像变量。于是图像开始朝着“让该层激活值更大”的方向更新——相当于网络在用自己的价值观“修改”这张图让它变得更像它想看到的东西。用生活里的话说这就像你拿着一张模糊照片去问一个训练有素的动物识别员“这是什么”他不仅会告诉你答案还会动手把照片里他觉得像眼睛、像羽毛的地方全都描一遍越描越真。网络做的正是这件事只不过它“描”的时候不受人类审美的约束。1.2 重复结构与诡异图案是怎么出现的如果你动手跑过一次Deep Dream会发现输出里充满了大量重复的形状一朵云被画成了好几个叠加的狗头一片树叶上长出很多眼睛。这种重复结构不是偶然而是CNN自身的层级特性决定的。浅层卷积核感受野小负责边缘、颜色、简单纹理所以浅层激活值放大后图像会进化出密集的几何纹样。深层卷积核感受野大学会了“眼睛”“嘴”“羽毛”“车轮”这类语义部件它检测到图像里某个区域有一点点像眼睛就会把这片区域朝眼睛方向推。由于一张图里能触发这种响应的位置不止一处网络就会像着了魔一样在同一张图里反复“画”出同类结构。加上多尺度金字塔以后这个循环会在不同分辨率上反复进行先是小尺度建立大的形变再逐步放大最后形成那种一层叠一层的“梦境感”。这也是为什么单跑一步梯度上升不会有这种效果但叠上几个octave之后就能看到明显变化。1.3 损失函数与梯度归一化的底层逻辑Deep Dream里最常用的损失是激活输出的L2范数即对选定层输出张量的每个元素求平方和再开根。这个损失会让网络倾向于把已有的大激活值推得更大而不是均匀放大所有位置。因为我只关心“激活能量”的总体水平不考虑方向所以这个损失天然适合做“无监督的强化”。但直接用原始梯度更新图像有一个问题梯度的绝对数值会随着图像内容剧烈变化可能某一步是几十下一步就变成几百步长很难控制。经典实现里不会用L2范数归一化梯度而是用标准差归一化——把梯度向量减去均值再除以标准差。这样做能把梯度压到零均值、单位方差的分布保留相对大小关系的同时让整体步长稳定比简单除以L2范数更适合处理图像这种高维非负数据。我自己的经验是如果直接用原始梯度加固定学习率图像经常在几步之内就变成纯黑纯白的噪点换成标准差归一化之后参数调节变得宽容很多学习率从0.005到0.05都能稳定出图。这个细节是整个Deep Dream最关键的工程点之一。2. 最小可运行版本PyTorch实现一个基础Deep Dream2.1 模型选择与torchvision加载方式实现Deep Dream不需要自己训练模型直接用预训练的图像分类模型就行。我推荐先用Inception_v3原因是它模块命名清晰有大量可以插hook的中间层而且Deep Dream这类实验对深层风格效果最好Inception_v3深层的inception block正好合适。这里有一个从torchvision加载模型的常见坑新版本里pretrainedTrue已经废弃要用weights参数指定权重枚举。import torch import torch.nn.functional as F import torchvision import torchvision.transforms as T from PIL import Image device torch.device(cuda if torch.cuda.is_available() else cpu) # 加载预训练Inception_v3关闭aux分支避免前向返回tuple model torchvision.models.inception_v3( weightstorchvision.models.Inception_V3_Weights.DEFAULT, aux_logitsFalse ).to(device).eval() # 冻结所有参数确保梯度只流向输入图像 for param in model.parameters(): param.requires_grad_(False)aux_logitsFalse这个参数很少有人提但这个细节在实际开发中很关键。Inception_v3正常推理时会额外计算一个辅助分类输出如果你不关掉它model(x)返回的就是一个包含两个元素的tuplehook注册也会多出一些不必要的计算。写代码时我会直接关掉省心又省显存。2.2 图像预处理与反处理ImageNet预训练模型要求输入满足特定的mean和std归一化。PyTorch里tensor的像素范围是[0,1]所以在送入模型前要减去均值、除以标准差反处理时则要做逆操作最后clip回[0,1]。mean torch.tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1).to(device) std torch.tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1).to(device) def preprocess(x): return (x - mean) / std def deprocess(x): x x * std mean return x.clamp(0, 1)我见很多人在这步直接写torch.clamp结果出来一张灰蒙蒙的图原因是在[0,1]范围外还有大量深度dream“过曝”的像素直接砍掉会让整张图的对比度失衡。更好用的做法是用百分位裁剪比如把最亮的1%和最暗的1%像素压到边界具体实现在调参部分再细说。基础版本先clamp就够看效果了。加载图片时Inception_v3的预期输入是299x299但我们做Deep Dream一般想吃稍微大一点的图因为后续还要做多尺度金字塔。我的习惯是先等比缩放到短边512再中心裁剪到512x512。分辨率也并不是越高越好512在消费级GPU上已经能让4个octave流畅跑完。def load_image(path, size512): img Image.open(path).convert(RGB) transform T.Compose([ T.Resize(size), T.CenterCrop(size), T.ToTensor(), ]) x transform(img).unsqueeze(0).to(device) return preprocess(x)这里有个很实用的判断如果你想看“原图已经较清晰”的图不要用299这种小尺寸因为多尺度下采样会把细节磨没但也不要一上来就1024显存和时间都会爆炸。512是一个让效果和资源消耗比较平衡的值。2.3 核心梯度上升循环hook截取中间层激活Deep Dream不需要修改模型结构只需要拿到中间某层的输出。PyTorch里最灵活的方式是注册forward hook。def deepdream(model, x, layer_name, iterations30, lr0.02): result x.clone().to(device).requires_grad_(True) # 根据层名找到模块并注册hook activation {} target_module dict(model.named_modules())[layer_name] def hook_fn(module, input, output): activation[layer_name] output handle target_module.register_forward_hook(hook_fn) for _ in range(iterations): activation.clear() model(result) # 前向hook会把激活写入dict loss activation[layer_name].norm() model.zero_grad() loss.backward() grad result.grad # 标准差归一化保留方向信息的同时稳定步长 grad_normalized grad / (grad.std() 1e-8) with torch.no_grad(): result lr * grad_normalized # 更新后重新挂载计算图 result result.detach().requires_grad_(True) handle.remove() return result有几个细节值得单独说。第一每次迭代我都调用activation.clear()因为hook在每次前向时都会覆盖同一个key不清空实际上也不会有问题但养成习惯可以避免以后换多个层时踩到旧值。第二更新梯度这一步一定要在no_grad下进行然后用detach().requires_grad_(True)重新挂载。如果不detach计算图会在iterations次循环里不断叠加显存会越吃越多几十步之后基本就OOM了。第三grad / (grad.std() 1e-8)中的1e-8是为了防止图像完全平滑时梯度标准差为0比如一开始输入纯色图这时梯度会全是0除以0会导致NaN。2.4 跑通第一张“梦境图”时能观察到什么基础版本只需要把图片forward一次跑30步梯度上升输出结果再deprocess保存。我第一次跑通时用的是普通街景照片第一个能感受到的变化是原本平滑的天空区域开始出现波纹状的纹理云的边缘逐渐长出类似羽毛的结构颜色饱和度明显变高画面中某些对比强烈的位置会生成小小的同心圆斑纹。这里要注意单次梯度上升产生的是“局部纹理放大”效果远远达不到经典Deep Dream那种充满重复结构、像另一个维度的图。你需要在后面加上多尺度金字塔才能看到真正意义上的“梦”。所以如果第一步跑出来的图变化不大不要急着怀疑代码很可能只是还没叠金字塔。另外特别建议每次迭代中打印一次loss值。正常情况loss应该单调上升且上升曲线不会是线性的往往是前期猛涨、后期趋缓。如果loss出现突然跳变到几千几万说明梯度爆炸了检查lr和归一化。3. 多尺度金字塔从纹路放大到真正“梦幻”的关键3.1 为什么单一尺度做不出远距离重复很多刚接触Deep Dream的人会想直接跑几百步梯度上升不就行了实际跑出来会发现单一尺度下网络只能在很小的局部邻域里做调整反复放大会让图像趋向高频纹理出现密集噪点但不会产生跨越整个画面的结构重复。原因不复杂CNN的感受野是有限的。Inception_v3的深层卷积在299x299输入上感受野也只有几十到上百像素对于512x512的图来说靠一次梯度上升很难把画面左上角和右下角同时改造成接近的语义结构。多尺度金字塔的本质是“先在低分辨率下让网络做大幅度的结构调整再把这种结构调整传递到高分辨率上细化”和我前面说的“先粗笔刷勾轮廓再细笔刷描细节”是同一个道理。3.2 octave金字塔的代码实现经典Deep Dream实现里有一个octave循环思路是先不断把图像缩小得到一个由小到大排列的图像序列然后从最小图开始做梯度上升每完成一个尺度就把该尺度的“梦境输出”与原始图的差值上采样到下一个尺度叠加回去继续。这样所有尺度共享同一个“细节增量”。def deepdream_octave(model, x, layer_name, iterations20, lr0.02, num_octaves4, octave_scale1.4): # 构建从最小到最大的octave图像序列 octaves [] cur x for _ in range(num_octaves - 1): cur F.interpolate(cur, scale_factor1 / octave_scale, modebilinear, align_cornersFalse) octaves.append(cur) octaves.append(x) octaves.reverse() detail torch.zeros_like(octaves[0]) # 上一尺度留下的细节增量 for i, octave in enumerate(octaves): if i 0: detail F.interpolate(detail, sizeoctave.shape[-2:], modebilinear, align_cornersFalse) x octave detail x deepdream(model, x, layer_name, iterationsiterations, lrlr) detail x - octave # 当前尺度上执行前后的差异作为新增细节 return x这个代码里最关键的是detail x - octave这一步。它捕获的是“网络在当前尺度上对这张图的修改量”而不会把整张图的历史状态都带下去。下一轮octave把这个增量上采样后叠加到更大的输入上再继续让网络修改这样既保持了跨尺度的一致性又不会让累计的改动糊成一团。interpolate里的align_cornersFalse也值得说一句。这是PyTorch对像素坐标定义的一种选择改为False时坐标按像素中心对齐做图像缩放时能减少一定的高频伪影。很多教程里写默认参数但在Deep Dream这种连续做多次缩放的场景对齐方式会影响最终画质。3.3 参数组合的经验区间经过大量实验我总结出一套比较稳的起步区间。这里直接给一个我常用的配置表新手按照这个区间调一般不会翻车。参数推荐值调整方向副作用提醒iterations_per_octave20~30越大细节越丰富超过50容易饱和失真lr0.01~0.05越大形变越剧烈超过0.1会噪声爆炸num_octaves3~5越多结构层次越丰富过多显存吃紧octave_scale1.3~1.6越小过渡越连续小于1.2会积累雪花点一个很有参考价值的做法是先用低参数快速跑通比如num_octaves2, iterations10肉眼确认方向和风格没问题后再逐步增加octave和迭代数。Deep Dream不是训练模型不用追求收敛它更像是一种“控制强度的放大器”参数越大不代表越好。4. 层选择决定“梦境风格”不同层的输出差异实验4.1 用hook批量尝试多个层如果你想看看不同层对同一张图的Dream效果差别不需要改太多代码写一个循环把layer_name作为参数传进去就行。我经常先跑一个探针脚本把Inception_v3里面几个关键层的效果都生成一遍放在一起对比。import torch from PIL import Image # 读入同一张图和预处理略 x load_image(test.jpg) candidate_layers [ Conv2d_2a_3x3, Mixed_3b, Mixed_4c, Mixed_5b, Mixed_5c, Mixed_6a, Mixed_6e, Mixed_7c, ] for layer_name in candidate_layers: out deepdream_octave(model, x, layer_name, iterations20, lr0.02, num_octaves4, octave_scale1.4) save_tensor(out, fdream_{layer_name}.jpg)4.2 Inception_v3不同层的实际感官差异我可以把实测下来的感受整理成一个表方便你按想要的效果直接选层。层名对应尺度生成效果特点我的使用建议Conv2d_2a_3x3最浅密集几何纹理、彩色马赛克感几乎没有语义适合做图形染色风格不适合“梦幻”效果Mixed_3b较浅小圆弧与波纹开始有局部形状重复轻度纹理增强适合不喜欢太夸张的用户Mixed_4c中等出现眼睛、羽毛、叶片的雏形结构开始清晰经典Deep Dream入门层不容易崩Mixed_5b中深出现大量重复物体部件风格最接近大家熟悉的Deep Dream我最常用的层出图梦幻且可控Mixed_5c较深形变更大开始出现怪异的组合结构喜欢重口味的可以试Mixed_6e深图面容易大范围变形可能出现对称的“怪物脸”慎用容易把整图毁掉Mixed_7c最深极端抽象大量物体堆叠接近纯幻觉显存允许时玩一玩成功出图会很惊艳为什么会有这种差异本质上是卷积网络自底向上逐渐从“局部纹理”走向“全局语义”的过程。浅层的每个卷积核只看得到一小块区域所以它放大出来的模式必然是小尺度的深层网络把底层特征组合成了对“物体部件”的响应放大时自然会产生类似脸部、眼睛这类让人觉得很“诡异”的结构。4.3 换用VGG16时需要注意什么很多读者会想换一个模型试效果比如VGG16。VGG16在torchvision里的结构非常直白所有卷积都在features这个Sequential模块里层的名称就是features.0、features.3这种索引。加载方式类似vgg torchvision.models.vgg16( weightstorchvision.models.VGG16_Weights.DEFAULT ).to(device).eval() for param in vgg.parameters(): param.requires_grad_(False)用VGG16做Deep Dream的差异点在于VGG的感受野比Inception系列偏小而且没有inception block那种多尺度并行结构所以同等层数下生成的“梦境”风格更偏向密集纹理重复结构的尺度更小。VGG16的features.24对应的效果大概相当于Inception_v3的Mixed_4cfeatures.29以上才开始出现明显的语义部件。如果你是从零开始实验我还是建议先Inception_v3因为层命名更语义化做对比实验时更容易定位。5. 踩坑实录从全黑废图到超大噪声的排查链路5.1 现象A迭代几十步后图像变成纯黑或噪点图这是最常见的翻车现场。排查链路要根据现象逐层缩小范围第一步看loss。如果把loss打印出来发现它在某一步突然从几十涨到几万基本可以确定是梯度爆炸。这时候先用最保守的lr0.001跑一遍如果正常了说明问题就是学习率过大。第二步检查梯度归一化。如果你用的是原始梯度而不是标准差归一化换成归一化之后通常能立竿见影。第三步检查是否octave太多导致形变叠加失控。把num_octaves降到2如果噪点消失说明问题出在金字塔层数过多而不是单步梯度。还有一种比较隐蔽的情况当octave_scale取到1.8以上相邻两层的尺度跳跃过大上采样的detail会带有强烈的块状纹理最终表现为类似锯齿的噪点这种情况更适合调octave_scale而不是降lr。5.2 现象B输出与输入几乎一样完全没变化代码跑通了但生成的图和原图基本一样这种问题方向跟噪点是反过来的。优先排查这么几处。首先确认模型的requires_grad_(False)真的生效了这步一般没问题但如果你用了model.eval()之后忘记冻结可能会对参数产生梯度反而影响图像梯度。其次result x.clone().to(device).requires_grad_(True)这行如果在clone之后被某个后续操作重新赋值了比如顺手写了个x x.permute(...)梯度计算图可能会断掉。我的建议是每一轮迭代后打印一次result.grad is None排除梯度根本没回传的情况。第三个高频原因是层选得太浅。比如选Conv2d_2a_3x3这个层输出的是基础的边缘和颜色响应即使放大100倍图像也只是颜色纹理加深肉眼看起来“变化不大”。这时候你需要换Mixed_4c或Mixed_5b再看。还有一个容易被忽视的点迭代次数太少也会看不出变化。Deep Dream前10步往往变化很小通常第15到25步之间才开始有明显的形态生成。如果只迭代5步输出几乎就是原图加一点锐化效果不要急于下“代码坏了”的结论。5.3 现象C图像上出现密集的“雪花点”伪影雪花点跟噪点不一样噪点是整张图花掉雪花点是高频细节颗粒分布在原本平滑的区域整体色调还能看。出现雪花点的常见原因是octave_scale太小。我在实验里把octave_scale从1.4调到1.1后生成的图在天空区域出现了大量细小圆形颗粒。原因是每层octave之间尺寸差异太小高频细节在多次上采样中被反复保留并放大最终形成类似白噪声的分布。解决办法很简单把octave_scale调回1.4以上同时适当减少num_octaves减少高频信息的累积次数。另外F.interpolate用align_cornersFalse也能减轻一部分雪花点。如果你已经在用align_cornersFalse那重点还是检查octave参数。5.4 现象D显存不足或速度慢到怀疑人生Deep Dream的显存消耗比标准推理要大不少因为每轮迭代都建立了从输入到指定层的最小计算图。我用512x512的输入、4个octave、Inception_v3大概会占到6GB左右显存。如果你的卡显存有限有三种缓解办法。最直接的是降输入尺寸从512降到384效果几乎不损失太多但显存和时间会显著下降。其次是减少octave数量把num_octaves从4降到3省掉的显存主要在于高分辨率层的计算。如果还需要再省可以用torch.cuda.amp.autocast()混合精度做前向和反向。我自己实测下来混合精度在Deep Dream里效果稳定因为梯度上升本身对精度不敏感适合无脑开启。如果你正好没有GPUCPU也不是完全不能跑只是时间会拉长到10分钟甚至更久。这时候建议把分辨率降到299、num_octaves2先用小图验证思路再做最终出图。6. 同一条技术路线的延伸从Deep Dream到更广的特征可视化6.1 换个损失就能做“单通道可视化”Deep Dream里用整层的L2范数作为损失如果你想看某一个具体卷积核在找什么只需要把损失改成指定通道的激活值范数。比如Inception_v3的Mixed_5b输出有几百个通道取第100个通道做梯度上升生成出来的图像就代表“这个卷积核最想看到的东西”。这类实验我建议用全零噪声图作为起点而不是真实照片。官方做法是从随机噪声开始这样生成的图像不会受原图内容的干扰能更纯粹地反映通道偏好。不过需要注意的是从噪声开始需要更大的迭代次数和更高学习率否则图像会一直停留在模糊色块状态。6.2 固定分类目标的“类幻觉图”把Deep Dream的损失从中间层激活换成最后一个全连接层某个类别的logit你就可以生成一张“网络认为最像该类别的图”。这个方向经常被用来做对抗样本的前置理解你让网络最大化“青蛙”的分数最后会得到一张充满眼睛和纹理的青蛙特征堆叠图。实现上几乎不需要改动前面代码只把hook截取激活换成模型输出logits model(result) loss logits[0, class_idx] # 目标类别分数这种做法的视觉冲击力很强但也有一个共同的负面效果——越到后期图像越容易产生高频噪点。原因跟目标函数有关全连接层的梯度对图像高频信息极敏感。这时候可以把输入图像做一个轻微的低通滤波再继续迭代或者提前终止在20步左右避免最后几张图废掉。6.3 把Deep Dream逻辑塞进训练流程里的前提条件如果你想把Deep Dream作为一种数据增强手段集成到训练中有一个前提条件必须想清楚Deep Dream的输出是“让模型更确信之前已学会的模式”它不会创造出训练集之外的语义。换句话说给分类器加上Deep Dream增强相当于不断强化模型已有的偏见而不像普通裁剪翻转那样增加分布外样本。感兴趣的话可以用小实验验证但不要指望它提升泛化能力。我个人在这条路上的体会是Deep Dream这种可视化技术虽然被很多后来的方法取代了一部分“酷炫感”但它依然是最容易直观理解“CNN在每一层到底看到了什么”的方式。每次跑通一层新的可视化就像打开了模型大脑里的一个暗格。最后分享一个我自己常用的实验习惯正式跑大图之前先把原图缩到256甚至224用最少的octave和迭代次数快速评估风格和层选择。确认方向后再上512或更高分辨率。这样一轮实验从等待十分钟缩短到一两分钟调试效率提升非常明显。Deep Dream这种项目没有标准答案多跑几张图、换几个层你对卷积网络层级语义的理解会比读十篇论文都来得更真切。