- 이번 포스팅에선 딥러닝 이미지 분석 모델인 CNN 을 살펴보고,
이를 이용한 이미지 분류기를 만들어보겠습니다.
CNN 은 경량 컴퓨터 비전 기술에 여전히 주요하게 사용되는 기술이며,
추후 LLM 모델의 멀티 모달에 사용되는 Vision Transformer(ViT) 를 이해하기 위한 발판이 될 것입니다.
- DNN 및 기본적인 인공지능 지식이 있다는 것을 가정해서 설명을 할 것이므로,
기본 지식이 필요하시다면,
위 데이터 사이언스 프로젝트에서 제가 정리한 관련 지식들을 차례로 습득하실 수 있습니다.
[CNN(Convolutional Neural Networks) 설명]
(CNN 정의)
- 컨볼루션 신경망(Convolution Neural Network : CNN) 이란,
생명체의 시각 피질의 구조를 모방한 신경망 모델입니다.
생명체의 신경망 최소 구조를 모방하여 만들어진 Neural Network 를 시각 신경 구조로 만들었다 생각하셔도 무방합니다.
- 컨폴루션 신경망 개발의 역사는 아래와 같습니다.
1959 년, David H. Hubel 과 Torsten Wiesel 이 뇌의 시각 피질이 어떻게 동작하는지를 확인하기 위하여,
마취된 고양이의 일차 시각 피질에 미세 전극을 넣었습니다.
이를 통하여 시각 피질이 여러 층으로 구성되어 있고, 첫번째 층은 주로 모서리와 직선 감지,
뒤쪽 층은 복잡한 모양과 패턴을 추출하는데 사용 된다는 것이 발견되었습니다.
(다층으로, 앞의 층일수록 단순한 형태에 반응하고, 뒤로 갈수록 앞의 단순한 형태를 조합한 복잡한 형태에 반응)
시각 피질의 동작에 대해서는 알게 되었지만,
여전히 인공지능 분야에서는 수학적 접근 방식으로 시각데이터를 분석하였고,
큰 성과를 내지는 못했습니다.
1990 년, Yann LeCun 이 손글씨 숫자 분류를 위하여 시각 피질의 구조를 모방한 새로운 신경망 구조로 발표 하였습니다.
2019년, Yann Lecun 은 CNN 의 발명으로 인하여 인공지능 분야에 대한 기여를 인정받아, 컴퓨터 과학 분야에서 가장 영애로운 상인 Turing Award 를 수상하였습니다.
(CNN 구조와 원리)
- Convolutional Kernel 과 시각 분석 원리
점이 모여 여러 형태의 선이 되고, 선이 모여 여러 형태의 면이 되고, 면 내에서 자잘한 점이나 선들이 결합하여 여러 질감이 표현되고, 질감이나 선, 점 등의 종합적인 데이터가 모여서, 비로소 인간이 실제로 눈으로 보는 정보들을 표현할 수 있을 것입니다.
먼저, 앞서 예시를 들었듯, 기본적인 선을 감지하는 알고리즘은 어떻게 구현하면 좋을까요?
이미지 내에서 어떤 선이 어디에 배치되었는지를 확인한다고 합시다.
순차적으로 이미지 내의 모든 픽셀을 순회하더라도 무엇이 직선인지 뭔지 모를 것입니다.
점을 감지하는 그 시점에는 그것은 점일 뿐이지 형태를 알 수 없으니까요.
선인지 뭔지를 감지하려면, 점'들'의 형태를 파악해야합니다.
즉, 하나의 점이 선에 포함되는지 면에 포함되는지를 알기 위해서는, 그 점뿐만이 아니라 그 점의 인접한 위치의 다른 점들까지 확인해야만 하는 것입니다.
점의 주변으로 다른 점들을 확인하여 해당 점이 어디에 속하는지 알기 위해서는 구역을 떼어내야 합니다.
예를들어 점을 중심으로 3x3 구역을 확인하여 해당 점이 선인지 아닌지를 판별하는 것입니다.
그리고 동일한 방식으로 다음 픽셀, 다음 픽셀을 판별해나가면,
이미지 데이터는 색 데이터의 모음이 아니라, '선', '선이 아님' 이라는 분류 결과의 데이터 행렬이 될 것입니다.
이렇게 주변 구역을 포함한 계산을 하기 위하여 준비된 n x m 크기의 필터를 컨볼루션 필터, 혹은 커널이라고 부릅니다.
커널의 계산법은 행렬곱입니다.
예를들어보겠습니다.
1 0 0 0
0 1 0 0
0 0 1 0
위와 같은 이미지가 있다고 할 때,
여기서 대각선을 추출해본다고 하면,
1 0 0
0 1 0
0 0 1
이라는 필터를 생각해보면 됩니다.
좌측 상단의 픽셀부터 적용하면,
필터가 닿지 않는 공간의 데이터를 0이라고 두고,
(1 * 0) + (0 * 0) + (0 * 0) +
(0 * 0) + (1 * 1) + (0 * 0) +
(0 * 0) + (0 * 0) + (1 * 1) = 2
(1 * 0) + (0 * 1) + (0 * 0) +
(0 * 0) + (1 * 0) + (0 * 1) +
(0 * 0) + (0 * 0) + (1 * 0) = 0
...
위와 같이 픽셀을 하나씩 이동하며 커널로 행렬곱을 하여 결과를 알아내면,
2 0 0 0
0 3 0 0
0 0 2 0
위와 같이 결과가 나옵니다.
커널의 연산 결과 나온 이 결과 행렬을 컨볼루션(합성곱) 층이라고 부릅니다.
위 컨볼루션 층은 대각선의 형태를 띈 픽셀 부분의 경우 큰 수가 나오고, 아니라면 작은 수가 나오게 표현된 것이죠.
이외에도 다양한 각도의 직선에 대한 커널을 만들 수 있습니다.
물론, 컨볼루션 층을 쌓아서 선보다 더 복잡한 형태를 추출해내는 커널을 만들 수도 있고요.
CNN 의 커널의 원리는 위와 같습니다.
CNN 은 딥러닝 모델답게, 오차역전파를 이용하여 커널까지 자동으로 학습을 시킬 수 있습니다.
즉, 수동으로 커널을 만들어낼 필요가 없으며, 학습시 사용한 정답에 어울리는 은닉된 커널을 스스로 만들어낼 수 있으며,
데이터만 충분하다면 판별하지 못하는 이미지가 없다는 뜻이 됩니다.
- CNN 구성요소 정리
CNN 을 그림으로 나타내면 위와 같습니다.
이미지라는 2차원 데이터가 있다면,
위에서 설명했듯, NxM 크기의 커널이, 좌측 상단에서 우측 하단까지 차례로,
몇 픽셀 단위(Stride)로 이동하며, Convolution 연산, 즉 필터와 픽셀간 연산을 수행합니다.
그림 속 '여러 특징(대각선 검출, 직선 검출, 색깔 검출, 질감 검출 등...)'을 검출하도록 학습되어있던 필터들(F개)이 하나의 이미지당 F개의 2차원 특징층(원본보다 사이즈는 작아짐)을 만들어내겠죠?
F 개의 2차원 데이터는 합쳐서 3차원으로 해석할 수 있습니다.
그러면 그렇게 만들어진 3차원 특징층들에 다시 해당 레벨에서 수행하는 3차원 필터로 Conv 연산을 수행하여 G 개 만큼의 특징맵을 만들고... 이런식으로 반복하는 것입니다.
그림의 특징들 -> 특징들의 특징들 -> 특징들의 특징들의 특징들 -> ...
이렇게 계속합니다.
왜 이러는 걸까요?
레벨별 검출하는 요소를 달리하기 위해서입니다.
위에서 보시면 CNN 레벨별 추출되는 특징들을 시각화 한 것입니다.
점이 모여 선이 되고, 선이 모여 면이 되고, 면이 모여 형태가 되는 것이죠?
가장 앞쪽의 필터에서 선과 같은 단순한 형태를 분석해내고,
이러한 선들의 정보를 분석해서 도형을 탐지하고,
도형들의 정보를 분석해서 최종적으로 형태를 분류하는 것입니다.
이러한 필터를 사람이 손수 만들려면 무척이나 힘들고 어려운 작업이겠지만,
CNN 은 DNN 의 원리에 따라 이러한 필터 역시 오차 역전파 및 학습으로 인해 자동으로 학습을 시켜줍니다.
중요한 학습의 방식은 DNN 과 같이 오차를 역전파해서 파라미터를 미세조정하는 것을 반복하는 방식을 사용하는데,
정확한 증명은 여기서 다루지 않습니다.
대략적 원리를 이해했으므로 용어정리만 하고 다음으로 넘어가겠습니다.
- CNN 레이어의 주요한 설정 변수는,
1. kernel_size : 앞서 설명한 커널의 사이즈입니다.
커널 크기가 크다면 하나의 판단을 위해 주변 픽셀을 많이 확인한다는 것입니다.
2. stride : 커널 계산시 모든 픽셀을 순회하려면 이것이 1 이 되고, 3 픽셀마다 한번씩 계산하려면 이것이 3 이 됩니다.
굳이 모든 픽셀을 순회하기보다는 띄엄띄엄 이미지 내의 특성을 파악하여,
대충 이미지가 어떤 클래스에 속하는지을 알아보는 것 뿐이라면, stride 를 적절히 크게 주어서 연산량을 줄이는 것이 좋습니다.
3. padding & padding_mode : 이미지 외곽에 여분을 주는 것입니다.
앞선 예시에서 커널이 이미지 외곽 부분을 확인할 때, 이미지가 없을 경우 0 으로 채워넣었었죠.
바로 이러한 용도의 설정입니다.
padding_mode 는 패딩을 한 이미지 수치를 0으로 줄지 뭘로 줄지를 결정하는 것입니다.
- Pooling
이미지는 희소한 데이터가 많습니다.
무슨 의미냐면, 의미없는 데이터량이 많다는 것입니다.
예를들어 어떤 이미지가 고양이인지 아닌지를 확인할 때, 이미지 내의 모든 픽셀이 전부 필요하지는 않습니다.
이미지의 상당한 부분을 가리더라도 고양이인지 아닌지에 대한 판단에는 별 영향이 없을 수도 있죠.
풀링은 연산량을 줄이기 위하여, 컨볼루션 층을 몇가지 구역으로 나누어서 각 구역별 대표적인 값을 추출함으로써 연산량을 줄이는 방법입니다.
커널 계산에서 Stride 를 두는 것과 비슷한 효과입니다.
Sub Sampling 이라고도 불리죠.
구역 내에서 대표값을 최대값으로 두는 것을 Max Pooling 이라고 합니다.
이 경우는 구역 내에서 가장 특징적인 값을 사용하는 것과 같은 의미이므로 보통 이것을 많이 사용합니다.
이외에는 평균값을 사용하는 Average Pooling 가 존재합니다.
(CNN 기술 응용)
- CNN 은 위와 같은 이론에서 알 수 있듯, 이미지의 특징을 찾아내는데 특화된 기술입니다.
위 그림 설명에서 전체 구조를 본다면 특징맵의 크기는 점차 줄어들고,
이는 이미지라는 데이터를 보다 작은 벡터에 투사할 수 있다는 것이죠.
- 위 그림 설명과 동일하게 이미지 분석 모델을 만드는 것이 CNN 의 가장 대표적인 활용법입니다.
이미지에서 시작해서 작은 특징 데이터까지 정보를 압축하고, 이를 Flatten Layer 로 직렬화한 이후에는 일반적인 DNN 과 같이 해당 입력 데이터가 무엇에 속하는지에 대한 분류 문제를 해결할 수 있습니다.
즉, 데이터를 압축하는 CNN 계층과, 압축된 데이터를 받아서 처리하는 DNN 부분으로 나뉩니다.
이 말은, 즉 CNN 이 하는 가장 큰 역할은 이미지 데이터를 압축하여 '고유성을 갖는 벡터 데이터'로 만드는 것을 의미합니다.
- Auto Encoder
CNN 의 위와 같은 데이터 압축 능력을 설명하기 위해 대표적인 대이터 인코딩 방식인 AE 를 살펴보겠습니다.
위에서 보이듯 X 데이터를 입력하면 X` 가 반환되는 모델입니다.
위 모델의 특징은, 정답 데이터를 준비할 필요가 없다는 것으로,
Encoder 부분으로 데이터를 입력하여 CNN 과 같은 연산의 결과로 원본보다 작은 벡터 공간으로 정보를 압축한 후,
그렇게 줄어든 정보를 입력값으로 하여 역 Conv연산(Transpose Convolution) 을 수행하여 원본과 동일한 이미지를 생성하게 합니다.
위 모델의 특징은 처음 입력 데이터가 그대로 정답 데이터가 된다는 것으로, 원본을 그대로 출력하는 것이 목적입니다.
이러한 구조와 원리를 가진 이유는, 정답 데이터가 없이 Encoder 부분이 정보를 축약하도록 하기 위한 것입니다.
생각해봅시다.
중앙에 위치한 Hidden Vector(Latent Vector).를 가지고 원본 이미지를 그대로 출력할 수 있다는 것은,
원본 이미지에 필수적인 정보를 히든 벡터가 요약해 가지고 있다는 것이 됩니다.
AE 가 꼭 CNN 을 사용할 필요는 없지만,
CNN 의 필터가 가진 의미와, 정보 축약이라는 의미에서 보았을 때,
미리 잘 학습된 CNN 모델이란, 해당 분류를 위해 필요한 정보를 잘 축약하는 모델이라는 것으로 해석이 가능합니다.
즉, 잘 학습된 CNN 모델의 특징맵 추출 부분만을 따로 떼어내어,
별도의 정보처리를 하는 다른 인공신경망 모델에 합성하는 방식으로 이용할 수 있을 것이며,
실제로 CNN 을 사용하는 인공신경망 모델들은 이미 성능이 검증된 대표적인 CNN 모델과 그 파라미터를 기반으로 접목하는 방식으로 응용을 하고 있습니다.
- 파인튜닝
앞서 CNN 의 특징 추출 부분을 떼어내어 다른 신경망의 입력값 전처리 계층으로 사용한다고 말씀드렸습니다.
강아지 사진을 주면 강아지라고 판단하고, 고양이 사진을 주면 고양이라고 판단하는 모델은,
해당 정보를 분류할 수 있는 충분한 정보를 특징맵으로 축약하고 있다는 것이기에 그대로 사용해도 좋으나,
때에 따라 특수한 상황에 더 최적화된 특징이 필요하기도 합니다.
이때, CNN 을 접목한 부위에 새로운 환경에 보다 더 어울리도록 마감을 해주는 작업이 파인튜닝입니다.
쉽게 설명하자면 CNN 을 붙인 후 CNN 부분도 다시한번 학습시켜주는 것을 의미합니다.
이때 주의할 점으로는,
접목하는 DNN 이 아직 아무것도 학습하지 않은 상태에서 CNN 도 같이 학습하게 되면, 기존에 잘 학습된 결과물에 악영향을 주기 쉽습니다.
고로 CNN 파인튜닝은 일단 파라미터를 동결시켜둔 상태에서 학습을 몇번 시킨 후, 최적화가 필요한 상태에서 CNN 동결을 풀어서 학습을 시키는 것이 순서이며,
위에서 설명했듯, low level 필터의 경우는 단순한 역할을 하기에, 학습시키지 않는 것이 좋으며, 대신 접합부와 가까운 레이어만을 선택적으로 학습시키는 것이 좋은 방식입니다.
- 이론의 마지막으로, 주로 사용되는 CNN 모델의 종류에 대해 간단히 정리하겠습니다.
모델 | 발표년도 | 주요 특징 |
LeNet-5 | 1998 | 최초의 실용적인 CNN (손글씨 숫자 인식 - MNIST) |
AlexNet | 2012 | GPU 기반 학습, ReLU 도입, ILSVRC 우승 → CNN 붐의 시작 |
ZFNet | 2013 | AlexNet 개선: 필터 크기 축소(11→7), 시각화 통한 이해 |
VGGNet | 2014 | 아주 깊은 네트워크(16~19층), 3x3 Conv 반복 → 구조 단순화 |
GoogLeNet (Inception v1) | 2014 | 다양한 크기의 필터를 병렬로 사용하는 Inception 모듈 |
ResNet | 2015 | Skip Connection 도입 → 깊은 네트워크 학습 가능 |
Inception v3/v4 | 2015-16 | Inception + 더 복잡한 구조 + BatchNorm 등 |
DenseNet | 2016 | 모든 레이어를 직접 연결 → 정보 흐름 강화 |
MobileNet | 2017 | 경량화 CNN, Depthwise Separable Conv 도입 |
EfficientNet | 2019 | 모델 크기 조절을 수학적으로 최적화 (컴파운드 스케일링) |
ConvNeXt | 2022 | ViT 이후 CNN을 재해석한 최신 CNN → ViT 성능에 근접 |
[CNN 모델 실습]
(Pytorch 이미지 분류기 만들기)
- Pytorch 데이터 분석을 위한 환경 구축은 가장 위 링크를 확인하세요.
이번에 실습할 내용은, 가장 기본적인 MNIST 손글씨 이미지를 학습하여 분류하는 이미지 분류기를 만들 것입니다.
기본적인 모델 구조를 만드는 것에서 시작해서 학습까지를 총체적으로 진행하기 위해서 위에서 소개한 모델을 사용하지는 않고 직접 만든 CNN 모델으 사용할 것인데,
추후에는 파인튜닝 방식으로 진행하세요.
- 저는 이미지 분류 모델을 만들기 위하여 Python 을 객체지향적으로 구축하였습니다.
먼저, 이미지 분류 모델의 경우는,
model_layers/cnn_mnist_clissifier/main_model.py
from torch import nn
class MainModel(nn.Module):
def __init__(self):
super().__init__()
# 모델 내 레이어
self.keep_prob = 0.5
# L1 ImgIn shape=(?, 28, 28, 1)
# Conv -> (?, 28, 28, 32)
# Pool -> (?, 14, 14, 32)
self.layer1 = nn.Sequential(
nn.Conv2d(1, 32, kernel_size=3, stride=1, padding=1),
nn.ReLU(),
nn.MaxPool2d(kernel_size=2, stride=2))
# L2 ImgIn shape=(?, 14, 14, 32)
# Conv ->(?, 14, 14, 64)
# Pool ->(?, 7, 7, 64)
self.layer2 = nn.Sequential(
nn.Conv2d(32, 64, kernel_size=3, stride=1, padding=1),
nn.ReLU(),
nn.MaxPool2d(kernel_size=2, stride=2))
# L3 ImgIn shape=(?, 7, 7, 64)
# Conv ->(?, 7, 7, 128)
# Pool ->(?, 4, 4, 128)
self.layer3 = nn.Sequential(
nn.Conv2d(64, 128, kernel_size=3, stride=1, padding=1),
nn.ReLU(),
nn.MaxPool2d(kernel_size=2, stride=2, padding=1))
# L4 FC 4x4x128 inputs -> 625 outputs
self.fc1 = nn.Linear(4 * 4 * 128, 625, bias=True)
nn.init.xavier_uniform_(self.fc1.weight)
self.layer4 = nn.Sequential(
self.fc1,
nn.ReLU(),
nn.Dropout(p=1 - self.keep_prob))
# L5 Final FC 625 inputs -> 10 outputs
self.fc2 = nn.Linear(625, 10, bias=True)
self._init_weights()
def _init_weights(self):
nn.init.xavier_uniform_(self.fc1.weight)
self.fc1.bias.data.fill_(0.01)
nn.init.xavier_uniform_(self.fc2.weight)
self.fc2.bias.data.fill_(0.01)
def forward(self, model_in):
model_out = self.layer1(model_in)
model_out = self.layer2(model_out)
model_out = self.layer3(model_out)
model_out = model_out.view(model_out.size(0), -1) # Flatten them for FC
model_out = self.layer4(model_out)
model_out = self.fc2(model_out)
return model_out
위와 같이 만들었습니다.
conv2d 계층에 relu 와 maxpooling 을 적용한 레이어들을 층층히 접층해서 마지막으로는 fully connected 레이어로 직렬화 한 것입니다.
위와 같은 pytorch 모델을 사용할 수 있도록 래핑한 코드로는,
import torch
import pandas as pd
from torch.utils.data import Dataset, random_split
from datetime import datetime
import os
# (GPU 디바이스 값 반환)
def get_gpu_support_device(
# GPU 지원 설정 (True 로 설정 해도 현재 디바이스가 GPU 를 지원 하지 않으면 CPU 를 사용 합니다.)
gpu_support
):
if gpu_support:
if torch.cuda.is_available():
device = "cuda"
elif torch.backends.mps.is_available() and torch.backends.mps.is_built():
device = "mps"
else:
device = "cpu"
else:
device = "cpu"
print(f"Device Selected : {device}")
return device
# (모델 파일 저장)
def save_model_file(
# 파일로 저장할 모델 객체
model,
# 생성된 모델 파일을 저장할 폴더 위치 (ex : "../_by_product_files/torch_model_files")
model_file_save_directory_path
):
save_file_full_path = f"{model_file_save_directory_path}/model({datetime.now().strftime('%Y_%m_%d_%H_%M_%S_%f')[:-3]}).pt"
torch.save(
model,
save_file_full_path
)
print(f"Model File Saved : {save_file_full_path}")
return save_file_full_path
# (모델 사용 데이터 셋)
class CsvModelDataset(Dataset):
def __init__(
self,
# 데이터를 읽어올 CSV 파일 경로 - 1 행에 라벨이 존재하고, 그 라벨로 x, y 데이터를 분류 합니다. (ex : "../datasets/linear.csv")
csv_file_full_url,
# 독립 변수로 사용할 컬럼의 라벨명 리스트 (ex : ['x1', 'x2'])
x_column_labels,
# 종속 변수로 사용할 컬럼의 라벨명 리스트 (ex : ['y1'])
y_column_labels
):
# CSV 에서 데이터 가져오기 (ex : [{x : [x1, x2], y : [y1]}, {x : [x1, x2], y : [y1]}, ...])
# CSV 파일 읽기
data_frame = pd.read_csv(csv_file_full_url)
# # 데이터 프레임을 변환 (ex : [{x : [x1, x2], y : [y1]}, {x : [x1, x2], y : [y1]}, ...])
self.data = [
{
"x": [item[column] for column in x_column_labels],
"y": [item[y_column] for y_column in y_column_labels]
}
for item in data_frame.to_dict(orient='records')
]
self.length = len(self.data)
def __getitem__(self, index):
return torch.FloatTensor(self.data[index]["x"]), torch.FloatTensor(self.data[index]["y"])
def __len__(self):
return self.length
# (Dataset 분리)
def split_dataset(
# 분리할 데이터 셋
dataset,
# 학습 데이터 비율 (ex : 0.8)
train_data_rate,
# 검증 데이터 비율 (ex : 0.2)
validation_data_rate
):
# rate 파라미터들의 합이 1인지 확인
total_rate = train_data_rate + validation_data_rate
assert total_rate == 1.0, f"Data split rates do not add up to 1.0 (current total: {total_rate})"
# 전체 데이터 사이즈
dataset_size = len(dataset)
print(f"Total Data Size : {dataset_size}")
# 목적에 따라 데이터 분리
train_size = int(dataset_size * train_data_rate) # 학습 데이터
validation_size = int(dataset_size * validation_data_rate) # 검증 데이터
# 학습, 검증, 테스트 데이터를 무작위로 나누기
train_dataset, validation_dataset = random_split(dataset, [train_size, validation_size])
print(f"Training Data Size : {len(train_dataset)}")
print(f"Validation Data Size : {len(validation_dataset)}")
return train_dataset, validation_dataset
# (모델 학습)
def train_model(
# 사용할 디바이스
device,
# 학습할 모델
model,
# 손실 함수 (ex : nn.MSELoss())
criterion,
# 옵티마이저 (ex : optim.SGD(model.parameters(), lr=learning_rate, momentum=momentum, weight_decay=weight_decay))
optimizer,
# 학습 데이터 로더
train_dataloader,
num_epochs=1000, # 학습 에포크 수
validation_dataloader=None, # 검증 데이터 로더(None 이라면 검증은 하지 않음)
# 체크포인트 파일 저장 폴더 경로 - None 이라면 저장하지 않음 (ex : "../check_points")
check_point_file_save_directory_path=None,
# 불러올 체크포인트 파일 경로 - None 이라면 불러 오지 않음 (ex : "../check_points/checkpoint(2024_02_29_17_51_09_330).pt")
check_point_load_file_full_path=None,
# 그래디언트 클리핑 기울기 최대 값 (ex : 1)
# 그래디언트 최대값을 설정하여, 그래디언트 폭주를 막아 오버피팅을 억제합니다.
# RNN 등 기울기가 폭주될 가능성이 있는 모델에 적용 하세요.
grad_clip_max_norm=None,
# 로그를 몇 에폭 만에 한번 실행 할지 여부 (0 이하는 검증도, 로깅도 하지 않음)
log_freq=1000
):
# 학습 시작 시간 기록
start_time = datetime.now()
print("Model Training Start!")
# 모델 디바이스 설정
model.to(device)
# 손실 함수 디바이스 설정
criterion.to(device)
# 모델에 학습 모드 설정 (Dropout, Batchnorm 등의 기능 활성화)
model.train()
# 체크포인트 불러오기
if check_point_load_file_full_path is not None:
checkpoint = torch.load(check_point_load_file_full_path)
model.load_state_dict(checkpoint["model_state_dict"])
optimizer.load_state_dict(checkpoint["optimizer_state_dict"])
print("Check Point Loaded")
print(f"ckp_description : {checkpoint['description']}")
# 학습 루프
for training_epoch in range(num_epochs):
training_loss = 0.0
training_correct = 0 # 올바르게 예측된 개수 초기화
# 배치 학습
for tx, ty in train_dataloader:
tx = tx.to(device)
ty = ty.to(device)
# 모델 순전파
model_out = model(tx)
# 모델 결과물 loss 계산
loss = criterion(model_out, ty)
# 옵티마이저 기울기 초기화
optimizer.zero_grad()
# loss 에 따른 신경망 역전파 후 기울기 계산
loss.backward()
# 그래디언트 클리핑
if grad_clip_max_norm is not None:
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=grad_clip_max_norm)
# 신경망 학습
optimizer.step()
training_loss += loss
training_correct += (torch.argmax(model_out, dim=1) == ty).sum().item()
training_loss = training_loss / len(train_dataloader)
# 1000 에 한번 실행
if log_freq > 0 and (training_epoch + 1) % log_freq == 0:
# 학습 시작부터 얼마나 시간이 지났는지 계산
elapsed_time = datetime.now() - start_time
validation_loss_string = "None"
validation_loss = 0
if validation_dataloader is not None:
# 검증 계산
model.eval() # Dropout, Batchnorm 등의 기능 비활성화
with torch.no_grad(): # Gradient 추적 계산을 하지 않음
for vx, vy in validation_dataloader:
vx = vx.to(device)
vy = vy.to(device)
model_out = model(vx)
loss = criterion(model_out, vy)
validation_loss += loss
model.train()
validation_loss = validation_loss / len(validation_dataloader)
validation_loss_string = f"{validation_loss:.3f}"
tl_vl_str = "None"
if validation_loss_string != "None":
tl_vl_str = f"{abs(training_loss - float(validation_loss)):.3f}"
print(
f"\nTrainingEpoch : {training_epoch + 1:4d},\n"
f"TrainingLoss : {training_loss:.3f},\n"
f"ValidationLoss : {validation_loss_string},\n"
f"TL-VL ABS : {tl_vl_str}\n"
f"Elapsed Time: {elapsed_time}"
)
if check_point_file_save_directory_path is not None:
if not os.path.exists(check_point_file_save_directory_path):
os.makedirs(check_point_file_save_directory_path)
# 학습 체크포인트 저장
current_time = datetime.now().strftime('%Y_%m_%d_%H_%M_%S_%f')[:-3]
check_point_file_name = f"checkpoint({current_time}).pt"
torch.save(
{
"model_state_dict": model.state_dict(),
"optimizer_state_dict": optimizer.state_dict(),
"training_loss": training_loss,
"validation_loss": validation_loss_string,
"model": "CustomModel",
"description": f"CustomModel 체크포인트-{current_time}"
},
f"{check_point_file_save_directory_path}/{check_point_file_name}",
)
print(f"CheckPoint File Saved : {check_point_file_name}")
print("\nModel Training Complete!")
이를 사용해서 학습을 진행하면,
import torch
from torch import nn
from torch.utils.data import DataLoader
import utils.torch_util as tu
import model_layers.cnn_mnist_classifier.main_model as cnn_mnist_classifier
import os
from torch import optim
import torchvision.datasets as dsets
import torchvision.transforms as transforms
# [MNIST 분류]
def main():
# 사용 가능 디바이스
device = tu.get_gpu_support_device(gpu_support=True)
# 데이터셋 객체 생성 (ex : tensor([[-10., 100., 82.], ...], device = cpu), tensor([[327.7900], ...], device = cpu))
# x : (28, 28), y : (1) 형식입니다. y 의 경우는 One Hot Encoding 없이 CrossEntropyLoss 함수에 그대로 넣어줘도 됩니다.
train_dataset = dsets.MNIST(
root='../resources/datasets/global/MNIST_data/',
train=True,
transform=transforms.ToTensor(),
download=True
)
validation_dataset = dsets.MNIST(
root='../resources/datasets/global/MNIST_data/',
train=False,
transform=transforms.ToTensor(),
download=True
)
# 데이터 로더 래핑
train_dataloader = DataLoader(train_dataset, batch_size=100, shuffle=True, drop_last=True)
validation_dataloader = DataLoader(validation_dataset, batch_size=10, shuffle=True, drop_last=True)
# 모델 생성
model = cnn_mnist_classifier.MainModel()
# 모델 학습
tu.train_model(
device=device,
model=model,
criterion=nn.CrossEntropyLoss(),
optimizer=optim.SGD(model.parameters(), lr=0.1),
train_dataloader=train_dataloader,
num_epochs=15,
validation_dataloader=validation_dataloader,
check_point_file_save_directory_path="../_by_product_files/check_point_files/cnn_mnist_classifier",
# check_point_load_file_full_path="../_by_product_files/check_point_files/~/checkpoint(2024_02_29_17_51_09_330).pt",
log_freq=1
)
# 모델 저장
model_file_save_directory_path = "../_by_product_files/torch_model_files/cnn_mnist_classifier"
if not os.path.exists(model_file_save_directory_path):
os.makedirs(model_file_save_directory_path)
save_file_full_path = tu.save_model_file(
model=model,
model_file_save_directory_path=model_file_save_directory_path
)
# # 저장된 모델 불러오기
model = torch.load(save_file_full_path, map_location=device, weights_only=False)
print("Model Load Complete!")
print(model)
if __name__ == '__main__':
main()
위와 같이 작성합니다.
MNIST 데이터를 가지고, 앞서 만든 CNN 모델로 이미지 분류기를 학습시키는 간단한 기능입니다.
실제 학습을 수행하면,
위와 같이 val loss 0.024 로 잘 학습되었음을 확인할 수 있습니다.
(CAM(Class Activation Map) 실습)
- 이왕 이미지 분류기를 만든김에 CAM 이라는 것을 실습해보겠습니다.
- 컴퓨터 비전 분야에서 사용되는 CNN 모델에서 대표적으로 사용되는 XAI 는 CAM(Class Activation Map)이라는 것이 있습니다.
이미지 분류 모델이 해당 입력 이미지가 '강아지'라고 판단하였다면,
이미지의 어느 부위가 '강아지'라는 결정을 내는 데에 큰 영향을 준 것인지에 대해 출력해주는 방식으로,
이미지와 동일한 크기의 맵을 만들어서, 최종 결정에 영향을 많이 끼치는 부분에 큰 점수를 주고, 아니면 낮은 점수를 주는 방식으로 결과에 대한 근거를 제시합니다.
- CAM 의 원리를 알아보겠습니다.
CNN 분류 모델의 경우, 기본 구조는,
CNN 특징 추출 레이어로 특징맵 추출 -> 특징맵을 Flatten 하여 벡터화 -> 특징 벡터를 Classification 하는 은닉층으로 결과 출력
위와 같습니다.
이때 우리가 알고 싶은 것은, 이미지의 어느 부분이 최종 분류 은닉층에 영향을 끼치는지에 대한 히트맵을 얻는 것입니다.
CAM 을 적용하기 위하여 모델의 구조를 조금 바꾸겠습니다.
CNN 특징 추출 레이어로 특징맵 추출 -> 출력된 특징맵 N 개의 개별 맵들마다 평균값을 가져오기 -> 특징 벡터를 Classification 하는 은닉층으로 결과 출력
위와 같습니다.
바뀐 부분이란, Flatten 레이어를 적용하는 부분을, "출력된 특징맵 N 개의 개별 맵들마다 평균값을 가져오기" 이것으로 바꾼 것 뿐인데, 이는 GAP(Global Average Pooling) 이라고 부릅니다.
특징맵이란, 이미지의 특성이죠?
CNN 레이어 끝에서 반환되는 특징맵들은, 이미지를 분류하기 위해 필요한 특징들에 대한 평가값을 행렬로 표현한 것입니다.
예를들어 특징맵 A 가 이미지의 거친 질감을 의미하고, 특징맵 B 가 이미지의 투명함을 의미할 때,
특징맵 A 의 각 값들은, 이미지의 구역별 거친 질감의 정도에 대한 값을 표현해주고,
특징맵 B 의 각 값들은, 이미지의 구역별 투명한 정도에 대한 값을 표현해주는 것입니다.
GAP 를 적용한다는 것은, 이미지의 전체적인 거친 질감과, 전체적인 투명한 정도를 평균내어 구한다는 뜻입니다.
이 결과를 종합하여 분류 결과를 구한다면,
각 특징맵들마다 분류 결과에 영향을 끼치는 정도를 알 수 있겠네요. (해당 위치에 연결된 파라미터)
영향력을 알아내는 법을 알게되었으므로, 실제 픽셀에 이를 투사해야합니다.
이때는 GAP 를 하기 전의 특징맵을 사용합니다.
특징맵은 축소되기는 했지만, 이미지의 위치적 특성이 보존되어 있습니다.
모든 특징맵의 각 값마다, 해당하는 가중치를 모두 곱해주고, 특징맵을 모두 더해주면 됩니다.
이렇게 하면, 높은 가중치가 나오는 특징맵의 값들이 부각되고, 낮은 가중치의 특징맵의 값은 낮아지며,
특징맵 내에서 높은 값이 나오는 픽셀 구역이 높은 값을 나타내게 되므로,
이것이 바로 해당 모델이 왜 이러한 분류 결과를 내었는지에 대한 근거가 되는 것입니다.
- CAM 구현
CAM 을 적용할 모델을 아래와 같이 준비합니다.
cnn_classifier_cam/main_model.py
import torch
from torch import nn
import torchvision.models as models
class MainModel(nn.Module):
def __init__(self):
super().__init__()
# 모델 내 레이어
# VGG16 모델 불러오기 (pre-trained weights 사용)
vgg16 = models.vgg16(weights=models.VGG16_Weights.IMAGENET1K_V1)
# VGG16의 첫 번째 conv layer부터 마지막 fully connected layer 전까지를 가져옴
self.features = vgg16.features
# GAP(Global Average Pooling) 레이어 -> FeatureMaps 의 각 Map 마다 평균을 구합니다.
# 예를들어 (5,5) 크기의 맵이 6 개 있다고 하면, (5,5) 크기 맵의 평균 풀링을 하여 1 개의 값으로 추출하여, (6) 사이즈의 벡터로 만듭니다.
# 이렇게 되면 Flatten 을 사용하지 않아도 최종 결정의 벡터가 나옵니다.
self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
# GAP 에서 나온 벡터를 사용한 최종 분류용 은닉층
self.classifier = nn.Linear(512, 10)
self._init_weights()
def _init_weights(self):
# 마지막 fully connected layer의 weight 초기화
nn.init.xavier_uniform_(self.classifier.weight)
self.classifier.bias.data.fill_(0.01)
# VGG 관련 레이어 파라미터는 학습하지 않게 동결
for param in self.features.parameters():
param.requires_grad = False
def forward(self, model_in):
# Feature Maps 추출
model_out = self.features(model_in)
# 추출된 Feature Maps 저장
# GAP(Global Average Pooling)
model_out = self.avgpool(model_out)
model_out = torch.squeeze(model_out)
# 분류
model_out = self.classifier(model_out)
return model_out
이번 모델은 이미 잘 학습된 VGG16 모델을 가지고 이미지 분류를 하고,
그 판단의 결과에 영향을 끼친 이미지 속 위치를 파악하는 것입니다.
cam 을 사용하는 코드는,
# Class Activation Mapping 생성
def generate_cam(
# CNN 분류 모델
image_classifier_cnn_model,
# CNN 분류 모델 입력 이미지
input_image,
# 분류 클래스 인덱스 (None 이라면 분류 결과가 가장 유력한 클래스)
target_class_idx=None
):
# 모델을 검증 모델로 변경
image_classifier_cnn_model.eval()
# 마지막 컨볼루션 레이어와 FC 레이어의 가중치 찾기
last_conv_layer = None
fc_layer_weights = None
# 모델의 레이어를 역순으로 탐색하여 마지막 컨볼루션 레이어와 FC 레이어의 가중치를 찾습니다.
for layer in image_classifier_cnn_model.modules():
if isinstance(layer, nn.Conv2d):
last_conv_layer = layer
elif isinstance(layer, nn.Linear):
fc_layer_weights = layer.weight
break
# 피쳐 맵을 저장할 변수
# feature_map 의 차원은 (배치 크기, 채널 수, 높이, 너비)
feature_map = None
# 후크를 이용하여 마지막 CNN 레이어에서 나오는 피쳐 맵을 저장
def forward_hook(module, input, output):
nonlocal feature_map
feature_map = output
# CNN 마지막 레이어에 후크 등록
hook_handle = last_conv_layer.register_forward_hook(forward_hook)
# 이미지 추론
output = image_classifier_cnn_model(input_image)
# 후크 제거
hook_handle.remove()
# 예측 결과 텐서에서 가장 높은 값을 가진 요소와 해당 요소의 인덱스 반환
max_value, max_index = torch.max(output, dim=0)
if target_class_idx is None:
target_class_idx = max_index
print(f"가장 높은 값을 가진 클래스 인덱스: {max_index.item()}")
print(f"가장 높은 값: {max_value.item()}")
print(f"선택한 클래스 인덱스: {target_class_idx}")
print(f"선택한 클래스 값: {output[target_class_idx]}")
# feature_map 의 높이와 너비 만한 빈 이미지 결과맵 생성
result_cam = torch.zeros(feature_map.shape[2], feature_map.shape[3])
# 추출된 feature_map 들을 순회
for i in range(feature_map.shape[1]):
result_cam += fc_layer_weights[target_class_idx, i] * feature_map[0, i, :, :]
# CAM을 원래 이미지의 크기로 변환
result_cam = f.interpolate(result_cam.unsqueeze(0).unsqueeze(0), size=(224, 224), mode='bilinear',
align_corners=False)
result_cam = result_cam.squeeze().detach().numpy()
# Normalize CAM
result_cam = (result_cam - result_cam.min()) / (result_cam.max() - result_cam.min())
return result_cam
def main():
# 사용 가능 디바이스
device = tu.get_gpu_support_device(gpu_support=True)
# 데이터셋 객체 생성 (ex : tensor([[-10., 100., 82.], ...], device = cpu), tensor([[327.7900], ...], device = cpu))
# x : (28, 28), y : (1) 형식입니다. y 의 경우는 One Hot Encoding 없이 CrossEntropyLoss 함수에 그대로 넣어줘도 됩니다.
transform = transforms.Compose([
transforms.Resize((224, 224)),
transforms.ToTensor(),
transforms.Normalize((0.485, 0.456, 0.406), (0.229, 0.224, 0.225)),
])
train_dataset = datasets.CIFAR10(root='../resources/datasets/global', train=True, transform=transform,
download=True)
validation_dataset = datasets.CIFAR10(root='../resources/datasets/global', train=False, transform=transform,
download=True)
# 데이터 로더 래핑
train_dataloader = DataLoader(train_dataset, batch_size=100, shuffle=True, drop_last=True)
validation_dataloader = DataLoader(validation_dataset, batch_size=10, shuffle=True, drop_last=True)
# 모델 생성
model = cnn_classifier_cam.MainModel()
# !!!학습이 필요하면 주석을 푸세요.!!!
# # 모델 학습 (== VGG 모델에 대한 전이학습)
# tu.train_model(
# device=device,
# model=model,
# criterion=nn.CrossEntropyLoss(),
# optimizer=optim.SGD(model.parameters(), lr=0.001, momentum=0.9),
# train_dataloader=train_dataloader,
# num_epochs=1,
# validation_dataloader=validation_dataloader,
# check_point_file_save_directory_path="../_by_product_files/check_point_files/cnn_classifier_cam",
# # check_point_load_file_full_path="../_by_product_files/check_point_files/~/checkpoint(2024_02_29_17_51_09_330).pt",
# log_freq=1
# )
#
# # 모델 저장
# model_file_save_directory_path = "../_by_product_files/torch_model_files/cnn_classifier_cam"
# if not os.path.exists(model_file_save_directory_path):
# os.makedirs(model_file_save_directory_path)
# save_file_full_path = tu.save_model_file(
# model=model,
# model_file_save_directory_path=model_file_save_directory_path
# )
# # 저장된 모델 불러오기
model_path = "../resources/datasets/49_xai_cam/cnn_classifier_cam_model/model(2024_04_15_22_50_06_450).pt"
model = torch.load(model_path, map_location="cpu", weights_only=False)
# 이미지 경로
image_path = '../resources/datasets/49_xai_cam/cat.jpg'
# 이미지 불러오기
original_image = Image.open(image_path)
# 이미지 전처리
input_tensor = transforms.Compose([
transforms.Resize((224, 224)), # ResNet 모델은 224x224 크기의 이미지를 필요로 합니다.
transforms.ToTensor(), # 이미지를 텐서로 변환
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) # 정규화
])(original_image)
# 배치 차원을 추가하여 모델에 입력할 수 있는 형태로 변경
input_tensor = input_tensor.unsqueeze(0) # (1, 3, 224, 224) 형태로 변경
# CAM 생성
cam = generate_cam(model, input_tensor, target_class_idx=None)
# CAM 을 0~255 사이의 값으로 조정
cam_heatmap = np.uint8(255 * cam)
# CAM 으로 히트맵 생성
cam_heatmap = plt.cm.jet(cam_heatmap)[:, :, :3]
# 원본 이미지를 CAM 히트맵 크기로 리사이즈
resized_image = original_image.resize((224, 224))
# 원본 이미지를 numpy 배열로 변환
resized_image_array = np.array(resized_image) / 255
# CAM 히트맵과 원본 이미지의 합성
combined_image = 0.5 * resized_image_array + 0.5 * cam_heatmap
# 결과를 시각화
plt.imshow(combined_image)
plt.axis('off')
plt.show()
if __name__ == '__main__':
main()
위와 같습니다.
generate_cam 부분에서, cnn 모델이 동작하고,
해당 동작에서 가장 많이 자극받은 부분을 추출하는 것입니다.
실제 실행하면,
가장 높은 값을 가진 클래스 인덱스: 5
가장 높은 값: 1.1644481420516968
선택한 클래스 인덱스: 5
선택한 클래스 값: 1.1644481420516968
위와 같이 CNN 모델이 판단한 결과에 큰 영향을 끼친 부분을 보여줍니다.
고양이라고 판단한 모델의 결과에 따라, 실제 이미지 속 고양이 부분에 큰 영향을 받은 것을 파악할 수 있네요.
- 이상입니다.
'Computer Vision' 카테고리의 다른 글
딥러닝 기반 포즈 인식(skeleton 탐지) (구 블로그 글 복구) (0) | 2025.04.12 |
---|---|
OpenCV, Dlib Python 얼굴인식기 제작 (구 블로그 글 복구) (0) | 2025.04.10 |