🐱🐶 CNN으로 고양이 vs 강아지 분류하기¶
목표: 합성곱 신경망(CNN)을 처음부터 만들어서 실제 사진 데이터로 학습시켜봅니다.
데이터: Kaggle Dogs vs. Cats (25,000장)
기대 성능: 약 70-75% 정확도 (기본 CNN으로는 이 정도가 한계)
📦 1. 환경 설정 및 라이브러리 임포트¶
In [1]:
import setup_env
--------------------------------------------------------------------------------
=== Hardware Acceleration ===
PyTorch version: 2.9.0a0+145a3a7bda.nv25.10
Using NVIDIA GPU (CUDA)
CUDA version: 13.0
GPU name: NVIDIA GeForce RTX 5070 Ti
GPU count: 1
Total GPU memory: 15.92 GB
Allocated memory: 0.00 GB
Free memory: 15.92 GB
Device: cuda
=== Matplotlib Settings ===
✅ Font: NanumGothic
=== System Info ===
OS: Ubuntu 24.04.3 LTS (Noble Numbat)
Kernel: 6.6.87.2-microsoft-standard-WSL2
Architecture: x86_64
Python: 3.12.3
Working directory: /workspace/ai-deeplearning/tutorial
=== Library Versions ===
NumPy: 2.1.0
Pandas: 3.0.0
Matplotlib: 3.10.7
Scikit-learn: 1.7.2
OpenCV: Not installed → !pip install -q opencv-python
Pillow: 12.0.0
Seaborn: 0.13.2
TensorFlow: Not installed → !pip install -q tensorflow
Transformers: 4.40.1
TorchVision: 0.24.0a0+094e7af5
=== Environment setup completed ===
--------------------------------------------------------------------------------
=== Visualizing Test Plot (Wide View) ===
=== GPU Usage Code Snippet === Device set to: cuda ---------------------------------------- # 아래 코드를 복사해서 모델과 데이터를 GPU로 보내세요: model = YourModel().to(device) data = data.to(device) ---------------------------------------- === Environment setup completed === --------------------------------------------------------------------------------
| 연도 | 모델 | Top-1 정확도 | Top-5 정확도 | 설명 |
|---|---|---|---|---|
| 2010 | SVM | 52.9% | 71.8% | CNN 이전 전통적 머신러닝 방식 |
| 2012 | AlexNet | 62.5% | 84.7% | 최초 딥러닝 CNN 우승, GPU 활용, 딥러닝 혁명의 시작 |
| 2013 | ZFNet | 64.4% | 88.8% | AlexNet 개선, NYU 연구팀 개발 |
| 2014 | GoogLeNet | 68.7% | 93.3% | 구글 개발, 22층 깊은 구조 |
| 2015 | ResNet | 78.6% | 96.4% | MS 개발, 152층, 인간 초월 |
| 2016 | SENet 계열 | 82.7% | 96.6% | 채널별 중요도 학습 |
| 2017 | SENet | 82.7% | 97.7% | 마지막 대회 - 38개 참가팀 중 29개 팀이 Top-5 95% 이상 달성, 벤치마크 종료 |
| 기준 | 인간 | ~75% | 94.9% | Karpathy 실험 기준 |
📥 2. 데이터셋 다운로드 및 준비¶
Kaggle API를 사용하여 Dogs vs. Cats 데이터를 다운로드합니다.
In [4]:
import os
import zipfile
import shutil
import requests
from tqdm import tqdm
from sklearn.model_selection import train_test_split
# 공통 경로 설정
base_dir = './data_dogs_cats'
zip_path = 'cats_and_dogs_25k.zip'
url = "https://download.microsoft.com/download/3/E/1/3E1C3F21-ECDB-4869-8368-6DEBA77B919F/kagglecatsanddogs_5340.zip"
print("✅ 설정 완료")
✅ 설정 완료
In [5]:
# 파일이 없을 때만 다운로드 실행
if not os.path.exists(zip_path):
print("🚀 25,000장 전체 데이터셋 다운로드 시작 (약 800MB)...")
response = requests.get(url, stream=True)
with open(zip_path, "wb") as f:
for data in tqdm(response.iter_content(chunk_size=1024)):
f.write(data)
print("✅ 다운로드 완료")
else:
print("📂 이미 데이터셋 압축 파일이 존재합니다. 다운로드를 건너뜁니다.")
📂 이미 데이터셋 압축 파일이 존재합니다. 다운로드를 건너뜁니다.
In [6]:
if not os.path.exists(base_dir):
print("📦 7:2:1 비율로 데이터 분할 및 폴더 정리 시작...")
# 임시 압축 해제
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
zip_ref.extractall('./temp_raw')
# train/val/test 폴더 생성
for split in ['train', 'val', 'test']:
for label in ['dog', 'cat']:
os.makedirs(os.path.join(base_dir, split, label), exist_ok=True)
# 원본 파일 리스트 추출 (Dog, Cat 각각)
dog_src = './temp_raw/PetImages/Dog'
cat_src = './temp_raw/PetImages/Cat'
dogs = [os.path.join(dog_src, f) for f in os.listdir(dog_src) if f.endswith('.jpg')]
cats = [os.path.join(cat_src, f) for f in os.listdir(cat_src) if f.endswith('.jpg')]
# --- 7:2:1 분할 로직 ---
# 1. 70%(Train) vs 30%(나머지)
train_dogs, rest_dogs = train_test_split(dogs, test_size=0.3, random_state=42)
train_cats, rest_cats = train_test_split(cats, test_size=0.3, random_state=42)
# 2. 남은 30% 중 2/3(전체의 20%)는 Val, 1/3(전체의 10%)는 Test
val_dogs, test_dogs = train_test_split(rest_dogs, test_size=1/3, random_state=42)
val_cats, test_cats = train_test_split(rest_cats, test_size=1/3, random_state=42)
# 파일 복사 함수 (정상 파일만)
def copy_valid_files(files, target_path):
count = 0
for f in files:
try:
if os.path.getsize(f) > 0: # 0바이트 파일 걸러내기
shutil.copy(f, target_path)
count += 1
except:
pass
return count
# 실제 복사 실행
print("🚚 파일을 폴더로 복사 중...")
copy_valid_files(train_dogs, os.path.join(base_dir, 'train', 'dog'))
copy_valid_files(train_cats, os.path.join(base_dir, 'train', 'cat'))
copy_valid_files(val_dogs, os.path.join(base_dir, 'val', 'dog'))
copy_valid_files(val_cats, os.path.join(base_dir, 'val', 'cat'))
copy_valid_files(test_dogs, os.path.join(base_dir, 'test', 'dog'))
copy_valid_files(test_cats, os.path.join(base_dir, 'test', 'cat'))
shutil.rmtree('./temp_raw')
print("✨ 모든 작업 완료!")
else:
print(f"✅ '{base_dir}' 폴더가 이미 존재하여 작업을 건너뜁니다.")
# 최종 개수 확인
for s in ['train', 'val', 'test']:
for l in ['dog', 'cat']:
p = os.path.join(base_dir, s, l)
print(f"📊 {s}/{l}: {len(os.listdir(p))}장")
✅ './data_dogs_cats' 폴더가 이미 존재하여 작업을 건너뜁니다.
📊 train/dog: 8749장 📊 train/cat: 8749장 📊 val/dog: 2500장 📊 val/cat: 2500장 📊 test/dog: 1250장 📊 test/cat: 1250장
🗂️ 3. 데이터 분할: Train / Validation / Test¶
전체 25,000장을 다음과 같이 분할합니다:
- Train: 70% (17,500장) - 모델 학습용
- Validation: 15% (3,750장) - 하이퍼파라미터 튜닝 및 조기종료
- Test: 15% (3,750장) - 최종 성능 평가
각 클래스(고양이/강아지)별로 균등하게 분할합니다.
🖼️ 4. 데이터 시각화¶
실제 이미지가 어떻게 생겼는지 확인해봅시다.
In [7]:
# 수정 시작: D2Coding 폰트 적용 및 이모지 제거로 한글 깨짐 해결
def show_sample_images(data_dir, n_samples=8):
"""
각 클래스에서 n_samples//2 개씩 샘플 이미지를 보여줍니다.
"""
import matplotlib.pyplot as plt
from PIL import Image
import pathlib
import matplotlib.font_manager as fm
# 1. D2Coding 폰트 설정 (경로 확인 필수)
font_path = '/usr/share/fonts/truetype/d2coding/D2Coding-Ver1.3.2-20180524.ttf'
if os.path.exists(font_path):
font_prop = fm.FontProperties(fname=font_path)
plt.rc('font', family='D2Coding')
# data_dir을 Path 객체로 변환
data_dir = pathlib.Path(data_dir)
fig, axes = plt.subplots(2, n_samples//2, figsize=(15, 6))
# 2. 고양이 샘플
cat_dir = data_dir / 'cat'
cat_files = list(cat_dir.glob('*.jpg'))[:n_samples//2]
for i, img_path in enumerate(cat_files):
img = Image.open(img_path)
axes[0, i].imshow(img)
axes[0, i].set_title(f'Cat #{i+1}', fontsize=10)
axes[0, i].axis('off')
# 3. 강아지 샘플
dog_dir = data_dir / 'dog'
dog_files = list(dog_dir.glob('*.jpg'))[:n_samples//2]
for i, img_path in enumerate(dog_files):
img = Image.open(img_path)
axes[1, i].imshow(img)
axes[1, i].set_title(f'Dog #{i+1}', fontsize=10)
axes[1, i].axis('off')
# 4. 제목 설정 (이모지는 폰트에서 지원하지 않으므로 텍스트만 사용)
plt.suptitle('고양이(Cat) vs 강아지(Dog) 샘플 이미지', fontsize=14, fontweight='bold')
plt.tight_layout()
plt.show()
# 실행
train_path = os.path.join(base_dir, 'train')
show_sample_images(train_path, n_samples=8)
# 수정 종료
In [8]:
# 수정 시작: Path 처리 및 D2Coding 폰트 명시적 적용
import pathlib
# 1. train_dir을 Path 객체로 확실히 변환
if isinstance(train_path, str):
train_dir_path = pathlib.Path(train_path)
else:
train_dir_path = train_path
print('📏 이미지 크기 분석 중... (샘플 100장)')
# glob 패턴 수정 (하위 폴더의 모든 jpg 찾기)
sample_files = list(train_dir_path.glob('*/*.jpg'))[:100]
widths = []
heights = []
for img_path in tqdm(sample_files):
img = Image.open(img_path)
widths.append(img.width)
heights.append(img.height)
# 2. 시각화 (D2Coding 폰트 설정 포함)
import matplotlib.font_manager as fm
font_path = '/usr/share/fonts/truetype/d2coding/D2Coding-Ver1.3.2-20180524.ttf'
font_prop = fm.FontProperties(fname=font_path)
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
# 너비 히스토그램
axes[0].hist(widths, bins=20, alpha=0.7, color='skyblue', edgecolor='black')
axes[0].set_xlabel('Width (pixels)', fontproperties=font_prop)
axes[0].set_ylabel('Frequency', fontproperties=font_prop)
axes[0].set_title('이미지 너비 분포', fontproperties=font_prop, fontsize=12)
axes[0].axvline(np.mean(widths), color='red', linestyle='--', label=f'평균: {np.mean(widths):.0f}px')
axes[0].legend(prop=font_prop)
# 높이 히스토그램
axes[1].hist(heights, bins=20, alpha=0.7, color='lightcoral', edgecolor='black')
axes[1].set_xlabel('Height (pixels)', fontproperties=font_prop)
axes[1].set_ylabel('Frequency', fontproperties=font_prop)
axes[1].set_title('이미지 높이 분포', fontproperties=font_prop, fontsize=12)
axes[1].axvline(np.mean(heights), color='red', linestyle='--', label=f'평균: {np.mean(heights):.0f}px')
axes[1].legend(prop=font_prop)
plt.tight_layout()
plt.show()
print(f'\n평균 크기: {np.mean(widths):.0f} × {np.mean(heights):.0f} pixels')
print(f'최소 크기: {np.min(widths):.0f} × {np.min(heights):.0f} pixels')
print(f'최대 크기: {np.max(widths):.0f} × {np.max(heights):.0f} pixels')
print('\n💡 모든 이미지를 224×224로 리사이즈하여 학습합니다.')
# 수정 종료
📏 이미지 크기 분석 중... (샘플 100장)
100%|██████████| 100/100 [00:00<00:00, 774.77it/s]
평균 크기: 394 × 359 pixels 최소 크기: 141 × 120 pixels 최대 크기: 500 × 500 pixels 💡 모든 이미지를 224×224로 리사이즈하여 학습합니다.
🔄 5. 데이터 전처리 및 증강 (Data Augmentation)¶
이미지를 모델에 입력하기 전에 전처리가 필요합니다:
- 리사이즈: 모든 이미지를 224×224로 통일
- 정규화: ImageNet 평균/표준편차로 정규화 (전이학습 대비)
- 증강 (Train only): 좌우반전, 회전 등으로 데이터 다양성 확보
In [9]:
# ImageNet 통계값 (RGB 각 채널별)
IMAGENET_MEAN = [0.485, 0.456, 0.406]
IMAGENET_STD = [0.229, 0.224, 0.225]
# 이미지 크기
IMG_SIZE = 224
# 훈련 데이터 전처리 (Data Augmentation 포함)
train_transform = transforms.Compose([
transforms.Resize((IMG_SIZE, IMG_SIZE)), # 224×224로 리사이즈
transforms.RandomHorizontalFlip(p=0.5), # 50% 확률로 좌우 반전
transforms.RandomRotation(degrees=15), # ±15도 랜덤 회전
transforms.ColorJitter( # 색상 변화
brightness=0.2, # 밝기 ±20%
contrast=0.2, # 대비 ±20%
saturation=0.2, # 채도 ±20%
hue=0.1 # 색조 ±10%
),
transforms.ToTensor(), # PIL Image → Tensor (0~1)
transforms.Normalize( # 정규화
mean=IMAGENET_MEAN,
std=IMAGENET_STD
)
])
# 검증/테스트 데이터 전처리 (Augmentation 없음)
val_test_transform = transforms.Compose([
transforms.Resize((IMG_SIZE, IMG_SIZE)),
transforms.ToTensor(),
transforms.Normalize(
mean=IMAGENET_MEAN,
std=IMAGENET_STD
)
])
print('✅ 데이터 전처리 파이프라인 정의 완료!')
print(f' 이미지 크기: {IMG_SIZE}×{IMG_SIZE}')
print(f' 정규화: ImageNet 평균/표준편차 사용')
✅ 데이터 전처리 파이프라인 정의 완료! 이미지 크기: 224×224 정규화: ImageNet 평균/표준편차 사용
In [10]:
# 수정 시작: 경로 변수명 통일 및 문자열 변환
# 위에서 정의한 base_dir을 기준으로 경로를 다시 한번 확실히 잡아줍니다.
train_path = os.path.join(base_dir, 'train')
val_path = os.path.join(base_dir, 'val')
test_path = os.path.join(base_dir, 'test')
train_dataset = datasets.ImageFolder(
root=str(train_path),
transform=train_transform
)
val_dataset = datasets.ImageFolder(
root=str(val_path), # 변수명이 val_path로 정의되어 있어야 합니다.
transform=val_test_transform
)
test_dataset = datasets.ImageFolder(
root=str(test_path),
transform=val_test_transform
)
print('✅ Dataset 생성 완료!')
print(f' Train: {len(train_dataset):,}장')
print(f' Val: {len(val_dataset):,}장')
print(f' Test: {len(test_dataset):,}장')
print(f'\n 클래스: {train_dataset.classes}')
print(f' 클래스 인덱스: {train_dataset.class_to_idx}')
# 수정 종료
✅ Dataset 생성 완료!
Train: 17,498장
Val: 5,000장
Test: 2,500장
클래스: ['cat', 'dog']
클래스 인덱스: {'cat': 0, 'dog': 1}
In [11]:
# DataLoader 생성
BATCH_SIZE = 128 # GPU 메모리에 맞게 조절 가능 (16, 32, 64 등)
NUM_WORKERS = 4 # CPU 코어 수에 맞게 조절
train_loader = DataLoader(
train_dataset,
batch_size=BATCH_SIZE,
shuffle=True, # 훈련 시 섞기
num_workers=NUM_WORKERS,
persistent_workers=True,
pin_memory=True # GPU 사용 시 속도 향상
)
val_loader = DataLoader(
val_dataset,
batch_size=BATCH_SIZE,
shuffle=False, # 검증 시 순서 유지
num_workers=NUM_WORKERS,
persistent_workers=True,
pin_memory=True
)
test_loader = DataLoader(
test_dataset,
batch_size=BATCH_SIZE,
shuffle=False,
num_workers=NUM_WORKERS,
persistent_workers=True,
pin_memory=True
)
print('✅ DataLoader 생성 완료!')
print(f' 배치 크기: {BATCH_SIZE}')
print(f' Train 배치 수: {len(train_loader)}')
print(f' Val 배치 수: {len(val_loader)}')
print(f' Test 배치 수: {len(test_loader)}')
✅ DataLoader 생성 완료! 배치 크기: 128 Train 배치 수: 137 Val 배치 수: 40 Test 배치 수: 20
In [12]:
# Augmentation 효과 시각화
def show_augmented_images(dataset, idx=0, n_augmentations=8):
"""
같은 이미지에 대해 여러 번 augmentation을 적용한 결과를 보여줍니다.
"""
# 원본 이미지 경로 가져오기
img_path, label = dataset.samples[idx]
original_img = Image.open(img_path)
fig, axes = plt.subplots(2, 4, figsize=(15, 7))
axes = axes.ravel()
for i in range(n_augmentations):
# Transform 적용
img_tensor = dataset.transform(original_img)
# 정규화 역변환 (시각화용)
img_numpy = img_tensor.numpy().transpose(1, 2, 0)
img_numpy = img_numpy * np.array(IMAGENET_STD) + np.array(IMAGENET_MEAN)
img_numpy = np.clip(img_numpy, 0, 1)
axes[i].imshow(img_numpy)
axes[i].set_title(f'Augmentation #{i+1}', fontsize=10)
axes[i].axis('off')
class_name = 'Cat' if label == 0 else 'Dog'
plt.suptitle(f'Data Augmentation 효과 ({class_name})', fontsize=14, fontweight='bold')
plt.tight_layout()
plt.show()
# 고양이 이미지로 augmentation 효과 확인
show_augmented_images(train_dataset, idx=0, n_augmentations=8)
🧠 6. CNN 모델 정의¶
처음부터 간단한 CNN을 만들어봅시다.
구조:
- Conv Layer 1: 3 → 32 channels
- Conv Layer 2: 32 → 64 channels
- Conv Layer 3: 64 → 128 channels
- Conv Layer 4: 128 → 256 channels
- Fully Connected: 256 → 2 (cats vs dogs)
In [13]:
class SimpleCNN(nn.Module):
"""
간단한 CNN 모델 (4개의 합성곱층 + 2개의 완전연결층)
"""
def __init__(self, num_classes=2):
super(SimpleCNN, self).__init__()
# ============ 특징 추출부 (Feature Extraction) ============
# Conv Block 1: 224x224x3 → 112x112x32
self.conv1 = nn.Sequential(
nn.Conv2d(3, 32, kernel_size=3, padding=1), # 3채널(RGB) → 32채널
nn.BatchNorm2d(32), # 배치 정규화
nn.ReLU(inplace=True), # 활성화 함수
nn.MaxPool2d(kernel_size=2, stride=2) # 크기 1/2로 축소
)
# Conv Block 2: 112x112x32 → 56x56x64
self.conv2 = nn.Sequential(
nn.Conv2d(32, 64, kernel_size=3, padding=1),
nn.BatchNorm2d(64),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=2, stride=2)
)
# Conv Block 3: 56x56x64 → 28x28x128
self.conv3 = nn.Sequential(
nn.Conv2d(64, 128, kernel_size=3, padding=1),
nn.BatchNorm2d(128),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=2, stride=2)
)
# Conv Block 4: 28x28x128 → 14x14x256
self.conv4 = nn.Sequential(
nn.Conv2d(128, 256, kernel_size=3, padding=1),
nn.BatchNorm2d(256),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=2, stride=2)
)
# ============ 분류부 (Classification) ============
# Global Average Pooling: 14x14x256 → 1x1x256
self.global_avg_pool = nn.AdaptiveAvgPool2d((1, 1))
# Fully Connected Layers
self.classifier = nn.Sequential(
nn.Dropout(0.5), # 과적합 방지 (50% 드롭)
nn.Linear(256, 128), # 256 → 128
nn.ReLU(inplace=True),
nn.Dropout(0.5),
nn.Linear(128, num_classes) # 128 → 2 (cats vs dogs)
)
def forward(self, x):
# 특징 추출
x = self.conv1(x) # 224 → 112
x = self.conv2(x) # 112 → 56
x = self.conv3(x) # 56 → 28
x = self.conv4(x) # 28 → 14
# Global Average Pooling
x = self.global_avg_pool(x) # 14x14x256 → 1x1x256
x = x.view(x.size(0), -1) # Flatten: (batch, 256)
# 분류
x = self.classifier(x)
return x
In [14]:
from torchsummary import summary
# 모델을 GPU(또는 CPU)로 이동
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = SimpleCNN().to(device)
# 시각화 (입력 사이즈: 3채널, 224x224)
summary(model, input_size=(3, 224, 224))
----------------------------------------------------------------
Layer (type) Output Shape Param #
================================================================
Conv2d-1 [-1, 32, 224, 224] 896
BatchNorm2d-2 [-1, 32, 224, 224] 64
ReLU-3 [-1, 32, 224, 224] 0
MaxPool2d-4 [-1, 32, 112, 112] 0
Conv2d-5 [-1, 64, 112, 112] 18,496
BatchNorm2d-6 [-1, 64, 112, 112] 128
ReLU-7 [-1, 64, 112, 112] 0
MaxPool2d-8 [-1, 64, 56, 56] 0
Conv2d-9 [-1, 128, 56, 56] 73,856
BatchNorm2d-10 [-1, 128, 56, 56] 256
ReLU-11 [-1, 128, 56, 56] 0
MaxPool2d-12 [-1, 128, 28, 28] 0
Conv2d-13 [-1, 256, 28, 28] 295,168
BatchNorm2d-14 [-1, 256, 28, 28] 512
ReLU-15 [-1, 256, 28, 28] 0
MaxPool2d-16 [-1, 256, 14, 14] 0
AdaptiveAvgPool2d-17 [-1, 256, 1, 1] 0
Dropout-18 [-1, 256] 0
Linear-19 [-1, 128] 32,896
ReLU-20 [-1, 128] 0
Dropout-21 [-1, 128] 0
Linear-22 [-1, 2] 258
================================================================
Total params: 422,530
Trainable params: 422,530
Non-trainable params: 0
----------------------------------------------------------------
Input size (MB): 0.57
Forward/backward pass size (MB): 74.66
Params size (MB): 1.61
Estimated Total Size (MB): 76.84
----------------------------------------------------------------
In [15]:
# 모델 파라미터 수 계산
def count_parameters(model):
"""
모델의 학습 가능한 파라미터 수를 계산합니다.
"""
return sum(p.numel() for p in model.parameters() if p.requires_grad)
n_params = count_parameters(model)
print(f'📊 총 파라미터 수: {n_params:,}개 ({n_params/1e6:.2f}M)')
# 레이어별 파라미터 수
print('\n레이어별 파라미터:')
for name, param in model.named_parameters():
if param.requires_grad:
print(f' {name:30} : {param.numel():>10,}')
📊 총 파라미터 수: 422,530개 (0.42M) 레이어별 파라미터: conv1.0.weight : 864 conv1.0.bias : 32 conv1.1.weight : 32 conv1.1.bias : 32 conv2.0.weight : 18,432 conv2.0.bias : 64 conv2.1.weight : 64 conv2.1.bias : 64 conv3.0.weight : 73,728 conv3.0.bias : 128 conv3.1.weight : 128 conv3.1.bias : 128 conv4.0.weight : 294,912 conv4.0.bias : 256 conv4.1.weight : 256 conv4.1.bias : 256 classifier.1.weight : 32,768 classifier.1.bias : 128 classifier.4.weight : 256 classifier.4.bias : 2
In [16]:
# 모델 입력/출력 테스트
dummy_input = torch.randn(1, 3, 224, 224).to(device)
output = model(dummy_input)
print(f'입력 크기: {dummy_input.shape}')
print(f'출력 크기: {output.shape}')
print(f'\n출력값 (로짓): {output}')
print(f'출력값 (확률): {torch.softmax(output, dim=1)}')
입력 크기: torch.Size([1, 3, 224, 224]) 출력 크기: torch.Size([1, 2]) 출력값 (로짓): tensor([[ 0.1315, -0.2536]], device='cuda:0', grad_fn=<AddmmBackward0>) 출력값 (확률): tensor([[0.5951, 0.4049]], device='cuda:0', grad_fn=<SoftmaxBackward0>)
⚙️ 7. 학습 설정¶
손실 함수, 옵티마이저, 학습률 스케줄러를 설정합니다.
In [17]:
# 수정 시작: 필수 라이브러리 임포트 추가 (nn, optim)
import torch.nn as nn
import torch.optim as optim
# 하이퍼파라미터 설정
EPOCHS = 100 # 에포크 수
LEARNING_RATE = 0.001 # 초기 학습률
WEIGHT_DECAY = 1e-4 # L2 정규화 (과적합 방지)
# 손실 함수 (Cross Entropy Loss)
# model이 미리 정의되어 있어야 합니다.
criterion = nn.CrossEntropyLoss()
# 옵티마이저 (Adam)
optimizer = optim.Adam(
model.parameters(),
lr=LEARNING_RATE,
weight_decay=WEIGHT_DECAY
)
# 학습률 스케줄러 (ReduceLROnPlateau)
# (주의) 최신 버전에서는 verbose=True 대신 아래와 같이 로그를 확인합니다.
scheduler = optim.lr_scheduler.ReduceLROnPlateau(
optimizer,
mode='min', # loss를 최소화
factor=0.5, # 학습률을 1/2로 감소
patience=3 # 3 epoch 동안 개선 없으면 감소
)
print('✅ 학습 설정 완료!')
print(f' Epochs: {EPOCHS}')
print(f' Learning Rate: {LEARNING_RATE}')
print(f' Weight Decay: {WEIGHT_DECAY}')
print(f' Optimizer: Adam')
print(f' Scheduler: ReduceLROnPlateau')
# 수정 종료
✅ 학습 설정 완료! Epochs: 100 Learning Rate: 0.001 Weight Decay: 0.0001 Optimizer: Adam Scheduler: ReduceLROnPlateau
🏋️ 8. 학습 루프¶
이제 모델을 학습시켜봅시다!
In [18]:
# 학습 기록용 딕셔너리
history = {
'train_loss': [],
'train_acc': [],
'val_loss': [],
'val_acc': [],
'lr': []
}
# Best model 저장용
best_val_acc = 0.0
best_model_path = 'best_cnn_model.pth'
In [19]:
def train_one_epoch(model, dataloader, criterion, optimizer, device):
"""
1 epoch 동안 모델을 학습시킵니다.
Returns:
avg_loss: 평균 손실
avg_acc: 평균 정확도
"""
model.train() # 학습 모드
running_loss = 0.0
running_corrects = 0
total_samples = 0
# 진행률 표시
pbar = tqdm(dataloader, desc='Training', leave=False)
for inputs, labels in pbar:
# GPU로 데이터 이동
inputs = inputs.to(device)
labels = labels.to(device)
# 그래디언트 초기화
optimizer.zero_grad()
# 순전파
outputs = model(inputs)
loss = criterion(outputs, labels)
# 역전파
loss.backward()
# 가중치 업데이트
optimizer.step()
# 통계 업데이트
batch_size = inputs.size(0)
running_loss += loss.item() * batch_size
_, preds = torch.max(outputs, 1)
running_corrects += torch.sum(preds == labels.data).item()
total_samples += batch_size
# 진행률 업데이트
pbar.set_postfix({
'loss': f'{loss.item():.4f}',
'acc': f'{running_corrects/total_samples:.4f}'
})
# 평균 계산
avg_loss = running_loss / total_samples
avg_acc = running_corrects / total_samples
return avg_loss, avg_acc
In [20]:
def validate(model, dataloader, criterion, device):
"""
검증 데이터로 모델을 평가합니다.
Returns:
avg_loss: 평균 손실
avg_acc: 평균 정확도
"""
model.eval() # 평가 모드
running_loss = 0.0
running_corrects = 0
total_samples = 0
# 그래디언트 계산 비활성화 (메모리 절약)
with torch.no_grad():
pbar = tqdm(dataloader, desc='Validation', leave=False)
for inputs, labels in pbar:
inputs = inputs.to(device)
labels = labels.to(device)
# 순전파만 수행
outputs = model(inputs)
loss = criterion(outputs, labels)
# 통계 업데이트
batch_size = inputs.size(0)
running_loss += loss.item() * batch_size
_, preds = torch.max(outputs, 1)
running_corrects += torch.sum(preds == labels.data).item()
total_samples += batch_size
# 평균 계산
avg_loss = running_loss / total_samples
avg_acc = running_corrects / total_samples
return avg_loss, avg_acc
In [21]:
# 학습 시작!
print('\n' + '='*80)
print('🏋️ 학습 시작!')
print('='*80 + '\n')
import time
start_time = time.time()
for epoch in range(EPOCHS):
epoch_start_time = time.time()
# ========== 학습 ==========
train_loss, train_acc = train_one_epoch(model, train_loader, criterion, optimizer, device)
# ========== 검증 ==========
val_loss, val_acc = validate(model, val_loader, criterion, device)
# ========== 학습률 업데이트 ==========
scheduler.step(val_loss)
current_lr = optimizer.param_groups[0]['lr']
# ========== 기록 저장 ==========
history['train_loss'].append(train_loss)
history['train_acc'].append(train_acc)
history['val_loss'].append(val_loss)
history['val_acc'].append(val_acc)
history['lr'].append(current_lr)
# ========== Best Model 저장 ==========
if val_acc > best_val_acc:
best_val_acc = val_acc
torch.save(model.state_dict(), best_model_path)
print(f'\n✨ Best model saved! (Val Acc: {val_acc:.4f})')
# ========== 진행 상황 출력 ==========
epoch_time = time.time() - epoch_start_time
print(f'Epoch [{epoch+1:3d}/{EPOCHS}] {epoch_time:5.1f}s | Train Loss: {train_loss:.4f} Acc: {train_acc:.2%} | Val Loss: {val_loss:.4f} Acc: {val_acc:.2%} | LR: {current_lr:.6f}')
# 학습 종료
total_time = time.time() - start_time
print(f'\n✅ 학습 완료! (총 소요 시간: {total_time/60:.1f}분)')
print(f'🏆 Best Validation Accuracy: {best_val_acc:.4f}')
================================================================================ 🏋️ 학습 시작! ================================================================================
✨ Best model saved! (Val Acc: 0.6062) Epoch [ 1/100] 33.4s | Train Loss: 0.6560 Acc: 60.61% | Val Loss: 0.6446 Acc: 60.62% | LR: 0.001000
✨ Best model saved! (Val Acc: 0.6666) Epoch [ 2/100] 33.8s | Train Loss: 0.6225 Acc: 65.01% | Val Loss: 0.6037 Acc: 66.66% | LR: 0.001000
✨ Best model saved! (Val Acc: 0.7180) Epoch [ 3/100] 33.2s | Train Loss: 0.5973 Acc: 68.29% | Val Loss: 0.5572 Acc: 71.80% | LR: 0.001000
✨ Best model saved! (Val Acc: 0.7394) Epoch [ 4/100] 33.3s | Train Loss: 0.5771 Acc: 70.03% | Val Loss: 0.5352 Acc: 73.94% | LR: 0.001000
Epoch [ 5/100] 33.1s | Train Loss: 0.5604 Acc: 71.53% | Val Loss: 0.5531 Acc: 72.20% | LR: 0.001000
Epoch [ 6/100] 33.4s | Train Loss: 0.5463 Acc: 72.70% | Val Loss: 0.5970 Acc: 67.46% | LR: 0.001000
✨ Best model saved! (Val Acc: 0.7590) Epoch [ 7/100] 33.2s | Train Loss: 0.5347 Acc: 73.97% | Val Loss: 0.4972 Acc: 75.90% | LR: 0.001000
Epoch [ 8/100] 33.2s | Train Loss: 0.5234 Acc: 74.61% | Val Loss: 0.6635 Acc: 63.68% | LR: 0.001000
Epoch [ 9/100] 33.4s | Train Loss: 0.5025 Acc: 76.08% | Val Loss: 0.5907 Acc: 70.22% | LR: 0.001000
Epoch [ 10/100] 33.4s | Train Loss: 0.4922 Acc: 76.99% | Val Loss: 0.5334 Acc: 74.80% | LR: 0.001000
✨ Best model saved! (Val Acc: 0.8082) Epoch [ 11/100] 33.5s | Train Loss: 0.4825 Acc: 77.59% | Val Loss: 0.4172 Acc: 80.82% | LR: 0.001000
Epoch [ 12/100] 33.9s | Train Loss: 0.4671 Acc: 78.67% | Val Loss: 0.9471 Acc: 58.72% | LR: 0.001000
Epoch [ 13/100] 33.8s | Train Loss: 0.4528 Acc: 79.30% | Val Loss: 0.4343 Acc: 79.90% | LR: 0.001000
Epoch [ 14/100] 33.4s | Train Loss: 0.4418 Acc: 79.88% | Val Loss: 0.4888 Acc: 75.96% | LR: 0.001000
✨ Best model saved! (Val Acc: 0.8426) Epoch [ 15/100] 33.6s | Train Loss: 0.4207 Acc: 81.35% | Val Loss: 0.3667 Acc: 84.26% | LR: 0.001000
Epoch [ 16/100] 35.2s | Train Loss: 0.4013 Acc: 82.48% | Val Loss: 0.4736 Acc: 77.38% | LR: 0.001000
Epoch [ 17/100] 33.3s | Train Loss: 0.3930 Acc: 82.52% | Val Loss: 0.5427 Acc: 72.76% | LR: 0.001000
Epoch [ 18/100] 33.4s | Train Loss: 0.3813 Acc: 83.64% | Val Loss: 0.4292 Acc: 79.72% | LR: 0.001000
Epoch [ 19/100] 33.6s | Train Loss: 0.3734 Acc: 83.90% | Val Loss: 0.6209 Acc: 69.68% | LR: 0.000500
Epoch [ 20/100] 33.2s | Train Loss: 0.3331 Acc: 85.86% | Val Loss: 0.4815 Acc: 77.86% | LR: 0.000500
Epoch [ 21/100] 33.5s | Train Loss: 0.3227 Acc: 86.41% | Val Loss: 0.4658 Acc: 80.74% | LR: 0.000500
✨ Best model saved! (Val Acc: 0.8876) Epoch [ 22/100] 33.6s | Train Loss: 0.3166 Acc: 86.81% | Val Loss: 0.2618 Acc: 88.76% | LR: 0.000500
Epoch [ 23/100] 33.1s | Train Loss: 0.3056 Acc: 86.96% | Val Loss: 0.6734 Acc: 72.34% | LR: 0.000500
Epoch [ 24/100] 33.3s | Train Loss: 0.2990 Acc: 87.37% | Val Loss: 0.2847 Acc: 88.16% | LR: 0.000500
Epoch [ 25/100] 33.7s | Train Loss: 0.2902 Acc: 88.03% | Val Loss: 0.5639 Acc: 76.66% | LR: 0.000500
✨ Best model saved! (Val Acc: 0.9020) Epoch [ 26/100] 33.2s | Train Loss: 0.2863 Acc: 88.50% | Val Loss: 0.2412 Acc: 90.20% | LR: 0.000500
Epoch [ 27/100] 33.5s | Train Loss: 0.2830 Acc: 88.54% | Val Loss: 0.5341 Acc: 75.08% | LR: 0.000500
Epoch [ 28/100] 33.6s | Train Loss: 0.2765 Acc: 88.55% | Val Loss: 0.2575 Acc: 89.80% | LR: 0.000500
Epoch [ 29/100] 33.4s | Train Loss: 0.2764 Acc: 88.27% | Val Loss: 0.2889 Acc: 87.80% | LR: 0.000500
Epoch [ 30/100] 33.5s | Train Loss: 0.2648 Acc: 89.11% | Val Loss: 0.3468 Acc: 84.84% | LR: 0.000250
Epoch [ 31/100] 33.2s | Train Loss: 0.2489 Acc: 89.88% | Val Loss: 0.4285 Acc: 81.56% | LR: 0.000250
✨ Best model saved! (Val Acc: 0.9056) Epoch [ 32/100] 33.4s | Train Loss: 0.2374 Acc: 90.28% | Val Loss: 0.2289 Acc: 90.56% | LR: 0.000250
✨ Best model saved! (Val Acc: 0.9186) Epoch [ 33/100] 33.4s | Train Loss: 0.2406 Acc: 90.27% | Val Loss: 0.2054 Acc: 91.86% | LR: 0.000250
Epoch [ 34/100] 35.2s | Train Loss: 0.2387 Acc: 90.32% | Val Loss: 0.3527 Acc: 86.00% | LR: 0.000250
Epoch [ 35/100] 33.2s | Train Loss: 0.2303 Acc: 90.82% | Val Loss: 0.2124 Acc: 91.50% | LR: 0.000250
Epoch [ 36/100] 33.4s | Train Loss: 0.2334 Acc: 90.59% | Val Loss: 0.2198 Acc: 90.92% | LR: 0.000250
Epoch [ 37/100] 33.4s | Train Loss: 0.2262 Acc: 90.91% | Val Loss: 0.2467 Acc: 89.50% | LR: 0.000125
✨ Best model saved! (Val Acc: 0.9268) Epoch [ 38/100] 33.4s | Train Loss: 0.2103 Acc: 91.38% | Val Loss: 0.1869 Acc: 92.68% | LR: 0.000125
Epoch [ 39/100] 33.6s | Train Loss: 0.2056 Acc: 91.87% | Val Loss: 0.1967 Acc: 91.86% | LR: 0.000125
Epoch [ 40/100] 33.2s | Train Loss: 0.2073 Acc: 91.52% | Val Loss: 0.3175 Acc: 87.10% | LR: 0.000125
Epoch [ 41/100] 33.2s | Train Loss: 0.2088 Acc: 91.83% | Val Loss: 0.1872 Acc: 92.50% | LR: 0.000125
Epoch [ 42/100] 33.2s | Train Loss: 0.2055 Acc: 91.74% | Val Loss: 0.2490 Acc: 89.86% | LR: 0.000063
Epoch [ 43/100] 33.3s | Train Loss: 0.1983 Acc: 91.99% | Val Loss: 0.1942 Acc: 92.26% | LR: 0.000063
✨ Best model saved! (Val Acc: 0.9292) Epoch [ 44/100] 33.4s | Train Loss: 0.1922 Acc: 92.40% | Val Loss: 0.1779 Acc: 92.92% | LR: 0.000063
✨ Best model saved! (Val Acc: 0.9296) Epoch [ 45/100] 33.5s | Train Loss: 0.1912 Acc: 92.39% | Val Loss: 0.1820 Acc: 92.96% | LR: 0.000063
Epoch [ 46/100] 33.5s | Train Loss: 0.1946 Acc: 92.27% | Val Loss: 0.1937 Acc: 92.20% | LR: 0.000063
Epoch [ 47/100] 33.4s | Train Loss: 0.1909 Acc: 92.36% | Val Loss: 0.1836 Acc: 92.48% | LR: 0.000063
✨ Best model saved! (Val Acc: 0.9308) Epoch [ 48/100] 33.3s | Train Loss: 0.1890 Acc: 92.51% | Val Loss: 0.1751 Acc: 93.08% | LR: 0.000063
Epoch [ 49/100] 34.2s | Train Loss: 0.1884 Acc: 92.47% | Val Loss: 0.1764 Acc: 93.04% | LR: 0.000063
✨ Best model saved! (Val Acc: 0.9326) Epoch [ 50/100] 33.3s | Train Loss: 0.1899 Acc: 92.47% | Val Loss: 0.1700 Acc: 93.26% | LR: 0.000063
Epoch [ 51/100] 33.3s | Train Loss: 0.1843 Acc: 92.59% | Val Loss: 0.1721 Acc: 93.24% | LR: 0.000063
Epoch [ 52/100] 34.3s | Train Loss: 0.1903 Acc: 92.59% | Val Loss: 0.1705 Acc: 93.12% | LR: 0.000063
Epoch [ 53/100] 34.3s | Train Loss: 0.1874 Acc: 92.46% | Val Loss: 0.1806 Acc: 93.20% | LR: 0.000063
Epoch [ 54/100] 33.5s | Train Loss: 0.1869 Acc: 92.47% | Val Loss: 0.2037 Acc: 91.44% | LR: 0.000031
✨ Best model saved! (Val Acc: 0.9330) Epoch [ 55/100] 33.8s | Train Loss: 0.1828 Acc: 92.68% | Val Loss: 0.1716 Acc: 93.30% | LR: 0.000031
Epoch [ 56/100] 33.3s | Train Loss: 0.1844 Acc: 92.73% | Val Loss: 0.1725 Acc: 93.06% | LR: 0.000031
✨ Best model saved! (Val Acc: 0.9338) Epoch [ 57/100] 33.2s | Train Loss: 0.1836 Acc: 92.47% | Val Loss: 0.1724 Acc: 93.38% | LR: 0.000031
Epoch [ 58/100] 33.6s | Train Loss: 0.1809 Acc: 92.75% | Val Loss: 0.1781 Acc: 93.00% | LR: 0.000016
Epoch [ 59/100] 33.6s | Train Loss: 0.1783 Acc: 93.00% | Val Loss: 0.1700 Acc: 93.30% | LR: 0.000016
✨ Best model saved! (Val Acc: 0.9346) Epoch [ 60/100] 33.8s | Train Loss: 0.1791 Acc: 92.87% | Val Loss: 0.1697 Acc: 93.46% | LR: 0.000016
Epoch [ 61/100] 33.3s | Train Loss: 0.1757 Acc: 93.03% | Val Loss: 0.1697 Acc: 93.34% | LR: 0.000016
Epoch [ 62/100] 33.4s | Train Loss: 0.1748 Acc: 93.05% | Val Loss: 0.1732 Acc: 93.14% | LR: 0.000016
Epoch [ 63/100] 33.6s | Train Loss: 0.1782 Acc: 93.00% | Val Loss: 0.1687 Acc: 93.36% | LR: 0.000016
Epoch [ 64/100] 33.9s | Train Loss: 0.1766 Acc: 93.04% | Val Loss: 0.1713 Acc: 93.44% | LR: 0.000016
Epoch [ 65/100] 33.7s | Train Loss: 0.1777 Acc: 92.96% | Val Loss: 0.1708 Acc: 93.30% | LR: 0.000016
Epoch [ 66/100] 33.9s | Train Loss: 0.1739 Acc: 93.16% | Val Loss: 0.1692 Acc: 93.38% | LR: 0.000016
Epoch [ 67/100] 33.8s | Train Loss: 0.1794 Acc: 92.83% | Val Loss: 0.1688 Acc: 93.30% | LR: 0.000008
Epoch [ 68/100] 33.3s | Train Loss: 0.1729 Acc: 93.05% | Val Loss: 0.1692 Acc: 93.40% | LR: 0.000008
Epoch [ 69/100] 36.3s | Train Loss: 0.1784 Acc: 92.95% | Val Loss: 0.1681 Acc: 93.40% | LR: 0.000008
Epoch [ 70/100] 33.3s | Train Loss: 0.1783 Acc: 92.99% | Val Loss: 0.1699 Acc: 93.30% | LR: 0.000008
✨ Best model saved! (Val Acc: 0.9350) Epoch [ 71/100] 33.5s | Train Loss: 0.1739 Acc: 93.18% | Val Loss: 0.1678 Acc: 93.50% | LR: 0.000008
Epoch [ 72/100] 33.9s | Train Loss: 0.1786 Acc: 92.85% | Val Loss: 0.1701 Acc: 93.32% | LR: 0.000008
Epoch [ 73/100] 34.1s | Train Loss: 0.1706 Acc: 93.31% | Val Loss: 0.1684 Acc: 93.42% | LR: 0.000008
Epoch [ 74/100] 33.4s | Train Loss: 0.1789 Acc: 92.83% | Val Loss: 0.1675 Acc: 93.46% | LR: 0.000008
Epoch [ 75/100] 34.2s | Train Loss: 0.1781 Acc: 92.98% | Val Loss: 0.1680 Acc: 93.44% | LR: 0.000008
Epoch [ 76/100] 33.6s | Train Loss: 0.1768 Acc: 92.88% | Val Loss: 0.1684 Acc: 93.48% | LR: 0.000008
✨ Best model saved! (Val Acc: 0.9352) Epoch [ 77/100] 33.5s | Train Loss: 0.1755 Acc: 93.09% | Val Loss: 0.1671 Acc: 93.52% | LR: 0.000008
Epoch [ 78/100] 33.8s | Train Loss: 0.1765 Acc: 93.18% | Val Loss: 0.1674 Acc: 93.48% | LR: 0.000008
Epoch [ 79/100] 33.8s | Train Loss: 0.1749 Acc: 93.20% | Val Loss: 0.1679 Acc: 93.32% | LR: 0.000008
Epoch [ 80/100] 33.6s | Train Loss: 0.1736 Acc: 93.13% | Val Loss: 0.1677 Acc: 93.42% | LR: 0.000008
Epoch [ 81/100] 33.2s | Train Loss: 0.1739 Acc: 93.02% | Val Loss: 0.1698 Acc: 93.22% | LR: 0.000004
Epoch [ 82/100] 33.6s | Train Loss: 0.1775 Acc: 93.07% | Val Loss: 0.1671 Acc: 93.52% | LR: 0.000004
Epoch [ 83/100] 33.3s | Train Loss: 0.1733 Acc: 93.06% | Val Loss: 0.1679 Acc: 93.44% | LR: 0.000004
Epoch [ 84/100] 33.5s | Train Loss: 0.1741 Acc: 93.12% | Val Loss: 0.1677 Acc: 93.44% | LR: 0.000004
Epoch [ 85/100] 35.6s | Train Loss: 0.1733 Acc: 93.24% | Val Loss: 0.1678 Acc: 93.42% | LR: 0.000002
Epoch [ 86/100] 33.8s | Train Loss: 0.1745 Acc: 93.20% | Val Loss: 0.1671 Acc: 93.50% | LR: 0.000002
✨ Best model saved! (Val Acc: 0.9354) Epoch [ 87/100] 33.8s | Train Loss: 0.1752 Acc: 93.10% | Val Loss: 0.1664 Acc: 93.54% | LR: 0.000002
Epoch [ 88/100] 33.6s | Train Loss: 0.1712 Acc: 93.28% | Val Loss: 0.1669 Acc: 93.38% | LR: 0.000002
Epoch [ 89/100] 34.1s | Train Loss: 0.1724 Acc: 93.15% | Val Loss: 0.1667 Acc: 93.50% | LR: 0.000002
Epoch [ 90/100] 33.6s | Train Loss: 0.1742 Acc: 92.98% | Val Loss: 0.1663 Acc: 93.44% | LR: 0.000002
✨ Best model saved! (Val Acc: 0.9356) Epoch [ 91/100] 33.9s | Train Loss: 0.1707 Acc: 93.21% | Val Loss: 0.1670 Acc: 93.56% | LR: 0.000002
Epoch [ 92/100] 33.2s | Train Loss: 0.1739 Acc: 93.00% | Val Loss: 0.1670 Acc: 93.54% | LR: 0.000002
Epoch [ 93/100] 33.6s | Train Loss: 0.1773 Acc: 92.94% | Val Loss: 0.1673 Acc: 93.26% | LR: 0.000002
Epoch [ 94/100] 33.2s | Train Loss: 0.1705 Acc: 93.12% | Val Loss: 0.1672 Acc: 93.54% | LR: 0.000001
Epoch [ 95/100] 34.1s | Train Loss: 0.1734 Acc: 93.02% | Val Loss: 0.1679 Acc: 93.36% | LR: 0.000001
Epoch [ 96/100] 33.3s | Train Loss: 0.1697 Acc: 93.35% | Val Loss: 0.1665 Acc: 93.46% | LR: 0.000001
Epoch [ 97/100] 34.3s | Train Loss: 0.1752 Acc: 93.14% | Val Loss: 0.1669 Acc: 93.48% | LR: 0.000001
Epoch [ 98/100] 35.1s | Train Loss: 0.1688 Acc: 93.25% | Val Loss: 0.1667 Acc: 93.54% | LR: 0.000000
Epoch [ 99/100] 32.8s | Train Loss: 0.1731 Acc: 93.14% | Val Loss: 0.1666 Acc: 93.48% | LR: 0.000000
Epoch [100/100] 34.2s | Train Loss: 0.1712 Acc: 93.20% | Val Loss: 0.1665 Acc: 93.54% | LR: 0.000000 ✅ 학습 완료! (총 소요 시간: 56.0분) 🏆 Best Validation Accuracy: 0.9356
📊 9. 학습 결과 시각화¶
In [24]:
# ===== Loss 곡선 =====
plt.figure(figsize=(12, 5))
plt.plot(history['train_loss'], label='Train Loss',
color='#1f77b4', marker='o', markersize=4, linewidth=2.5, alpha=0.8)
plt.plot(history['val_loss'], label='Val Loss',
color='#ff7f0e', marker='s', markersize=4, linewidth=2.5, alpha=0.8)
plt.xlabel('Epoch', fontsize=13)
plt.ylabel('Loss', fontsize=13)
plt.title('📉 Loss Curve (학습/검증 손실)', fontsize=15, fontweight='bold', pad=15)
plt.legend(fontsize=12, loc='upper right', framealpha=0.9)
plt.grid(True, alpha=0.3, linestyle='--', linewidth=0.7)
plt.xlim(0, len(history['train_loss'])-1)
plt.tight_layout()
plt.show()
# ===== Accuracy 곡선 =====
plt.figure(figsize=(12, 5))
plt.plot(history['train_acc'], label='Train Acc',
color='#1f77b4', marker='o', markersize=4, linewidth=2.5, alpha=0.8)
plt.plot(history['val_acc'], label='Val Acc',
color='#ff7f0e', marker='s', markersize=4, linewidth=2.5, alpha=0.8)
plt.xlabel('Epoch', fontsize=13)
plt.ylabel('Accuracy', fontsize=13)
plt.title('📈 Accuracy Curve (학습/검증 정확도)', fontsize=15, fontweight='bold', pad=15)
plt.legend(fontsize=12, loc='lower right', framealpha=0.9)
plt.grid(True, alpha=0.3, linestyle='--', linewidth=0.7)
plt.xlim(0, len(history['train_acc'])-1)
plt.ylim(0, 1.05)
plt.tight_layout()
plt.show()
# 최종 성능 요약
print('\n' + '='*70)
print('📊 최종 성능 요약')
print('='*70)
print(f'Final Train Loss: {history["train_loss"][-1]:.4f}')
print(f'Final Train Acc: {history["train_acc"][-1]:.2%}')
print(f'Final Val Loss: {history["val_loss"][-1]:.4f}')
print(f'Final Val Acc: {history["val_acc"][-1]:.2%}')
print(f'Best Val Acc: {best_val_acc:.2%}')
print('='*70)
====================================================================== 📊 최종 성능 요약 ====================================================================== Final Train Loss: 0.1712 Final Train Acc: 93.20% Final Val Loss: 0.1665 Final Val Acc: 93.54% Best Val Acc: 93.56% ======================================================================
In [22]:
# Loss 그래프
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# Loss 곡선
axes[0].plot(history['train_loss'], label='Train Loss', marker='o', linewidth=2)
axes[0].plot(history['val_loss'], label='Val Loss', marker='s', linewidth=2)
axes[0].set_xlabel('Epoch', fontsize=12)
axes[0].set_ylabel('Loss', fontsize=12)
axes[0].set_title('📉 Loss Curve', fontsize=14, fontweight='bold')
axes[0].legend(fontsize=11)
axes[0].grid(True, alpha=0.3)
# Accuracy 곡선
axes[1].plot(history['train_acc'], label='Train Acc', marker='o', linewidth=2)
axes[1].plot(history['val_acc'], label='Val Acc', marker='s', linewidth=2)
axes[1].set_xlabel('Epoch', fontsize=12)
axes[1].set_ylabel('Accuracy', fontsize=12)
axes[1].set_title('📈 Accuracy Curve', fontsize=14, fontweight='bold')
axes[1].legend(fontsize=11)
axes[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# 최종 성능 요약
print('\n' + '='*60)
print('📊 최종 성능 요약')
print('='*60)
print(f'Final Train Loss: {history["train_loss"][-1]:.4f}')
print(f'Final Train Acc: {history["train_acc"][-1]:.4f}')
print(f'Final Val Loss: {history["val_loss"][-1]:.4f}')
print(f'Final Val Acc: {history["val_acc"][-1]:.4f}')
print(f'Best Val Acc: {best_val_acc:.4f}')
print('='*60)
============================================================ 📊 최종 성능 요약 ============================================================ Final Train Loss: 0.1712 Final Train Acc: 0.9320 Final Val Loss: 0.1665 Final Val Acc: 0.9354 Best Val Acc: 0.9356 ============================================================
In [26]:
# 학습률 변화 시각화
plt.figure(figsize=(12, 5))
plt.plot(history['lr'], marker='o', markersize=4, linewidth=2.5, color='red', alpha=0.8)
plt.xlabel('Epoch', fontsize=13)
plt.ylabel('Learning Rate', fontsize=13)
plt.title('⚙️ Learning Rate Schedule', fontsize=15, fontweight='bold', pad=15)
plt.grid(True, alpha=0.3, linestyle='--', linewidth=0.7)
plt.yscale('log')
plt.xlim(0, len(history['lr'])-1)
plt.tight_layout()
plt.show()
🎯 10. 테스트 세트 평가¶
In [27]:
# Best model 로드
model.load_state_dict(torch.load(best_model_path))
print(f'✅ Best model 로드 완료! ({best_model_path})')
# 테스트 세트 평가
test_loss, test_acc = validate(model, test_loader, criterion, device)
print('\n' + '='*60)
print('🎯 Test Set 최종 성능')
print('='*60)
print(f'Test Loss: {test_loss:.4f}')
print(f'Test Acc: {test_acc:.4f} ({test_acc*100:.2f}%)')
print('='*60)
✅ Best model 로드 완료! (best_cnn_model.pth)
Validation: 0%| | 0/20 [00:00<?, ?it/s]
============================================================ 🎯 Test Set 최종 성능 ============================================================ Test Loss: 0.1718 Test Acc: 0.9316 (93.16%) ============================================================
In [28]:
# Confusion Matrix 계산
from sklearn.metrics import confusion_matrix, classification_report
# 전체 예측 수집
all_preds = []
all_labels = []
model.eval()
with torch.no_grad():
for inputs, labels in tqdm(test_loader, desc='Collecting predictions'):
inputs = inputs.to(device)
outputs = model(inputs)
_, preds = torch.max(outputs, 1)
all_preds.extend(preds.cpu().numpy())
all_labels.extend(labels.numpy())
# Confusion Matrix
cm = confusion_matrix(all_labels, all_preds)
# 시각화
plt.figure(figsize=(8, 6))
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues',
xticklabels=['Cats', 'Dogs'],
yticklabels=['Cats', 'Dogs'],
cbar_kws={'label': 'Count'})
plt.xlabel('Predicted', fontsize=12)
plt.ylabel('Actual', fontsize=12)
plt.title('🔍 Confusion Matrix', fontsize=14, fontweight='bold')
plt.tight_layout()
plt.show()
# Classification Report
print('\n📋 Classification Report:')
print(classification_report(all_labels, all_preds,
target_names=['Cats', 'Dogs'],
digits=4))
Collecting predictions: 100%|██████████| 20/20 [00:02<00:00, 8.27it/s]
📋 Classification Report:
precision recall f1-score support
Cats 0.9319 0.9312 0.9316 1250
Dogs 0.9313 0.9320 0.9316 1250
accuracy 0.9316 2500
macro avg 0.9316 0.9316 0.9316 2500
weighted avg 0.9316 0.9316 0.9316 2500
🖼️ 11. 예측 결과 시각화¶
In [29]:
def show_predictions(model, dataset, device, n_samples=12):
"""
랜덤 샘플에 대한 예측 결과를 시각화합니다.
"""
model.eval()
# 랜덤 인덱스 선택
indices = np.random.choice(len(dataset), n_samples, replace=False)
fig, axes = plt.subplots(3, 4, figsize=(16, 12))
axes = axes.ravel()
class_names = ['🐱 Cat', '🐶 Dog']
for i, idx in enumerate(indices):
# 이미지와 레이블 가져오기
img_tensor, true_label = dataset[idx]
# 예측
with torch.no_grad():
img_tensor = img_tensor.unsqueeze(0).to(device)
output = model(img_tensor)
probabilities = torch.softmax(output, dim=1)
pred_label = torch.argmax(probabilities, dim=1).item()
confidence = probabilities[0, pred_label].item()
# 이미지 역정규화 (시각화용)
img_numpy = img_tensor.cpu().squeeze(0).numpy().transpose(1, 2, 0)
img_numpy = img_numpy * np.array(IMAGENET_STD) + np.array(IMAGENET_MEAN)
img_numpy = np.clip(img_numpy, 0, 1)
# 시각화
axes[i].imshow(img_numpy)
# 제목 (정답 여부에 따라 색상 변경)
is_correct = pred_label == true_label
title_color = 'green' if is_correct else 'red'
title = f'True: {class_names[true_label]}\n'
title += f'Pred: {class_names[pred_label]} ({confidence*100:.1f}%)'
axes[i].set_title(title, fontsize=10, color=title_color, fontweight='bold')
axes[i].axis('off')
plt.suptitle('🎯 예측 결과 (초록=정답, 빨강=오답)', fontsize=16, fontweight='bold')
plt.tight_layout()
plt.show()
# 테스트 세트에서 예측 결과 시각화
show_predictions(model, test_dataset, device, n_samples=12)
In [31]:
# 오답 노트: 틀린 예측들만 보기
def show_wrong_predictions(model, dataset, device, n_samples=20):
"""
틀린 예측들만 골라서 보여줍니다.
"""
model.eval()
wrong_indices = []
# 틀린 예측 찾기
print('🔍 틀린 예측 찾는 중...')
for idx in tqdm(range(len(dataset))):
img_tensor, true_label = dataset[idx]
with torch.no_grad():
img_tensor = img_tensor.unsqueeze(0).to(device)
output = model(img_tensor)
pred_label = torch.argmax(output, dim=1).item()
if pred_label != true_label:
wrong_indices.append(idx)
if len(wrong_indices) >= n_samples:
break
if len(wrong_indices) == 0:
print('🎉 완벽합니다! 틀린 예측이 없습니다!')
return
print(f'\n❌ 총 {len(wrong_indices)}개의 오답을 찾았습니다.\n')
# 시각화
n_cols = 5
n_rows = (len(wrong_indices) + n_cols - 1) // n_cols
fig, axes = plt.subplots(n_rows, n_cols, figsize=(20, 4*n_rows))
axes = axes.ravel()
class_names = ['🐱 Cat', '🐶 Dog']
for i, idx in enumerate(wrong_indices[:n_samples]):
img_tensor, true_label = dataset[idx]
with torch.no_grad():
img_tensor_batch = img_tensor.unsqueeze(0).to(device)
output = model(img_tensor_batch)
probabilities = torch.softmax(output, dim=1)
pred_label = torch.argmax(probabilities, dim=1).item()
confidence = probabilities[0, pred_label].item()
# 이미지 역정규화
img_numpy = img_tensor.numpy().transpose(1, 2, 0)
img_numpy = img_numpy * np.array(IMAGENET_STD) + np.array(IMAGENET_MEAN)
img_numpy = np.clip(img_numpy, 0, 1)
axes[i].imshow(img_numpy)
title = f'True: {class_names[true_label]}\n'
title += f'Pred: {class_names[pred_label]} ({confidence*100:.1f}%)'
axes[i].set_title(title, fontsize=10, color='red', fontweight='bold')
axes[i].axis('off')
# 빈 칸 제거
for i in range(len(wrong_indices), len(axes)):
axes[i].axis('off')
plt.suptitle('❌ 오답 노트: 틀린 예측들', fontsize=16, fontweight='bold', color='red')
plt.tight_layout()
plt.show()
show_wrong_predictions(model, test_dataset, device, n_samples=20)
🔍 틀린 예측 찾는 중...
12%|█▏ | 300/2500 [00:02<00:16, 136.14it/s]
❌ 총 20개의 오답을 찾았습니다.