전이학습(Transfer Learning) 완벽 가이드¶
1. 전이학습이란?¶
전이학습(Transfer Learning)은 한 작업(Source Task)에서 학습한 지식을 다른 작업(Target Task)에 활용하는 머신러닝 기법입니다.
1.1 일상생활의 비유¶
- 🎹 피아노를 배운 사람이 키보드를 더 빠르게 배움
- 🚗 자동차 운전을 배운 사람이 트럭 운전을 더 쉽게 배움
- 🇰🇷 한국어를 아는 사람이 일본어를 더 빠르게 배움 (한자 지식 활용)
딥러닝에서도 마찬가지로, 이미 학습된 모델의 지식을 새로운 문제에 재활용합니다!
2. 왜 전이학습이 필요한가?¶
2.1 전통적인 학습 vs 전이학습¶
| 구분 | 전통적인 학습 (From Scratch) | 전이학습 (Transfer Learning) |
|---|---|---|
| 데이터 요구량 | 대량의 데이터 필요 (수만~수백만 개) | 적은 데이터로 가능 (수백~수천 개) |
| 학습 시간 | 매우 오래 걸림 (수일~수주) | 빠름 (수시간~수일) |
| 계산 비용 | 높음 (고성능 GPU 장시간 사용) | 낮음 (상대적으로 적은 자원) |
| 성능 | 데이터가 충분하면 좋음 | 일반적으로 더 좋은 성능 |
| 초기 가중치 | 랜덤 초기화 | 사전 학습된 가중치 |
2.2 전이학습의 핵심 가정¶
전이학습이 효과적으로 작동하려면 다음 가정이 성립해야 합니다:
Source Domain과 Target Domain 간에 유사성이 존재해야 함
예시:
- ✅ ImageNet으로 학습 → 강아지 품종 분류 (유사성 높음)
- ✅ 영어 감성분석 → 한국어 감성분석 (유사성 중간)
- ❌ 이미지 분류 → 주식 가격 예측 (유사성 낮음)
3. 전이학습의 수학적 정의¶
3.1 기본 표기법¶
Domain (도메인): $\mathcal{D} = \{\mathcal{X}, P(X)\}$
- $\mathcal{X}$: 특징 공간 (Feature Space)
- $P(X)$: 주변 확률 분포 (Marginal Probability Distribution)
Task (작업): $\mathcal{T} = \{\mathcal{Y}, f(\cdot)\}$
- $\mathcal{Y}$: 레이블 공간 (Label Space)
- $f(\cdot)$: 목적 함수 (Objective Function)
3.2 전이학습의 공식적 정의¶
주어진 Source Domain $\mathcal{D}_S$와 Source Task $\mathcal{T}_S$, 그리고 Target Domain $\mathcal{D}_T$와 Target Task $\mathcal{T}_T$에 대해:
$$ \mathcal{D}_S \neq \mathcal{D}_T \text{ 또는 } \mathcal{T}_S \neq \mathcal{T}_T $$
전이학습의 목표는 $\mathcal{D}_S$와 $\mathcal{T}_S$의 지식을 활용하여 Target Domain에서의 목적 함수 $f_T(\cdot)$의 성능을 향상시키는 것입니다.
3.3 손실 함수¶
Source Task 손실 함수:
$$ \mathcal{L}_S(\theta) = \frac{1}{n_S} \sum_{i=1}^{n_S} \ell(f_\theta(x_i^S), y_i^S) $$
Target Task 손실 함수:
$$ \mathcal{L}_T(\theta) = \frac{1}{n_T} \sum_{i=1}^{n_T} \ell(f_\theta(x_i^T), y_i^T) $$
전이학습의 목표:
$$ \theta^* = \arg\min_\theta \mathcal{L}_T(\theta) $$
단, $\theta$의 초기값은 Source Task에서 학습된 $\theta_S$로 시작
4. 전이학습의 주요 전략¶
4.1 전략 비교표¶
| 전략 | 설명 | 사전학습 층 | 새 층 | 적합한 상황 |
|---|---|---|---|---|
| Feature Extraction | 사전학습 모델 동결, 마지막 층만 학습 | 동결 (frozen) | 학습 | 데이터 매우 적음, 유사도 높음 |
| Fine-tuning (전체) | 모든 층을 재학습 | 학습 | 학습 | 데이터 충분, 유사도 중간 |
| Fine-tuning (부분) | 일부 상위 층만 재학습 | 하위층 동결 | 상위층+새층 학습 | 데이터 중간, 유사도 중간 |
| Linear Probing | 사전학습 모델 완전 동결, 선형층만 추가 | 동결 | 선형층만 학습 | 평가 목적, 빠른 프로토타입 |
4.2 전략 1: Feature Extraction (특징 추출)¶
[입력] → [사전학습층 🔒] → [새로운 분류층 🔓] → [출력]
(고정됨) (학습함)
수식:
$$ \begin{align} z &= \phi_{\theta_{\text{frozen}}}(x) \quad \text{(특징 추출, 고정된 가중치)} \\ \hat{y} &= g_{\theta_{\text{new}}}(z) \quad \text{(새 분류층, 학습 가능)} \end{align} $$
4.3 전략 2: Fine-tuning (미세 조정)¶
[입력] → [사전학습층 🔓] → [새로운 분류층 🔓] → [출력]
(작은 학습률) (큰 학습률)
수식:
전체 모델의 파라미터 $\theta = [\theta_{\text{pretrained}}, \theta_{\text{new}}]$를 다른 학습률로 업데이트:
$$ \begin{align} \theta_{\text{pretrained}}^{(t+1)} &= \theta_{\text{pretrained}}^{(t)} - \eta_1 \nabla_{\theta_{\text{pretrained}}} \mathcal{L}_T \\ \theta_{\text{new}}^{(t+1)} &= \theta_{\text{new}}^{(t)} - \eta_2 \nabla_{\theta_{\text{new}}} \mathcal{L}_T \end{align} $$
여기서 $\eta_1 \ll \eta_2$ (사전학습 층은 작은 학습률 사용)
5. 전이학습 프로세스¶
5.1 단계별 흐름도¶
Step 1: 사전 학습 (Pre-training)
↓
[대규모 데이터셋] → [신경망 모델] → [사전학습된 가중치]
(예: ImageNet) (예: ResNet) (θ_pretrained)
Step 2: 전이 (Transfer)
↓
[사전학습된 가중치] → [모델 구조 수정] → [초기화된 모델]
(분류층 교체)
Step 3: 미세 조정 (Fine-tuning)
↓
[타겟 데이터셋] → [모델 학습] → [최종 모델]
(적은 데이터) (낮은 학습률)
5.2 학습률 스케줄링¶
Fine-tuning시 차별적 학습률(Discriminative Learning Rate) 사용:
| 층 | 학습률 | 이유 |
|---|---|---|
| 하위 층 (Layer 1-3) | $10^{-5}$ ~ $10^{-4}$ | 일반적 특징, 거의 변경 불필요 |
| 중간 층 (Layer 4-6) | $10^{-4}$ ~ $10^{-3}$ | 도메인 특화 특징 |
| 상위 층 (Layer 7-9) | $10^{-3}$ ~ $10^{-2}$ | 작업 특화 특징 |
| 새 분류층 | $10^{-2}$ ~ $10^{-1}$ | 랜덤 초기화, 많은 학습 필요 |
6. 전이학습의 효과 분석¶
6.1 데이터 효율성¶
전이학습을 사용하면 필요한 데이터량이 급격히 감소합니다:
| 정확도 목표 | From Scratch | Transfer Learning | 데이터 절감률 |
|---|---|---|---|
| 70% | 10,000장 | 500장 | 95% |
| 80% | 50,000장 | 2,000장 | 96% |
| 90% | 200,000장 | 10,000장 | 95% |
6.2 수렴 속도¶
학습 곡선 비교:
- From Scratch: 손실이 천천히 감소, 100 epoch 이상 필요
- Transfer Learning: 빠른 수렴, 10-30 epoch로 충분
수학적으로 표현하면:
$$ \text{Convergence Rate (Transfer)} \approx 5-10 \times \text{Convergence Rate (Scratch)} $$
7. 실전 적용 가이드¶
7.1 의사결정 트리¶
시작
│
├─ 타겟 데이터가 매우 적은가? (< 1,000개)
│ YES → Feature Extraction 사용
│ NO → 다음 질문으로
│
├─ Source와 Target 도메인이 매우 유사한가?
│ YES → Fine-tuning (전체 모델)
│ NO → Fine-tuning (상위 층만)
│
└─ 계산 자원이 제한적인가?
YES → Feature Extraction 또는 부분 Fine-tuning
NO → Fine-tuning (전체 모델)
7.2 도메인 유사도에 따른 전략¶
| Source Domain | Target Domain | 권장 전략 | 이유 |
|---|---|---|---|
| ImageNet (일반 이미지) | 강아지 품종 분류 | Fine-tuning 전체 | 높은 유사도 |
| ImageNet | 의료 X-ray | Fine-tuning 상위층 | 중간 유사도 |
| ImageNet | 위성 이미지 | Feature Extraction + 새 층 | 낮은 유사도 |
| BERT (영어) | 한국어 텍스트 | Fine-tuning + Adapter | 다른 언어 |
8. 전이학습의 이론적 배경¶
8.1 왜 전이학습이 작동하는가?¶
딥러닝 모델의 층별 학습 특성:
$$ f(x) = f_n \circ f_{n-1} \circ \cdots \circ f_2 \circ f_1(x) $$
- 하위 층 ($f_1, f_2, f_3$): 일반적 특징 (edges, corners, textures)
- 중간 층 ($f_4, f_5, f_6$): 중간 수준 특징 (parts, patterns)
- 상위 층 ($f_7, f_8, f_9$): 작업 특화 특징 (faces, objects)
하위 층의 특징은 도메인에 무관하게 유용하므로 재사용 가능!
8.2 표현 유사도 (Representation Similarity)¶
두 도메인 간 표현의 유사도를 측정:
$$ \text{Similarity}(\mathcal{D}_S, \mathcal{D}_T) = \frac{1}{n} \sum_{i=1}^{n} \cos(\phi(x_i^S), \phi(x_i^T)) $$
여기서 $\phi(\cdot)$는 중간 층의 특징 표현
유사도가 높을수록 전이학습 효과 증가!
9. 주의사항 및 한계¶
9.1 Negative Transfer (부정적 전이)¶
Source와 Target이 너무 다르면 오히려 성능이 감소할 수 있습니다:
$$ \text{Performance}_{\text{transfer}} < \text{Performance}_{\text{scratch}} $$
발생 조건:
- Source와 Target의 분포가 크게 다름: $P_S(X) \ll P_T(X)$
- 작업이 완전히 다름: $\mathcal{Y}_S \cap \mathcal{Y}_T = \emptyset$
9.2 최적 전략 선택표¶
| 타겟 데이터 크기 | 유사도 높음 | 유사도 중간 | 유사도 낮음 |
|---|---|---|---|
| 매우 적음 (< 1K) | Feature Extraction | Feature Extraction | From Scratch 고려 |
| 적음 (1K-10K) | Fine-tuning (부분) | Feature Extraction | Feature Extraction |
| 중간 (10K-100K) | Fine-tuning (전체) | Fine-tuning (부분) | Fine-tuning (상위층) |
| 많음 (> 100K) | Fine-tuning (전체) | Fine-tuning (전체) | Fine-tuning (전체) |
10. 요약 정리¶
10.1 핵심 포인트¶
✅ 전이학습은 적은 데이터로 높은 성능을 달성하는 강력한 기법
✅ 하위 층은 일반적, 상위 층은 특화적 특징을 학습
✅ Source와 Target의 유사도가 성공의 핵심
✅ Fine-tuning시 차별적 학습률 사용 필수
10.2 수식 정리¶
| 개념 | 수식 |
|---|---|
| 전이학습 목표 | $\min_\theta \mathcal{L}_T(\theta) \text{ with } \theta_0 = \theta_S$ |
| Feature Extraction | $\hat{y} = g_{\theta_{\text{new}}}(\phi_{\theta_{\text{frozen}}}(x))$ |
| Fine-tuning | $\theta^{(t+1)} = \theta^{(t)} - \eta \nabla_\theta \mathcal{L}_T$ |
| 차별적 학습률 | $\eta_{\text{lower}} \ll \eta_{\text{upper}} \ll \eta_{\text{new}}$ |
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 === --------------------------------------------------------------------------------
🐶🐱 데이터셋 준비¶
Microsoft Cats and Dogs Dataset¶
출처: Microsoft Research (Kaggle Dogs vs. Cats)
| 항목 | 내용 |
|---|---|
| 총 이미지 수 | 약 25,000장 |
| 클래스 | 개 / 고양이 (2개) |
| 각 클래스 | 약 12,500장씩 |
| 파일 크기 | 약 850MB |
데이터 분할¶
전체 데이터 (25,000장)
│
├─ Train (70%): 17,500장
├─ Validation (20%): 5,000장
└─ Test (10%): 2,500장
분할 비율: 7:2:1
다운로드¶
# Microsoft 공식 다운로드 링크
url = "https://download.microsoft.com/.../kagglecatsanddogs_5340.zip"
목표¶
이 데이터셋으로 기본 CNN vs 전이학습 성능을 비교합니다.
| 모델 | 예상 정확도 |
|---|---|
| 기본 CNN | ~93% |
| 전이학습 (ResNet) | ~97% |
자, 데이터를 다운로드해봅시다! 🚀
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("✅ 설정 완료")
✅ 설정 완료
# 파일이 없을 때만 다운로드 실행
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("📂 이미 데이터셋 압축 파일이 존재합니다. 다운로드를 건너뜁니다.")
📂 이미 데이터셋 압축 파일이 존재합니다. 다운로드를 건너뜁니다.
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장
def show_sample_images(data_dir, n_samples=8):
"""
각 클래스에서 n_samples//2 개씩 샘플 이미지를 보여줍니다.
"""
from PIL import Image
import pathlib
data_dir = pathlib.Path(data_dir)
fig, axes = plt.subplots(2, n_samples//2, figsize=(15, 6))
# 고양이 샘플
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'고양이 #{i+1}', fontsize=10)
axes[0, i].axis('off')
# 강아지 샘플
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'강아지 #{i+1}', fontsize=10)
axes[1, i].axis('off')
plt.suptitle('🐱 고양이 vs 강아지 🐶 샘플 이미지', 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)
from PIL import Image
import pathlib
# train_dir을 Path 객체로 변환
train_dir_path = pathlib.Path(train_path)
print('📏 이미지 크기 분석 중... (샘플 5,000장)')
# 모든 jpg 파일 찾기
sample_files = list(train_dir_path.glob('*/*.jpg'))[:5000]
widths = []
heights = []
for img_path in tqdm(sample_files):
img = Image.open(img_path)
widths.append(img.width)
heights.append(img.height)
# 시각화
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)')
axes[0].set_ylabel('Frequency')
axes[0].set_title('이미지 너비 분포', fontsize=12)
axes[0].axvline(np.mean(widths), color='red', linestyle='--', label=f'평균: {np.mean(widths):.0f}px')
axes[0].legend()
# 높이 히스토그램
axes[1].hist(heights, bins=20, alpha=0.7, color='lightcoral', edgecolor='black')
axes[1].set_xlabel('Height (pixels)')
axes[1].set_ylabel('Frequency')
axes[1].set_title('이미지 높이 분포', fontsize=12)
axes[1].axvline(np.mean(heights), color='red', linestyle='--', label=f'평균: {np.mean(heights):.0f}px')
axes[1].legend()
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로 리사이즈하여 학습합니다.')
📏 이미지 크기 분석 중... (샘플 5,000장)
100%|██████████| 5000/5000 [00:05<00:00, 837.23it/s]
평균 크기: 410 × 357 pixels 최소 크기: 60 × 40 pixels 최대 크기: 500 × 500 pixels 💡 모든 이미지를 224×224로 리사이즈하여 학습합니다.
📐 이미지 리사이즈 방법 비교¶
1. 리사이즈 방식의 차이¶
Resize (단순 변형)¶
transforms.Resize((224, 224))
- 원본 비율 무시하고 강제로 224×224로 변환
- ⚠️ 이미지가 찌그러질 수 있음 (왜곡 발생)
- ✅ 모든 영역 보존 (정보 손실 없음)
예시:
원본: 500×375 → 224×224
→ 가로: 500→224 (축소)
→ 세로: 375→224 (확대)
→ 결과: 가로로 찌그러짐
Resize + CenterCrop (중앙 자르기)¶
transforms.Resize(256)
transforms.CenterCrop(224)
- 짧은 쪽을 256으로 확대/축소 (비율 유지)
- 중앙에서 224×224 크기만큼 자름
- ✅ 비율 유지 (왜곡 없음)
- ⚠️ 가장자리 정보 손실
예시:
원본: 500×375
→ Resize(256): 341×256 (비율 유지)
→ CenterCrop(224): 224×224 (양쪽 58px씩 자름)
→ 결과: 왜곡 없음, 가장자리만 손실
RandomResizedCrop (랜덤 크롭, 추천!)¶
transforms.RandomResizedCrop(224, scale=(0.8, 1.0))
- 매번 다른 위치와 크기로 자름 (증강 효과!)
- ✅ 비율 유지 (왜곡 없음)
- ✅ 강력한 데이터 증강
- ⚠️ 일부 영역 손실 (문제 안 됨)
예시:
Epoch 1: 왼쪽 상단 80% 영역 → 224×224
Epoch 2: 오른쪽 하단 95% 영역 → 224×224
Epoch 3: 중앙 90% 영역 → 224×224
→ 매번 다른 영역 학습 (일반화 능력 향상!)
2. 비교표¶
| 방법 | 왜곡 | 정보손실 | 증강효과 | 추천용도 |
|---|---|---|---|---|
| Resize(224,224) | ⚠️ 있음 | ✅ 없음 | ❌ 없음 | 비추천 |
| Resize + CenterCrop | ✅ 없음 | ⚠️ 약간 | ❌ 없음 | 검증/테스트 |
| RandomResizedCrop | ✅ 없음 | ⚠️ 약간 | ✅ 강함 | 훈련 (추천!) |
3. 데이터 증강(Augmentation)이란?¶
왜 필요한가?¶
- 제한된 데이터로 모델이 과적합되는 것을 방지
- 같은 이미지를 다양한 형태로 학습 → 일반화 능력 향상
주요 증강 기법¶
| 기법 | 효과 | 예시 |
|---|---|---|
| RandomHorizontalFlip | 좌우 방향에 덜 민감하게 | 왼쪽 보는 강아지 ↔ 오른쪽 보는 강아지 |
| RandomRotation | 각도 변화에 대응 | 정면 ↔ 약간 기울어짐 |
| ColorJitter | 조명 환경 변화 대응 | 밝은 사진 ↔ 어두운 사진 |
| RandomResizedCrop | 위치/크기 변화 대응 | 전체 고양이 ↔ 얼굴만 |
증강 전후 비교¶
원본 1장 → 증강 적용 → 매 epoch마다 다른 100가지 변형
→ 1,000장 데이터가 100,000장처럼 작동!
4. 왜 훈련과 검증이 다른가?¶
| 훈련 데이터 | 검증/테스트 데이터 | |
|---|---|---|
| 목적 | 다양한 상황 학습 | 실제 성능 측정 |
| 증강 | ✅ 사용 | ❌ 사용 안 함 |
| 이유 | 일반화 능력 향상 | 공정한 평가 필요 |
검증/테스트는 "실력 테스트"이므로 원본 그대로 평가해야 공정해요!
요약¶
✅ 훈련: RandomResizedCrop (증강 강력!)
✅ 검증/테스트: Resize + CenterCrop (일관성)
✅ ImageNet 정규화 필수 (전이학습용)
# =====================================
# 데이터 전처리 파이프라인 정의 (개선 버전)
# =====================================
# ImageNet 데이터셋의 통계값 (사전학습 모델과 동일하게 정규화)
IMAGENET_MEAN = [0.485, 0.456, 0.406] # RGB 각 채널의 평균
IMAGENET_STD = [0.229, 0.224, 0.225] # RGB 각 채널의 표준편차
# 입력 이미지 크기
IMG_SIZE = 224
# ────────────────────────────────────
# 훈련 데이터 전처리 (강력한 Data Augmentation)
# ────────────────────────────────────
train_transform = transforms.Compose([
# 1. RandomResizedCrop: 비율 유지하면서 랜덤 크롭 (왜곡 없음!)
# - 매번 다른 위치/크기로 자름 → 강력한 증강 효과
# - scale=(0.8, 1.0): 원본의 80~100% 크기 영역을 자름
# - ratio=(3./4., 4./3.): 가로세로 비율 0.75~1.33 허용
transforms.RandomResizedCrop(
IMG_SIZE, # 최종 크기: 224×224
scale=(0.8, 1.0), # 원본의 80~100% 크기로 랜덤 자르기
ratio=(3./4., 4./3.) # 가로세로 비율 범위
),
# 2. 랜덤 좌우 반전: 50% 확률로 이미지를 좌우 반전
# 효과: 방향에 덜 민감하게 학습
transforms.RandomHorizontalFlip(p=0.5),
# 3. 랜덤 회전: -15도 ~ +15도 사이에서 랜덤하게 회전
# 효과: 다양한 각도에 대응
transforms.RandomRotation(degrees=15),
# 4. 색상 변화: 밝기, 대비, 채도, 색조를 랜덤하게 조정
# 효과: 다양한 조명 환경에서 작동
transforms.ColorJitter(
brightness=0.2, # 밝기 ±20%
contrast=0.2, # 대비 ±20%
saturation=0.2, # 채도 ±20%
hue=0.1 # 색조 ±10%
),
# 5. Tensor 변환: PIL Image (0~255) → Tensor (0~1)
transforms.ToTensor(),
# 6. 정규화: ImageNet 통계값으로 정규화 (전이학습 필수!)
# 공식: (pixel - mean) / std
transforms.Normalize(
mean=IMAGENET_MEAN,
std=IMAGENET_STD
)
])
# ────────────────────────────────────
# 검증/테스트 데이터 전처리 (Augmentation 없음!)
# ────────────────────────────────────
# 이유: 공정한 성능 평가를 위해 원본 그대로 사용
val_test_transform = transforms.Compose([
# 1. Resize: 짧은 쪽을 256으로 확대/축소 (비율 유지)
# 예: 500×375 → 341×256
transforms.Resize(256),
# 2. CenterCrop: 중앙에서 224×224 크기로 자르기
# 예: 341×256 → 224×224 (양쪽 균등하게 자름)
# 효과: 비율 유지 + 왜곡 없음
transforms.CenterCrop(IMG_SIZE),
# 3. Tensor 변환
transforms.ToTensor(),
# 4. 정규화 (훈련 데이터와 동일한 통계값 사용)
transforms.Normalize(
mean=IMAGENET_MEAN,
std=IMAGENET_STD
)
])
print('✅ 데이터 전처리 파이프라인 정의 완료!')
print(f' 📐 이미지 크기: {IMG_SIZE}×{IMG_SIZE}')
print(f' 📊 정규화: ImageNet 평균/표준편차 사용')
print(f' 🔄 훈련 증강: RandomResizedCrop + 좌우반전 + 회전 + 색상변화')
print(f' ✋ 검증/테스트: Resize + CenterCrop (증강 없음)')
print('\n💡 개선 사항:')
print(' - RandomResizedCrop 사용으로 왜곡 없이 강력한 증강!')
print(' - 검증/테스트는 CenterCrop으로 일관된 평가!')
✅ 데이터 전처리 파이프라인 정의 완료! 📐 이미지 크기: 224×224 📊 정규화: ImageNet 평균/표준편차 사용 🔄 훈련 증강: RandomResizedCrop + 좌우반전 + 회전 + 색상변화 ✋ 검증/테스트: Resize + CenterCrop (증강 없음) 💡 개선 사항: - RandomResizedCrop 사용으로 왜곡 없이 강력한 증강! - 검증/테스트는 CenterCrop으로 일관된 평가!
from torchvision import datasets
# 경로 설정
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),
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}
# 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
from torchvision import transforms
def show_augmentation_comparison(dataset, idx=0, n_augmentations=7):
"""
원본 이미지와 augmentation 적용 결과를 함께 보여줍니다.
"""
# 원본 이미지 경로 가져오기
img_path, label = dataset.samples[idx]
original_img = Image.open(img_path)
# 원본 크기 확인
orig_w, orig_h = original_img.size
fig, axes = plt.subplots(2, 4, figsize=(16, 8))
axes = axes.ravel()
# 첫 번째 칸: 원본 이미지
axes[0].imshow(original_img)
axes[0].set_title(f'원본\n({orig_w}×{orig_h})', fontsize=11, fontweight='bold')
axes[0].axis('off')
# 나머지: Augmentation 적용
for i in range(1, n_augmentations + 1):
# 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'증강 #{i}\n(224×224)', fontsize=11)
axes[i].axis('off')
class_name = '고양이' if label == 0 else '강아지'
plt.suptitle(f'🔄 Data Augmentation 효과 비교 ({class_name})',
fontsize=16, fontweight='bold')
plt.tight_layout()
plt.show()
print(f'📐 원본 크기: {orig_w}×{orig_h} pixels')
print(f'📐 증강 후 크기: 224×224 pixels')
print(f'🔄 적용된 증강: RandomResizedCrop, 좌우반전, 회전, 색상변화')
# 고양이 이미지로 확인
print('=== 고양이 이미지 증강 ===')
show_augmentation_comparison(train_dataset, idx=0, n_augmentations=7)
print('\n' + '='*60 + '\n')
# 강아지 이미지로 확인
print('=== 강아지 이미지 증강 ===')
dog_idx = next(i for i, (path, label) in enumerate(train_dataset.samples) if label == 1)
show_augmentation_comparison(train_dataset, idx=dog_idx, n_augmentations=7)
=== 고양이 이미지 증강 ===
📐 원본 크기: 300×281 pixels 📐 증강 후 크기: 224×224 pixels 🔄 적용된 증강: RandomResizedCrop, 좌우반전, 회전, 색상변화 ============================================================ === 강아지 이미지 증강 ===
📐 원본 크기: 327×500 pixels 📐 증강 후 크기: 224×224 pixels 🔄 적용된 증강: RandomResizedCrop, 좌우반전, 회전, 색상변화
# 리사이즈 방식 비교 시각화
def show_resize_methods_comparison(dataset, idx=0):
"""
다양한 리사이즈 방식을 비교하여 보여줍니다.
"""
# 원본 이미지 가져오기
img_path, label = dataset.samples[idx]
original_img = Image.open(img_path)
orig_w, orig_h = original_img.size
# 다양한 리사이즈 방법 정의
resize_methods = {
'원본': None,
'Resize(224,224)\n강제 변형': transforms.Compose([
transforms.Resize((224, 224)),
transforms.ToTensor()
]),
'Resize(256)\nCenterCrop(224)': transforms.Compose([
transforms.Resize(256),
transforms.CenterCrop(224),
transforms.ToTensor()
]),
'RandomResizedCrop\n(80% 영역)': transforms.Compose([
transforms.RandomResizedCrop(224, scale=(0.8, 0.8)),
transforms.ToTensor()
]),
'RandomResizedCrop\n(50% 영역)': transforms.Compose([
transforms.RandomResizedCrop(224, scale=(0.5, 0.5)),
transforms.ToTensor()
]),
'RandomResizedCrop\n(100% 전체)': transforms.Compose([
transforms.RandomResizedCrop(224, scale=(1.0, 1.0)),
transforms.ToTensor()
])
}
fig, axes = plt.subplots(2, 3, figsize=(15, 10))
axes = axes.ravel()
for idx, (method_name, transform) in enumerate(resize_methods.items()):
if transform is None:
# 원본 이미지
axes[idx].imshow(original_img)
axes[idx].set_title(f'{method_name}\n({orig_w}×{orig_h})',
fontsize=12, fontweight='bold', color='red')
else:
# 변환 적용
img_tensor = transform(original_img)
img_numpy = img_tensor.numpy().transpose(1, 2, 0)
axes[idx].imshow(img_numpy)
axes[idx].set_title(f'{method_name}\n(224×224)', fontsize=12)
axes[idx].axis('off')
# 테두리 추가
for spine in axes[idx].spines.values():
spine.set_edgecolor('gray')
spine.set_linewidth(2)
class_name = '고양이' if label == 0 else '강아지'
plt.suptitle(f'📐 리사이즈 방식 비교 ({class_name})',
fontsize=16, fontweight='bold')
plt.tight_layout()
plt.show()
print(f'\n📊 리사이즈 방식 설명:')
print(f' 1️⃣ 원본: {orig_w}×{orig_h} (변환 없음)')
print(f' 2️⃣ Resize(224,224): 강제로 224×224로 변형 → ⚠️ 왜곡 발생')
print(f' 3️⃣ Resize+CenterCrop: 비율 유지 후 중앙 자르기 → ✅ 왜곡 없음')
print(f' 4️⃣ RandomResizedCrop(80%): 80% 영역만 사용 → ✅ 약간 줌인')
print(f' 5️⃣ RandomResizedCrop(50%): 50% 영역만 사용 → ✅ 많이 줌인')
print(f' 6️⃣ RandomResizedCrop(100%): 전체 영역 사용 → ✅ 줌 없음')
# 실행
print('=== 고양이 이미지 리사이즈 비교 ===')
show_resize_methods_comparison(train_dataset, idx=0)
print('\n' + '='*80 + '\n')
# 강아지로도 비교
print('=== 강아지 이미지 리사이즈 비교 ===')
dog_idx = next(i for i, (path, label) in enumerate(train_dataset.samples) if label == 1)
show_resize_methods_comparison(train_dataset, idx=dog_idx)
=== 고양이 이미지 리사이즈 비교 ===
📊 리사이즈 방식 설명: 1️⃣ 원본: 300×281 (변환 없음) 2️⃣ Resize(224,224): 강제로 224×224로 변형 → ⚠️ 왜곡 발생 3️⃣ Resize+CenterCrop: 비율 유지 후 중앙 자르기 → ✅ 왜곡 없음 4️⃣ RandomResizedCrop(80%): 80% 영역만 사용 → ✅ 약간 줌인 5️⃣ RandomResizedCrop(50%): 50% 영역만 사용 → ✅ 많이 줌인 6️⃣ RandomResizedCrop(100%): 전체 영역 사용 → ✅ 줌 없음 ================================================================================ === 강아지 이미지 리사이즈 비교 ===
📊 리사이즈 방식 설명: 1️⃣ 원본: 327×500 (변환 없음) 2️⃣ Resize(224,224): 강제로 224×224로 변형 → ⚠️ 왜곡 발생 3️⃣ Resize+CenterCrop: 비율 유지 후 중앙 자르기 → ✅ 왜곡 없음 4️⃣ RandomResizedCrop(80%): 80% 영역만 사용 → ✅ 약간 줌인 5️⃣ RandomResizedCrop(50%): 50% 영역만 사용 → ✅ 많이 줌인 6️⃣ RandomResizedCrop(100%): 전체 영역 사용 → ✅ 줌 없음
🎯 전이학습용 사전학습 모델 선택¶
1. 주요 사전학습 모델 소개¶
ResNet (Residual Network)¶
- 개발: Microsoft Research (2015)
- 특징: Skip Connection으로 깊은 네트워크 학습 가능
- 변형: ResNet18, ResNet34, ResNet50, ResNet101, ResNet152
- 장점: 안정적이고 검증된 성능, 전이학습에 가장 많이 사용됨
EfficientNet¶
- 개발: Google (2019)
- 특징: 효율적인 스케일링 (Width + Depth + Resolution)
- 변형: EfficientNet-B0 ~ B7
- 장점: 적은 파라미터로 높은 성능, 최신 아키텍처
VGG (Visual Geometry Group)¶
- 개발: Oxford (2014)
- 특징: 간단하고 균일한 구조 (3×3 Conv 반복)
- 변형: VGG16, VGG19
- 단점: 파라미터 수가 매우 많아 무거움
MobileNet¶
- 개발: Google (2017)
- 특징: 경량화 모델 (모바일/임베디드용)
- 변형: MobileNetV2, MobileNetV3
- 장점: 빠른 추론 속도, 적은 메모리
2. 모델 성능 비교표¶
| 모델 | ImageNet Top-1 정확도 | 파라미터 수 | 추론 속도 | 메모리 사용 | 전이학습 성능 |
|---|---|---|---|---|---|
| ResNet18 | 69.8% | 11.7M | ⚡⚡⚡ 빠름 | 💾 적음 | ⭐⭐⭐⭐ 좋음 |
| ResNet50 | 76.1% | 25.6M | ⚡⚡ 보통 | 💾💾 중간 | ⭐⭐⭐⭐⭐ 매우 좋음 |
| EfficientNet-B0 | 77.3% | 5.3M | ⚡⚡⚡ 빠름 | 💾 적음 | ⭐⭐⭐⭐⭐ 매우 좋음 |
| EfficientNet-B3 | 81.6% | 12M | ⚡⚡ 보통 | 💾💾 중간 | ⭐⭐⭐⭐⭐ 최고 |
| VGG16 | 71.6% | 138M | ⚡ 느림 | 💾💾💾 많음 | ⭐⭐⭐ 보통 |
| MobileNetV2 | 71.9% | 3.5M | ⚡⚡⚡⚡ 매우 빠름 | 💾 매우 적음 | ⭐⭐⭐ 보통 |
3. 우리 프로젝트에 적합한 모델¶
추천 순위¶
🥇 1순위: ResNet50 (균형잡힌 선택)¶
import torchvision.models as models
model = models.resnet50(pretrained=True)
- ✅ 검증된 안정적인 성능
- ✅ 적절한 파라미터 수 (25.6M)
- ✅ 전이학습 효과 우수
- ✅ 풍부한 레퍼런스와 문서
- 🎯 개-고양이 분류 예상 정확도: 97-98%
🥈 2순위: EfficientNet-B0 (효율성 최고)¶
model = models.efficientnet_b0(pretrained=True)
- ✅ 최소 파라미터로 최고 성능
- ✅ 빠른 학습/추론 속도
- ✅ 최신 아키텍처
- ⚠️ ResNet보다 약간 복잡
- 🎯 개-고양이 분류 예상 정확도: 97-99%
🥉 3순위: ResNet18 (가벼운 선택)¶
model = models.resnet18(pretrained=True)
- ✅ 매우 빠른 학습
- ✅ 적은 메모리 사용
- ⚠️ ResNet50보다 약간 낮은 성능
- 🎯 개-고양이 분류 예상 정확도: 95-97%
4. 모델별 특징 상세 비교¶
ResNet 계열의 차이¶
ResNet18 (18층)
├─ Conv 블록: 8개
├─ 파라미터: 11.7M
└─ 특징: 가볍고 빠름
ResNet50 (50층) ← 추천!
├─ Conv 블록: 16개
├─ 파라미터: 25.6M
└─ 특징: 성능과 속도의 균형
ResNet101 (101층)
├─ Conv 블록: 33개
├─ 파라미터: 44.5M
└─ 특징: 매우 깊음, 과적합 위험
EfficientNet 계열의 차이¶
EfficientNet-B0 ← 추천!
├─ 입력 크기: 224×224
├─ 파라미터: 5.3M
└─ 특징: 가장 효율적
EfficientNet-B3
├─ 입력 크기: 300×300
├─ 파라미터: 12M
└─ 특징: 성능 더 높음, 학습 시간↑
EfficientNet-B7
├─ 입력 크기: 600×600
├─ 파라미터: 66M
└─ 특징: 최고 성능, 매우 무거움
5. 최종 선택 가이드¶
이렇게 선택하세요!¶
| 상황 | 추천 모델 | 이유 |
|---|---|---|
| 균형잡힌 학습 | ResNet50 | 안정적이고 검증된 성능 |
| 빠른 실험 | ResNet18 | 빠른 학습으로 여러 실험 가능 |
| 최고 성능 | EfficientNet-B0/B3 | 최신 기술로 최고 정확도 |
| 메모리 부족 | MobileNetV2 | 가장 가벼움 |
| 교육 목적 | ResNet50 | 가장 많이 사용되는 표준 |
6. 우리 튜토리얼의 선택¶
📌 ResNet50을 선택합니다!¶
선택 이유:
- ✅ 검증된 성능: 수많은 프로젝트에서 입증됨
- ✅ 풍부한 자료: 레퍼런스와 예제가 많음
- ✅ 적절한 복잡도: 너무 가볍지도, 무겁지도 않음
- ✅ 교육 가치: 실무에서 가장 많이 사용
- ✅ 성능: 개-고양이 분류에 충분히 높은 정확도 (97-98%)
예상 결과:
기본 CNN (직접 설계) → 93% 정확도
ResNet50 (전이학습) → 97-98% 정확도
개선 폭 → +4-5% (상대적으로 70% 오류 감소!)
다음 단계¶
이제 ResNet50 모델을 다운로드하고 우리 데이터에 맞게 Fine-tuning 해봅시다! 🚀
import torchvision.models as models
# ResNet50 사전학습 모델 로드
model = models.resnet50(pretrained=True)
print('✅ ResNet50 모델 다운로드 완료!')
🤔 VGG16은 왜 ResNet50보다 성능이 낮을까?¶
1. 기본 비교¶
| 항목 | VGG16 | ResNet50 |
|---|---|---|
| 층 수 | 16층 | 50층 (3배 더 깊음) |
| 파라미터 | 138M | 25.6M (5배 더 적음) |
| Top-1 정확도 | 71.6% | 76.1% (4.5% 더 높음) |
🤯 역설: 파라미터가 5배 많은데 성능은 더 낮다!
2. 주요 원인 3가지¶
❌ 문제 1: Gradient Vanishing (기울기 소실)¶
VGG16의 문제:
입력 → Conv1 → Conv2 → ... → Conv16 → 출력
↑ ↓
└──────── 역전파(학습) ─────────┘
깊어질수록 gradient가 사라짐:
Layer 16: gradient = 1.0
Layer 12: gradient = 0.5
Layer 8: gradient = 0.1
Layer 4: gradient = 0.001 ← 거의 학습 안 됨!
Layer 1: gradient = 0.00001 ← 학습 실패!
ResNet50의 해결책: Skip Connection
┌─────────────────┐
입력 ────┤ Conv → Conv │─── 출력
│ └─────────────────┘ ↑
│ │
└─────────────────────────────┘
지름길 (Shortcut)
50층이어도 gradient가 잘 전달됨!
Layer 50 → Layer 1까지 학습 가능 ✅
❌ 문제 2: 비효율적인 FC Layer¶
VGG16의 파라미터 분포:
Conv 층: 15M (11%)
FC 층: 123M (89%) ← 여기에 파라미터 집중!
FC Layer 세부:
7×7×512 → 4096 뉴런: 102M 파라미터
4096 → 4096: 16M 파라미터
4096 → 1000: 4M 파라미터
총 FC만 122M! (전체의 88%)
ResNet50의 효율적 설계:
Conv 층: 23.6M (92%)
FC 층: 2M (8%)
Global Average Pooling 사용:
7×7×2048 → 평균 → 2048 (파라미터 0개!)
2048 → 1000: 2M 파라미터
FC는 2M만! 효율적 ✅
❌ 문제 3: 단순 반복 vs 효율적 설계¶
VGG16: 단순 반복
Conv 3×3, 64
Conv 3×3, 64
Conv 3×3, 128
Conv 3×3, 128
...
(그냥 같은 구조 반복)
→ 비효율적
→ 파라미터만 많고 표현력 낮음
ResNet50: Bottleneck 구조
Conv 1×1, 64 (차원 축소)
↓
Conv 3×3, 64 (핵심 연산)
↓
Conv 1×1, 256 (차원 확장)
→ 같은 표현력, 파라미터 1/3
→ 더 깊게 쌓을 수 있음 ✅
3. 실험으로 증명¶
VGG 계열 (한계 명확)¶
| 모델 | 층 수 | 파라미터 | 정확도 | 학습 가능? |
|---|---|---|---|---|
| VGG11 | 11층 | 133M | 69.0% | ✅ 가능 |
| VGG16 | 16층 | 138M | 71.6% | ✅ 가능 |
| VGG19 | 19층 | 144M | 72.4% | ⚠️ 어려움 |
| VGG25+ | 25층+ | 150M+ | ❌ 학습 실패 | ❌ 불가능 |
→ 20층 넘어가면 gradient 소실로 학습 자체가 안 됨!
ResNet 계열 (깊이의 승리)¶
| 모델 | 층 수 | 파라미터 | 정확도 | 학습 가능? |
|---|---|---|---|---|
| ResNet18 | 18층 | 11.7M | 69.8% | ✅ 쉬움 |
| ResNet34 | 34층 | 21.8M | 73.3% | ✅ 쉬움 |
| ResNet50 | 50층 | 25.6M | 76.1% | ✅ 쉬움 |
| ResNet101 | 101층 | 44.5M | 77.4% | ✅ 가능 |
| ResNet152 | 152층 | 60M | 78.3% | ✅ 가능 |
→ 150층도 문제없이 학습 가능!
4. 비유로 이해하기¶
🏢 VGG16 = 구식 아파트¶
- 방은 16개 (적당)
- 가구가 너무 많음 (138M)
- 배치가 비효율적
- 뒷방까지 소리(gradient)가 안 들림
- → 낭비가 심함
🏗️ ResNet50 = 현대식 아파트¶
- 방은 50개 (많음)
- 가구는 적당 (25.6M)
- 각 방마다 지름길(shortcut) 있음
- 어디서든 소리가 잘 들림
- → 효율적!
5. 핵심 교훈 (2015년 딥러닝 혁명)¶
중요한 발견¶
❌ "층이 깊다고 무조건 좋은 게 아니다"
❌ "파라미터 많다고 무조건 좋은 게 아니다"
✅ "구조 설계가 가장 중요하다!"
6. 요약¶
VGG16이 ResNet50보다 성능 낮은 이유:
| 문제 | VGG16 | ResNet50 |
|---|---|---|
| Gradient 전달 | ❌ 16층도 소실 심함 | ✅ Skip Connection으로 150층도 가능 |
| 파라미터 효율 | ❌ FC에 90% 낭비 | ✅ Global Pooling으로 효율적 |
| 구조 설계 | ❌ 단순 반복 | ✅ Bottleneck으로 효율적 |
| 결과 | 138M, 71.6% | 25.6M, 76.1% |
결론: 많다고 좋은 게 아니라, 잘 설계된 게 좋다! 🎯
📦 PyTorch(torchvision) 사전학습 모델 로드 가이드¶
| 카테고리 | 모델명 | Python 로드 코드 (임포트 포함) | 주요 특징 |
|---|---|---|---|
| 표준 (ResNet) | resnet50 | from torchvision import modelsmodel = models.resnet50(weights='DEFAULT') |
전이학습의 정석. 가장 안정적인 성능. |
| 경량 (Mobile) | mobilenet_v3 | from torchvision import modelsmodel = models.mobilenet_v3_small(weights='DEFAULT') |
스마트폰/임베디드용. 속도가 매우 빠름. |
| 효율 (Efficient) | efficientnet_b0 | from torchvision import modelsmodel = models.efficientnet_b0(weights='DEFAULT') |
가성비 모델. 적은 연산으로 높은 정확도. |
| 고밀도 (DenseNet) | densenet121 | from torchvision import modelsmodel = models.densenet121(weights='DEFAULT') |
층간 연결이 촘촘함. 세밀한 특징 추출에 유리. |
| 클래식 (VGG) | vgg16 | from torchvision import modelsmodel = models.vgg16(weights='DEFAULT') |
구조가 직관적이나 파라미터가 많아 무거움. |
| 최신 (ViT) | vit_b_16 | from torchvision import modelsmodel = models.vit_b_16(weights='DEFAULT') |
Transformer 구조 적용. 대용량 데이터에 강함. |
💡 팁: 최신 PyTorch 환경에서는 pretrained=True 대신 weights='DEFAULT'를 사용하는 것이 표준입니다.
pip install torch-summary
Collecting torch-summary Downloading torch_summary-1.4.5-py3-none-any.whl.metadata (18 kB) Downloading torch_summary-1.4.5-py3-none-any.whl (16 kB) Installing collected packages: torch-summary Successfully installed torch-summary-1.4.5 WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager, possibly rendering your system unusable. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv. Use the --root-user-action option if you know what you are doing and want to suppress this warning. Note: you may need to restart the kernel to use updated packages.
# =====================================
# ResNet50 사전학습 모델 로드
# =====================================
import torch
import torch.nn as nn
import torch.optim as optim
import torchvision.models as models
from torchsummary import summary
# 🔍 사전학습 모델이란?
# ImageNet이라는 거대한 데이터셋(100만 개 이상의 이미지)으로
# 이미 학습된 모델을 말합니다. 처음부터 학습하는 것보다 훨씬 효율적!
print('📥 ResNet50 사전학습 모델 다운로드 중...')
# ResNet50 불러오기 (ImageNet 가중치 포함)
# pretrained=True: ImageNet으로 학습된 가중치를 함께 다운로드
# 최신 버전에서는 weights=models.ResNet50_Weights.DEFAULT 사용 권장
model = models.resnet50(pretrained=True)
print('✅ ResNet50 다운로드 완료!')
print(f' 총 파라미터: {sum(p.numel() for p in model.parameters()):,}개')
# 💡 파라미터란? 모델이 학습하는 "가중치"들입니다
# ResNet50은 약 2,500만 개의 파라미터를 가지고 있어요!
# =====================================
# 모델 구조 확인
# =====================================
print('\n📋 ResNet50 구조 (마지막 부분):')
# (input_size는 ResNet 표준인 3채널 224x224 기준입니다)
summary(model, input_size=(3, 224, 224))
# 💡 ResNet50 구조:
# - layer1, layer2, layer3, layer4: 특징(feature)을 추출하는 층들
# - avgpool: 평균 풀링 (특징을 요약)
# - fc (Fully Connected): 최종 분류를 담당하는 층
# =====================================
# 우리 데이터에 맞게 모델 수정
# =====================================
print('\n🔧 모델 수정 중...')
# ResNet50 마지막 FC Layer 확인
print(f'원본 FC Layer: {model.fc}')
print(f' 입력: 2048 features')
print(f' 출력: 1000 classes (ImageNet)')
# 💡 ImageNet은 1000개의 클래스(강아지 종류, 고양이, 자동차 등)를 분류하도록 학습됨
# 🎯 우리는 개/고양이 2개 클래스만 필요!
# 마지막 FC Layer를 2개 출력으로 교체
num_features = model.fc.in_features # 2048 (이전 층에서 나오는 특징의 개수)
model.fc = nn.Linear(num_features, 2) # 2개 클래스로 변경 (0: 고양이, 1: 개)
# 💡 nn.Linear(2048, 2): 2048개의 입력을 받아 2개의 출력(개/고양이 확률)을 만듦
print(f'\n수정된 FC Layer: {model.fc}')
print(f' 입력: {num_features} features')
print(f' 출력: 2 classes (Dog/Cat)')
# =====================================
# GPU로 모델 이동
# =====================================
# device가 이미 정의되어 있다고 가정 (예: device = torch.device('cuda' if torch.cuda.is_available() else 'cpu'))
# 💡 GPU를 사용하면 학습 속도가 10배 이상 빨라집니다!
model = model.to(device)
print(f'\n✅ 모델을 {device}로 이동 완료!')
# =====================================
# 전이학습 전략 선택
# =====================================
print('\n🎯 전이학습 전략 선택')
print('='*70)
print('전략 1: Feature Extraction (특징 추출)')
print(' - 사전학습 층 전부 동결 (freeze)')
print(' - 마지막 FC Layer만 학습')
print(' - 빠른 학습, 적은 데이터에 적합')
print(' - 예) 데이터가 1000개 미만일 때')
print()
print('전략 2: Fine-tuning (미세 조정)')
print(' - 전체 모델 학습 (학습률 차등 적용)')
print(' - 더 높은 성능, 충분한 데이터 필요')
print(' - 예) 데이터가 1000개 이상일 때')
print('='*70)
# 🎯 여기서는 전략 2 (Fine-tuning) 사용!
# 개/고양이 데이터셋이 충분히 크다면 Fine-tuning이 더 좋은 성능을 냅니다
strategy = 'fine-tuning'
print(f'\n선택: {strategy.upper()}')
if strategy == 'feature-extraction':
# 전략 1: 모든 파라미터 동결
# 💡 동결(freeze)이란? 가중치를 업데이트하지 않겠다는 의미
# requires_grad = False: 이 파라미터는 학습하지 않음
for param in model.parameters():
param.requires_grad = False
# 마지막 FC Layer만 학습 가능하게
# 💡 새로 만든 FC Layer만 우리 데이터에 맞게 학습!
for param in model.fc.parameters():
param.requires_grad = True
print('✅ 사전학습 층 동결 완료')
print('✅ FC Layer만 학습 가능')
else: # fine-tuning
# 전략 2: 모든 파라미터 학습 가능 (기본값)
# 💡 하지만 단순히 전부 학습하는 게 아니라,
# 층마다 다른 학습률을 적용해서 조심스럽게 조정합니다!
for param in model.parameters():
param.requires_grad = True
print('✅ 전체 모델 학습 가능')
# 학습 가능한 파라미터 수 확인
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
total_params = sum(p.numel() for p in model.parameters())
print(f'\n📊 파라미터 통계:')
print(f' 전체 파라미터: {total_params:,}개')
print(f' 학습 가능: {trainable_params:,}개')
print(f' 동결됨: {total_params - trainable_params:,}개')
# =====================================
# 손실 함수 및 옵티마이저 설정
# =====================================
print('\n⚙️ 학습 설정')
print('='*70)
# 손실 함수: CrossEntropyLoss (분류 문제)
# 💡 CrossEntropyLoss란?
# - 분류 문제에서 가장 많이 쓰이는 손실 함수
# - 모델의 예측과 정답 사이의 차이를 계산
# - 값이 작을수록 모델이 정확하게 예측한 것!
criterion = nn.CrossEntropyLoss()
print('손실 함수: CrossEntropyLoss')
# =========================================================================
# 🎯 [핵심] 전이학습 10배의 법칙 및 차등 학습률(Differential LR) 전략
# =========================================================================
# 1. 하위 층 (Layer 1-2) [LR: 1e-5]:
# - 이미지의 선, 면 등 '기본기'를 담당. 이미 완성된 지식이므로 아주 미세하게만 조정.
#
# 2. 중간 층 (Layer 3-4) [LR: 1e-4]:
# - 눈, 코, 귀 등 '응용 특징'을 담당. 하위 층보다 10배 더 강하게 우리 데이터에 맞춤.
#
# 3. 최종 층 (FC Layer) [LR: 1e-3]:
# - '최종 판단'을 담당. 새로 갈아 끼운 '백지' 상태이므로 가장 강하게(100배) 학습.
#
# 💡 전략: "이미 아는 기본기는 보존하고(Low LR), 새로 배우는 판단력에 집중한다(High LR)"
# =========================================================================
optimizer = optim.Adam([
{'params': model.layer1.parameters(), 'lr': 1e-5}, # 기준 (1배)
{'params': model.layer2.parameters(), 'lr': 1e-5},
{'params': model.layer3.parameters(), 'lr': 1e-4}, # 10배 더 강하게
{'params': model.layer4.parameters(), 'lr': 1e-4},
{'params': model.fc.parameters(), 'lr': 1e-3} # 100배 더 강하게
])
if strategy == 'fine-tuning':
# Fine-tuning: 차등 학습률 적용
# 💡 왜 차등 학습률을 사용할까?
# - 하위 층(layer1, 2): 이미 ImageNet에서 잘 학습된 "일반적인 특징"을 포착
# (예: 선, 곡선, 질감 등) → 거의 변경하지 않음 (매우 낮은 학습률)
# - 상위 층(layer3, 4): 좀 더 구체적인 특징을 포착
# → 조금 조정 (낮은 학습률)
# - FC 층: 우리 데이터에 맞게 새로 학습해야 함
# → 많이 학습 (높은 학습률)
optimizer = optim.Adam([
# 💡 Adam 옵티마이저: 학습률을 자동으로 조절해주는 똑똑한 최적화 알고리즘
# 하위 층: 거의 변경하지 않음 (이미 좋은 특징을 포착하고 있음)
{'params': model.layer1.parameters(), 'lr': 1e-5}, # 0.00001
{'params': model.layer2.parameters(), 'lr': 1e-5}, # 0.00001
# 중간 층: 약간만 조정 (우리 데이터에 맞게 미세 조정)
{'params': model.layer3.parameters(), 'lr': 1e-4}, # 0.0001
{'params': model.layer4.parameters(), 'lr': 1e-4}, # 0.0001
# 새로운 FC 층: 많이 학습 (완전히 새로운 층이므로)
{'params': model.fc.parameters(), 'lr': 1e-3} # 0.001
])
print('옵티마이저: Adam (차등 학습률)')
print(' Layer 1-2: 0.00001 (사전학습 층, 거의 변경 안 함)')
print(' Layer 3-4: 0.0001 (중간 층, 약간 조정)')
print(' FC Layer: 0.001 (새 층, 많이 학습)')
else: # feature-extraction
# Feature Extraction: FC Layer만 학습
# 💡 사전학습된 층은 고정하고, 새로운 FC Layer만 우리 데이터로 학습
optimizer = optim.Adam(model.fc.parameters(), lr=1e-3)
print('옵티마이저: Adam')
print(' 학습률: 0.001 (FC Layer만)')
# =====================================
# 학습률 스케줄러 (수정됨!)
# =====================================
# 💡 학습률 스케줄러란?
# - 학습이 진행될수록 학습률을 조절하는 도구
# - 처음엔 큰 학습률로 빠르게 학습하고,
# 나중엔 작은 학습률로 세밀하게 조정
# ⚠️ verbose 파라미터가 최신 PyTorch에서 제거되어 수정!
scheduler = optim.lr_scheduler.ReduceLROnPlateau(
optimizer, # 사용할 옵티마이저
mode='max', # 'max': 정확도가 높을수록 좋음 / 'min': 손실이 낮을수록 좋음
factor=0.5, # 학습률 감소 비율 (0.5 = 절반으로)
patience=3, # 몇 epoch 동안 개선이 없으면 학습률을 줄일지
# verbose=True는 제거됨! (PyTorch 최신 버전)
)
print('학습률 스케줄러: ReduceLROnPlateau')
print(' 모드: max (정확도 기준)')
print(' 개선 없으면 3 epoch 후 학습률을 절반으로 감소')
print(' 예) 0.001 → 0.0005 → 0.00025 ...')
# 💡 이렇게 하면 학습이 정체될 때 자동으로 학습률을 줄여서
# 더 세밀하게 최적화할 수 있어요!
print('='*70)
print('✅ 전이학습 준비 완료!')
# =====================================
# 💡 학습 루프에서 스케줄러 사용 방법
# =====================================
print('\n📝 참고: 학습 루프에서 스케줄러 사용 예시')
print('''
for epoch in range(num_epochs):
# ... 학습 코드 ...
# Validation 정확도 계산
val_acc = validate(model, val_loader)
# 스케줄러 업데이트 (validation 정확도 전달)
scheduler.step(val_acc)
# 현재 학습률 확인 (선택사항)
current_lr = optimizer.param_groups[0]['lr']
print(f'현재 학습률: {current_lr:.6f}')
''')
📥 ResNet50 사전학습 모델 다운로드 중...
✅ ResNet50 다운로드 완료!
총 파라미터: 25,557,032개
📋 ResNet50 구조 (마지막 부분):
=================================================================
Layer (type:depth-idx) Param #
=================================================================
├─Conv2d: 1-1 9,408
├─BatchNorm2d: 1-2 128
├─ReLU: 1-3 --
├─MaxPool2d: 1-4 --
├─Sequential: 1-5 --
| └─Bottleneck: 2-1 --
| | └─Conv2d: 3-1 4,096
| | └─BatchNorm2d: 3-2 128
| | └─Conv2d: 3-3 36,864
| | └─BatchNorm2d: 3-4 128
| | └─Conv2d: 3-5 16,384
| | └─BatchNorm2d: 3-6 512
| | └─ReLU: 3-7 --
| | └─Sequential: 3-8 16,896
| └─Bottleneck: 2-2 --
| | └─Conv2d: 3-9 16,384
| | └─BatchNorm2d: 3-10 128
| | └─Conv2d: 3-11 36,864
| | └─BatchNorm2d: 3-12 128
| | └─Conv2d: 3-13 16,384
| | └─BatchNorm2d: 3-14 512
| | └─ReLU: 3-15 --
| └─Bottleneck: 2-3 --
| | └─Conv2d: 3-16 16,384
| | └─BatchNorm2d: 3-17 128
| | └─Conv2d: 3-18 36,864
| | └─BatchNorm2d: 3-19 128
| | └─Conv2d: 3-20 16,384
| | └─BatchNorm2d: 3-21 512
| | └─ReLU: 3-22 --
├─Sequential: 1-6 --
| └─Bottleneck: 2-4 --
| | └─Conv2d: 3-23 32,768
| | └─BatchNorm2d: 3-24 256
| | └─Conv2d: 3-25 147,456
| | └─BatchNorm2d: 3-26 256
| | └─Conv2d: 3-27 65,536
| | └─BatchNorm2d: 3-28 1,024
| | └─ReLU: 3-29 --
| | └─Sequential: 3-30 132,096
| └─Bottleneck: 2-5 --
| | └─Conv2d: 3-31 65,536
| | └─BatchNorm2d: 3-32 256
| | └─Conv2d: 3-33 147,456
| | └─BatchNorm2d: 3-34 256
| | └─Conv2d: 3-35 65,536
| | └─BatchNorm2d: 3-36 1,024
| | └─ReLU: 3-37 --
| └─Bottleneck: 2-6 --
| | └─Conv2d: 3-38 65,536
| | └─BatchNorm2d: 3-39 256
| | └─Conv2d: 3-40 147,456
| | └─BatchNorm2d: 3-41 256
| | └─Conv2d: 3-42 65,536
| | └─BatchNorm2d: 3-43 1,024
| | └─ReLU: 3-44 --
| └─Bottleneck: 2-7 --
| | └─Conv2d: 3-45 65,536
| | └─BatchNorm2d: 3-46 256
| | └─Conv2d: 3-47 147,456
| | └─BatchNorm2d: 3-48 256
| | └─Conv2d: 3-49 65,536
| | └─BatchNorm2d: 3-50 1,024
| | └─ReLU: 3-51 --
├─Sequential: 1-7 --
| └─Bottleneck: 2-8 --
| | └─Conv2d: 3-52 131,072
| | └─BatchNorm2d: 3-53 512
| | └─Conv2d: 3-54 589,824
| | └─BatchNorm2d: 3-55 512
| | └─Conv2d: 3-56 262,144
| | └─BatchNorm2d: 3-57 2,048
| | └─ReLU: 3-58 --
| | └─Sequential: 3-59 526,336
| └─Bottleneck: 2-9 --
| | └─Conv2d: 3-60 262,144
| | └─BatchNorm2d: 3-61 512
| | └─Conv2d: 3-62 589,824
| | └─BatchNorm2d: 3-63 512
| | └─Conv2d: 3-64 262,144
| | └─BatchNorm2d: 3-65 2,048
| | └─ReLU: 3-66 --
| └─Bottleneck: 2-10 --
| | └─Conv2d: 3-67 262,144
| | └─BatchNorm2d: 3-68 512
| | └─Conv2d: 3-69 589,824
| | └─BatchNorm2d: 3-70 512
| | └─Conv2d: 3-71 262,144
| | └─BatchNorm2d: 3-72 2,048
| | └─ReLU: 3-73 --
| └─Bottleneck: 2-11 --
| | └─Conv2d: 3-74 262,144
| | └─BatchNorm2d: 3-75 512
| | └─Conv2d: 3-76 589,824
| | └─BatchNorm2d: 3-77 512
| | └─Conv2d: 3-78 262,144
| | └─BatchNorm2d: 3-79 2,048
| | └─ReLU: 3-80 --
| └─Bottleneck: 2-12 --
| | └─Conv2d: 3-81 262,144
| | └─BatchNorm2d: 3-82 512
| | └─Conv2d: 3-83 589,824
| | └─BatchNorm2d: 3-84 512
| | └─Conv2d: 3-85 262,144
| | └─BatchNorm2d: 3-86 2,048
| | └─ReLU: 3-87 --
| └─Bottleneck: 2-13 --
| | └─Conv2d: 3-88 262,144
| | └─BatchNorm2d: 3-89 512
| | └─Conv2d: 3-90 589,824
| | └─BatchNorm2d: 3-91 512
| | └─Conv2d: 3-92 262,144
| | └─BatchNorm2d: 3-93 2,048
| | └─ReLU: 3-94 --
├─Sequential: 1-8 --
| └─Bottleneck: 2-14 --
| | └─Conv2d: 3-95 524,288
| | └─BatchNorm2d: 3-96 1,024
| | └─Conv2d: 3-97 2,359,296
| | └─BatchNorm2d: 3-98 1,024
| | └─Conv2d: 3-99 1,048,576
| | └─BatchNorm2d: 3-100 4,096
| | └─ReLU: 3-101 --
| | └─Sequential: 3-102 2,101,248
| └─Bottleneck: 2-15 --
| | └─Conv2d: 3-103 1,048,576
| | └─BatchNorm2d: 3-104 1,024
| | └─Conv2d: 3-105 2,359,296
| | └─BatchNorm2d: 3-106 1,024
| | └─Conv2d: 3-107 1,048,576
| | └─BatchNorm2d: 3-108 4,096
| | └─ReLU: 3-109 --
| └─Bottleneck: 2-16 --
| | └─Conv2d: 3-110 1,048,576
| | └─BatchNorm2d: 3-111 1,024
| | └─Conv2d: 3-112 2,359,296
| | └─BatchNorm2d: 3-113 1,024
| | └─Conv2d: 3-114 1,048,576
| | └─BatchNorm2d: 3-115 4,096
| | └─ReLU: 3-116 --
├─AdaptiveAvgPool2d: 1-9 --
├─Linear: 1-10 2,049,000
=================================================================
Total params: 25,557,032
Trainable params: 25,557,032
Non-trainable params: 0
=================================================================
🔧 모델 수정 중...
원본 FC Layer: Linear(in_features=2048, out_features=1000, bias=True)
입력: 2048 features
출력: 1000 classes (ImageNet)
수정된 FC Layer: Linear(in_features=2048, out_features=2, bias=True)
입력: 2048 features
출력: 2 classes (Dog/Cat)
✅ 모델을 cuda로 이동 완료!
🎯 전이학습 전략 선택
======================================================================
전략 1: Feature Extraction (특징 추출)
- 사전학습 층 전부 동결 (freeze)
- 마지막 FC Layer만 학습
- 빠른 학습, 적은 데이터에 적합
- 예) 데이터가 1000개 미만일 때
전략 2: Fine-tuning (미세 조정)
- 전체 모델 학습 (학습률 차등 적용)
- 더 높은 성능, 충분한 데이터 필요
- 예) 데이터가 1000개 이상일 때
======================================================================
선택: FINE-TUNING
✅ 전체 모델 학습 가능
📊 파라미터 통계:
전체 파라미터: 23,512,130개
학습 가능: 23,512,130개
동결됨: 0개
⚙️ 학습 설정
======================================================================
손실 함수: CrossEntropyLoss
옵티마이저: Adam (차등 학습률)
Layer 1-2: 0.00001 (사전학습 층, 거의 변경 안 함)
Layer 3-4: 0.0001 (중간 층, 약간 조정)
FC Layer: 0.001 (새 층, 많이 학습)
학습률 스케줄러: ReduceLROnPlateau
모드: max (정확도 기준)
개선 없으면 3 epoch 후 학습률을 절반으로 감소
예) 0.001 → 0.0005 → 0.00025 ...
======================================================================
✅ 전이학습 준비 완료!
📝 참고: 학습 루프에서 스케줄러 사용 예시
for epoch in range(num_epochs):
# ... 학습 코드 ...
# Validation 정확도 계산
val_acc = validate(model, val_loader)
# 스케줄러 업데이트 (validation 정확도 전달)
scheduler.step(val_acc)
# 현재 학습률 확인 (선택사항)
current_lr = optimizer.param_groups[0]['lr']
print(f'현재 학습률: {current_lr:.6f}')
# =====================================
# 1. ResNet50 모델 학습 코드
# =====================================
import torch
import torch.nn as nn
import torch.optim as optim
import time
from tqdm import tqdm
import copy
# =====================================
# 💡 학습 설정
# =====================================
NUM_EPOCHS = 20 # 총 학습 에포크 수
EARLY_STOP_PATIENCE = 5 # 조기 종료: 5 epoch 동안 개선 없으면 중단
SAVE_PATH = 'best_model.pth' # 최고 성능 모델 저장 경로
# 기존에 정의된 변수 사용 (환경에 맞게 조정)
# BATCH_SIZE = 32
# device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print('='*85)
print('🚀 ResNet50 전이학습 시작!')
print('='*85)
print(f'📊 학습 설정:')
print(f' - 총 에포크: {NUM_EPOCHS}')
print(f' - 조기 종료: {EARLY_STOP_PATIENCE} epoch 동안 개선 없으면 중단')
print(f' - 장비: {device}')
print('='*85)
# =====================================
# 💡 학습 함수 정의
# =====================================
def train_one_epoch(model, train_loader, criterion, optimizer, device):
""" 한 에포크 동안 모델을 학습시키는 함수 """
model.train()
running_loss = 0.0
running_corrects = 0
total_samples = 0
progress_bar = tqdm(train_loader, desc='Training', leave=False)
for inputs, labels in progress_bar:
inputs = inputs.to(device)
labels = labels.to(device)
optimizer.zero_grad()
outputs = model(inputs)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
_, preds = torch.max(outputs, 1)
batch_size = inputs.size(0)
running_loss += loss.item() * batch_size
running_corrects += torch.sum(preds == labels.data)
total_samples += batch_size
progress_bar.set_postfix({
'loss': f'{loss.item():.4f}',
'acc': f'{running_corrects.double() / total_samples:.4f}'
})
epoch_loss = running_loss / total_samples
epoch_acc = running_corrects.double() / total_samples
return epoch_loss, epoch_acc.item()
def validate(model, val_loader, criterion, device):
""" 검증 데이터로 모델 성능을 평가하는 함수 """
model.eval()
running_loss = 0.0
running_corrects = 0
total_samples = 0
with torch.no_grad():
progress_bar = tqdm(val_loader, desc='Validation', leave=False)
for inputs, labels in progress_bar:
inputs = inputs.to(device)
labels = labels.to(device)
outputs = model(inputs)
loss = criterion(outputs, labels)
_, preds = torch.max(outputs, 1)
batch_size = inputs.size(0)
running_loss += loss.item() * batch_size
running_corrects += torch.sum(preds == labels.data)
total_samples += batch_size
progress_bar.set_postfix({
'loss': f'{loss.item():.4f}',
'acc': f'{running_corrects.double() / total_samples:.4f}'
})
epoch_loss = running_loss / total_samples
epoch_acc = running_corrects.double() / total_samples
return epoch_loss, epoch_acc.item()
# =====================================
# 💡 전체 학습 루프
# =====================================
def train_model(model, train_loader, val_loader, criterion, optimizer,
scheduler, num_epochs, device, save_path, patience):
""" 전체 학습 프로세스를 관리하는 함수 (출력 최적화) """
since = time.time()
history = {
'train_loss': [], 'train_acc': [],
'val_loss': [], 'val_acc': [], 'lr': []
}
best_model_wts = copy.deepcopy(model.state_dict())
best_acc = 0.0
epochs_no_improve = 0
early_stop = False
print('\n🎯 학습 시작!\n')
for epoch in range(num_epochs):
if early_stop:
print(f'\n⏹️ 조기 종료! ({patience} epoch 동안 개선 없음)')
break
epoch_start_time = time.time() # 에포크 시작 시간
# 1️⃣ 훈련 단계
train_loss, train_acc = train_one_epoch(
model, train_loader, criterion, optimizer, device
)
# 2️⃣ 검증 단계
val_loss, val_acc = validate(
model, val_loader, criterion, device
)
# 3️⃣ 학습률 스케줄러 업데이트 및 정보 수집
scheduler.step(val_acc)
current_lr = optimizer.param_groups[0]['lr']
epoch_time = time.time() - epoch_start_time
# 4️⃣ 결과 한 줄 출력 (수정된 핵심 부분)
print(f"Epoch [{epoch+1:2d}/{num_epochs}] {epoch_time:5.1f}s | "
f"Train Loss: {train_loss:.4f} Acc: {train_acc:7.2%} | "
f"Val Loss: {val_loss:.4f} Acc: {val_acc:7.2%} | "
f"LR: {current_lr:.6f}", end="")
# 5️⃣ 학습 기록 저장
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)
# 6️⃣ 최고 성능 모델 저장
if val_acc > best_acc:
print(f" ⭐ Best!")
best_acc = val_acc
best_model_wts = copy.deepcopy(model.state_dict())
torch.save({
'epoch': epoch + 1,
'model_state_dict': model.state_dict(),
'optimizer_state_dict': optimizer.state_dict(),
'best_acc': best_acc,
'history': history
}, save_path)
epochs_no_improve = 0
else:
epochs_no_improve += 1
print(f" (Patience: {epochs_no_improve}/{patience})")
if epochs_no_improve >= patience:
early_stop = True
# 학습 종료 요약
time_elapsed = time.time() - since
print('\n' + '='*85)
print(f'✅ 학습 완료! | 총 소요 시간: {time_elapsed // 60:.0f}분 {time_elapsed % 60:.0f}초')
print(f'🏆 최고 검증 정확도: {best_acc:7.2%}')
print('='*85)
model.load_state_dict(best_model_wts)
return model, history
# =====================================
# 💡 학습 실행
# =====================================
print('\n' + '='*85)
print('🎓 모델 학습을 시작합니다...')
print('='*85)
trained_model, training_history = train_model(
model=model,
train_loader=train_loader,
val_loader=val_loader,
criterion=criterion,
optimizer=optimizer,
scheduler=scheduler,
num_epochs=NUM_EPOCHS,
device=device,
save_path=SAVE_PATH,
patience=EARLY_STOP_PATIENCE
)
print('\n🎉 모든 학습이 완료되었습니다!')
print('\n💡 다음 단계:')
print(' 1. 2_visualize_results.py 실행 → 학습 그래프 확인')
print(' 2. 3_test_model.py 실행 → 테스트 데이터 평가')
===================================================================================== 🚀 ResNet50 전이학습 시작! ===================================================================================== 📊 학습 설정: - 총 에포크: 20 - 조기 종료: 5 epoch 동안 개선 없으면 중단 - 장비: cuda ===================================================================================== ===================================================================================== 🎓 모델 학습을 시작합니다... ===================================================================================== 🎯 학습 시작!
Training: 0%| | 0/137 [00:00<?, ?it/s]
Epoch [ 1/20] 39.6s | Train Loss: 0.0599 Acc: 97.49% | Val Loss: 0.0381 Acc: 98.54% | LR: 0.000010 ⭐ Best!
Epoch [ 2/20] 40.7s | Train Loss: 0.0261 Acc: 99.09% | Val Loss: 0.0240 Acc: 98.98% | LR: 0.000010 ⭐ Best!
Epoch [ 3/20] 38.6s | Train Loss: 0.0190 Acc: 99.31% | Val Loss: 0.0222 Acc: 99.16% | LR: 0.000010 ⭐ Best!
Epoch [ 4/20] 38.4s | Train Loss: 0.0151 Acc: 99.49% | Val Loss: 0.0309 Acc: 99.04% | LR: 0.000010 (Patience: 1/5)
Epoch [ 5/20] 38.5s | Train Loss: 0.0134 Acc: 99.55% | Val Loss: 0.0329 Acc: 98.86% | LR: 0.000010 (Patience: 2/5)
Epoch [ 6/20] 39.6s | Train Loss: 0.0124 Acc: 99.57% | Val Loss: 0.0277 Acc: 99.12% | LR: 0.000010 (Patience: 3/5)
Epoch [ 7/20] 39.8s | Train Loss: 0.0099 Acc: 99.67% | Val Loss: 0.0295 Acc: 99.18% | LR: 0.000010 ⭐ Best!
Epoch [ 8/20] 38.5s | Train Loss: 0.0071 Acc: 99.78% | Val Loss: 0.0425 Acc: 98.64% | LR: 0.000010 (Patience: 1/5)
Epoch [ 9/20] 38.8s | Train Loss: 0.0100 Acc: 99.64% | Val Loss: 0.0479 Acc: 98.60% | LR: 0.000010 (Patience: 2/5)
Epoch [10/20] 38.7s | Train Loss: 0.0105 Acc: 99.70% | Val Loss: 0.0278 Acc: 98.94% | LR: 0.000010 (Patience: 3/5)
Epoch [11/20] 40.6s | Train Loss: 0.0108 Acc: 99.62% | Val Loss: 0.0293 Acc: 99.10% | LR: 0.000005 (Patience: 4/5)
Epoch [12/20] 38.6s | Train Loss: 0.0051 Acc: 99.80% | Val Loss: 0.0204 Acc: 99.40% | LR: 0.000005 ⭐ Best!
Epoch [13/20] 38.4s | Train Loss: 0.0040 Acc: 99.86% | Val Loss: 0.0251 Acc: 99.30% | LR: 0.000005 (Patience: 1/5)
Epoch [14/20] 38.3s | Train Loss: 0.0025 Acc: 99.90% | Val Loss: 0.0272 Acc: 99.16% | LR: 0.000005 (Patience: 2/5)
Epoch [15/20] 40.6s | Train Loss: 0.0018 Acc: 99.93% | Val Loss: 0.0229 Acc: 99.36% | LR: 0.000005 (Patience: 3/5)
Epoch [16/20] 38.5s | Train Loss: 0.0027 Acc: 99.94% | Val Loss: 0.0294 Acc: 99.18% | LR: 0.000003 (Patience: 4/5)
Epoch [17/20] 38.6s | Train Loss: 0.0018 Acc: 99.93% | Val Loss: 0.0215 Acc: 99.48% | LR: 0.000003 ⭐ Best!
Epoch [18/20] 38.4s | Train Loss: 0.0014 Acc: 99.96% | Val Loss: 0.0251 Acc: 99.24% | LR: 0.000003 (Patience: 1/5)
Epoch [19/20] 38.4s | Train Loss: 0.0007 Acc: 99.98% | Val Loss: 0.0217 Acc: 99.44% | LR: 0.000003 (Patience: 2/5)
Epoch [20/20] 40.6s | Train Loss: 0.0013 Acc: 99.96% | Val Loss: 0.0234 Acc: 99.30% | LR: 0.000003 (Patience: 3/5) ===================================================================================== ✅ 학습 완료! | 총 소요 시간: 13분 8초 🏆 최고 검증 정확도: 99.48% ===================================================================================== 🎉 모든 학습이 완료되었습니다! 💡 다음 단계: 1. 2_visualize_results.py 실행 → 학습 그래프 확인 2. 3_test_model.py 실행 → 테스트 데이터 평가
# =====================================
# 2. 학습 결과 시각화 (최종 에러 방지 버전)
# =====================================
# 💡 [설정] 모든 폰트 및 수식 엔진 에러 방지
plt.rcParams['axes.unicode_minus'] = False # 음수 부호 하이픈 사용
plt.rcParams['mathtext.fontset'] = 'custom' # 수식 폰트 사용자 설정
plt.rcParams['mathtext.default'] = 'regular' # 수식에 일반 폰트 사용 (에러 방지 핵심)
# 시스템별 폰트 설정
system = platform.system()
if system == 'Darwin': plt.rcParams['font.family'] = 'AppleGothic'
elif system == 'Windows': plt.rcParams['font.family'] = 'Malgun Gothic'
else: plt.rcParams['font.family'] = 'NanumGothic'
print('='*70)
print('📊 학습 결과 시각화 (에포크별 분석)')
print('='*70)
# =====================================
# 💡 히스토리 로드
# =====================================
try:
checkpoint = torch.load('best_model.pth')
training_history = checkpoint['history']
print(f'✅ 모델 로드 완료 (에포크: {len(training_history["train_loss"])})')
except:
print('⚠️ best_model.pth를 찾을 수 없습니다.')
exit()
epochs_range = range(1, len(training_history['train_loss']) + 1)
# ─────────────────────────────────
# 1️⃣ 손실(Loss) 그래프
# ─────────────────────────────────
plt.figure(figsize=(10, 5))
plt.plot(epochs_range, training_history['train_loss'], 'b-o', label='Train Loss', markersize=4)
plt.plot(epochs_range, training_history['val_loss'], 'r-s', label='Val Loss', markersize=4)
plt.title('Loss Analysis', fontsize=16, fontweight='bold')
plt.xlabel('Epoch')
plt.ylabel('Loss')
plt.legend()
plt.grid(True, alpha=0.3) # linestyle='--'가 cursive 에러를 유발할 수 있어 기본값 사용
plt.tight_layout()
plt.show()
# ─────────────────────────────────
# 2️⃣ 정확도(Accuracy) 그래프
# ─────────────────────────────────
plt.figure(figsize=(10, 5))
plt.plot(epochs_range, training_history['train_acc'], 'b-o', label='Train Acc', markersize=4)
plt.plot(epochs_range, training_history['val_acc'], 'r-s', label='Val Acc', markersize=4)
plt.title('Accuracy Analysis', fontsize=16, fontweight='bold')
plt.xlabel('Epoch')
plt.ylabel('Accuracy')
plt.legend(loc='lower right')
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
====================================================================== 📊 학습 결과 시각화 (에포크별 분석) ====================================================================== ✅ 모델 로드 완료 (에포크: 17)
# 요약 출력은 터미널에만 (그래프와 별개)
print('\n📈 학습 리포트 요약')
print('-' * 80)
print(f"최고 검증 정확도: {max(training_history['val_acc'])*100:.2f}%")
print(f"최종 학습률: {training_history['lr'][-1]:.8f}")
print('-' * 80)
📈 학습 리포트 요약 -------------------------------------------------------------------------------- 최고 검증 정확도: 99.48% 최종 학습률: 0.00000250 --------------------------------------------------------------------------------
# =====================================
# 3. 테스트 데이터로 최종 평가
# =====================================
import torch
import torch.nn as nn
from tqdm import tqdm
import numpy as np
from sklearn.metrics import confusion_matrix, classification_report
import matplotlib.pyplot as plt
import seaborn as sns
# 한글 폰트 설정
plt.rcParams['font.family'] = 'NanumGothic'
plt.rcParams['axes.unicode_minus'] = False
print('='*70)
print('🧪 테스트 데이터로 최종 평가')
print('='*70)
# =====================================
# 💡 저장된 모델 로드
# =====================================
print('\n📥 저장된 모델 로드 중...')
# 모델 체크포인트 로드
checkpoint = torch.load('best_model.pth')
# 모델에 가중치 로드
model.load_state_dict(checkpoint['model_state_dict'])
model = model.to(device)
print('✅ 모델 로드 완료!')
print(f' 학습된 에포크: {checkpoint["epoch"]}')
print(f' 최고 검증 정확도: {checkpoint["best_acc"]:.4f}')
print('='*70)
# =====================================
# 💡 테스트 함수 정의 (상세 버전)
# =====================================
def test_model(model, test_loader, criterion, device, class_names):
"""
테스트 데이터로 모델을 평가하고 상세 결과 반환
Args:
model: 평가할 모델
test_loader: 테스트 데이터 로더
criterion: 손실 함수
device: GPU/CPU
class_names: 클래스 이름 리스트
Returns:
test_loss: 평균 손실
test_acc: 평균 정확도
all_preds: 전체 예측값 리스트
all_labels: 전체 정답 리스트
all_probs: 전체 확률값 리스트
"""
# 평가 모드로 전환
model.eval()
running_loss = 0.0
running_corrects = 0
total_samples = 0
# 예측 결과 저장을 위한 리스트
all_preds = [] # 예측 클래스
all_labels = [] # 실제 클래스
all_probs = [] # 예측 확률
# 그래디언트 계산 비활성화
with torch.no_grad():
progress_bar = tqdm(test_loader, desc='Testing', leave=True)
for inputs, labels in progress_bar:
inputs = inputs.to(device)
labels = labels.to(device)
# Forward Pass
outputs = model(inputs)
loss = criterion(outputs, labels)
# 확률 계산 (Softmax)
probs = torch.softmax(outputs, dim=1)
# 예측값 계산
_, preds = torch.max(outputs, 1)
# 통계 누적
batch_size = inputs.size(0)
running_loss += loss.item() * batch_size
running_corrects += torch.sum(preds == labels.data)
total_samples += batch_size
# 결과 저장 (CPU로 이동)
all_preds.extend(preds.cpu().numpy())
all_labels.extend(labels.cpu().numpy())
all_probs.extend(probs.cpu().numpy())
# 진행률 업데이트
progress_bar.set_postfix({
'loss': f'{loss.item():.4f}',
'acc': f'{running_corrects.double() / total_samples:.4f}'
})
# 평균 계산
test_loss = running_loss / total_samples
test_acc = running_corrects.double() / total_samples
return test_loss, test_acc.item(), all_preds, all_labels, all_probs
# =====================================
# 💡 테스트 실행
# =====================================
print('\n🚀 테스트 시작...\n')
# 클래스 이름 가져오기 (예: ['cats', 'dogs'])
class_names = test_dataset.classes
test_loss, test_acc, predictions, true_labels, probabilities = test_model(
model=model,
test_loader=test_loader,
criterion=criterion,
device=device,
class_names=class_names
)
print('\n' + '='*70)
print('🏁 테스트 완료!')
print('='*70)
print(f'Test Loss: {test_loss:.4f}')
print(f'Test Acc: {test_acc:.4f} ({test_acc*100:.2f}%)')
print('='*70)
# =====================================
# 💡 혼동 행렬(Confusion Matrix) 생성
# =====================================
print('\n📊 혼동 행렬(Confusion Matrix) 생성 중...')
# numpy 배열로 변환
predictions = np.array(predictions)
true_labels = np.array(true_labels)
probabilities = np.array(probabilities)
# 혼동 행렬 계산
cm = confusion_matrix(true_labels, predictions)
# 시각화
plt.figure(figsize=(10, 8))
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues',
xticklabels=class_names,
yticklabels=class_names,
cbar_kws={'label': '개수'})
plt.title('혼동 행렬(Confusion Matrix)', fontsize=16, fontweight='bold', pad=20)
plt.ylabel('실제(True Label)', fontsize=12)
plt.xlabel('예측(Predicted Label)', fontsize=12)
# 각 셀에 백분율 추가
for i in range(len(class_names)):
for j in range(len(class_names)):
percentage = cm[i, j] / cm[i].sum() * 100
plt.text(j+0.5, i+0.7, f'({percentage:.1f}%)',
ha='center', va='center', fontsize=10, color='gray')
plt.tight_layout()
plt.savefig('confusion_matrix.png', dpi=150, bbox_inches='tight')
print('✅ 혼동 행렬 저장: confusion_matrix.png')
plt.show()
# =====================================
# 💡 클래스별 성능 분석
# =====================================
print('\n📈 클래스별 성능 분석')
print('='*70)
# Classification Report
report = classification_report(true_labels, predictions,
target_names=class_names,
digits=4)
print(report)
# 클래스별 정확도 계산
for i, class_name in enumerate(class_names):
class_mask = (true_labels == i)
class_acc = (predictions[class_mask] == true_labels[class_mask]).mean()
class_total = class_mask.sum()
class_correct = (predictions[class_mask] == true_labels[class_mask]).sum()
print(f'{class_name.upper()}:')
print(f' 총 샘플: {class_total}개')
print(f' 정답 개수: {class_correct}개')
print(f' 정확도: {class_acc:.4f} ({class_acc*100:.2f}%)')
print()
print('='*70)
# =====================================
# 💡 예측 확률 분포 분석
# =====================================
print('\n📊 예측 확률 분포 분석')
print('='*70)
# 정답과 오답으로 분류
correct_mask = (predictions == true_labels)
incorrect_mask = ~correct_mask
# 정답 예측의 확률
correct_probs = probabilities[correct_mask, predictions[correct_mask]]
# 오답 예측의 확률
incorrect_probs = probabilities[incorrect_mask, predictions[incorrect_mask]]
print(f'정답 예측 평균 확률: {correct_probs.mean():.4f} (신뢰도 높음)')
print(f'오답 예측 평균 확률: {incorrect_probs.mean():.4f} (신뢰도 낮음)')
# 확률 분포 시각화
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# 정답 예측 확률 분포
axes[0].hist(correct_probs, bins=30, color='green', alpha=0.7, edgecolor='black')
axes[0].axvline(correct_probs.mean(), color='darkgreen', linestyle='--', linewidth=2,
label=f'평균: {correct_probs.mean():.3f}')
axes[0].set_title('정답 예측의 확률 분포', fontsize=14, fontweight='bold')
axes[0].set_xlabel('예측 확률', fontsize=12)
axes[0].set_ylabel('빈도', fontsize=12)
axes[0].legend()
axes[0].grid(True, alpha=0.3)
# 오답 예측 확률 분포
axes[1].hist(incorrect_probs, bins=30, color='red', alpha=0.7, edgecolor='black')
axes[1].axvline(incorrect_probs.mean(), color='darkred', linestyle='--', linewidth=2,
label=f'평균: {incorrect_probs.mean():.3f}')
axes[1].set_title('오답 예측의 확률 분포', fontsize=14, fontweight='bold')
axes[1].set_xlabel('예측 확률', fontsize=12)
axes[1].set_ylabel('빈도', fontsize=12)
axes[1].legend()
axes[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('prediction_confidence.png', dpi=150, bbox_inches='tight')
print('✅ 확률 분포 저장: prediction_confidence.png')
plt.show()
print('='*70)
# =====================================
# 💡 최종 요약
# =====================================
print('\n🎯 최종 평가 요약')
print('='*70)
print(f'총 테스트 샘플: {len(true_labels):,}개')
print(f'정답 개수: {correct_mask.sum():,}개')
print(f'오답 개수: {incorrect_mask.sum():,}개')
print(f'\n📊 최종 테스트 정확도: {test_acc:.4f} ({test_acc*100:.2f}%)')
print()
print('저장된 파일:')
print(' - confusion_matrix.png (혼동 행렬)')
print(' - prediction_confidence.png (예측 확률 분포)')
print('='*70)
print('\n✅ 모든 평가가 완료되었습니다! 🎉')
====================================================================== 🧪 테스트 데이터로 최종 평가 ====================================================================== 📥 저장된 모델 로드 중... ✅ 모델 로드 완료! 학습된 에포크: 6 최고 검증 정확도: 0.9936 ====================================================================== 🚀 테스트 시작...
Testing: 100%|██████████| 20/20 [00:02<00:00, 6.86it/s, loss=0.0373, acc=0.9920]
====================================================================== 🏁 테스트 완료! ====================================================================== Test Loss: 0.0370 Test Acc: 0.9920 (99.20%) ====================================================================== 📊 혼동 행렬(Confusion Matrix) 생성 중... ✅ 혼동 행렬 저장: confusion_matrix.png
📈 클래스별 성능 분석
======================================================================
precision recall f1-score support
cat 0.9912 0.9928 0.9920 1250
dog 0.9928 0.9912 0.9920 1250
accuracy 0.9920 2500
macro avg 0.9920 0.9920 0.9920 2500
weighted avg 0.9920 0.9920 0.9920 2500
CAT:
총 샘플: 1250개
정답 개수: 1241개
정확도: 0.9928 (99.28%)
DOG:
총 샘플: 1250개
정답 개수: 1239개
정확도: 0.9912 (99.12%)
======================================================================
📊 예측 확률 분포 분석
======================================================================
정답 예측 평균 확률: 0.9983 (신뢰도 높음)
오답 예측 평균 확률: 0.9010 (신뢰도 낮음)
✅ 확률 분포 저장: prediction_confidence.png
====================================================================== 🎯 최종 평가 요약 ====================================================================== 총 테스트 샘플: 2,500개 정답 개수: 2,480개 오답 개수: 20개 📊 최종 테스트 정확도: 0.9920 (99.20%) 저장된 파일: - confusion_matrix.png (혼동 행렬) - prediction_confidence.png (예측 확률 분포) ====================================================================== ✅ 모든 평가가 완료되었습니다! 🎉
# =====================================
# 💡 오답 노트: 틀린 예측들만 보기
# =====================================
def show_wrong_predictions(model, dataset, device, n_samples=20):
"""
틀린 예측들만 골라서 보여줍니다.
"""
model.eval()
wrong_indices = []
# 틀린 예측 찾기
print('\n🔍 틀린 예측 찾는 중...')
for idx in tqdm(range(len(dataset))):
img_tensor, true_label = dataset[idx]
with torch.no_grad():
img_tensor_batch = img_tensor.unsqueeze(0).to(device)
output = model(img_tensor_batch)
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']
# ⭐ 수정: ImageNet 정규화 값 (전처리에서 사용한 값)
IMAGENET_MEAN = [0.485, 0.456, 0.406]
IMAGENET_STD = [0.229, 0.224, 0.225]
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.savefig('wrong_predictions.png', dpi=150, bbox_inches='tight') # ⭐ 추가: 저장
print('✅ 오답 노트 저장: wrong_predictions.png')
plt.show()
# 실행
print('\n' + '='*70)
print('📝 오답 노트 생성')
print('='*70)
show_wrong_predictions(model, test_dataset, device, n_samples=20)
print('='*70)
====================================================================== 📝 오답 노트 생성 ====================================================================== 🔍 틀린 예측 찾는 중...
97%|█████████▋| 2434/2500 [00:29<00:00, 82.78it/s]
❌ 총 20개의 오답을 찾았습니다. ✅ 오답 노트 저장: wrong_predictions.png
======================================================================