CAM基础知识
更新:2023年3月29日22:30:15,发现之前有一些地方有歧义难理解,现在予以更正
参考链接:如何利用CAM(类激活图)动态可视化模型的学习过程
一、作用
类激活图可以显示模型在训练过程中,权重或重心在何处、如何转移,分类模型是根据哪一部分的特征进行判别的。简而言之,就是模仿人类识别物体的过程,随着模型的迭代,找到相关任务的关键部位。
二、定义
CAM列激活图由两部分加权构成:原图+特征图。其中特征图是通过:最后全连接层的参数(权重矩阵W)与最后输出的特征图集合对应相乘再相加(重叠)而形成,即显示模型是依据哪些特征图进行决策分类的。
- 输入:训练集或测试集图片,大小为[224,224,3]
- VGG16预训练卷积层最后一层的特征图大小为7*7,共512张,所以大小为[7,7,512]
- 经过全局平均池化(GAP),512张特征图被降维为长度512的特征向量
- 最后,特征向量与权重矩阵W点积,再经过softmax函数压缩为[0,1]区间内的概率
三、计算
相关论文:
Zhou, Bolei, et al. “Learning deep features for discriminative localization.” Proceedings of the IEEE conference on computer vision and pattern recognition. 2016.
1、 原理
由于采用迁移学习的方式,卷积层冻结固定,所以对于同一张图片,卷积层的输出特征始终不变,模型的分类概率只随着全连接层的权重矩阵W发生变化,这就是模型的学习更新过程。
权重矩阵W可以理解为对长度为512的特征向量的加权,毕竟特征向量是由特征图全局平局池化GAP所得。归根到底是对特征图集合的加权,所以利用特征图集合与权重矩阵相乘,再重叠为一张特征图,就可以模拟模型分类过程中,是依据哪部分区域做出判断的。
举例:假设初始模型在刚开始训练时,利用第10/50/40号特征图作为判断的依据,正确率为78%。使得损失值loss很大,则在反向传播过程中,权重矩阵W会不断更新,在损失函数约束下,找到有效的特征图作为判断的依据,那么当loss小到一定程度,或预测的准确率上升到一定程度,那么此时的模型便学会了判别,有了正确分类的能力。
2、形式
- 模型训练过程CAM
在模型训练过程中,将模型每一次更新的权重矩阵W保存下来,存储为fc.pkl,通过将不同批次不同迭代次数下的权重矩阵与特征图集合进行点乘相加,通过热力图和原图的加权,获得最终的类激活图CAM,可以模拟模型从一开始胡乱猜测,到后续拥有像人一样识别能力IDE过程。效果如下所示。
- 模型训练结果CAM
不需要清楚模型迭代训练过程是如何的,只想观察模型针对输入图片最后特征图的响应结果。则不需要训练模型,只需要加载训练好的模型,获取最后的特征图集合及权重矩阵W,通过opencv相关热力图函数,实现原图和热力图的加权结果可视化。效果如下所示:
3、代码
- 模型训练过程可视化
代码链接:类激活图的动态可视化
特点:针对迁移学习冻结所有卷积层,只对最后一层参数更新,框架使用的keras。数据集使用猫狗数据集 - 模型训练结果可视化
代码将在下文贴出,并给出相关步骤的注释和解释
特点加载预训练好的模型,迁移学习,框架使用pytorch,数据集使用蜜蜂和蚂蚁的数据集
CAM实现代码
一、参考链接
1、类激活图的动态可视化
缺点:框架使用的keras,实现的是模型从无到有建立的动态可视化CAM,借鉴度不高
2、Pytorch可视化神经网络热力图(CAM)
缺点:实现的方式和细节与本文都不太一样,框架需要重写,需要保留最后一个feature map的梯度,借鉴度不高
3、师兄实现CAM的代码
缺点:缺点是不可能会有的,针对自己对CAM的理解,做了一些简化和优化
二、使用须知
1、框架使用pytorch
2、需要加载预训练好的模型,包含或不包含全连接层(fc)皆可,任意模型(自己搭建的或现有的)皆可,二分类任 务(多分类也可)。本文以ResNet101,蜜蜂蚂蚁数据集为例。
3、实现针对输入图片,给出其全连接层参数矩阵W指导下的激活热力图可视化。
三、代码
1、加载预训练模型
model_ft = models.resnet101(pretrained=False)
num_ftrs = model_ft.fc.in_features
model_ft.fc = nn.Linear(num_ftrs, class_num)
model_ft.load_state_dict(torch.load('./ant_bee_model.pth', map_location=lambda storage, loc:storage)) #加载训练完保存好的模型
model_features = nn.Sequential(*list(model_ft.children())[:-2])
fc_weights = model_ft.state_dict()['fc.weight'].cpu().numpy() #[2,2048] numpy数组取维度fc_weights[0].shape->(2048,)
class_ = {0:'ant', 1:'bee'}
model_ft.eval()
model_features.eval()
- model_features为训练好的模型去掉最后两层的重组模型,最后两层依次为全局平均池化层和全连接层。所以该参数用于获取最后一层特征图集合(也就是池化和展平前的特征图)。
- 自定义模型获取中间特征图的方式:在模型训练过程forward中,在所需特征图下方加上
self.finalconv = x.detach()
, 获取方式为:features = model.finalconv.cpu().numpy()
- model.eval() 很重要,千万不能省,用于将模型设置为评估模式。因为在测试过程中,我们不再更新模型参数,而只是使用已经训练好的参数来计算输出。在测试过程中,如果不将模型设置为评估模式,则会导致不一致的结果。
fc = torch.load("fc.pkl", map_location=lambda storage, loc: storage)
fc.eval()
#fc = nn.Linear(num_ftrs, class_num) #如果没有保存fc参数,则可以用这一行替代
out = torch.nn.functional.adaptive_avg_pool2d(output, (1,1)) #补充
out = torch.flatten(out, 1) #补充
logit = fc(out) #补充
- 如果除了CAM激活图,还想要输出对应预测概率,并且加载模型本身只保存到最后卷积层,则需要上述代码块的操作。即需要在后续补充全局平均池化层GAP、展平层flatten_layer,全连接层fc_layer。
2、获得特征图集合及预测结果
#img_path = './bee.jpg' #单张测试
_, img_name = os.path.split(img_path)
img = Image.open(img_path).convert('RGB')
img_tensor = transforms['val'](img).unsqueeze(0) #[1,3,224,224] => [3,224,224]
features = model_features(img_tensor).detach().cpu().numpy() #最终的特征图集合[1,2048,7,7]
logit = model_ft(img_tensor) #[ 3.3207, -2.9495]
h_x = torch.nn.functional.softmax(logit, dim=1).data.squeeze() #每个类别对应概率([0.9981, 0.0019])
#下面for循环的目的是依次打印该图片针对每个类别的概率,不需要可以去掉
probs, idx = h_x.sort(0, True) #输出概率升序排列
probs = probs.cpu().numpy() #[0.9981, 0.0019]
idx = idx.cpu().numpy() #[1, 0]
for i in range(class_num):
print('{:.3f} -> {}'.format(probs[i], class_[idx[i]])) #打印预测结果
print(img_name + ' output for the top1 prediction: %s' % class_[idx[0]]) #y预测第一
- transforms[‘val’] 即获得预训练模型时,对测试集进行的变换操作的封装。若不需要则可以去掉,但需要将img转换为tensor张量,使用transforms.ToTensor()。
- features 即为特征图集合,h_x即为预测概率,probs和idx经过sort函数是按照概率从大到小排列的,所以0索引就对应概率最大值及其类别(0或1),如果只需要模型预测概率最大对应的类激活图输出,则利用max函数即可
3、获取特征集合加权后的结果
def returnCAM(feature_conv, weight_softmax, class_idx):
b, c, h, w = feature_conv.shape #1,2048,7,7
output_cam = []
for idx in class_idx: #输出每个类别的预测效果
cam = weight_softmax[idx].dot(feature_conv.reshape((c, h*w)))
#(1, 2048) * (2048, 7*7) -> (1, 7*7)
cam = cam.reshape(h, w)
cam_img = (cam - cam.min()) / (cam.max() - cam.min()) #Normalize
cam_img = np.uint8(255 * cam_img) #Format as CV_8UC1 (as applyColorMap required)
output_cam.append(cam_img)
return output_cam
CAMs = returnCAM(features, fc_weights, [idx[0]]) #输出预测概率最大的特征图集对应的CAM
- for循环的目的在于输出每个类别的预测效果,只输出预测概率最大值结果不需要for循环。一般都不需要for循环的,那么将idx改为0,即最大概率的矩阵权重。
- future_map的大小为[1,2048,7,7],需要与参数矩阵fc_weights[2048, ]点乘(numpy模块指的是矩阵乘法),通过对应的矩阵变换,最终获得[49, ] -> [7, 7]的一张加权特征图结果
4、 一张特征图转化为热力图
img = cv2.imread(img_path)
height, width, _ = img.shape #读取输入图片的尺寸
heatmap = cv2.applyColorMap(cv2.resize(CAMs[0], (width, height)), cv2.COLORMAP_JET) #CAM resize match input image size
result = heatmap * 0.3 + img * 0.5 #比例可以自己调节
5、 显示方案和存储结果
text = '%s %.2f%%' % (class_[idx[0]], probs[0]*100) #激活图结果上的文字显示
cv2.putText(result, text, (210, 40), fontFace=cv2.FONT_HERSHEY_SIMPLEX, fontScale=0.9,
color=(123, 222, 238), thickness=2, lineType=cv2.LINE_AA)
CAM_RESULT_PATH = r'D:/Pycode/hymenoptera/CAM/' #CAM结果的存储地址
if not os.path.exists(CAM_RESULT_PATH):
os.mkdir(CAM_RESULT_PATH)
image_name_ = img_name.split(".")[-2]
cv2.imwrite(CAM_RESULT_PATH + image_name_ + '_' + 'pred_' + class_[idx[0]] + '.jpg', result) #写入存储磁盘