[tensorflow] ResNet50에 GradCAM 적용하기 / 코드해설 o

 

📌GradCAM 원리

그림 1

$c$라고 표시된 노드에서 출력 $o_c$가 나오는데, $o_c$는 특징맵 F에 따라 값이 변한다.

F에는 8*8*4=256개의 화소가 있는데 각 화소는 $o_c$에 영향을 미친다.

즉, $k$번째 특징 맵의 $i$번째 행의 $j$번째 열에 있는 화소 $f_{ij}^k$의 값이 바뀌면 $o_c$도 바뀜

이 변화량은 그림에서 볼 수 있듯이 그레디언트 요소 $\frac{\partial o_c}{\partial f_{ij}^k}$로 정의됨

(이 값은 텐서플로가 제공하는 함수를 사용하여 계산)

식 1

GradCAM은 F를 구성하는 특징맵의 화소 각각에 대해 $\frac{\partial o_c}{\partial f_{ij}^k}$를 계산한 다음, 식 1을 사용해 $k$번째 특징 맵의 그레디언트에 전역 평균 풀링을 적용하여 가중치 $\mathcal{a}_c^k$를 구한다.

 

식 2

$\mathcal{a}_c^k$가 크다면 $f^k$를 구성하는 화소를 모두 0으로 바꾸면 부류 $c$에 속할 확률 $o_c$가 크게 떨어질 것.

이런 원리에 따라, 시각화 맵을 위의 식으로 계산함. 

 

자세한 설명은 아래 게시물 2.3에서 확인

 

10) 설명 가능 인공지능 - 파이썬으로 만드는 인공지능

인간은 의사결정 이유를 설명하는 능력이 뛰어나다. 인공지능이 전문가의 의사결정을 대치하려면 인간 수준의 설명 능력을 갖추어야한다. 현재 딥러닝은 '블랙 박스'! 이를 '유리 박스'모델로

haystar.tistory.com

전체 코드


import numpy as np
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras.applications import resnet50, ResNet50
from tensorflow.keras.preprocessing import image
import matplotlib.pyplot as plt
import matplotlib.cm as cm

#사전 학습된 신경망 모델을 불러오고 구조 확인
model = ResNet50(weights='imagenet')
model.summary()

#지정된 영상을 불러와 크기 조정하고 화면에 디스플레이
image_path = '/content/hummingbird.jpg'
img = image.load_img(image_path, target_size = (224,224))
plt.matshow(img)

#영상을 신경망 형태로 변환
x=image.img_to_array(img)
x=np.expand_dims(x,axis=0)
x=resnet50.preprocess_input(x)

#인식을 시도하고 top-5결과를 출력
preds= model.predict(x)
print("예측 결과 : ",resnet50.decode_predictions(preds,top=5)[0])

#신경망 모델의 특 징 추출 부분에서 마지막 층을 지정
#특징 추출 부분만으로 구성된 model_1만들기
last_conv_layer = model.get_layer("conv5_block3_out")

model_1= keras.Model(model.inputs, last_conv_layer.output)

#분류 (전역평균풀링 또는 완전연결층) 부분만으로 구성된 model__2만들기
input_2 = keras.Input(shape=last_conv_layer.output.shape[1:])
x_2 = model.get_layer("avg_pool")(input_2)
x_2 = model.get_layer("predictions")(x_2)
model_2=keras.Model(input_2,x_2)

#GradientTape함수를 이용한 그레디언트 계산
with tf.GradientTape() as tape:
    output_1 = model_1(x)
    tape.watch(output_1) #마지막 층으로 미분하기 위한 준비
    preds = model_2(output_1)
    class_id = tf.argmax(preds[0])
    output_2 = preds[:,class_id]

grads = tape.gradient(output_2, output_1) #그레디언트 계산
pooled_grads = tf.reduce_mean(grads,axis=(0,1,2)) #식5 적용

output_1 = output_1.numpy()[0]
pooled_grads = pooled_grads.numpy()
for i in range(pooled_grads.shape[-1]):
    output_1[:,:,i]*=pooled_grads[i]
heatmap=np.mean(output_1, axis=-1)

heatmap =np.maximum(heatmap, 0)/np.max(heatmap) #정규화
plt.matshow(heatmap)

#열지도를 입력 영상에 씌움
img =image.load_img(image_path) #입력 영상을 다시 받음

img=image.img_to_array(img)
heatmap=np.uint8(255*heatmap) # [0,255]로 변환

jet = cm.get_cmap("jet") #jet 컬러맵으로 표시
color = jet(np.arange(256))[:,:3]
color_heatmap = color[heatmap]

color_heatmap = keras.preprocessing.image.array_to_img(color_heatmap)
color_heatmap = color_heatmap.resize((img.shape[1], img.shape[0]))
color_heatmap = keras.preprocessing.image.img_to_array(color_heatmap)

overlay_img= color_heatmap*0.4+img #덧씌움
overlay_img = keras.preprocessing.image.array_to_img(overlay_img)
plt.matshow(overlay_img)

실행 결과


벌새의 몸통과 날개 영역을 빨갛게 표시하는 것을 보아 GradCAM이 ResNet50의 의사 결정을 제대로 설명한다는 것을 확인할 수 있다.

 

코드 해설


📢 프로그램에 필요한 라이브러리

import numpy as np
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras.applications import resnet50, ResNet50
from tensorflow.keras.preprocessing import image
import matplotlib.pyplot as plt
import matplotlib.cm as cm

 

ResNet50 모델 불러오기

model = ResNet50(weights='imagenet')
model.summary()

ResNet50 모델을 불러와 model객체에 저장하고 summary함수를 이용해 model의 구조 확인

모델의 구조를 확인하는 일은 매우 중요함!!
실행결과에서 회색 음영으로 표시된 부분을 보자
입력층의 input_74의 내용을 보면 (None, 224, 224, 3)크기의 텐서를 입력해야 함을 알 수 있음

▶224*224 크기의 RGB영상을 여러개 동시에 입력할 수 있도록 지정한 것

첫번째 차원이 None인 이유?
영상이 몇 장 입력될 것인지 미리 알 수 없기 때문이다.
만일 10장의 영상이 입력된다면 (10, 224, 224, 3)크기의 텐서가 입력된다.

출력층인 predictions를 보면 (None, 1000)크기의 텐서가 출력됨을 알 수 있다.

▶Imagenet은 1000개 부류로 구성되기 때문

그림 2. 전역 평균 풀링을 사용하는 컨볼루션 신경망 구조

출력층 바로 전에 있는 agv_pool층은 GlobalAveragePooling2라는 추가 설명을 보아 전역 평균 풀링 연산을 사용하는 층임을 알 수 있다.

위 그림에서 GAP이라고 표시된 층에 해당한다.

agv_pool층의 바로 이전엔 conv5_block3_out층이 있다. 이 층은 특징 추출을 담당하는 마지막 부분의 층, 즉 F라고 표시된 층에 해당한다.

 

데이터 준비

image_path = '/content/hummingbird.jpg'
img = image.load_img(image_path, target_size = (224,224))
plt.matshow(img)

x=image.img_to_array(img)
x=np.expand_dims(x,axis=0)
x=resnet50.preprocess_input(x)

현재 폴더에서 hummingbird.jpg라는 영상을 불러와 신경망에 맞게 224*224 크기로 조정한다.

그 다음 img객체를 신경망에 입력할 형태로 텐서를 변환한다.

img_to_array함수를 통해 PIL형의 객체 img를 numpy배열형의 객체 x로 변환한다.

expand_dims함수를 통해 (224,224,3)크기의 텐서를 신경망에 맞게 (1,224,224,3) 형태로 조정한다.

reesnet50클래스가 제공하는 전처리 함수인 resnet50.preprocess_input을 적용하여 ResNet50의 입력 형태로 조정함.

이 전처리 함수는 [0, 255] 범위의 화솟값을 [-1,1] 범위로 변환하는 등의 일을 수행한다.

 

이미지 인식

preds= model.predict(x)
print("예측 결과 : ",resnet50.decode_predictions(preds,top=5)[0])

model객체의 predict함수에 영상을 입력하여 인식을 시도한다.

preds객체는 (1,1000) 크기의 텐서. 

영상을 한 장만 입력하여 첫번재 차원은 1, 1000개 부류로 분류하므로 두 번째 차원은 1000

매개변수를 top=5로 설정했기 때문에 확률 값이 가장 큰 5개 부류를 선택하여 출력한다.

 

GradCAM알고리즘 시작!

 

model을 두 부분으로 나눔

신경망 모델의 특징 추출 부분에서 마지막 층을 지정

last_conv_layer = model.get_layer("conv5_block3_out")

conv5_block3_out 층을 그레디언트를 계산할 층으로 선택하여 last_conv_layer에 저장한다.

위 그림2 에서 F에 해당한다.

 

이제는 ResNet50신경망 전체에 해당하는 model을 두 부분으로 나누어 앞부분을 model_1, 뒷부분을 model_2로 설정할 것이다.

그림 1에서 보면 입력부터 F층까지가 model_1, 나머지 뒷부분이 model_2에 해당한다.

model_1의 출력으로 model_2의 출력을 미분하여 그레디언트를 계산하기 위함
즉, 위 식1에서 $\frac{\partial o_c}{\partial f_{ij}^k}$를 계산하기 위해서다.

model_1의 출력은 $f_{ij}^k$, model_2의 출력은 $o_c$에 해당한다.

model_1 정의

model_1= keras.Model(model.inputs, last_conv_layer.output)

분류 (전역평균풀링, 완전연결층) 부분만으로 구성된 모델 model_2만들기

input_2 = keras.Input(shape=last_conv_layer.output.shape[1:])
x_2 = model.get_layer("avg_pool")(input_2)
x_2 = model.get_layer("predictions")(x_2)
model_2=keras.Model(input_2,x_2)

 

그레디언트 $\frac{\partial o_c}{\partial f_{ij}^k}$계산

with tf.GradientTape() as tape:
    output_1 = model_1(x)
    tape.watch(output_1) #마지막 층으로 미분하기 위한 준비
    preds = model_2(output_1)
    class_id = tf.argmax(preds[0])
    output_2 = preds[:,class_id]

grads = tape.gradient(output_1, output_1) #그레디언트 계산
pooled_grads = tf.reduce_mean(grads,axis=(0,1,2)) #식5 적용

 

코드를 자세히 살펴보자.

 

output_1 = model_1(x)

$f_{ij}^k$를 계산한다.

tape.watch(output_1)

tf.GradientTape.watch()는 텐서가 GradientTape에 의해 추적되도록 한다.

GradientTape는 미분 계산 값을 기록하는 객체
    preds = model_2(output_1)
    class_id = tf.argmax(preds[0])

모델 2의 출력값을 얻고 가장 확률이 높은 인덱스를 얻어옴

output_2 = preds[:,class_id]

$o_c$를 계산한다.

grads = tape.gradient(output_2, output_1)

$f_{ij}^k$를 구성하는 화소 각각에 대해 $o_c$를 미분하여 그레디언트를 계산한다.

그림 1에 적용한다면 grads는 (8,8,4)텐서 모양이 되고 실제로는 conv_block3_out층의 텐서 모양인 (1,7,7,2048)이 된다.

 

pooled_grads = tf.reduce_mean(grads,axis=(0,1,2))

식1의 전역 평균 풀링을 적용한다.

특징 맵별로 모든 화솟값을 평균하여 하나의 값으로 변환한다.

그림1에 적용한다면 (4)크기의 텐서가 되고, 실제 프로그램 실행 결과에서는 (2048)크기의 텐서가 됨

이때 axis의 의미는 아래 링크에 잘 정리되어 있다.

 

NumPy 강좌 - np.sum 함수에서 axis 의미

NumPy 함수를 사용하다보면 axis 아규먼트를 사용하는 것이 있습니다. 어떤 의미일까 고민해보다가 적어봅니다. 최초 작성 2019. 11. 7 넘파이 모듈을 임포트 하고 크기 2 x 3 x 4인 넘파이 배열에 0~23까

webnautes.tistory.com

 

식 2 적용하는 부분

output_1 = output_1.numpy()[0]
pooled_grads = pooled_grads.numpy()
for i in range(pooled_grads.shape[-1]):
    output_1[:,:,i]*=pooled_grads[i]

 

heatmap 출력

heatmap=np.mean(output_1, axis=-1)
heatmap =np.maximum(heatmap, 0)/np.max(heatmap) #정규화
plt.matshow(heatmap)

heatmap을 [0,1]범위로 정규화하고 출력한다.

프로그램 실행결과를 확인하면 7*7 크기의 heatmap이 출력되었음

 

heatmap을 입력 영상에 덧씌움

img =image.load_img(image_path) #입력 영상을 다시 받음

img=image.img_to_array(img)
heatmap=np.uint8(255*heatmap) # [0,255]로 변환

jet = cm.get_cmap("jet") #jet 컬러맵으로 표시
color = jet(np.arange(256))[:,:3]
color_heatmap = color[heatmap]

color_heatmap = keras.preprocessing.image.array_to_img(color_heatmap)
color_heatmap = color_heatmap.resize((img.shape[1], img.shape[0]))
color_heatmap = keras.preprocessing.image.img_to_array(color_heatmap)

overlay_img= color_heatmap*0.4+img #덧씌움
overlay_img = keras.preprocessing.image.array_to_img(overlay_img)
plt.matshow(overlay_img)

 

코드를 자세히 살펴보자

 

img =image.load_img(image_path) #입력 영상을 다시 받음

img=image.img_to_array(img)
heatmap=np.uint8(255*heatmap) # [0,255]로 변환

원본 영상을 다시 불러오고 heatmap과 혼합할 수 있는 형태로 변환한다.

jet = cm.get_cmap("jet") #jet 컬러맵으로 표시
color = jet(np.arange(256))[:,:3]
color_heatmap = color[heatmap]

파이썬이 제공하는 여러가지 색상표 중 열지도를 표시하기 적당한 jet색상표 사용

[0,255]범위를 파란색부터 빨간색까지 분포하는 색으로 매핑함.

이 색상표를 사용하면 heatmap에서 중요하지 않은 화소(0에 가까운 화소)는 파란색, 중요하게 작용한 화소(255에 가까운 화소)는 빨간색으로 표시된다.

color_heatmap = keras.preprocessing.image.array_to_img(color_heatmap)
color_heatmap = color_heatmap.resize((img.shape[1], img.shape[0]))
color_heatmap = keras.preprocessing.image.img_to_array(color_heatmap)

heatmap을 원본영상 크기로 확대하고 원본 영상과 혼합할 수 있는 형태로 변환함.

overlay_img= color_heatmap*0.4+img #덧씌움

원본영상을 혼합

overlay_img = keras.preprocessing.image.array_to_img(overlay_img)

 

영상 형태로 변환

plt.matshow(overlay_img)

혼합한 영상 출력

 

 

 

 


참고교재 파이썬으로 만드는 인공지능 - 한빛미디어