[1]. Attribution
{1}. Attribution이란?
AI가 해당 결론을 내린 이유가 뭔지 입력의 어떤 부분이 얼마나 영향을 줬는지 수치로 측정하는 것이다.
쉽게 말하면 AI의 판단 근거를 역추적하는 기술이다.
예를 들어
- 입력 이미지 → AI → 종양 발견 (확률 94%)
- Attribution은 "종양 있음" 이 판단은 어떤 영역 때문인지 수치로 측정해 준다.
{2}. 배경
기존 AI의 문제 - 블랙박스
딥러닝 모델은 성능은 뛰어난데 왜 그런 결론을 냈는지 알 수 없다.
이건 특히 의료 AI에서 치명적이다.
의사 입장에서 AI가 왜 정상이라고 했는지를 모르면 AI를 믿고 사용할 수가 없다.
{3}. XAI 등장
{2} 문제를 해결하려고 나온 분야가 XAI이다.
Attribution은 XAI의 핵심 도구 중 하나이다.
{4}. 핵심 아이디어
"입력의 각 부분에 기여도(점수)를 매기자"
Attribution의 핵심은 입력의 각 요소가 출력에 얼마나 기여했는지 수치화하는 것이다.
예를 들면
- 입력 : 뇌 MRI 이미지 (픽셀들)
- 출력 : "종양 있음 94%"
Attribution 결과 :
- 이 부분 픽셀들 → +0.9 (종양 판단에 강하게 기여)
- 이 부분 픽셀들 → +0.2 (약하게 기여)
- 이 부분 픽셀들 → 0.0 (기여 없음)
- 이 부분 픽셀들 → -0.3 (오히려 방해)
→ 이걸 시각화하면 히트맵이 나온다.
[2]. 기본 구조
{1}. 전체 흐름
- 입력
- 학습된 AI 모델
- 출력 (예측값)
- Attribution 계산
- 히트맵 시각화
{2}. 입력
Attribution의 입력은 픽셀 단위로 쪼개진 이미지다.
- 28x28 이미지 = 784개의 픽셀
- 각 픽셀은 0 ~ 225 사이 숫자이다.
{3}. 학습된 AI 모델
Attribution은 이미 학습이 완료된 모델에 적용한다.
- 학습 완료된 모델 f
- x를 넣으면 → 출력 f(x)가 나옴
중요한 건 모델 자체를 건드리지는 않는다.
모델은 고정된 채로, 입력과 출력만 분석한다.
{4}. 출력(예측값)
모델이 입력을 보고 내린 결론이다.
- f(x) = 0.94 ⇒ 종양 있음 94%
- f(x) = 0.06 ⇒ 정상 6%
Attribution은 이 출력값을 기준으로 "어떤 입력이 이 숫자를 만들었나"를 역추적한다.
{5}. Attribution 계산
각 픽셀이 출력에 얼마나 기여했는지 점수로 변환하는 단계이다.
- 픽셀 1 ⇒ 기여도 +0.8 (종양 판단에 강하게 기여)
- 픽셀 2 ⇒ 기여도 +0.3 (약하게 기여)
- 픽셀 3 ⇒ 기여도 0.0 (기여 없음)
- 픽셀 4 ⇒ 기여도 -0.2 (오히려 방해)
{6}. 히트맵 시각화
{5}에서 나온 기여도 점수를 색깔로 변환해서 원본 이미지 위에 덮는다.
- 기여도 높음 (양수) ⇒ 빨간색
- 기여도 없음 (0) ⇒ 회색
- 기여도 낮음 (음수) ⇒ 파란색
생성된 히트맵을 보고 의사는 "AI가 이 빨간 영역을 보고 종양이라고 판단했구나"라고 할 수 있다.
[3]. 수식 - 완전성 공리
모든 픽셀의 기여도를 다 더하면 반드시 모델의 실제 출력값이 나와야 한다는 조건을 완전성 공리라고 한다.


쉽게 설명하면
모든 픽셀 기여도의 합 = 실제 출력 - 기준 출력이다.
비유하자면
팀 프로젝트에서 팀원 기여도를 다 더하면 프로젝트 총점이 나와야 한다.
기여도가 허공으로 사라지거나 생기면 안 된다.
{1}. 기존점(Baseline) x` - 아무 정보도 없는 입력
이미지에서 기준점 : 완전히 검은 이미지 (픽셀 전부 0)
텍스트에서 기준점 : 빈 문장
(1). 왜 필요한가?
Attribution은 "기준점 대비 실제 입력이 얼마나 다른가?"를 측정하는 것이다.
기준이 없으면 기여도를 상대적으로 비교할 수 없다.
{2}. Attribution 계산 방법별 수식
(1). Gradient 기반 (Saliency Map)

"픽셀 x_i를 조금 바꿨을 때 출력 f(x)가 얼마나 변하나?"
기울기가 크다 = 이 픽셀이 출력에 큰 영향을 줌 ⇒ 기여도 높음
기울기가 작다 = 이 픽셀이 출력에 별 영향 없음 ⇒ 기여도 낮음
(2). I ntegrated Gradients (적분 기울기)

수식이 복잡해 보이지만 쪼개면 단순하다.
- (x_i - x`_i) : 기준점 대비 실제 픽셀값 차이
- ∫... dα : 기준점 → 실제 입력까지의 기울기 평균
- 곱하기 : 차이 x 평균 기울기 = 최종 기여도
비유하자면 기준점(검은 이미지)에서 실제 이미지까지 조금씩 걸어가면서 매 걸음마다 기울기를 재고, 평균을 내는 것이다.
Gradient 기반이 한 지점만 보는 것이라면 Integrated Gradients는 전체 경로를 다 보는 것이다.
(3). SHAP

핵심만 보면
f(S U {i}) - f(S)
- 픽셀 i를 포함했을 때 출력 - 픽셀 i를 뺐을 때 출력
- 픽셀 i의 순수한 기여도이다.
비유하자면
축구팀에서 선수 한 명의 기여도를 측정할 때,
가능한 모든 팀 조합에서 그 선수가 있을 때와 없을 때 성적 차이의 평균을 내는 것이다.
단순히 "이 선수가 좋냐?"가 아니라 "어떤 조합에서도 공평하게" 기여도를 측정한다.
(4). 정리
각 feature 기여도를 다 더하면 baseline 대비 예측값 변화량과 정확히 일치해야 한다.
설명이 새거나 부풀려지면 안 된다는 보존 법칙이 완전성 공리이다.
완전성 공리를 위해 결국 아래 조건을 만족해야 한다.

- 좌변 : 모든 feature 기여도의 합
- 우변 : 실제 예측 - baseline 예측
둘이 반드시 같아야 한다.
완전성 공리에 대해 기법들을 다시 정리해 보면 아래와 같다.
| 방법 | 특징 |
| Gradient | 한 지점의 기울기 ⇒ 빠름 |
| Integrated Gradients | 완정성 공리 만족 |
| SHAP | 공정하지만 느림 |
Gradient가 완전성을 만족 못하는 이유는 한 지점의 기울기만 보기 때문이다.
전체 경로를 보는 Integrated Gradients와 모든 조합을 보는 SHAP만 수학적으로 완전성을 보장한다.
[4]. Attribution 변형
{1}. Gradient 기반 변형
(1) SmoothGrad
"노이즈를 여러 번 추가해서 평균을 내자"
기본 Gradient의 문제는 픽셀 하나에 기울기를 한 번만 계산하기 때문에 히트맵이 너무 노이즈가 많고, 불안정하다.
SmoothGrad는 입력에 랜덤 노이즈를 n번 추가해서 기울기를 n번 계산하고 평균을 낸다.
- 기본 Gradient : 이미지 한 장 → 기울기 1번 계산
- SmoothGrad : 이미지 / 노이즈 50번 → 기울기 50번 계산 = 평균


비유하자면
사진을 한 장 찍으면 흔들릴 수 있는데
50장 찍어서 겹치면 흔들림이 사라지고 선명해지는 것이다.
즉, 노이즈가 평균내면서 상쇄되는 것이다.
(2). Grad-CAM++
"Grad-CAM보다 더 정확한 영역을 잡자"
기본 Grad-cAM의 문제 :
- 같은 클래스 객체가 여러 개 있을 때 일부만 잡음
- 큰 객체의 전체 영역을 못 잡음
Grad-CAM++는 기울기를 픽셀 단위로 가중치를 다르게 줘서 이 문제를 해결한다.
(3). FullGrad
"중간층의 기울기도 전부 포함하자"
기본 Gradient는 입력층 기울기만 본다.
근데 딥러닝은 중간에 수많은 층이 있고, 각 층에서도 중요한 정보가 있다.
FullGrad는 입력층 + 모든 중간층의 기울기를 다 합산한다.
{2}. Perturbation 기반 변형
(1). Kernel SHAP
"LIME의 허점을 SHAP으로 보완하자"
LIME의 문제 : 근사 방식이 수학적으로 불안정해서 실행할 때마다 결과가 달라질 수 있다.
Kernel SHAP은 LIME의 샘플링 방식에 SHAP의 가중치 계산을 결합해서 이 문제를 해결한다.
- LIME : 랜덤 샘플링 → 근사 → 불안정
- Kernel SHAP : SHAP 가중치 적용 → 수학적으로 안정

핵심은
- 작은 조합과 큰 조합에 높은 가중치
- 중간 크기 조합에 낮은 가중치
⇒ 극단적인 경우를 더 중요하게 본다.
비유하자면
선수 기여도 측정할 때 "혼자일 때"와 "전원일 때"가 가장 중요하고,
중간 팀 구성은 덜 중요한 것처럼 극단적인 경우에 더 높은 가중치를 준다.
(2). Meaningful Perturbation
"가리는 방식을 더 자연스럽게 하자"
기본 Occlusion의 문제 :
픽셀을 검은색으로 덮어버린다. 근데 이게 모델 입장에서 너무 부자연스럽다.
Meaningful Perturbation은 픽셀을 블러(흐리게) 처리해서 더 자연스럽게 가린다.
그리고 "어느 영역을 가려야 출력이 가장 많이 변하나"를 최적화 문제로 푼다.
{3}. Propagation 기반 변형
(1). LRP (Layer-wise Relevance Propagation)
"출력에서 입력 방향으로 기여도를 역전파 하자"
출력값에서 시작하여 층을 거꾸로 거슬러 올라가며 기여도를 분배한다.
- 출력 : 종양 확률 0.94
- 최종 층 : 각 뉴런에 0.94를 분배
- 중간층 : 받은 기여도를 다시 분배
- 입력 층 : 최종 픽셀별 기여도
핵심은 보존 법칙이라는 것이다.

아래층에서 위 층으로 전달되는 기여도의 합 = 항상 일정
기여도가 사라지거나 생기지 않음
(2). Deep LIFT
"기준점 대비 활성화 차이로 기여도를 계산하자"
기본 Gradient의 문제 :
활성화 함수(ReLU)가 0인 구간에서 기울기도 0이 돼버린다. ⇒ 기여도 손실
Deep LIFT는 기울기 대신 "기준점 대비 활성화 값의 차이"로 기여도를 계산한다.

- Δy = f(x) - f(x`) : 실제 출력 - 기준 출력
- Δx = x - x : f(x) - f(x) : 실제 입력 - 기준 입력
⇒ 기울기 대신 차이로 계산하니까 ReLU=0가 되어 문제없다.
비유하자면
방 밝기(출력)가 바뀐 이류를 분석할 때, 기울기 방식은 "지금 이 순간 스위치 민감도"를 보는 거고,
Deep LIFT는 "불 켜기 전후 밝기 차이'를 보는 것이다.
스위치가 꺼져 있어도(ReLU=0) 차이는 측정할 수 있다.
{4}. 정리
| 계열 | 방법 | 핵심 개선 | 단점 |
| Gradient | SmoothGrad | 노이즈 평균으로 안정화 | 느림 (n번 계산) |
| Gradient | Grad-CAM++ | 픽셀별 가중치로 정확도 향상 | 복잡한 계산 |
| Gradient | Full Grad | 전체 층 기울기 합산 | 메모리 많이 필요 |
| Perturbation | Kernel SHAP | SHAP 가중치로 안정화 | 여전히 느림 |
| Perturbation | Meaningful Pertubation | 자연스러운 마스킹 | 최적화 필요 |
| Perturbation | LRP | 층별 역전파로 보존 법칙 만족 | 구조마다 규칙 다름 |
| Perturbation | Deep LIFT | ReLU=0 문제 해결 | 기준점 선택 민감 |
[5]. 실습
{1}. Grad-CAM 시각화
(1). 코드
# ======================================================
# GradCAM 구현 (Google Colab용)
# 학습된 CNN이 이미지의 어느 영역을 보고 판단했는지 히트맵으로 시각화
# 데이터셋: MNIST (손글씨 숫자)
# ======================================================
# ── 1. 라이브러리 임포트 ────────────────────────────
import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import datasets, transforms
import matplotlib.pyplot as plt
import numpy as np
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"사용 디바이스: {DEVICE}")
# ── 2. 데이터 로드 ──────────────────────────────────
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize([0.5], [0.5])
])
dataset = datasets.MNIST(root="./data", train=False, download=True, transform=transform)
dataloader = torch.utils.data.DataLoader(dataset, batch_size=64, shuffle=True)
# ── 3. CNN 분류 모델 정의 ────────────────────────────
# GradCAM은 학습된 CNN 모델에 적용하는 것
# 먼저 CNN 분류기를 학습시키고, 그 모델에 GradCAM 적용
class SimpleCNN(nn.Module):
def __init__(self):
super().__init__()
# 특징 추출부 (Conv 층) - GradCAM이 이 부분을 분석
self.features = nn.Sequential(
# 28x28 → 14x14
nn.Conv2d(1, 32, kernel_size=3, padding=1),
nn.ReLU(),
nn.MaxPool2d(2),
# 14x14 → 7x7
nn.Conv2d(32, 64, kernel_size=3, padding=1),
nn.ReLU(),
nn.MaxPool2d(2),
)
# 분류부 (Linear 층)
self.classifier = nn.Sequential(
nn.Linear(64 * 7 * 7, 128),
nn.ReLU(),
nn.Linear(128, 10) # 0~9 클래스
)
def forward(self, x):
x = self.features(x)
x = x.view(x.size(0), -1)
return self.classifier(x)
# ── 4. 모델 학습 ────────────────────────────────────
train_dataset = datasets.MNIST(root="./data", train=True, download=True, transform=transform)
train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=128, shuffle=True)
model = SimpleCNN().to(DEVICE)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
print("CNN 학습 시작...")
for epoch in range(5):
for imgs, labels in train_loader:
imgs, labels = imgs.to(DEVICE), labels.to(DEVICE)
optimizer.zero_grad()
loss = criterion(model(imgs), labels)
loss.backward()
optimizer.step()
print(f"Epoch {epoch+1}/5 완료")
print("CNN 학습 완료!")
model.eval()
# ── 5. GradCAM 구현 ─────────────────────────────────
# GradCAM 핵심 원리:
# ① 특정 층의 특징맵(feature map)을 저장
# ② 출력에 대한 특징맵의 기울기를 저장
# ③ 기울기의 평균 → 특징맵에 가중치로 적용
# ④ 히트맵 생성
class GradCAM:
def __init__(self, model, target_layer):
self.model = model
self.target_layer = target_layer
self.gradients = None # 기울기 저장
self.activations = None # 특징맵 저장
# Hook: 특징맵과 기울기를 자동으로 저장
target_layer.register_forward_hook(self._save_activation)
target_layer.register_backward_hook(self._save_gradient)
def _save_activation(self, module, input, output):
# 순전파 시 특징맵 저장
self.activations = output.detach()
def _save_gradient(self, module, grad_input, grad_output):
# 역전파 시 기울기 저장
self.gradients = grad_output[0].detach()
def generate(self, img, class_idx=None):
# ① 순전파
output = self.model(img)
# ② 타겟 클래스 결정 (없으면 가장 높은 확률 클래스)
if class_idx is None:
class_idx = output.argmax(dim=1).item()
# ③ 역전파 (타겟 클래스에 대해)
self.model.zero_grad()
output[0, class_idx].backward()
# ④ 기울기 평균 → 채널별 가중치
weights = self.gradients.mean(dim=[2, 3], keepdim=True) # (1, C, 1, 1)
# ⑤ 특징맵에 가중치 적용 후 합산
cam = (weights * self.activations).sum(dim=1, keepdim=True) # (1, 1, H, W)
# ⑥ ReLU 적용 (양수 기여만 남김)
cam = torch.relu(cam)
# ⑦ 0~1 정규화
cam -= cam.min()
cam /= cam.max() + 1e-8
return cam.squeeze().cpu().numpy(), class_idx
# ── 6. 시각화 함수 ──────────────────────────────────
def show_gradcam(model, gradcam, dataloader, num=8):
imgs, labels = next(iter(dataloader))
imgs, labels = imgs[:num].to(DEVICE), labels[:num]
fig, axes = plt.subplots(3, num, figsize=(num * 2, 6))
for i in range(num):
img = imgs[i:i+1]
label = labels[i].item()
cam, pred = gradcam.generate(img)
# 원본 이미지
orig = img.squeeze().cpu().numpy()
orig = (orig + 1) / 2 # [-1,1] → [0,1]
# GradCAM 히트맵을 원본 크기로 리사이즈
cam_resized = torch.tensor(cam).unsqueeze(0).unsqueeze(0)
cam_resized = torch.nn.functional.interpolate(
cam_resized, size=(28, 28), mode='bilinear', align_corners=False
).squeeze().numpy()
# 원본 이미지
axes[0, i].imshow(orig, cmap='gray')
axes[0, i].set_title(f"정답: {label}", fontsize=9)
axes[0, i].axis('off')
# GradCAM 히트맵
axes[1, i].imshow(cam_resized, cmap='jet')
axes[1, i].set_title(f"예측: {pred}", fontsize=9)
axes[1, i].axis('off')
# 원본 + 히트맵 오버레이
axes[2, i].imshow(orig, cmap='gray')
axes[2, i].imshow(cam_resized, cmap='jet', alpha=0.5)
axes[2, i].set_title("오버레이", fontsize=9)
axes[2, i].axis('off')
axes[0, 0].set_ylabel("원본", fontsize=10)
axes[1, 0].set_ylabel("GradCAM", fontsize=10)
axes[2, 0].set_ylabel("오버레이", fontsize=10)
plt.suptitle("GradCAM 시각화 — 빨간 영역 = AI가 집중한 곳", fontsize=12)
plt.tight_layout()
plt.show()
# ── 7. GradCAM 실행 ─────────────────────────────────
# 마지막 Conv 층에 GradCAM 적용
target_layer = model.features[3] # 두 번째 Conv2d 층
gradcam = GradCAM(model, target_layer)
show_gradcam(model, gradcam, dataloader, num=8)
# ── 8. 특정 숫자만 골라서 GradCAM 보기 ─────────────
def show_gradcam_single(digit):
"""특정 숫자에 대한 GradCAM만 시각화"""
for imgs, labels in dataloader:
idx = (labels == digit).nonzero(as_tuple=True)[0]
if len(idx) == 0:
continue
img = imgs[idx[0]:idx[0]+1].to(DEVICE)
label = labels[idx[0]].item()
break
cam, pred = gradcam.generate(img)
orig = img.squeeze().cpu().numpy()
orig = (orig + 1) / 2
cam_resized = torch.tensor(cam).unsqueeze(0).unsqueeze(0)
cam_resized = torch.nn.functional.interpolate(
cam_resized, size=(28, 28), mode='bilinear', align_corners=False
).squeeze().numpy()
fig, axes = plt.subplots(1, 3, figsize=(9, 3))
axes[0].imshow(orig, cmap='gray'); axes[0].set_title(f"원본: {label}"); axes[0].axis('off')
axes[1].imshow(cam_resized, cmap='jet'); axes[1].set_title(f"GradCAM"); axes[1].axis('off')
axes[2].imshow(orig, cmap='gray')
axes[2].imshow(cam_resized, cmap='jet', alpha=0.5)
axes[2].set_title("오버레이"); axes[2].axis('off')
plt.suptitle(f"숫자 {digit} — AI가 집중한 영역", fontsize=12)
plt.tight_layout()
plt.show()
# 사용 예시
# show_gradcam_single(7) # 숫자 7에 대한 GradCAM
# show_gradcam_single(0) # 숫자 0에 대한 GradCAM
(2). 결과

히트맵이 거의 전부 파란색이 나오는 문제점이 발생했다.
1. 원인 - 마지막 Conv 층이 너무 작음
- 지금 Grad-CAM을 두 번째 Conv 층 (7x7)에 적용하고 있다.
- 7x7 특징맵을 28x28로 다시 키우면 너무 뭉개져서 히트맵이 작은 점으로만 나오는 것이다.
2. 해결책 - 첫 번째 Conv 층으로 변경
(3). 수정

히트맵이 숫자 획을 따라 잘 나왔다.
[6]. 한계
{1}. 불안정성 (Instability)
같은 이미지에 입력을 아주 조금만 바꿔도 Attribution 결과가 크게 달라진다.
- 원본 이미지 ⇒ 히트맵 A
- 픽셀 하나만 변경 ⇒ 히트맵 B (완전히 다름)
{2}. 기준점 민감성 (Baseline Sensitivity)
Integrated Gradients, SHAP 같은 방법은 기준점을 뭘로 설정하냐에 따라 결과가 달라진다.
- 기준점 = 검은 이미지 ⇒ 히트맵 A
- 기준점 = 흰 이미지 ⇒ 히트맵 B
- 기준점 = 흐린 이미지 ⇒ 히트맵 C
정답이 되는 기준점이 없어서 연구자마다 다른 기준점을 써서 결과가 달라질 수 있다.
{3}. 설명과 모델 동작의 불일치
Attribution이 보여주는 히트맵이 실제로 모델이 판단한 근거와 다를 수 있다.
- 히트맵 : "이 영역을 보고 판단했어"
- 실제 : 모델 내부에서 전혀 다른 패턴으로 판단했을 수도 있음
{4}. 계산 비용
방법마다 계산 비용이 크게 달라진다.
- Gradient : 빠르지만 기울기 한 번 계산
- GradCAM : 빠르지만 특정 층만 분석
- SHAP : 모든 조합을 계산해서 느림
- Integrated Gradients : 여러 번 적분해서 중간 속도이다.
특히 SHAP은 이미지처럼 픽셀 수가 많으면 계산량이 폭발적으로 증가한다.
{5}. 평가 기준 부재
Attribution 결과가 얼마나 정확한지 객관적으로 평가할 방법이 없다.
- GAN : Loss 값으로 어느 정도 판단 가능
- Attribution : “이 히트맵이 맞냐 틀리냐”를 측정하는 기준이 없음
⇒ 결국 사람눈으로 판단해야 함
Attribution은 XAI 기법들이 공통적으로 추구하는 목표다.
"각 입력 특성이 예측에 얼마나 기여했는가?"를 구하는 것이 Attribution이고,
SHAP, LIME, Grad-CAM, Gradient 등의 기법들은 이 목표를 각자의 방식으로 달성하려는 시도들이다.
기법들의 차이는 접근 방식에 있다. SHAP은 게임이론 기반으로 수학적으로 공정하게 기여도를 분배하고,
LIME은 주변을 근사 모델로 흉내 내 국소적으로 계산한다.
Gradient 계열은 입력 변화에 출력이 얼마나 민감하 진 지를 보고,
Grad-CAM은 이를 공간적으로 시각화한다.
Attribution의 공리들-완전성, 더미, 대칭성 등은 단순한 이론적 조건이 아니라 기법들을 평가하는 실질적인 기준이 된다.
SHAP이 이론적으로 가장 탄탄하다고 평가받는 이유가 이 공리들을 모두 만족하기 때문이고,
Grad-CAM++이 Grad-CAM 이후에 등장한 이유도 완전성 공리를 만족하지 못하는 문제를 보완하기 위해서였다.
결국 Attribution은 기법들의 존재 이유이자, 기법들을 평가하는 기준이다.
'AI > AI' 카테고리의 다른 글
| XAI 기법 - SHAP (0) | 2026.05.02 |
|---|---|
| XAI 기법 - LIME (1) | 2026.05.02 |
| Attention Mechanism (0) | 2026.05.02 |
| Saliency Map (0) | 2026.04.30 |
| Activation Function (활성화 함수) (0) | 2026.04.29 |