티스토리 뷰
목차
지난 글에서는 깊은 K-평균 알고리즘을 이용한 비지도 학습 코드에 대해 뜯어보았다.
[PyTorch]비지도 학습 - 깊은 K-평균 알고리즘 (오토인코더 + K-평균 알고리즘)
이번 글에서는 설명 가능한 AI와 CAM에 대해 알아보고, 코드를 살펴보도록 하겠다.
지난 글과 마찬가지로 먼저 설명 가능한 AI와 CAM에 대해 간단히 알아보고 넘어가자.
설명 가능한 AI(Explainable AI, XAI)
딥러닝은 복잡한 모델 구조와 수백만 개의 파라미터를 가진 블랙박스(black box)처럼 동작한다.
모델의 예측이 왜 그렇게 나왔는지 이해하기 어려워지면서 설명 가능한 AI가 주목받는다.
XAI는 모델의 내부 작동 방식, 의사 결정 과정을 해석 가능하게 만들어 신뢰성을 높이는 것을 목표로 한다.
왜 중요한가?
- 신뢰와 투명성: 의료, 금융, 자율주행 등 민감한 분야에서 AI의 결정에 대한 이유를 요구하는 경우가 많다.
- 디버깅과 개선: 모델이 데이터에서 학습한 잘못된 패턴을 식별하고 수정할 수 있다.
- 법적 및 윤리적 요구사항: AI의 의사결정 과정에 대한 설명이 법적으로 요구되거나 윤리적으로 필요할 수 있다.
CAM(Class Activation Map)
CAM은 CNN(Convolutional Neural Network) 기반 모델이
입력 이미지에서 어떤 영역에 주목했는지 시각화하는 기법이다.
모델이 결과를 도출하기 위해 사용하는 특징 맵(feature map)과 분류기(classifier)의 가중치를 활용해 중요도를 계산한다.
이를 통해 모델이 "어디를 보고 판단했는지" 이해할 수 있다.
왜 중요한가?
- 모델 이해
- 의료 이미지 진단: 질병이 있는 부위에 모델이 올바르게 주목했는지 확인할 수 있다.
- 자율주행: 차량이 도로의 주요 지점에 주목하고 있는지 확인 가능하다.
- 데이터 품질 개선
- 잘못된 라벨이나 이상치(outlier)를 발견하는 데 도움을 준다.
- 모델 개선
- 모델이 부적절한 패턴에 의존하고 있다면 데이터 증강이나 아키텍처 변경을 통해 개선할 수 있다.
CAM의 종류
- 기본 CAM(Class Activation Map)
- 네트워크 마지막 층의 특징 맵과 가중치를 결합해 활성화 맵을 생성한다.
- 분류기 직전에 전역 평균 풀링(Global Average Pooling, GAP)을 사용하는 모델에서만 동작한다.
- Grad-CAM(Gradient-weighted CAM)
- 모델의 미분(gradient)을 활용해 가중치를 계산한다.
- GAP 없이도 작동하며, 모든 CNN 모델에 적용 가능하다.
- 분류뿐만 아니라 객체 탐지, 세분화 등 다양한 작업에서도 사용된다.
- Score-CAM
- Gradient를 사용하지 않고, 모델의 출력 점수 변화로 가중치를 계산한다.
- Grad-CAM보다 노이즈가 적고 안정적이다.
- Grad-CAM++
- Grad-CAM의 개선 버전으로, 픽셀별로 가중치를 더 세밀하게 계산한다.
- 다중 객체가 있는 이미지에서도 더 정확한 결과를 제공한다.
- Ablation-CAM
- 특정 뉴런을 비활성화했을 때 출력 변화량을 기반으로 가중치를 계산한다.
- 계산 비용이 크지만 높은 설명력을 가진다.
코드에서 사용된 CAM
우리 코드에서 사용할 CAM은 기본 CAM에 가까운 방식이다.
Grad-CAM처럼 gradient를 활용하지 않고,
모델의 마지막 특징 맵과 분류기의 가중치(weight)를 조합해 활성화 맵을 생성한다.
- 코드의 동작 과정
- 특징 맵 추출: 모델의 마지막 레이어(model.layer4[1].bn2)에서 forward hook을 사용해 특징 맵을 가져온다.
- 가중치 활용: 분류기의 가중치(model.fc.weight)와 특징 맵을 곱해 클래스별 활성화 맵을 계산한다.
- 시각화: 활성화 맵을 0~255로 정규화한 후, 원본 이미지 위에 오버레이해 시각화한다.
- 장점
- 간단한 구현: 추가적인 모델 수정 없이 기존의 ResNet 구조에 바로 적용 가능하다.
- 설명력 제공: 모델이 특정 클래스를 예측할 때 어디에 주목했는지 확인할 수 있다.
계속해서 코드로 가자.
선 요약
이 글에서 다룰 코드는 아래와 같다.
import numpy as np
from matplotlib import pyplot as plt
import cv2
import torch
import torchvision
import torchvision.transforms as transforms
from torch.utils.data import DataLoader
import torch.nn as nn
import torch.optim as optim
from tqdm import trange
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
print(device)
transform = transforms.Compose(
[
transforms.Resize(128),
transforms.ToTensor(),
transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)),
]
)
trainset = torchvision.datasets.STL10(
root="./data", split="train", download=True, transform=transform
) # 96x96
trainloader = torch.utils.data.DataLoader(trainset, batch_size=32, shuffle=True)
# 10 classes: airplane, bird, car, cat, deer, dog, horse, monkey, ship, truck
model = torchvision.models.resnet18(weights="DEFAULT")
model.conv1 = nn.Conv2d(
3, 64, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False
)
model.fc = nn.Linear(512, 10)
model = model.to(device)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=1e-4, weight_decay=1e-2)
num_epochs = 10
ls = 2
pbar = trange(num_epochs)
for epoch in pbar:
correct = 0
total = 0
running_loss = 0.0
for data in trainloader:
inputs, labels = data[0].to(device), data[1].to(device)
optimizer.zero_grad()
outputs = model(inputs)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
running_loss += loss.item()
_, predicted = torch.max(outputs.detach(), 1)
total += labels.size(0)
correct += (predicted == labels).sum().item()
cost = running_loss / len(trainloader)
acc = 100 * correct / total
if cost < ls:
ls = cost
torch.save(model.state_dict(), "./models/stl10_resnet18.pth")
pbar.set_postfix({"loss": cost, "train acc": acc})
correct = 0
total = 0
with torch.no_grad():
model.eval()
for data in trainloader:
images, labels = data[0].to(device), data[1].to(device)
outputs = model(images)
_, predicted = torch.max(outputs.data, 1)
total += labels.size(0)
correct += (predicted == labels).sum().item()
print("Accuracy of the network on the train images: %d %%" % (100 * correct / total))
print(model)
activation = {}
def get_activation(name):
def hook(model, input, output):
activation[name] = output.detach()
return hook
def cam(model, trainset, img_sample, img_size):
model.eval()
with torch.no_grad(): # requires_grad 비활성화
model.layer4[1].bn2.register_forward_hook(
get_activation("final")
) # feature extraction의 마지막 feature map 구하기
data, label = trainset[img_sample] # 이미지 한 장과 라벨 불러오기
data.unsqueeze_(0) # 4차원 3차원 [피쳐수 ,너비, 높이] -> [1,피쳐수 ,너비, 높이]
output = model(data.to(device))
_, prediction = torch.max(output, 1)
act = activation[
"final"
].squeeze() # 4차원 [1,피쳐수 ,너비, 높이] -> 3차원 [피쳐수 ,너비, 높이]
w = model.fc.weight # classifer의 가중치 불러오기
for idx in range(act.size(0)): # CAM 연산
if idx == 0:
tmp = act[idx] * w[prediction.item()][idx]
else:
tmp += act[idx] * w[prediction.item()][idx]
# 모든 이미지 팍셀값을 0~255로 스케일하기
normalized_cam = tmp.cpu().numpy()
normalized_cam = (normalized_cam - np.min(normalized_cam)) / (
np.max(normalized_cam) - np.min(normalized_cam)
)
original_img = np.uint8((data[0][0] / 2 + 0.5) * 255)
# 원본 이미지 사이즈로 리사이즈
cam_img = cv2.resize(np.uint8(normalized_cam * 255), dsize=(img_size, img_size))
return cam_img, original_img
def plot_cam(model, trainset, img_size, start):
end = start + 20
fig, axs = plt.subplots(2, (end - start + 1) // 2, figsize=(20, 5))
fig.subplots_adjust(hspace=0.01, wspace=0.01)
axs = axs.ravel()
for i in range(start, end):
cam_img, original_img = cam(model, trainset, i, img_size)
axs[i - start].imshow(original_img, cmap="gray")
axs[i - start].imshow(cam_img, cmap="jet", alpha=0.5)
axs[i - start].axis("off")
plt.show()
fig.savefig("./results/cam.png")
plot_cam(model, trainset, 128, 10)
결과는 다음과 같으며,
핵심 워크플로우는 다음과 같다.
- 환경 설정 및 데이터 준비
- 라이브러리 불러오기
- GPU/CPU 디바이스 설정
- STL10 데이터셋 로드 및 전처리 (이미지 크기 조정, 정규화)
- 모델 구성
- ResNet18 모델 불러오기
- 첫 번째 컨볼루션 레이어와 최종 분류 레이어 수정
- 모델을 GPU/CPU에 배치
- 학습 설정
- 손실 함수 (CrossEntropyLoss)와 옵티마이저 (Adam) 정의
- 학습 파라미터 설정 (에포크 수, 학습률 등)
- 모델 학습
- 배치 단위로 데이터 불러오기
- 순전파 → 손실 계산 → 역전파 → 가중치 업데이트
- 정확도 및 손실 계산
- 손실이 개선될 때 모델 저장
- 모델 평가
- 학습 데이터로 정확도 확인
- torch.no_grad()를 사용해 평가 단계에서 그래프 생성을 방지
- CAM 생성 및 시각화
- 특정 레이어의 활성화 맵 추출 (hook 설정)
- Class Activation Map(CAM) 계산
- CAM과 원본 이미지를 겹쳐 시각화 및 저장
핵심 아이디어:
- 모델 학습: ResNet18을 STL10 데이터셋에 맞게 미세 조정한다.
- CAM: 신경망이 이미지에서 어떤 영역을 주목했는지 시각적으로 확인한다.
계속해서 코드를 뜯어보자.
필요한 라이브러리 및 설정
# 행렬 연산 및 이미지 데이터를 다루기 위한 필수 라이브러리
import numpy as np # 행렬 및 배열 연산을 수행하는 라이브러리이다.
# 시각화를 위해 사용되는 라이브러리
from matplotlib import pyplot as plt # 데이터와 이미지를 플롯(그래프) 형태로 시각화한다.
# 이미지 처리 작업을 위한 라이브러리
import cv2 # OpenCV 라이브러리로, 이미지 크기 조정, 필터링 등을 처리한다.
# PyTorch 및 관련 모듈
import torch # 딥러닝 모델 구현 및 학습을 지원하는 딥러닝 프레임워크이다.
import torchvision # PyTorch와 함께 사용되며, 이미지 관련 데이터셋과 사전 학습된 모델을 제공한다.
import torchvision.transforms as transforms # 데이터 전처리와 증강 작업을 수행하는 모듈이다.
# 데이터 로더 관련 모듈
from torch.utils.data import DataLoader # 데이터셋을 배치 단위로 나눠 학습에 사용되도록 처리한다.
# 신경망 정의 및 학습 관련 모듈
import torch.nn as nn # 딥러닝 모델의 계층(레이어)을 정의할 때 사용한다.
import torch.optim as optim # 모델의 가중치를 업데이트하는 최적화 알고리즘을 제공한다.
# 학습 진행 상황을 보기 위한 프로그레스 바
from tqdm import trange # 학습 루프 진행 상황을 시각적으로 표시한다.
- numpy
- 행렬 및 배열 연산을 지원하는 라이브러리이다.
- 딥러닝에서는 주로 데이터의 수치 변환, 텐서 조작, 정규화 등에 사용된다.
- 예: 이미지 데이터를 numpy 배열로 변환하거나, CAM(클래스 활성화 맵) 계산 시 사용된다.
- matplotlib.pyplot
- 데이터 시각화를 위해 사용되는 라이브러리이다.
- 학습 결과(손실, 정확도)나 이미지 데이터를 시각화하는 데 유용하다.
- 예: CAM 이미지를 오버레이한 결과를 출력할 때 사용된다.
- cv2
- OpenCV의 핵심 모듈로 이미지 처리 작업을 수행한다.
- 이미지를 크기 조정하거나, 배열 데이터를 시각적으로 표현하기 위한 형태로 변환할 때 사용된다.
- 예: CAM 이미지를 원본 이미지 크기에 맞게 리사이즈할 때 활용된다.
- torch
- 딥러닝 프레임워크로, 신경망 모델을 정의하고 학습을 수행하는 데 사용된다.
- CPU와 GPU를 모두 지원하며, 강력한 자동 미분 기능을 제공한다.
- 주요 기능:
- 모델 정의 및 학습
- 데이터 처리
- 텐서 조작(딥러닝에서의 기본 데이터 구조)
- torchvision
- 이미지 관련 데이터셋과 사전 학습된 모델, 데이터 변환 기능을 제공한다.
- 이 프로젝트에서는 STL10 데이터셋과 ResNet18 사전 학습 모델을 불러오는 데 사용된다.
- torchvision.transforms
- 이미지 데이터의 전처리 및 증강(augmentation)을 수행하는 데 사용된다.
- 주요 기능:
- 크기 조정 (Resize)
- 텐서 변환 (ToTensor)
- 정규화 (Normalize)
- torch.utils.data.DataLoader
- 데이터셋을 배치 단위로 나눠 효율적으로 학습에 사용되도록 한다.
- 이 모듈은 데이터를 섞거나(batch shuffle), 병렬 처리로 빠르게 로드할 수 있다.
- torch.nn
- 딥러닝 모델의 계층(예: Conv2D, Linear, ReLU 등)을 정의하는 모듈이다.
- 이 프로젝트에서는 ResNet18 모델 구조를 수정하는 데 사용된다.
- torch.optim
- 모델 학습 시 가중치를 업데이트하기 위한 최적화 알고리즘을 제공한다.
- 이 프로젝트에서는 Adam 옵티마이저를 사용한다.
- tqdm.trange
- 학습 루프 진행 상황을 시각적으로 표시하기 위한 모듈이다.
- 학습이 얼마나 진행되었는지 쉽게 알 수 있다.
디바이스 설정
# PyTorch에서 사용 가능한 디바이스를 설정하는 코드이다.
# torch.cuda.is_available()는 CUDA를 사용할 수 있는 환경인지 확인하는 함수이다.
# CUDA가 활성화된 경우 GPU를 사용하도록 설정하고, 그렇지 않으면 CPU를 사용한다.
# torch.device는 계산에 사용할 디바이스를 명시적으로 지정한다.
# "cuda:0"은 첫 번째 GPU를 의미한다. 여러 GPU가 있는 경우 "cuda:1", "cuda:2"와 같이 사용할 수 있다.
# GPU는 대규모 병렬 연산을 수행할 수 있으므로, 딥러닝 학습 속도를 크게 높일 수 있다.
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
# 사용 가능한 디바이스를 출력해 현재 학습 환경이 GPU인지 CPU인지 확인한다.
print(device)
- torch.cuda.is_available():
- CUDA는 NVIDIA의 GPU에서 병렬 계산을 수행하기 위한 플랫폼이다.
- 이 함수는 현재 CUDA를 사용할 수 있는지 여부를 반환한다.
- GPU가 설치되어 있고, 드라이버와 PyTorch가 올바르게 설정된 경우 True를 반환한다.
- torch.device:
- PyTorch에서 데이터와 모델이 계산될 디바이스를 지정하는 객체이다.
- "cuda:0"는 첫 번째 GPU를 의미하며, 두 번째 GPU를 사용하려면 "cuda:1"로 설정한다.
- CPU를 사용할 때는 "cpu"로 설정한다.
- "cuda:0" if torch.cuda.is_available() else "cpu":
- CUDA를 사용할 수 있다면 "cuda:0"를 선택하고, 그렇지 않다면 "cpu"를 선택한다.
- 이 조건문은 GPU 환경이 없는 경우 자동으로 CPU로 전환하도록 작성되었다.
- print(device):
- 선택된 디바이스를 출력한다.
- 학습 또는 추론 과정에서 GPU가 제대로 사용되고 있는지 확인하는 데 유용하다.
데이터 전처리 및 데이터 로드
# 데이터 전처리를 정의한다.
transform = transforms.Compose(
[
# Resize: 이미지를 128x128 크기로 조정한다.
# STL10 데이터셋의 기본 이미지는 96x96 크기이므로, 더 큰 크기로 업스케일링한다.
transforms.Resize(128),
# ToTensor: 이미지를 PyTorch 텐서 형태로 변환한다.
# 변환 후, 이미지 데이터는 [C, H, W] (채널, 높이, 너비) 형태로 저장된다.
# 픽셀 값의 범위도 [0, 255]에서 [0.0, 1.0]로 정규화된다.
transforms.ToTensor(),
# Normalize: 이미지의 픽셀 값을 정규화한다.
# RGB 각 채널의 평균과 표준편차를 사용해 (0.5, 0.5, 0.5) 기준으로
# 평균을 0, 표준편차를 1로 맞춘다. 이는 모델 학습 성능을 높이는 데 도움을 준다.
transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)),
]
)
# STL10 데이터셋을 불러온다.
trainset = torchvision.datasets.STL10(
root="./data", # 데이터셋을 저장할 경로를 지정한다.
split="train", # 학습용 데이터를 사용한다.
download=True, # 데이터셋이 없으면 자동으로 다운로드한다.
transform=transform # 정의한 전처리 과정을 적용한다.
)
# DataLoader를 사용해 데이터를 배치 단위로 불러온다.
trainloader = torch.utils.data.DataLoader(
trainset, # 전처리가 적용된 학습용 STL10 데이터셋
batch_size=32, # 한 번에 가져올 이미지의 수 (배치 크기)
shuffle=True # 데이터를 랜덤으로 섞는다. 이는 학습 성능을 높이는 데 유용하다.
)
- transforms.Compose
- Compose는 여러 데이터 전처리 과정을 묶어 순차적으로 적용한다.
- 이 코드는 데이터를 모델이 잘 학습할 수 있도록 사전 처리하는 역할을 한다.
- a) transforms.Resize(128)
- 이미지를 128x128 크기로 조정한다.
- STL10 데이터셋의 원래 이미지 크기는 96x96이지만, ResNet 모델은 더 큰 입력 이미지에서 더 좋은 성능을 낼 수 있다.
- 업스케일링은 학습 성능을 높이는 데 기여할 수 있다.
- b) transforms.ToTensor()
- 이미지를 PyTorch 텐서로 변환한다.
- 변환 후 이미지 데이터의 형태는 (C, H, W)가 된다.
- C: 채널 수 (RGB 이미지의 경우 3)
- H: 이미지의 높이
- W: 이미지의 너비
- 픽셀 값은 [0, 255]에서 [0.0, 1.0] 사이로 정규화된다.
- c) transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))
- 각 채널별 평균값과 표준편차를 사용해 정규화한다.
- Normalize의 역할:
- 평균이 0이고 표준편차가 1이 되도록 픽셀 값을 조정한다.
- 이는 학습 속도와 모델의 성능을 높이는 데 도움을 준다.
- torchvision.datasets.STL10
- STL10은 이미지 분류를 위한 벤치마크 데이터셋이다.
- split="train"은 학습 데이터만 가져온다는 의미다.
- download=True는 데이터셋이 없을 경우 인터넷에서 다운로드한다.
- torch.utils.data.DataLoader
- DataLoader는 데이터셋을 학습 가능한 형태로 배치(batch) 단위로 로드한다.
- batch_size=32는 한 번에 32개의 이미지를 가져온다는 의미다.
- shuffle=True는 데이터를 랜덤하게 섞어서 가져온다.
- 이는 데이터 순서에 따른 학습 편향을 줄이는 데 도움을 준다.
요약
- 전처리 정의: Resize로 이미지를 128x128로 조정하고, ToTensor로 PyTorch 텐서로 변환하며, Normalize로 픽셀 값을 정규화한다.
- 데이터셋 불러오기: STL10 데이터셋을 로드하고, 정의된 전처리를 적용한다.
- DataLoader 생성: 데이터를 32개의 배치 단위로 랜덤하게 섞어 가져온다.
이 과정은 데이터를 모델에 입력하기 전, 적절히 정제하고 학습에 최적화된 상태로 만드는 중요한 단계다.
모델 정의
# ResNet18 모델을 불러온다. 사전 학습된 가중치를 사용한다.
model = torchvision.models.resnet18(weights="DEFAULT")
# 첫 번째 컨볼루션 레이어(conv1)를 수정한다.
# 기본적으로 ResNet18은 이미지넷 데이터셋(224x224 입력)을 처리하도록 설계되었다.
# STL10 데이터는 96x96 크기이며, 이를 더 잘 처리하도록 커널 크기와 패딩을 변경한다.
model.conv1 = nn.Conv2d(
3, # 입력 채널 수 (RGB 이미지이므로 3)
64, # 출력 채널 수
kernel_size=(3, 3), # 커널 크기를 3x3으로 설정한다.
stride=(1, 1), # 필터가 이동하는 픽셀 수를 1로 설정한다.
padding=(1, 1), # 경계 처리를 위해 입력 이미지의 가장자리에 1픽셀씩 패딩을 추가한다.
bias=False # 편향(bias)은 사용하지 않는다. 이는 Batch Normalization과 함께 자주 사용되는 설정이다.
)
# 마지막 완전연결층(fc, Fully Connected Layer)을 수정한다.
# ResNet18의 기본 설정에서는 1000개의 클래스를 분류한다.
# 이를 10개의 클래스를 분류하도록 변경한다.
model.fc = nn.Linear(
in_features=512, # ResNet18의 마지막 레이어의 출력 크기
out_features=10 # STL10 데이터셋의 클래스 개수
)
# 모델을 GPU 또는 CPU로 이동시킨다.
# CUDA가 활성화되어 있으면 GPU로 학습을 수행하고, 그렇지 않으면 CPU를 사용한다.
model = model.to(device)
- ResNet18 불러오기:
- ResNet(Residual Network)은 딥러닝에서 널리 사용되는 네트워크 구조이다.
- ResNet18은 18개의 레이어로 구성되어 있으며, "잔차 연결(skip connection)"이라는 기법을 사용해 기울기 소실 문제를 해결한다.
- 여기서는 torchvision에서 제공하는 resnet18 모델을 사전 학습된 가중치와 함께 불러온다.
- 첫 번째 컨볼루션 레이어 수정:
- 기본 conv1 설정:
- ResNet18의 초기 설계에서는 입력 이미지가 224x224 크기라는 가정하에 7x7 커널, 2의 스트라이드, 3의 패딩을 사용한다.
- 이는 더 큰 필터 크기로 공간적 정보를 보다 넓게 통합하려는 목적이다.
- 수정 이유:
- STL10 데이터는 96x96 크기로 ResNet18의 기본 설정과 다르다.
- 따라서 커널 크기를 3x3으로 줄이고 스트라이드를 1로 변경해 더 작은 필터를 사용해 세밀한 정보를 포착한다.
- 패딩을 1로 설정해 출력 크기를 입력 크기와 동일하게 유지한다.
- 편향을 사용하지 않는 이유는 Batch Normalization이 편향을 대체하는 역할을 하기 때문이다.
- 마지막 완전연결층 수정:
- ResNet18의 기본 설정은 ImageNet 데이터셋(1000개 클래스)을 분류하기 위한 것이다.
- STL10 데이터셋은 10개의 클래스로 구성되므로 출력 크기를 10으로 설정한다.
- 마지막 레이어의 입력 크기(in_features=512)는 ResNet18 아키텍처의 고정된 설정으로, Global Average Pooling을 통해 얻어진다.
- 모델 이동:
- 학습 속도를 높이기 위해 GPU(CUDA)를 사용한다.
- to(device)를 호출하면 모델의 모든 매개변수가 GPU 또는 CPU로 이동한다.
손실 함수 및 옵티마이저 정의
# 손실 함수 정의
criterion = nn.CrossEntropyLoss()
# CrossEntropyLoss는 다중 클래스 분류 문제에서 자주 사용되는 손실 함수이다.
# 이 손실 함수는 모델이 예측한 클래스 확률 분포와 실제 클래스(정답) 간의 차이를 계산한다.
# 내부적으로 소프트맥스 함수와 음의 로그 우도(Negative Log-Likelihood, NLL) 손실을 포함한다.
# 따라서 모델이 잘못된 클래스에 높은 확률을 할당할수록 손실 값이 커진다.
# 옵티마이저 정의
optimizer = optim.Adam(model.parameters(), lr=1e-4, weight_decay=1e-2)
# Adam 옵티마이저는 경사 하강법(Gradient Descent)을 개선한 알고리즘이다.
# 학습률(lr)을 0.0001로 설정해 학습 속도를 조절한다.
# weight_decay는 0.01로 설정되어 가중치 감쇠를 적용한다.
# 가중치 감쇠는 L2 정규화를 통해 과적합을 방지하는 데 사용된다.
- 손실 함수 (criterion)
- nn.CrossEntropyLoss는 다중 클래스 분류 문제에 사용된다.
- 이 함수는 두 단계를 포함한다:
- 소프트맥스 계산: 모델의 출력(logit)을 확률 분포로 변환한다.
- 예를 들어, 모델 출력이 [2.0, 1.0, 0.1]이라면, 소프트맥스를 적용해 [0.7, 0.2, 0.1]과 같은 확률로 변환한다.
- 음의 로그 우도(NLL) 손실 계산: 실제 클래스의 확률 값을 기반으로 손실 값을 계산한다.
- 정답 레이블이 첫 번째 클래스(인덱스 0)라면, 손실은 -log(0.7)로 계산된다.
- 소프트맥스 계산: 모델의 출력(logit)을 확률 분포로 변환한다.
- 역할:
- 손실 값이 작을수록 모델 예측이 정답과 가까운 확률 분포를 생성한다.
- 손실 값이 클수록 모델의 예측이 정답과 멀리 떨어져 있다.
- 옵티마이저 (optimizer)
- optim.Adam은 Adam(Adaptive Moment Estimation) 알고리즘을 구현한다.
- 장점: SGD(Stochastic Gradient Descent)에 비해 학습률을 자동으로 조정해 학습 속도를 높이고 안정성을 제공한다.
- 주요 하이퍼파라미터:
- lr (학습률): 한 번의 업데이트에서 가중치가 얼마나 변화할지를 결정한다.
- 여기서는 lr=1e-4로 설정해, 안정적이고 세밀한 학습을 가능하게 한다.
- weight_decay: L2 정규화 항으로, 가중치 값을 제약해 과적합을 방지한다.
- 여기서는 weight_decay=1e-2로 설정해 가중치의 크기를 제한한다.
- lr (학습률): 한 번의 업데이트에서 가중치가 얼마나 변화할지를 결정한다.
- 작동 방식:
- 모델의 각 파라미터에 대해 **1차 모멘트(기대값)**와 **2차 모멘트(분산)**를 계산해, 각각의 학습률을 조정한다.
- 이를 통해 중요한 파라미터는 더 빠르게, 덜 중요한 파라미터는 느리게 학습한다.
- optim.Adam은 Adam(Adaptive Moment Estimation) 알고리즘을 구현한다.
CrossEntropyLoss의 계산 예시
# 예제 데이터: 모델 출력 (logits)과 정답 레이블
logits = torch.tensor([[2.0, 1.0, 0.1]]) # 모델의 예측
labels = torch.tensor([0]) # 정답 레이블 (클래스 0)
# CrossEntropyLoss 적용
loss_function = nn.CrossEntropyLoss()
loss = loss_function(logits, labels)
print(loss.item()) # 출력: 손실 값
- 위 예제에서:
- 소프트맥스: [2.0, 1.0, 0.1] → [0.7, 0.2, 0.1]
- 손실: -log(0.7) ≈ 0.3567
Adam 옵티마이저의 계산 흐름
- 학습 과정에서 각 파라미터는 다음 방식으로 업데이트된다:
- 기울기 계산: 손실 함수에 대해 가중치의 변화량을 계산한다.
- 1차 모멘트와 2차 모멘트 업데이트:
- 1차 모멘트(평균): 현재와 이전 기울기를 조합한다.
- 2차 모멘트(분산): 기울기의 크기에 기반해 조정한다.
- 가중치 업데이트:
- 각 가중치에 대해 조정된 학습률로 업데이트한다.
Adam은 기존 경사 하강법보다 빠르게 수렴하면서, 안정적이고 적응적인 학습을 제공한다.
모델 학습
# 학습 반복 횟수를 설정한다 (에포크 수)
num_epochs = 10
# 최소 손실 값을 저장하기 위한 변수 (초기값은 2로 설정)
ls = 2
# 진행 상황을 표시하기 위해 tqdm의 trange를 사용한다
pbar = trange(num_epochs)
# 전체 에포크 반복
for epoch in pbar:
# 에포크마다 정확도와 손실을 초기화한다
correct = 0
total = 0
running_loss = 0.0
# 배치 단위로 데이터 학습
for data in trainloader:
# 입력 데이터(inputs)와 레이블(labels)을 가져와 GPU로 보낸다
inputs, labels = data[0].to(device), data[1].to(device)
# 옵티마이저의 그래디언트를 초기화한다
optimizer.zero_grad()
# 모델을 통해 예측 값을 계산한다
outputs = model(inputs)
# 손실(loss)을 계산한다 (예측 값과 실제 값의 차이)
loss = criterion(outputs, labels)
# 손실을 기반으로 역전파를 수행해 그래디언트를 계산한다
loss.backward()
# 계산된 그래디언트를 사용해 모델의 가중치를 업데이트한다
optimizer.step()
# 손실 값을 누적해 총 손실(running_loss)을 계산한다
running_loss += loss.item()
# 예측 값을 계산한다 (가장 높은 확률의 클래스 선택)
_, predicted = torch.max(outputs.detach(), 1)
# 전체 레이블의 개수를 누적한다
total += labels.size(0)
# 정확히 예측한 개수를 누적한다
correct += (predicted == labels).sum().item()
# 에포크가 끝난 후, 평균 손실(cost)을 계산한다
cost = running_loss / len(trainloader)
# 정확도(acc)를 계산한다
acc = 100 * correct / total
# 가장 낮은 손실 값을 업데이트하고, 해당 모델 가중치를 저장한다
if cost < ls:
ls = cost
torch.save(model.state_dict(), "./models/stl10_resnet18.pth")
# tqdm 진행 바에 현재 에포크의 손실과 정확도를 표시한다
pbar.set_postfix({"loss": cost, "train acc": acc})
- 학습 설정:
- num_epochs = 10: 학습 반복 횟수를 10으로 설정한다. 에포크란 데이터셋 전체를 한 번 학습하는 것을 의미한다.
- ls = 2: 최소 손실 값(ls)을 초기값 2로 설정한다. 모델이 학습하면서 손실 값이 개선되면 이를 갱신한다.
- 진행 상황 표시:
- pbar = trange(num_epochs): tqdm의 trange를 사용해 학습 진행 상황을 표시한다. 에포크 진행률과 추가 정보를 확인할 수 있다.
- 에포크 시작:
- correct = 0, total = 0, running_loss = 0.0: 에포크가 시작될 때마다 정확도와 손실 값을 초기화한다.
- 데이터 로드 및 학습:
- 데이터 준비:
- inputs, labels = data[0].to(device), data[1].to(device): 입력 데이터와 레이블 데이터를 GPU로 이동시킨다.
- 모델 예측:
- outputs = model(inputs): 입력 데이터를 모델에 전달해 출력 값을 얻는다.
- 손실 계산:
- loss = criterion(outputs, labels): 예측 값과 실제 값 간의 차이를 손실 함수(CrossEntropyLoss)로 계산한다.
- 역전파 및 가중치 업데이트:
- loss.backward(): 역전파를 통해 각 가중치의 그래디언트를 계산한다.
- optimizer.step(): 계산된 그래디언트를 사용해 가중치를 업데이트한다.
- 손실 값 누적:
- running_loss += loss.item(): 현재 배치의 손실 값을 누적해 총 손실 값을 계산한다.
- 데이터 준비:
- 정확도 계산:
- _, predicted = torch.max(outputs.detach(), 1): 출력 값 중 가장 높은 확률을 가진 클래스의 인덱스를 예측 값으로 선택한다.
- total += labels.size(0): 전체 레이블 개수를 누적한다.
- correct += (predicted == labels).sum().item(): 예측 값이 실제 값과 일치하는 경우를 누적한다.
- 에포크 종료 후 계산:
- cost = running_loss / len(trainloader): 평균 손실 값을 계산한다.
- acc = 100 * correct / total: 정확도를 계산한다.
- 모델 저장:
- if cost < ls: 현재 에포크의 평균 손실 값이 기존 최소 손실 값보다 작다면 갱신한다.
- torch.save(model.state_dict(), "./models/stl10_resnet18.pth"): 모델의 가중치를 저장한다.
- 진행 바 업데이트:
- pbar.set_postfix({"loss": cost, "train acc": acc}): tqdm의 진행 바에 현재 손실 값과 정확도를 표시한다.
모델 평가
# 평가 시 그래디언트 계산을 비활성화해 메모리와 연산 자원을 절약한다.
with torch.no_grad():
# 모델을 평가 모드로 전환한다. 이는 Dropout과 BatchNorm 같은 레이어가 학습 시와 다른 동작을 하도록 설정한다.
model.eval()
correct = 0 # 올바르게 분류된 샘플 수를 초기화한다.
total = 0 # 전체 샘플 수를 초기화한다.
# 학습 데이터에 대해 배치 단위로 평가를 진행한다.
for data in trainloader:
# 배치를 GPU 또는 CPU에 로드한다.
images, labels = data[0].to(device), data[1].to(device)
# 모델의 출력을 계산한다.
outputs = model(images)
# 가장 높은 확률을 가진 클래스의 인덱스를 예측값으로 선택한다.
_, predicted = torch.max(outputs.data, 1)
# 배치 내 전체 샘플 수를 누적한다.
total += labels.size(0)
# 예측값과 실제 라벨이 일치하는 샘플의 수를 누적한다.
correct += (predicted == labels).sum().item()
# 학습 데이터에 대한 정확도를 계산한다.
print("Accuracy of the network on the train images: %d %%" % (100 * correct / total))
with torch.no_grad():
- 평가 단계에서는 그래디언트를 계산할 필요가 없다.
- torch.no_grad()를 사용하면 불필요한 메모리 사용과 연산을 방지한다.
- 학습 시와 달리 역전파를 수행하지 않으므로 메모리를 절약하고 속도를 향상시킨다.
model.eval()
- 모델을 평가 모드로 전환한다.
- Dropout 레이어는 학습 시 일부 노드를 랜덤으로 비활성화하지만, 평가 시에는 항상 활성화 상태로 유지된다.
- BatchNorm 레이어는 학습 시 미니배치 통계를 사용하지만, 평가 시에는 전체 데이터셋의 평균과 분산을 사용한다.
for data in trainloader:
images, labels = data[0].to(device), data[1].to(device)
outputs = model(images)
- trainloader에서 데이터를 배치 단위로 가져온다.
- 이미지는 images, 라벨은 labels 변수에 저장된다.
- to(device)를 통해 데이터를 GPU로 이동시켜 연산 속도를 높인다.
- outputs = model(images)는 모델에 입력 데이터를 전달해 예측 결과를 얻는다.
_, predicted = torch.max(outputs.data, 1)
- outputs는 모델의 최종 출력값으로, 각 클래스에 대한 확률 점수를 포함한다.
- torch.max(outputs.data, 1)은 각 배치에서 가장 높은 확률을 가진 클래스의 인덱스를 반환한다.
- outputs.data: 모델의 출력값에서 requires_grad를 제거한 텐서이다.
- 1: 두 번째 축(클래스 축)에서 최대값을 찾는다.
total += labels.size(0)
correct += (predicted == labels).sum().item()
- labels.size(0): 현재 배치의 샘플 수를 반환한다.
- predicted == labels: 예측값과 실제 라벨이 일치하는지 비교해 불리언 텐서를 반환한다.
- .sum().item(): 불리언 텐서에서 True 값을 모두 더한 후, 파이썬 스칼라 값으로 변환한다.
- total과 correct는 전체 데이터셋에 대해 반복적으로 값을 누적한다.
print("Accuracy of the network on the train images: %d %%" % (100 * correct / total))
- 학습 데이터셋에서의 정확도를 계산한다.
- 정확도는 (correct / total) * 100으로 계산된다.
- 정수 형태로 출력하기 위해 %d 형식을 사용한다.
요약
- torch.no_grad(): 그래디언트 계산을 비활성화한다.
- model.eval(): 모델을 평가 모드로 전환한다.
- 데이터 반복: trainloader에서 데이터를 배치 단위로 가져온다.
- 모델 출력: 입력 데이터를 모델에 전달해 예측값을 얻는다.
- 정확도 계산: 올바르게 분류된 샘플의 수를 누적해 최종 정확도를 계산한다.
CAM 생성
# 활성화 값을 저장하기 위한 딕셔너리
activation = {}
# 특정 레이어의 활성화 값을 저장하는 hook 함수
def get_activation(name):
# 모델의 forward pass 중간 결과를 저장하는 함수
def hook(model, input, output):
# 해당 레이어의 출력 값을 activation 딕셔너리에 저장
activation[name] = output.detach() # 그래프 연산과의 연결을 끊어 메모리 사용을 최소화
return hook
# CAM(Class Activation Map) 생성 함수
def cam(model, trainset, img_sample, img_size):
model.eval() # 모델을 평가 모드로 전환해 dropout 등의 비활성화
with torch.no_grad(): # 역전파가 필요 없는 경우로 설정해 메모리 사용 절약
# 마지막 활성화 값(layer4[1].bn2)을 저장하기 위해 hook을 등록
model.layer4[1].bn2.register_forward_hook(get_activation("final"))
# 데이터셋에서 샘플 이미지를 불러옴
data, label = trainset[img_sample] # 데이터셋의 i번째 이미지와 레이블을 로드
data.unsqueeze_(0) # [C, H, W] -> [1, C, H, W] 형태로 배치 차원을 추가
# 모델의 출력을 계산
output = model(data.to(device))
_, prediction = torch.max(output, 1) # 출력에서 가장 높은 확률의 클래스 추출
# 마지막 활성화 맵(특징 맵)을 가져옴
act = activation["final"].squeeze() # [1, C, H, W] -> [C, H, W]
# 모델의 최종 분류 레이어(fc)의 가중치를 가져옴
w = model.fc.weight # [num_classes, num_features]
# CAM 연산: 활성화 값과 해당 클래스의 가중치를 곱함
for idx in range(act.size(0)): # 활성화 맵의 채널 수만큼 반복
if idx == 0: # 첫 번째 채널의 경우
tmp = act[idx] * w[prediction.item()][idx] # 가중치 곱한 첫 채널의 값 저장
else: # 이후 채널은 계속 더함
tmp += act[idx] * w[prediction.item()][idx]
# 결과를 numpy로 변환하고 0~255 범위로 정규화
normalized_cam = tmp.cpu().numpy() # 텐서를 numpy 배열로 변환
normalized_cam = (normalized_cam - np.min(normalized_cam)) / (
np.max(normalized_cam) - np.min(normalized_cam)
)
# 원본 이미지를 시각화용으로 변환
original_img = np.uint8((data[0][0] / 2 + 0.5) * 255) # Normalize를 되돌림
# CAM 이미지를 입력 이미지와 동일한 크기로 리사이즈
cam_img = cv2.resize(np.uint8(normalized_cam * 255), dsize=(img_size, img_size))
return cam_img, original_img
activation = {}
def get_activation(name):
def hook(model, input, output):
activation[name] = output.detach()
return hook
- activation은 특정 레이어의 출력값을 저장하기 위한 딕셔너리다.
- get_activation 함수는 PyTorch의 forward hook을 활용해 지정한 레이어의 출력값을 저장한다.
- hook은 모델이 데이터를 처리할 때 특정 레이어에서 추가 동작을 수행하도록 한다.
- output.detach(): 텐서를 그래프에서 분리해 역전파가 필요 없는 값으로 변환한다.
model.eval()
with torch.no_grad():
model.layer4[1].bn2.register_forward_hook(get_activation("final"))
- model.eval(): 모델을 평가 모드로 전환해 드롭아웃 등 학습 전용 기능을 비활성화한다.
- torch.no_grad(): 역전파 계산을 비활성화해 메모리 및 계산량을 줄인다.
- register_forward_hook: layer4[1].bn2 레이어의 출력값을 저장하기 위해 forward hook을 등록한다.
data, label = trainset[img_sample]
data.unsqueeze_(0)
output = model(data.to(device))
_, prediction = torch.max(output, 1)
- trainset[img_sample]: 특정 이미지와 라벨을 가져온다.
- unsqueeze_: 3차원 [채널, 높이, 너비] 텐서를 4차원 [배치, 채널, 높이, 너비]로 변환한다.
- 모델은 배치 단위 입력만 처리할 수 있기 때문이다.
- model(data.to(device)): 모델에 데이터를 입력하고 예측 결과를 얻는다.
- torch.max(output, 1): 각 클래스의 확률 중 가장 높은 값을 가진 클래스를 선택한다.
- 반환값 prediction은 예측된 클래스의 인덱스다.
act = activation["final"].squeeze()
w = model.fc.weight
- activation["final"]: 지정한 레이어의 활성화 값(특징 맵)을 가져온다.
- 활성화 값은 [채널 수, 높이, 너비] 형태로 저장된다.
- squeeze(): 차원이 1인 배치를 제거해 [채널 수, 높이, 너비]로 만든다.
- model.fc.weight: 분류 레이어의 가중치를 가져온다.
for idx in range(act.size(0)):
if idx == 0:
tmp = act[idx] * w[prediction.item()][idx]
else:
tmp += act[idx] * w[prediction.item()][idx]
- CAM은 각 채널의 활성화 값과 해당 클래스의 가중치를 곱한 후 합산해 계산한다.
- act[idx]: 특정 채널의 특징 맵이다.
- w[prediction.item()][idx]: 예측된 클래스에 해당하는 가중치다.
- tmp: 모든 채널의 값을 누적해 최종 CAM을 생성한다.
normalized_cam = tmp.cpu().numpy()
normalized_cam = (normalized_cam - np.min(normalized_cam)) / (
np.max(normalized_cam) - np.min(normalized_cam)
)
original_img = np.uint8((data[0][0] / 2 + 0.5) * 255)
cam_img = cv2.resize(np.uint8(normalized_cam * 255), dsize=(img_size, img_size))
- CAM 정규화:
- tmp.cpu().numpy(): 텐서를 NumPy 배열로 변환한다.
- (normalized_cam - np.min(normalized_cam)) / (np.max(normalized_cam) - np.min(normalized_cam)):
- CAM 값을 0~1 사이로 정규화한다.
- 원본 이미지 복원:
- (data[0][0] / 2 + 0.5): 정규화된 원본 이미지를 원래 픽셀 값으로 변환한다.
- np.uint8: 데이터를 0~255 사이의 정수로 변환한다.
- CAM 이미지 리사이즈:
- cv2.resize: 정규화된 CAM 이미지를 원본 이미지 크기(img_size x img_size)로 리사이즈한다.
return cam_img, original_img
- cam_img: 원본 이미지 크기로 변환된 CAM 이미지다.
- original_img: 원래의 입력 이미지를 복원한 값이다.
CAM 시각화
def plot_cam(model, trainset, img_size, start):
# 시작 인덱스부터 20개의 이미지를 시각화할 범위를 설정한다
end = start + 20
# 2행으로 구성된 서브플롯을 생성하고, 이미지 크기 및 여백 설정
fig, axs = plt.subplots(2, (end - start + 1) // 2, figsize=(20, 5))
fig.subplots_adjust(hspace=0.01, wspace=0.01)
# 서브플롯의 축을 평면 배열로 변환하여 쉽게 접근할 수 있도록 설정
axs = axs.ravel()
# 지정된 범위 내의 이미지를 순회하며 CAM과 원본 이미지를 시각화
for i in range(start, end):
# CAM 생성 및 원본 이미지를 가져온다
cam_img, original_img = cam(model, trainset, i, img_size)
# 원본 이미지를 흑백(`gray`)으로 출력한다
axs[i - start].imshow(original_img, cmap="gray")
# CAM 이미지를 `jet` 색상으로 덮어씌워 강조한다
axs[i - start].imshow(cam_img, cmap="jet", alpha=0.5)
# 시각적으로 필요 없는 축 정보를 제거한다
axs[i - start].axis("off")
# 결과를 화면에 출력한다
plt.show()
# 결과 이미지를 파일로 저장한다
fig.savefig("./results/cam.png")
fig, axs = plt.subplots(2, (end - start + 1) // 2, figsize=(20, 5))
fig.subplots_adjust(hspace=0.01, wspace=0.01)
axs = axs.ravel()
- plt.subplots: 2행으로 구성된 서브플롯을 생성한다.
- (end - start + 1) // 2는 필요한 열의 개수를 계산한다. 20개의 이미지를 출력하려면 2행 10열의 격자를 만든다.
- figsize=(20, 5): 전체 플롯 크기를 설정한다.
- 가로 20인치, 세로 5인치 크기의 그림을 만든다.
- subplots_adjust: 서브플롯 사이의 여백을 조정한다.
- hspace=0.01: 세로 간격을 최소화한다.
- wspace=0.01: 가로 간격을 최소화한다.
- axs.ravel(): 2차원 배열 형태의 서브플롯을 1차원 배열로 변환한다. 이를 통해 반복문에서 서브플롯을 쉽게 접근할 수 있다.
for i in range(start, end):
cam_img, original_img = cam(model, trainset, i, img_size)
axs[i - start].imshow(original_img, cmap="gray")
axs[i - start].imshow(cam_img, cmap="jet", alpha=0.5)
axs[i - start].axis("off")
- for i in range(start, end): start부터 end까지 이미지를 순회한다.
- cam(model, trainset, i, img_size): cam 함수를 호출해 원본 이미지와 CAM 이미지를 가져온다.
- model: 학습된 모델이다.
- trainset: 시각화할 데이터셋이다.
- i: 현재 이미지의 인덱스이다.
- img_size: 이미지 크기를 설정한다.
- imshow: 이미지를 시각화한다.
- original_img: 원본 이미지를 흑백(gray) 색상으로 표시한다.
- cam_img: jet 색상을 사용해 CAM 이미지를 덮어 표시한다. alpha=0.5는 투명도를 설정해 원본과 CAM이 겹치도록 만든다.
- axis("off"): 축 눈금을 제거해 이미지에만 집중할 수 있도록 한다.
plt.show()
fig.savefig("./results/cam.png")
- plt.show: 화면에 모든 서브플롯을 출력한다.
- fig.savefig: 시각화 결과를 ./results/cam.png 경로에 저장한다.
결론
이렇게 해서 설명 가능한 AI와 CAM에 대해 알아보았다.
솔직히 인공지능에 대한 신뢰나 근거는 둘째치고 이런 식으로 어딜 바라보는지 알 수 있다는 점이 흥미로웠다.
그리고 파이토치는 정말 무엇이든지 가능한 것인가.. 하는 생각도.
계속 가보자구!
끝!
'Python > PyTorch' 카테고리의 다른 글
[PyTorch]비지도 학습 - 깊은 K-평균 알고리즘 (오토인코더 + K-평균 알고리즘) (0) | 2024.12.10 |
---|---|
[PyTorch]생성적 적대 신경망(GAN - Generative Adversarial Network) (1) | 2024.12.09 |
[PyTorch]오토인코더(Autoencoder) (2) | 2024.12.04 |
[Pytorch]Vanilla RNN과 확장된 기법들: LSTM, GRU, Bidirectional LSTM, Transformer (2) | 2024.12.03 |
[PyTorch]전이 학습(Transfer Learning) (0) | 2024.11.27 |
[PyTorch]Vanilla RNN을 활용한 코스피 예측 문제 (1) | 2024.11.26 |
- Total
- Today
- Yesterday
- 칼이사
- 맛집
- 여행
- a6000
- 유럽
- 면접 준비
- 야경
- RX100M5
- 남미
- 백준
- Algorithm
- spring
- 중남미
- 지지
- 세계일주
- BOJ
- 리스트
- 스프링
- 유럽여행
- 자바
- Backjoon
- 세계여행
- 알고리즘
- 스트림
- Python
- 세모
- 기술면접
- java
- 파이썬
- 동적계획법
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |