李毅宏的机器学习作业5
李毅宏的机器学习作业5
-
作业要求
-
Task1——Sailency Map
-
Task2——Filter Visualization
-
- 核心代码
-
Task3——LIME
-
参考资料
作业要求
基于作业三的结果,在其中我们开发了一个基于CNN的食物分类系统。本次作业是对神经网络的基础研究阶段展开深入分析,并具体从四个不同的任务入手进行详细探讨。
Task1——Sailency Map
卷积网络在分类任务中表现出色,在测试集上的准确率达到很高然而解析神经网络性能高的原因并非易事就目前而言,在我们眼中神经网络仍是一个难以窥透本质的黑箱模型其内部机理完全不清楚对于人类而言不可解的事物往往让人失去信心因此对神经网络进行解析工作变得尤为必要
当我们建立一个能够分辨食物的分类器时,该分类器表现出卓越的食物识别能力,自然会充满好奇地探讨这种能力的具体实现机制.我们自然会充满好奇地探讨这个分类器是如何实现这种识别能力的?它是否真正地掌握了这些特征,或者是否通过其他手段误导了我们?
在优化模型的过程中,我们实际上是在对损失函数 loss 进行求解,其目标是最小化 model 参数所带来的误差累积.然而,如果我们能够将 model 参数视为特征向量,并将其视为原始 input 数据的一部分,那么我们就可以直接从 loss 函数出发,对其在 input 数据层面上进行求导运算.这样做的好处在于,通过对图片 部分区域像素进行梯度分析,我们可以评估这些像素点对于最终 output 结果的影响程度.
下面是参考代码的实现:
def normalize(image):
return (image - image.min()) / (image.max() - image.min())
#归一化
def compute_saliency_maps(x, y, model):
model.eval()
x = x.cuda()
# 最關鍵的一行 code
# 因為我們要計算 loss 對 input image 的微分,原本 input x 只是一個 tensor,預設不需要 gradient(梯度)
# 這邊我們明確的告知 pytorch 這個 input x 需要gradient,這樣我們執行 backward 後 x.grad 才會有微分的值
x.requires_grad_()
y_pred = model(x)
loss_func = torch.nn.CrossEntropyLoss()
loss = loss_func(y_pred, y.cuda())
loss.backward()
saliencies = x.grad.abs().detach().cpu() #detach()用于切断反向传播,这样不会被梯度改变
# saliencies: (batches, channels, height, weight)
'''
因為接下來我們要對每張圖片畫 saliency map,每張圖片的 gradient scale 很可能有巨大落差
可能第一張圖片的 gradient 在 100 ~ 1000,但第二張圖片的 gradient 在 0.001 ~ 0.0001
如果我們用同樣的色階去畫每一張 saliency 的話,第一張可能就全部都很亮,第二張就全部都很暗,
如此就看不到有意義的結果,我們想看的是「單一張 saliency 內部的大小關係」,
所以這邊我們要對每張 saliency 各自做 normalize。手法有很多種,這邊只採用最簡單的(平均值)
'''
saliencies = torch.stack([normalize(item) for item in saliencies])
return saliencies
# 指定想要一起 visualize 的圖片 indices
img_indices = [83, 4218, 4707, 8598] #随机选择4张图片
images, labels = train_set.getbatch(img_indices) #读取图片与对应标签
saliencies = compute_saliency_maps(images, labels, model)#计算对应的saliencies
# 使用 matplotlib 畫出來
fig, axs = plt.subplots(2, len(img_indices), figsize=(15, 8))
for row, target in enumerate([images, saliencies]):
for column, img in enumerate(target):
axs[row][column].imshow(img.permute(1, 2, 0).numpy())
'''
小知識:permute 是什麼,為什麼這邊要用?
在 pytorch 的世界,image tensor 各 dimension 的意義通常為 (channels, height, width)
但在 matplolib 的世界,想要把一個 tensor 畫出來,形狀必須為 (height, width, channels)
因此 permute 是一個 pytorch 很方便的工具來做 dimension 間的轉換
這邊 img.permute(1, 2, 0),代表轉換後的 tensor,其
- 第 0 個 dimension 為原本 img 的第 1 個 dimension,也就是 height
- 第 1 個 dimension 為原本 img 的第 2 個 dimension,也就是 width
- 第 2 個 dimension 為原本 img 的第 0 個 dimension,也就是 channels
'''
plt.show()
# 從第二張圖片的 saliency,我們可以發現 model 有認出蛋黃的位置
# 從第三、四張圖片的 saliency,雖然不知道 model 細部用食物的哪個位置判斷,但可以發現 model 找出了食物的大致輪廓
案例的展示:

在黑图中,亮点被视为模型识别图像的主要参考点. 模型真正依据的是图片中对应像素的实际信息.
Task2——Filter Visualization
convolution kernels, also known as filter banks in convolutional neural networks, can be visualized in a manner that reveals what they have learned. How can one visualize a filter? By adjusting the input image through the gradient ascent method to maximize the activation of a specific filter, and then displaying the resulting image. This displayed image represents the pattern detected by that filter. Typically, filters in shallow layers detect basic features such as colors or brightness, while deeper filters are able to identify more complex structures like lines and textures.
核心代码
下面这段是实现Filter Visualization的核心代码。
def filter_explaination(x, model, cnnid, filterid, iteration=100, lr=1):
# x: 要用來觀察哪些位置可以 activate 被指定 filter 的圖片們
# cnnid, filterid: 想要指定第幾層 cnn 中第幾個 filter
model.eval()
def hook(model, input, output):
global layer_activations
layer_activations = output
hook_handle = model.cnn[cnnid].register_forward_hook(hook)
'''
這一行是在告訴 pytorch,當 forward 「過了」第 cnnid 層 cnn 後,要先呼叫 hook 這個我們定義的 function 後才可以繼續 forward 下一層 cnn
因此上面的 hook function 中,我們就會把該層的 output,也就是 activation map 記錄下來,這樣 forward 完整個 model 後我們就不只有 loss
也有某層 cnn 的 activation map
注意:到這行為止,都還沒有發生任何 forward。我們只是先告訴 pytorch 等下真的要 forward 時該多做什麼事
注意:hook_handle 可以先跳過不用懂,等下看到後面就有說明了
'''
# Filter activation: 我們先觀察 x 經過被指定 filter 的 activation map
model(x.cuda())
# 這行才是正式執行 forward,因為我們只在意 activation map,所以這邊不需要把 loss 存起來
filter_activations = layer_activations[:, filterid, :, :].detach().cpu()
# 根據 function argument 指定的 filterid 把特定 filter 的 activation map 取出來
# 因為目前這個 activation map 我們只是要把他畫出來,所以可以直接 detach from graph 並存成 cpu tensor
# Filter visualization: 接著我們要找出可以最大程度 activate 該 filter 的圖片
x = x.cuda()
# 從一張 random noise 的圖片開始找 (也可以從一張 dataset image 開始找)
x.requires_grad_()
# 我們要對 input image 算偏微分
optimizer = Adam([x], lr=lr)
# 利用偏微分和 optimizer,逐步修改 input image 來讓 filter activation 越來越大
for iter in range(iteration):
optimizer.zero_grad()
model(x)
objective = -layer_activations[:, filterid, :, :].sum()
# 與上一個作業不同的是,我們並不想知道 image 的微量變化會怎樣影響 final loss
# 我們想知道的是,image 的微量變化會怎樣影響 activation 的程度
# 因此 objective 是 filter activation 的加總,然後加負號代表我們想要做 maximization
objective.backward()
# 計算 filter activation 對 input image 的偏微分
optimizer.step()
# 修改 input image 來最大化 filter activation
filter_visualization = x.detach().cpu().squeeze()[0]
# 完成圖片修改,只剩下要畫出來,因此可以直接 detach 並轉成 cpu tensor
hook_handle.remove()
# 很重要:一旦對 model register hook,該 hook 就一直存在。如果之後繼續 register 更多 hook
# 那 model 一次 forward 要做的事情就越來越多,甚至其行為模式會超出你預期 (因為你忘記哪邊有用不到的 hook 了)
# 因此事情做完了之後,就把這個 hook 拿掉,下次想要再做事時再 register 就好了。
return filter_activations, filter_visualization
成果展示:

Task3——LIME
Lime是简写形式Local Interpretable Model-Agnostic Explanations所代表的概念。其中Local一词指局部保真特性——这表明我们期望模型对特定样本的行为具有可解释性。这一要求必须具备可理解性(interpretable),即能够让人类轻松解读。此外Lime的优势在于它无需对特定模型进行调整即可提供可靠的全局解释
为了避免深入到模型内部进行分析,Lime不会对模型结构进行深入研究。为了确定哪些输入部分对预测结果具有重要影响,我们将每个输入值在其周围施加微小的变化,观察其预测行为的变化情况。接着,我们通过分析这些数据点与原始数据的距离来分配权重,并推导出一个具有可解释性的模型和预测结果
LIME可以直接使用pip安装
pip install lime
参考代码所给出的lime的使用demo
def predict(input):
# input: numpy array, (batches, height, width, channels)
model.eval()
input = torch.FloatTensor(input).permute(0, 3, 1, 2)
# 需要先將 input 轉成 pytorch tensor,且符合 pytorch 習慣的 dimension 定義
# 也就是 (batches, channels, height, width)
output = model(input.cuda())
return output.detach().cpu().numpy()
def segmentation(input):
# 利用 skimage 提供的 segmentation 將圖片分成 100 塊
return slic(input, n_segments=100, compactness=1, sigma=1)
img_indices = [83, 4218, 4707, 8598]
images, labels = train_set.getbatch(img_indices)
fig, axs = plt.subplots(1, 4, figsize=(15, 8))
np.random.seed(16)
# 讓實驗 reproducible
for idx, (image, label) in enumerate(zip(images.permute(0, 2, 3, 1).numpy(), labels)):
x = image.astype(np.double)
# lime 這個套件要吃 numpy array
explainer = lime_image.LimeImageExplainer()
explaination = explainer.explain_instance(image=x, classifier_fn=predict, segmentation_fn=segmentation)
# 基本上只要提供給 lime explainer 兩個關鍵的 function,事情就結束了
# classifier_fn 定義圖片如何經過 model 得到 prediction
# segmentation_fn 定義如何把圖片做 segmentation
lime_img, mask = explaination.get_image_and_mask(
label=label.item(),
positive_only=False,
hide_rest=False,
num_features=11,
min_weight=0.05
)
# 把 explainer 解釋的結果轉成圖片
axs[idx].imshow(lime_img)
plt.show()
结果展示

从前文三章的图中可以看出, model能够识别出食物的位置,并以此为主要参考点来进行判断. 唯一例外的是第四张图, model似乎更倾向于直接识别"碗"的形状来判断该图片是否属于"soup"这个类别. 红色标注碗中的内容物则表明,仅凭观察碗中的物品可能会干扰辨识过程. 当model仅关注碗中呈现为黄色且形状为圆形的部分而忽视了"碗"本身的存在时,可能会误判为其他类型的圆形黄色物体.
参考资料
<>
<>
