本册小节 · 直接定位
  1. 准备:本实验的固定材料
  2. 1. 用项目中的模型,观察三个固定数字
  3. 2. 先找共同现象,再核对位置
  4. 3. 同样大小的遮挡,怎样成为可解释的比较?
  5. 4. 把观察变成下一项检验

返回第二讲 · 真实响应与遮挡对照 · 第二讲导读与学习材料

先看见响应,再检验改变输入会发生什么。 本册复用第二讲同一台26,698参数CNN,观察MNIST test 0/1/2(标签7/2/1),不重新训练。

今天的条件 可以直接做什么 看哪里
只读材料,尚无运行文件 比较第2节三张输入同一通道,再解释第3节遮挡A/B 已保存真实图、色标、改变像素数与logit
已有匹配模型与MNIST测试文件 从准备代码依次运行至第1节,再选第2或3节 核对模型SHA-256与本次打印结果
想做一个单变量干预 第3节只改 FILL: 0→0.5 背景B是否仍不改输入,以及A/B的实际扰动量

课堂先预测: 同样的5×5遮挡面积是否等于相同的输入变化?先看历史A/B对照,再预测把填充值改为0.5时,背景B还能不能保持原logit。

返回课件:两层响应 → 同一通道 → 遮挡对照。

准备:本实验的固定材料

阅读保存图无需运行准备代码。 若要复算,先在项目根目录准备以下三个文件;它们是文件依赖,不是另一Notebook的内存:

文件 用途
results/cnn-plain-state.pt 第二讲小CNN的已训练权重
data/t10k-images-idx3-ubyte.gz 官方MNIST测试图像
data/t10k-labels-idx1-ubyte.gz 对应测试标签

可下载课件缓存包,把其中 data/ 与 results/ 按原目录解压到项目根目录,不要多套一层文件夹。运行需要 numpy matplotlib torch。

最小顺序: 导入 → 检查文件/MD5并计算模型哈希 → 构建CNN并加载权重 → 第1节固定三图前向 → 第2节换通道或第3节遮挡。第3节不依赖第2节的绘图,但依赖第1节的 inputs / measured。

代码打印 课件同一检查点: True/False;它不会因哈希不同自动中止。显示 False 时先检查文件来源:若是自己重训的权重,可观察但须记录为新模型,不能把新值覆盖成课件旧观测。没有文件时可继续读保存图,不必临时重训。

查看代码 · 单元 5
import copy, gzip, hashlib, io, json, os, time
from pathlib import Path
from collections import OrderedDict
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle
import torch
from torch import nn
from torch.nn import functional as F
torch.set_num_threads(2)
torch.manual_seed(20260917)

文件错误怎样定位。 missing 会列出当前找不到的相对路径;MD5不匹配表示测试文件身份不符;load_state_dict形状不匹配常表示放入了另一结构的模型。先检查目录与文件来源,不修改校验值来让代码通过。

查看代码 · 单元 7
MODEL_PATH=Path('results/cnn-plain-state.pt')
TEST_FILES={
 't10k-images-idx3-ubyte.gz':'9fb629c4189551a2d022fa330f9573f3',
 't10k-labels-idx1-ubyte.gz':'ec29112dd5afa0611ce80d1b7f02629c'}
missing=[str(p) for p in [MODEL_PATH,*[Path('data')/n for n in TEST_FILES]] if not p.is_file()]
if missing:
    raise RuntimeError('请先按 LAB 02 默认流程产生这些项目文件,再返回本页:'+', '.join(missing))
asset_bytes={'cnn-plain-state.pt':MODEL_PATH.read_bytes()}
for name,md5 in TEST_FILES.items():
    raw=(Path('data')/name).read_bytes()
    assert hashlib.md5(raw).hexdigest()==md5, 'MNIST 文件不匹配:'+name
    asset_bytes[name]=raw
model_sha256=hashlib.sha256(asset_bytes['cnn-plain-state.pt']).hexdigest()
COURSE_MODEL_SHA256='ba8b738cc67d4ec3cd83d75a5dbe8a70ec548377335ba0abaaa7c23cd14eeba3'
print('课件同一检查点:',model_sha256==COURSE_MODEL_SHA256)
查看代码 · 单元 8
source_state=torch.load(io.BytesIO(asset_bytes['cnn-plain-state.pt']),map_location='cpu',weights_only=True)
def make_cnn():
    return nn.Sequential(OrderedDict([
      ('conv1',nn.Conv2d(1,8,3,padding=1)),('relu1',nn.ReLU()),('pool1',nn.MaxPool2d(2)),
      ('conv2',nn.Conv2d(8,16,3,padding=1)),('relu2',nn.ReLU()),('pool2',nn.MaxPool2d(2)),
      ('flatten',nn.Flatten()),('hidden',nn.Linear(784,32)),('relu3',nn.ReLU()),('logits',nn.Linear(32,10))]))
source_model=make_cnn();source_model.load_state_dict(source_state);source_model.eval()
assert sum(p.numel() for p in source_model.parameters())==26698
print('已读取项目内的 26698 参数 CNN;PyTorch',torch.__version__)
print('当前模型 SHA-256:',model_sha256)

1. 用项目中的模型,观察三个固定数字

先预测:同一个通道面对 7、2、1,会有哪些共同响应?本节从项目文件做真实前向,不重新训练。模型 SHA-256 与课件相同,才是同一模型;自己重训后应保存新的身份与结果。

L1 是 Conv1→ReLU 后、Pool1 前,8×28×28;L2 是 Conv2→ReLU 后、Pool2 前,16×14×14。下一节只改变观察的层和通道。

查看代码 · 单元 11
raw_images=gzip.decompress(asset_bytes['t10k-images-idx3-ubyte.gz'])
raw_labels=gzip.decompress(asset_bytes['t10k-labels-idx1-ubyte.gz'])
images=np.frombuffer(raw_images,dtype=np.uint8,offset=16).reshape(10000,1,28,28)[:3].copy()
labels=np.frombuffer(raw_labels,dtype=np.uint8,offset=8)[:3].copy()
assert labels.tolist()==[7,2,1]
inputs=torch.tensor(images,dtype=torch.float32)/255
with torch.no_grad():
    relu1=source_model.relu1(source_model.conv1(inputs))
    relu2=source_model.relu2(source_model.conv2(source_model.pool1(relu1)))
    logits=source_model(inputs)
measured={'inputs':inputs.numpy(),'labels':labels,'test_indices':np.arange(3),
          'relu1':relu1.numpy(),'relu2':relu2.numpy(),'logits':logits.numpy()}
response_manifest={'color':{'layers':{
 'relu1':{'max':max(float(relu1.max()),1e-12)},
 'relu2':{'max':max(float(relu2.max()),1e-12)}}}}
print('固定 test 序号:',measured['test_indices'].tolist(),'真实标签:',labels.tolist())
print('模型预测:',logits.argmax(1).tolist())
print('本次同层统一色标:',response_manifest['color']['layers'])
print('模型 SHA-256:',model_sha256)

2. 先找共同现象,再核对位置

同一个通道,面对 7、2、1 会在哪里响应? 先沿同一列从输入看到响应,再横向比较三列的共同现象。

三个真实MNIST输入、同一CNN第二层第0通道响应及理论输入支持范围

已保存的真实参考:官方 test 序号 0/1/2,标签 7/2/1;课件检查点 ba8b738c…14eeba3,relu2/c0。输入为 28×28、灰度范围 0–1;响应为 14×14,三图统一色标 0–8.802(来自同层三输入、全部通道)。金框上方为理论输入支持范围,下方为最大响应单元,坐标从 0 编号。图来自此前本地前向计算。

先指出一种共同现象: 三个输入都能激活此通道,峰值却不在相同坐标。能否因此把它命名为“数字 7 检测器”?为什么还不够?

只改一次: 保持 LAYER='relu2',把 CHANNEL=0 改成 1。先预测响应位置是否相同,再运行。随后可单独换层;L1 参考范围约 0–2.363,L2 约 0–8.802,层间颜色深浅不能直接比较。使用自己重训的模型时,代码会打印本次哈希并重新统计同层色标。

查看代码 · 单元 14
LAYER='relu2'   # relu1 / relu2
CHANNEL=0      # L1: 0..7; L2: 0..15
assert LAYER in ('relu1','relu2')
activation={'relu1':relu1.numpy(),'relu2':relu2.numpy()}[LAYER]
assert 0<=CHANNEL<activation.shape[1]
vmax=response_manifest['color']['layers'][LAYER]['max']
def support(row,col,layer):
    size,jump,offset=(3,1,-1) if layer=='relu1' else (8,2,-3)
    top,left=row*jump+offset,col*jump+offset
    return max(0,top),max(0,left),min(28,top+size),min(28,left+size)
def peak_summary(a):
    a=np.asarray(a)
    assert a.ndim==2 and np.isfinite(a).all()
    peak=float(a.max())
    ties=np.argwhere(a==peak)
    # These are post-ReLU responses. Zero has no meaningful positive hotspot.
    if peak<=0:
        return {'peak':peak,'peak_row_col':None,'tied_maxima':len(ties),'has_positive_response':False}
    row,col=map(int,ties[0])
    return {'peak':peak,'peak_row_col':[row,col],'tied_maxima':len(ties),'has_positive_response':True}
fig,axes=plt.subplots(2,3,figsize=(10,6),layout='constrained')
observations=[]
for j in range(3):
    a=activation[j,CHANNEL];summary=peak_summary(a)
    axes[0,j].imshow(measured['inputs'][j,0],cmap='gray',vmin=0,vmax=1)
    axes[0,j].set_title(f"test {j}: label {measured['labels'][j]}")
    im=axes[1,j].imshow(a,cmap='Blues',vmin=0,vmax=vmax,interpolation='nearest')
    if summary['has_positive_response']:
        row,col=summary['peak_row_col']
        top,left,bottom,right=support(row,col,LAYER)
        axes[0,j].add_patch(Rectangle((left-.5,top-.5),right-left,bottom-top,fill=False,color='#FDB515',lw=2))
        axes[1,j].add_patch(Rectangle((col-.5,row-.5),1,1,fill=False,color='#FDB515',lw=2))
        axes[1,j].set_title(f'{LAYER}/c{CHANNEL}'+chr(10)+f'peak ({row},{col}) = {summary["peak"]:.3f}; ties={summary["tied_maxima"]}')
        summary['support']=[top,left,bottom,right]
    else:
        axes[1,j].set_title(f'{LAYER}/c{CHANNEL}'+chr(10)+'no positive response; no hotspot')
        summary['support']=None
    observations.append({'test_index':j,**summary})
fig.colorbar(im,ax=axes.ravel().tolist(),shrink=.7,label='response (same layer scale)')
plt.show()
print(json.dumps(observations,ensure_ascii=False,indent=2))

解释:最大处只是当前输入的一处响应高点。三个例子不足以命名稳定语义;理论支持范围也不说明每个像素都在此时产生影响。L1 c0 与 L2 c0 是两个不同通道,并非同一个 filter 穿过两层。

继续一步:换一个通道,保持色标不变。代码同时记录峰值与并列最大位置数量;正响应并列时只为定位而选行优先的第一个,并明确报告 ties。若响应全为 0,坐标与支持区为 None,不画响应框或输入框,不能把首个数组位置解读成热点。

3. 同样大小的遮挡,怎样成为可解释的比较?

从“哪里响应”继续到“改变输入后,输出怎样变化”。 固定第 0 个输入、目标类别 7、该类 logit、5×5 框与填充值 0;只把框放到两个位置。

真实数字7原图与笔画遮挡A和零变化背景对照B

同一课件检查点的本地测量:原始 logit=14.3833;A 左上角 (行19,列10),实际改变 16 像素,logit=13.9187;B 位于 (0,0),改变 0 像素,logit 不变。灰度范围 0–1;两框虽同为 5×5,扰动强度不同。

先解释: B 为什么看起来放了框,却没有真正改变输入?它是无像素改变的负对照,不是扰动强度匹配的对照。

只改一次: 将 FILL 改为 .5,保持位置、尺寸、模型与目标不变。先预测 B 还会不会保持原分数,再运行并比较“改变像素数”和“绝对改变量之和”。遮挡自身也可能引入异常输入;分数下降不足以证明某块区域是必要或充分原因。这里的判断限定于同一模型与指定输入,不直接外推为所有物体的因果解释。

同一CNN和数字7的保存遮挡对照:同为5乘5框,A改16像素而B改0像素,对应不同logit变化

打开矢量图

先分两列记录。 “遮挡框面积”描述操作范围,“改变像素数/绝对改变量”描述实际干预。把 FILL 改为0.5后,只能从新前向得到新的logit,图中的历史数值不随参数自动更新。

查看代码 · 单元 18
SAMPLE=0
TARGET=int(measured['labels'][SAMPLE])
PATCH_SIZE=5
FILL=0.0
PATCH_A=(19,10)
PATCH_B=(0,0)
assert 0<=SAMPLE<3 and 0<=TARGET<10 and 0<PATCH_SIZE<=28
def occlude(x,corner):
    row,col=corner
    assert 0<=row<=28-PATCH_SIZE and 0<=col<=28-PATCH_SIZE
    z=x.clone();z[:,:,row:row+PATCH_SIZE,col:col+PATCH_SIZE]=FILL
    return z
original=inputs[SAMPLE:SAMPLE+1]
variants=torch.cat([original,occlude(original,PATCH_A),occlude(original,PATCH_B)])
with torch.no_grad():
    scores=source_model(variants)[:,TARGET].numpy()
fig,axes=plt.subplots(1,3,figsize=(9,3))
change=(variants-original).abs()
changed_pixels=(change>0).flatten(1).sum(1).tolist()
absolute_change=change.flatten(1).sum(1).tolist()
for j,(ax,x,score,name) in enumerate(zip(axes,variants[:,0],scores,['original','patch A','patch B'])):
    ax.imshow(x,cmap='gray',vmin=0,vmax=1)
    if j:
        row,col=(PATCH_A,PATCH_B)[j-1]
        ax.add_patch(Rectangle((col-.5,row-.5),PATCH_SIZE,PATCH_SIZE,fill=False,color='#FDB515',lw=2))
    ax.set_title(f'{name}: logit={score:.3f}'+chr(10)+f'{changed_pixels[j]} changed pixels');ax.axis('off')
plt.show()
occlusion_result={'sample':SAMPLE,'target':TARGET,'score':'target logit','patch_size':PATCH_SIZE,'fill':FILL,
                  'patch_A':PATCH_A,'patch_B':PATCH_B,'scores':scores.tolist(),
                  'changed_pixels':changed_pixels,'absolute_pixel_change_sum':absolute_change,
                  'original_minus_A':float(scores[0]-scores[1]),'original_minus_B':float(scores[0]-scores[2])}
print(json.dumps(occlusion_result,ensure_ascii=False,indent=2))

4. 把观察变成下一项检验

只改变一项 运行前先写 输出后核对 接下来怎样追问
固定层,换通道 哪些输入可能共同激活 同一色标下的位置、峰值与并列最大数 增加事先选定的输入,检验共同现象
固定位置与框大小,改填充值 背景B会不会仍是零扰动 改变像素数、绝对改变量、目标logit差 把扰动强度与位置分别控制

给同伴讲清一个结果:我只改了什么 → 哪个读数变化 → 支持了哪个判断 → 哪个结论还不能推出。 不需要为完成表格而重跑整套训练。

历史10,000张测试图的9533/8464正确数来自LAB02原图/右移实验;本册的三个固定输入不能用来重新报告总体准确率。

响应材料与来源清单 · 第二讲 CLS05 · 观察与迁移 · 第二讲导读

返回课堂导读 · 返回幻灯片

运行本节代码生成你的图表,再对照运行前的预测。记录本次模型、参数和观察结果;不同模型的结果不要混写成课件的既有实验。

本册计算边界:先按同一色标比较真实通道,再用遮挡对照检查指定输出。全零响应检查只验证显示规则;遮挡分数来自本册加载的模型。两种检验不相互替代。

回到主讲继续判断