姓名:成杰 学号:21021210653 学院:电子工程学院
转自:https://zhuanlan.zhihu.com/p/509645247
【嵌牛导读】
CAM(Class Activation Mapping):将CNN在分类时使用的分类依据(图中对应的类别特征)在原图的位置进行可视化,并绘制成热图,以此作为物体定位的依据的方法。
【嵌牛鼻子】
神经网络、可视化
【嵌牛提问】
CAM方法的原理?
【嵌牛正文】
1. 整体思路
CNN网络做分类时之所以丢失了物体的位置信息,是因为网络末端使用了全连接层,通过使用GAP替代全连接层,从而使卷积网络的定位能力能延续到网络的最后一层,故保持经典网络(如VGGnet、Alexnet和GoogleNet)的卷积部分,只在输出层前(用于分类的softmax)将全连接层替换为GAP,并将它们作为得出分类的特征。通过这样的连接结构,可以把图片中重要的区域用输出层权重映射回卷积层特征的方式标记出来,这种方法称为类别激活映射或类别激活图。
2. 全局平均池化层(GAP)和全局最大池化层(GMP)对比
GAP和GMP都是全局池化的方法,也有学者在做弱监督物体定位时采用了这两种方法,而文章之所以选择GAP有以下两个原因:
(1) GMP希望网络更关注物体的1个discriminaltive part,更关注物体的边缘识别,取最大的部分,而GAP则更希望网络识别物体的整个范围。在求平均值时,GAP可以综合并找到所有discriminaltive part来得到最大激活,对于低激活的区域就会减少特定输出,即GAP相对于GMP来说识别这个物体辨别性区域的损失更小。
(2) GMP由于只取了区域最大值,所以其他低分的区域对最终分类的得分都不会有影响,GMP的分类性能和GAP相当,但GAP的定位能力强于GMP。
3. 原理
CAM激活图由两部分加权构成:原图+特征图。其中特征图是通过:最后全连接层的参数(权重矩阵W)与最后输出的特征图集合对应相乘再相加(重叠)而形成,即显示模型是依据特征图进行决策分类的。
(1)输入:训练集或测试集图片,大小为[224,224,3]
(2) VGG16预训练卷积层最后一层的特征图大小为7*7,共512张,所以大小为[7,7,512]
(3)经过全局平均池化(GAP),512张特征图被降维为长度512的特征向量
(4)最后特征向量与权重矩阵W点积,再经过softmax函数压缩为[0,1]区间内的概率
由于采用迁移学习的方式,卷积层冻结固定,所以对于同一张图片,卷积层的输出特征始终不变,模型的分类概率只随着全连接层的权重矩阵W发生变化,这就是模型的学习更新过程。
权重矩阵W可以理解为对长度为512的特征向量的加权,毕竟特征向量是由特征图全局平局池化GAP所得。归根到底是对特征图集合的加权,所以利用特征图集合与权重矩阵相乘,再重叠为一张特征图,就可以模拟模型分类过程中,是依据哪部分区域做出判断的。
假设初始模型在刚开始训练时,利用某个特征图作为判断的依据,并计算正确率,此时损失值loss很大,则在反向传播过程中,权重矩阵W会不断更新,在损失函数约束下,找到有效的特征图作为判断的依据,那么当loss小到一定程度,或预测的准确率上升到一定程度,那么此时的模型便学会了判别,有了正确分类的能力。
计算方法如下图所示。对于一个CNN模型,对其最后一个feature map做全局平均池化(GAP)计算各通道均值,然后通过全连接层等映射到class score,找出argmax,计算最大的那一类的输出相对于最后一个feature map的梯度,再把这个梯度可视化到原图上即可。直观来说,就是看一下网络抽取到的高层特征的哪部分对最终的classifier影响更大。
GAP操作后输出最后一个卷积层每个单元feature-map的平均值,之后再接一个softmax层用于分类,而该层的所有参数作为权重wck,对前方的GAP得到的feature-map做加权总和得到最后的输出,即CAM输出。此时CAM的输出尺寸和feature-map大小一致,故需要通过上采样方式还原叠加到原图中。
CAM的可视化是通过fk激活值实现的,激活值越大的地方说明该区域越有可能属于对应某个分类,通过改变图像尺寸,将激活图还原成原图大小的图片,即可得到该分类对应在原图的位置,加权越多的区域颜色亮度越大,在通过设置阈值即可画出覆盖该区域的BBox,从而得到物体在图片中的定位边框。
该方法的缺点是只能适用于最后一层特征图和全连接之间是GAP操作。如果不是,就需要用户修改网络并重新训练(或 fine-tune)。修改网络全连接为GAP形式,利用GAP层与全连接的权重作为特征融合权重,对特征图进行线性融合获取CAM。
CAM 的实现依赖于全局平均池化层,通过全局平均池化得到 feature map 每一个通道的权重,然后线性加权求和得到网络关注区域的热力图。因此对于很多网络都不能直接使用,需要把网络后面的全连接层改为全局平均池化。CAM虽然简单,但是它要求修改原模型的结构,导致需要重新训练该模型,这大大限制了它的使用场景。如果模型已经上线了,或着训练的成本非常高,我们几乎是不可能为了它重新训练的。
4. 代码实现
fromPILimportImagefromtorchvisionimportmodels,transformsfromtorch.autogradimportVariablefromtorch.nnimportfunctionalasFimportnumpyasnpimportcv2importjsonLABELS_URL='https://s3.amazonaws.com/outcome-blog/imagenet/labels.json'# 下载label# 使用本地的图片和下载到本地的labels.json文件LABELS_PATH="labels.json"# networks such as googlenet, resnet, densenet already use global average pooling at the end, so CAM could be used directly.model_id=2# 选择使用的网络ifmodel_id==1:net=models.squeezenet1_1(pretrained=True)finalconv_name='features'# this is the last conv layer of the networkelifmodel_id==2:net=models.resnet18(pretrained=True)finalconv_name='layer4'elifmodel_id==3:net=models.densenet161(pretrained=True)finalconv_name='features'net.eval()print(net)# 获取特定层的feature map# hook the feature extractorfeatures_blobs=[]defhook_feature(module,input,output):# input是注册层的输入 output是注册层的输出print("hook input",input[0].shape)features_blobs.append(output.data.cpu().numpy())# 对layer4层注册,把layer4层的输出append到features里面net._modules.get(finalconv_name).register_forward_hook(hook_feature)# 注册到finalconv_name,如果执行net()的时候,# 会对注册的钩子也执行,这里就是执行了 layer4()print(net._modules)# 这里是利用钩子函数来获取最后的feature map,# _modules.get()方法会返回指定层的网络结构,即layer4的结构,# 然后使用register_forward_hook在前向传播的过程中为layer4注册hook函数,# 接下来会在前向传播的过程中截取layer4的输入和输出,# 在hook_feature函数中实现对于输入和输出的操作。# 得到softmax weightparams=list(net.parameters())# 将参数变换为列表 按照weights bias 排列 池化无参数weight_softmax=np.squeeze(params[-2].data.numpy())# 提取softmax 层的参数 (weights,-1是bias)# 打印一下网络的参数,可以得到网络的最后两个参数是fc.weight和fc.biasforname,datainnet.named_parameters():print(name,":",data.size())# 生成CAM图的函数,完成权重和feature相乘操作,最后resize成上采样defreturnCAM(feature_conv,weight_softmax,class_idx):# generate the class activation maps upsample to 256x256size_upsample=(256,256)bz,nc,h,w=feature_conv.shape# 获取feature_conv特征的尺寸output_cam=[]# class_idx为预测分值较大的类别的数字表示的数组,一张图片中有N类物体则数组中N个元素foridxinclass_idx:# weight_softmax中预测为第idx类的参数w乘以feature_map(为了相乘,故reshape了map的形状)# w1*c1 + w2*c2+ .. -> (w1,w2..) * (c1,c2..)^T -> (w1,w2...)*((c11,c12,c13..),(c21,c22,c23..))# weight_softmax[idx]的含义:我们之前取到的fc.weight是一个(1000,512)的矩阵,# 因为对于ImageNet来说是一个1000分类的问题,那么weight_softmax的第i行就代表第i类的权重,# 传入的第三个参数class_idx是一个列表,里面有若干值,# 代表我们想得到对于一张图片来说被分成不同的类映射回原图分别是原图的哪一部分在起作用。# 对于最后的特征图做一个reshape,feature_conv.reshape((nc, h*w)将特征图变成每一行代表原特征图的一个通道,# 然后对于权重和reshape之后的图像做一个矩阵乘法就完成了对于feature map每个加权通道的求和。cam=weight_softmax[idx].dot(feature_conv.reshape((nc,h*w)))# 把原来的相乘再相加转化为矩阵# 将feature_map的形状reshape回去cam=cam.reshape(h,w)# 归一化操作(最小的值为0,最大的为1)cam=cam-np.min(cam)cam_img=cam/np.max(cam)# 转换为图片的255的数据cam_img=np.uint8(255*cam_img)# resize 图片尺寸与输入图片一致output_cam.append(cv2.resize(cam_img,size_upsample))returnoutput_cam# 数据处理,先缩放尺寸到(256*256),再变换数据类型为tensor,最后normalizenormalize=transforms.Normalize(mean=[0.485,0.456,0.406],std=[0.229,0.224,0.225])preprocess=transforms.Compose([transforms.Resize((256,256)),transforms.ToTensor(),normalize])img_pil=Image.open('2103.jpg')img_tensor=preprocess(img_pil)# 处理图片为Variable数据img_variable=Variable(img_tensor.unsqueeze(0))# 将图片输入网络得到预测类别分值logit=net(img_variable)# 使用本地的 LABELS_PATHwithopen(LABELS_PATH)asf:data=json.load(f).items()classes={int(key):valuefor(key,value)indata}# 使用softmax打分h_x=F.softmax(logit,dim=1).data.squeeze()# 分类分值# 对分类的预测类别分值排序,输出预测值和在列表中的位置probs,idx=h_x.sort(0,True)# 转换数据类型probs=probs.numpy()idx=idx.numpy()# output the predictionforiinrange(0,5):print('{:.3f} -> {}'.format(probs[i],classes[idx[i]]))# generate class activation mapping for the top1 predictionCAMs=returnCAM(features_blobs[0],weight_softmax,[idx[0]])# render the CAM and outputprint('output CAM.jpg for the top1 prediction: %s'%classes[idx[0]])img=cv2.imread('2103.jpg')height,width,_=img.shapeheatmap=cv2.applyColorMap(cv2.resize(CAMs[0],(width,height)),cv2.COLORMAP_JET)result=heatmap*0.3+img*0.5cv2.imwrite('CAM.jpg',result)