第一把“AI手术刀”:用Jupyter Notebook解剖并可视化一个神经网络
第一把“AI手术刀”用Jupyter Notebook解剖并可视化一个神经网络当黑箱不再神秘在单元格之间逐层剥开深度学习的“直觉”楔子为什么我们需要“看见”神经网络深度学习常被诟病为“炼金术”——调参如撒盐效果看缘分。但真正让工程师失眠的不是数学公式的复杂而是推理过程的不透明。我们丢进一张猫图网络吐出“猫”的标签中间发生了什么几十亿个参数像一片黑暗森林没有地图没有路径。但如果我们能解剖它呢不是用抽象的张量形状而是用可视化的手术刀一刀一刀切下去看到每一层如何扭曲输入空间如何放大特征如何形成决策边界。这就是本文的目标用 Jupyter Notebook 作为手术台PyTorch 作为刀柄Matplotlib 作为显微镜完整解剖一个为 MNIST 设计的浅层卷积神经网络。我们不做训练演示那太无聊而是做事后尸检与动态探查——加载预训练权重逐层钩取激活值绘制特征地图并最终用交互式控件“拨动”神经元的响应。准备好你的 Jupyter 环境。我们开始动刀。第一章手术器械清单 —— 环境与模型骨架首先确保你的笔记本里有这些工具importtorchimporttorch.nnasnnimporttorch.nn.functionalasFimportnumpyasnpimportmatplotlib.pyplotaspltfrommatplotlib.widgetsimportSlider,Buttonimporttorchvisionimporttorchvision.transformsastransformsfromPILimportImageimportwarnings warnings.filterwarnings(ignore)我们使用一个极简的 ConvNet专门为 28×28 灰度图设计。它只有 3 个卷积块和 2 个全连接层总参数量约 12 万——足够小可以完全装进大脑又足够深能展示层次语义。classNet(nn.Module):def__init__(self):super().__init__()self.conv1nn.Conv2d(1,16,3,padding1)self.conv2nn.Conv2d(16,32,3,padding1)self.conv3nn.Conv2d(32,64,3,padding1)self.poolnn.MaxPool2d(2,2)self.fc1nn.Linear(64*4*4,128)self.fc2nn.Linear(128,10)self.dropnn.Dropout(0.25)defforward(self,x,return_activationsFalse):# 我们在此处埋下钩子返回各层输出acts{}xself.pool(F.relu(self.conv1(x)))acts[conv1]x xself.pool(F.relu(self.conv2(x)))acts[conv2]x xself.pool(F.relu(self.conv3(x)))acts[conv3]x xx.view(-1,64*4*4)xF.relu(self.fc1(x))acts[fc1]x xself.drop(x)xself.fc2(x)acts[logits]xifreturn_activations:returnx,actsreturnx我们用 MNIST 测试集上一轮预训练好的权重精度 99.2%。你可以自己训练 5 个 epoch或加载我提供的权重文件。这里假设你已经有了model.pth。modelNet()model.load_state_dict(torch.load(model.pth,map_locationcpu))model.eval()第二章第一刀 —— 输入样本的“术前成像”任何解剖都需要先看“病人”全貌。选一张数字“5”的图片我们把它转换成模型真正看到的张量——归一化后的 1×28×28。transformtransforms.Compose([transforms.ToTensor(),transforms.Normalize((0.1307,),(0.3081,))])# 取测试集第一个样本testsettorchvision.datasets.MNIST(root./data,trainFalse,downloadTrue,transformtransform)img,labeltestset[5]# 索引5对应一个手写5img_batchimg.unsqueeze(0)plt.figure(figsize(4,4))plt.imshow(img.squeeze(),cmapgray)plt.title(f原始输入 - 标签{label})plt.axis(off)plt.show()这一刀没有血只是看清起点。第三章第二刀 —— 前向钩子截取每一层的“组织切片”PyTorch 的register_forward_hook是我们的活检钳。我们在每个卷积层和全连接层后挂载钩子记录激活张量。activation_outputs{}defget_hook(name):defhook(module,input,output):activation_outputs[name]output.detach()returnhook# 注册handles[]forname,layerinmodel.named_children():ifisinstance(layer,(nn.Conv2d,nn.Linear)):handles.append(layer.register_forward_hook(get_hook(name)))执行一次前向传播同时收集激活withtorch.no_grad():output,actsmodel(img_batch,return_activationsTrue)# activation_outputs 里现在有 conv1, conv2, conv3, fc1 的 tensor现在我们把这些“切片”摊开在显微镜下。第四章第三刀 —— 卷积核的可视化第一层在“看”什么第一层卷积核16 个 3×3 滤波器是直接作用在原始像素上的。我们把它画出来就能理解网络最底层的“感受野偏好”。kernelsmodel.conv1.weight.data.cpu().numpy()# shape: [16, 1, 3, 3]fig,axesplt.subplots(4,4,figsize(8,8))fori,axinenumerate(axes.flat):# 归一化到0-1显示kkernels[i,0]k(k-k.min())/(k.max()-k.min()1e-8)ax.imshow(k,cmapRdBu,interpolationnearest)ax.set_title(fFilter{i1})ax.axis(off)plt.suptitle(第一层卷积核 (3x3 边缘/纹理检测器),fontsize14)plt.tight_layout()plt.show()你会看到有些核像水平边缘检测器有些像垂直边缘有些像斑点——这印证了视觉皮层初级细胞的经典发现。第五章第四刀 —— 特征图接力从边缘到语义的“变形记”这是最精彩的部分。我们绘制三层卷积输出的特征图取前 8 个通道观察同一张“5”如何被逐层抽象。defplot_activation_maps(acts_dict,layer_name,num_channels8):tensoracts_dict[layer_name].squeeze(0)# [C, H, W]cmin(num_channels,tensor.shape[0])fig,axesplt.subplots(2,c//2,figsize(12,4))axesaxes.flatten()foriinrange(c):fmtensor[i].cpu().numpy()fm(fm-fm.min())/(fm.max()-fm.min()1e-8)axes[i].imshow(fm,cmapviridis)axes[i].set_title(fch{i1})axes[i].axis(off)plt.suptitle(f{layer_name}特征图 (前{c}通道),fontsize14)plt.tight_layout()plt.show()forlayerin[conv1,conv2,conv3]:plot_activation_maps(acts,layer,num_channels8)观察结果conv116通道14×14特征图仍清晰保留数字轮廓每个通道响应不同的局部纹理边缘方向。conv232通道7×7开始出现“碎片化”某些通道对数字的特定弧度敏感但空间位置仍可辨识。conv364通道4×4特征图已经高度抽象每个小图几乎看不出原始数字形状而是某种“语义码”——比如某个通道专门响应上半部分的圆弧另一个响应右下角的收笔。这就是层次特征复用的直观证据。第六章第五刀 —— 全连接层的“决策热力”哪个神经元被点燃fc1有 128 个神经元它们是最后的“特征向量”。我们把这 128 个激活值画成条形图并高亮前 10 个最强响应。fc1_actacts[fc1].squeeze().cpu().numpy()# [128]plt.figure(figsize(14,4))plt.bar(range(128),fc1_act,colorsteelblue,alpha0.7)plt.scatter(np.argsort(fc1_act)[-10:],np.sort(fc1_act)[-10:],colorred,s50,labelTop 10 激活)plt.xlabel(神经元索引)plt.ylabel(ReLU 激活值)plt.title(全连接层 fc1 的响应分布 (128维))plt.legend()plt.grid(axisy,alpha0.3)plt.show()通常这 128 维向量中只有不到 20% 的神经元有显著响应0.5其余接近零——说明网络内部表示是稀疏的。更进一步我们可以看输出 logits10 维logitsacts[logits].squeeze().cpu().numpy()probsF.softmax(torch.tensor(logits),dim0).numpy()plt.figure(figsize(8,4))plt.bar(range(10),probs,color[greenifilabelelsegrayforiinrange(10)])plt.xticks(range(10))plt.ylim(0,1)plt.title(f输出概率分布 — 正确类{label}置信度{probs[label]:.3f})plt.show()此时你会看到数字 5 的置信度通常在 0.95 以上其他类被有效压制。第七章第六刀 —— 交互式“神经元拨片”滑动 ReLU 阈值观察特征图变化静态图不过瘾。我们构建一个交互控件用一个滑块调整某个特征图的二值化阈值实时看到哪些空间位置被激活。fig,axplt.subplots(figsize(6,5))plt.subplots_adjust(bottom0.2)# 选取 conv2 的第 5 个通道作为演示feature_mapacts[conv2][0,4].cpu().numpy()# [7,7]imax.imshow(feature_map,cmaphot,interpolationbilinear)ax.set_title(conv2 第5通道 - 拖动阈值查看高响应区域)ax.axis(off)ax_threshplt.axes([0.2,0.05,0.6,0.03])sliderSlider(ax_thresh,阈值,0.0,feature_map.max(),valinitfeature_map.mean())defupdate(val):thslider.val maskednp.where(feature_mapth,feature_map,np.nan)im.set_data(masked)ax.set_title(f阈值 {th:.2f}(响应像素{np.sum(~np.isnan(masked))}个))fig.canvas.draw_idle()slider.on_changed(update)plt.show()拖动滑块时你会看到背景噪声逐渐消失只有数字“5”的笔画区域保留强响应。这说明卷积层已经学会了空间对齐的特征检测。第八章第七刀 —— 错误案例的“尸检报告”当网络看走眼时内部发生了什么找一张模型预测错误的样本如果有重复上述钩子流程然后对比正确样本的激活分布差异。这里我们强行选一个混淆样本比如将“3”误判为“5”。# 从测试集中找第一个预测错误的样本model.eval()wrong_idxNoneforiinrange(len(testset)):img_i,lbl_itestset[i]withtorch.no_grad():predmodel(img_i.unsqueeze(0)).argmax().item()ifpred!lbl_i:wrong_idxibreakifwrong_idxisnotNone:err_img,err_labeltestset[wrong_idx]withtorch.no_grad():_,err_actsmodel(err_img.unsqueeze(0),return_activationsTrue)# 对比 conv3 平均激活值correct_avgacts[conv3].mean().item()wrong_avgerr_acts[conv3].mean().item()print(f错误样本索引{wrong_idx}: 真实{err_label}, 预测{pred})print(f正确样本 conv3 平均激活:{correct_avg:.4f})print(f错误样本 conv3 平均激活:{wrong_avg:.4f})print(差异率: {:.2f}%.format(abs(correct_avg-wrong_avg)/correct_avg*100))# 并排显示两张图fig,(ax1,ax2)plt.subplots(1,2,figsize(8,4))ax1.imshow(testset[5][0].squeeze(),cmapgray)ax1.set_title(正确样本 (5))ax1.axis(off)ax2.imshow(err_img.squeeze(),cmapgray)ax2.set_title(f错误样本 ({err_label}→{pred}))ax2.axis(off)plt.show()通常你会发现错误样本的中间层激活值整体偏低或者在某些关键通道上的响应分布与正确样本存在结构性差异——这为对抗样本检测和不确定性估计提供了直观线索。第九章第八刀 —— 梯度反向传播的热力图Grad-CAM 简易版我们不止看前向还要看反向——即输入像素对最终决策的贡献度。用简单的“输入梯度”方法img_tensorimg_batch.clone().requires_grad_(True)outputmodel(img_tensor)target_classoutput[0,label]# 对正确类求梯度target_class.backward()gradimg_tensor.grad.squeeze().cpu().numpy()# [28,28]gradnp.abs(grad)# 取绝对值grad(grad-grad.min())/(grad.max()-grad.min()1e-8)plt.figure(figsize(8,4))plt.subplot(1,2,1)plt.imshow(img.squeeze(),cmapgray)plt.title(原始图像)plt.axis(off)plt.subplot(1,2,2)plt.imshow(grad,cmaphot,interpolationbilinear)plt.title(输入梯度热力图 (对正确类的敏感区域))plt.axis(off)plt.show()这张热力图会告诉你网络在做决策时最关注图像的哪些像素区域。对于数字“5”热点往往集中在左上弧线和右下收笔处——这与人类识别数字的关键特征惊人一致。第十章缝合与启示 —— 我们解剖出了什么经过这八刀我们得到了以下病理解剖报告第一层边缘检测器可解释性最高。第二层局部组合纹理部分可解释。第三层语义碎片几乎不可直接解读但统计上区分度最强。全连接层稀疏编码只有少数神经元对特定输入“兴奋”。输出层概率分布置信度反映决策清晰度。梯度反向输入空间中的关键决策区域与人类视觉注意力有重叠。更重要的是我们证明了神经网络不是黑箱而是一座分层分解的滤波器组。每一层都在做“从具体到抽象”的降维与升维转换。Jupyter Notebook 赋予我们即时交互的能力让每一次ShiftEnter都像一次外科手术的精确切割。附手术室注意事项实用建议钩子管理用完记得for h in handles: h.remove()避免内存泄漏。大模型适配对于 ResNet-50 或 Transformer请使用torch.utils.viz或TensorBoard本文方法同样适用只需调整特征图尺寸。交互性能在 Notebook 中使用%matplotlib notebook替代inline可获得更流畅的滑块体验。权重复用若没有预训练权重可自行训练 3 个 epochMNIST 很快收敛不影响可视化效果。最后一把刀留给你的思考现在把这张图片换成你自己的照片、一个手绘符号甚至一段噪声——观察网络如何从“确定”滑向“困惑”。你可以修改网络结构增加 BN、改变激活函数然后再次切开它看内部组织是否发生形态变化。可视化不是终点而是理解的开端。每一把“AI 手术刀”切下的不是参数的血肉而是工程直觉的基石。当你下一次调参失败时不妨先打开 Notebook看看你的网络在“想”什么——答案往往就在那几张特征图里。代码完整版已封装为单个.ipynb文件可随本文附件下载。建议你亲手运行每一个单元格并试着替换不同的输入样本。那才是真正的“主刀时刻”。全文完如果你自己有电子文档需要在线阅读的需求如果你有word\Excel\ppt文档需要在线阅读的需求如果你希望你的电子文档在手机、平板、电脑阅读时进度同步的需求可以试试【个人文档管理平台】www.mcbook.site一杯奶茶钱就可以成为会员省去了文档在公司/家里/邮箱 传来传去的麻烦。更多技术文章见公众号: 大城市小农民推荐阅读个人文档/书籍管理平台介绍