모델이 개 품종을 맞힐 때, 사진의 어느 부분이 그 답에 기여했는지 알고 싶다. 모델이 반응하는 대상이 개인가, 아니면 배경의 무언가인가? 이를 확인하려고 그 기여도를 히트맵으로 보여 주는 두 가지 방법, CAM과 Grad-CAM을 ResNet50과 Stanford Dogs 데이터셋으로 구현했다.

처음 테스트한 이미지 50장에서 두 방법의 결과가 같게 나왔고, 나는 이 일치를 Grad-CAM 구현이 잘못됐다는 증거로 받아들였다. 실제로 코드에서 문제를 찾기는 했다. 하지만 이 비교에서 무엇을 기대했는지도 다시 생각해야 했다. 내가 들여다보기로 한 ResNet50의 지점에서는 CAM과 Grad-CAM이 일치할 수학적 이유가 있기 때문이다.

이 노트는 구현상의 문제와 그 잘못된 기대를 나눠서 정리한 것이다. 둘을 구분하지 않으면, 그림을 다르게 보이게 하려고 멀쩡히 돌아가는 코드를 계속 고치게 될 수 있다.

히트맵은 어떻게 계산되는가

내 맵이 왜 일치했는지 이해하려면 ResNet50이 사진을 클래스 점수로 바꾸는 과정을 따라가 봐야 한다. 네트워크 끝부분에서 이미지는 피처맵(feature map)이라 부르는 작은 공간 맵 여러 장으로 표현된다. 표준 입력 기준 크기는 다음과 같다.

표현 크기
입력 이미지 224 × 224 픽셀
최종 피처맵 피처맵 2,048개, 각각 7 × 7 칸
전역 평균 풀링 이후 피처맵마다 평균값 하나

네트워크의 분류기는 이 평균값들을 조합해 클래스마다 점수를 계산한다. CAM(class activation mapping)은 분류기의 가중치를 이용해, 각 맵의 기여가 이미지의 어느 위치에서 나왔는지 거꾸로 추적한다. 특정 품종에 대해 각 피처맵에 그 품종의 가중치를 곱한 뒤, 가중치를 곱한 맵들을 모두 더하는 방식이다. 기여가 클수록 결과에서 더 강하게 드러난다.

CAM과 Grad-CAM을 비교한다는 것은 결국 이 가중치를 얻는 두 가지 방식을 비교하는 일이다. CAM은 전역 평균 풀링(global average pooling)과 선형 분류기 사이의 연결에 기대고 있다. 반면 Grad-CAM은 피처맵 값이 조금 바뀌면 선택한 클래스 점수가 얼마나 달라지는지를 묻는다. 이 미분값이 그래디언트다. Grad-CAM은 피처맵마다 그래디언트를 평균해 가중치를 얻고, 가중치를 곱한 맵들을 합친 다음, 그 결과에 ReLU를 적용해 양수 값만 남긴다.

Grad-CAM은 그 연결에 의존하지 않기 때문에 더 많은 네트워크 구조에 적용할 수 있다. 그렇다고 어떤 구조에서든 CAM과 다른 그림이 나오리라고 기대해야 한다는 뜻은 아니다.

ResNet50에서 맵이 일치한 이유

일치하는 그림을 처음 봤을 때 나는 전역 평균 풀링이 하는 일을 놓치고 있었다. 최종 피처맵의 49칸은 모두 그 맵의 평균에 똑같이 기여하고, 선형 분류기는 그 평균에 선택한 클래스의 고정 가중치를 곱한다.

그래서 가공하지 않은 클래스 점수(raw class score)의 그래디언트는 한 맵 안의 모든 칸에서 같다. 분류기 가중치를 49로 나눈 값이다. 맵마다 가중치는 다를 수 있지만, 각 맵 안에서 그래디언트를 평균하면 CAM의 가중치에 비례하는 값이 다시 나온다.

두 히트맵을 같은 표시 범위로 스케일하면 공통 인수 49는 사라진다. 두 방법이 양수 값을 남기는 방식까지 같다면, 화면에 표시되는 맵도 일치할 수 있다. 이 관계는 Grad-CAM 논문 3.1절에서 유도된다.

따라서 두 결과가 일치한다는 사실만으로는 디버깅 테스트가 되지 않는다. 중요한 것은 내 코드가 의도한 레이어에서 의도한 방법을 계산하고 있느냐이고, 이는 최종 그림의 생김새보다 그래디언트와 아키텍처를 통해 더 직접적으로 확인할 수 있다.

PyTorch에서 그래디언트 저장하기

내 구현에는 이와 별개의 문제도 있었다. forward hook에서 output.detach()를 저장한 것이다. 이 hook은 모델이 이미지를 처리하는 동안 모듈의 출력을 붙잡아 둔다. PyTorch는 그 출력과 클래스 점수를 잇는 연산을 기록해 두었다가, 이 기록을 거꾸로 따라가며 그래디언트를 계산한다. 그런데 detach()를 호출하는 바람에 값만 저장되고 그 기록과의 연결은 끊겨 버렸다.

그래서 hook이 원래 출력을 그대로 유지하도록 바꾸고, 그 출력에 retain_grad()를 호출해 PyTorch가 그래디언트를 저장하게 했다. 선택한 클래스 점수에 대해 backward()를 실행하면, 저장해 둔 출력의 .grad에 결과가 담긴다. 이제는 비슷해 보이는 두 그림에서 미루어 짐작하지 않고도 그래디언트 신호가 있는지 확인할 수 있다.

한 모듈에 일반 backward hook과 full backward hook을 섞어 쓸 수 없다는 오류도 만났다. retain_grad()를 쓰면서 이 구현에서는 모듈 backward hook을 쓰지 않아도 됐다. 예전 설명에서는 이 충돌을 ResNet의 BatchNormalization 레이어 탓으로 돌렸지만, 오류 메시지만으로는 어떤 등록이 원인이었는지 확정할 수 없다.

들여다볼 레이어 고르기

나중에 hook 위치를 model.layer4에서 model.layer4[-1].conv3로 옮기자 두 맵이 더 이상 정확히 일치하지 않았다. 이 변화는 처음 설명했던 것보다 더 조심스럽게 볼 필요가 있다. 나는 layer4에 전역 평균 풀링이 포함된다고 썼었다. 하지만 Torchvision 구현을 보면 avgpool은 layer4가 값을 반환한 다음에 적용된다.

conv3로 옮긴 것은 마지막 residual block 안에서 더 앞쪽의 표현을 고른 셈이다. 이 지점 뒤로는 여전히 batch normalization, residual 덧셈, ReLU가 이어진다. 이 ReLU는 음수 값을 0으로 바꾸기 때문에, 앞쪽 값의 영향을 어떤 위치에서는 막고 다른 위치에서는 통과시킬 수 있다. 따라서 이 앞쪽 지점의 그래디언트가 마지막 블록 이후에서 기대되는 균일한 공간 패턴을 보일 필요는 없다.

결국 새 그림은 다른 표현을 설명하고 있는 것이다. 위치를 바꾸자 처음에 찾던, 눈에 보이는 차이가 실제로 생기긴 했다. 그래도 그 차이를 예전 hook 위치가 틀렸다는 증거로 볼 수는 없다.

Stanford Dogs 정답 박스와 비교하기

히트맵을 Stanford Dogs에 주석으로 달린 정답 바운딩 박스와도 비교했다. 지표로는 IoU(intersection over union)를 썼다. 예측 영역과 정답 영역이 겹치는 넓이를, 두 영역을 합친 넓이에 대한 비율로 나타낸 값이다.

방법 보고된 평균 IoU
CAM 0.355
Grad-CAM 0.357

비교 설정: 이미지 50장, 학습 2 epoch, 히트맵 임계값 0.5.

이 결과만으로는 어느 한쪽 방법을 더 낫다고 볼 근거가 거의 없다. 방법만이 아니라 hook을 건 레이어도 함께 바꿨으므로, 작은 차이를 Grad-CAM만의 효과로 볼 수도 없다. 두 맵 모두 분류기의 출력을 거칠게 요약한 공간 지도라는 점은 그대로다.

비교를 다시 한다면 이미지, 클래스 점수, 레이어, 맵 처리 방식을 고정해야 한다. 먼저 아키텍처가 예측하는 CAM과 Grad-CAM의 관계부터 확인하고, 그다음에 위치 추정(localization)을 살펴볼 것이다. 처음 접근에서 바꾸고 싶은 것은, 두 맵이 일치하면 분명 무언가 잘못됐다는 가정이다.