전이학습(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}}$

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) ===
No description has been provided for this image
=== 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%

자, 데이터를 다운로드해봅시다! 🚀

In [3]:
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 [4]:
# 파일이 없을 때만 다운로드 실행
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 [5]:
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장
In [6]:
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)
No description has been provided for this image
In [7]:
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] 
No description has been provided for this image
평균 크기: 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 정규화 필수 (전이학습용)

In [8]:
# =====================================
# 데이터 전처리 파이프라인 정의 (개선 버전)
# =====================================

# 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으로 일관된 평가!
In [9]:
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}
In [10]:
# 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 [11]:
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)
=== 고양이 이미지 증강 ===
No description has been provided for this image
📐 원본 크기: 300×281 pixels
📐 증강 후 크기: 224×224 pixels
🔄 적용된 증강: RandomResizedCrop, 좌우반전, 회전, 색상변화

============================================================

=== 강아지 이미지 증강 ===
No description has been provided for this image
📐 원본 크기: 327×500 pixels
📐 증강 후 크기: 224×224 pixels
🔄 적용된 증강: RandomResizedCrop, 좌우반전, 회전, 색상변화
In [12]:
# 리사이즈 방식 비교 시각화
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)
=== 고양이 이미지 리사이즈 비교 ===
No description has been provided for this image
📊 리사이즈 방식 설명:
  1️⃣  원본: 300×281 (변환 없음)
  2️⃣  Resize(224,224): 강제로 224×224로 변형 → ⚠️ 왜곡 발생
  3️⃣  Resize+CenterCrop: 비율 유지 후 중앙 자르기 → ✅ 왜곡 없음
  4️⃣  RandomResizedCrop(80%): 80% 영역만 사용 → ✅ 약간 줌인
  5️⃣  RandomResizedCrop(50%): 50% 영역만 사용 → ✅ 많이 줌인
  6️⃣  RandomResizedCrop(100%): 전체 영역 사용 → ✅ 줌 없음

================================================================================

=== 강아지 이미지 리사이즈 비교 ===
No description has been provided for this image
📊 리사이즈 방식 설명:
  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을 선택합니다!¶

선택 이유:

  1. ✅ 검증된 성능: 수많은 프로젝트에서 입증됨
  2. ✅ 풍부한 자료: 레퍼런스와 예제가 많음
  3. ✅ 적절한 복잡도: 너무 가볍지도, 무겁지도 않음
  4. ✅ 교육 가치: 실무에서 가장 많이 사용
  5. ✅ 성능: 개-고양이 분류에 충분히 높은 정확도 (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 models
model = models.resnet50(weights='DEFAULT')
전이학습의 정석. 가장 안정적인 성능.
경량 (Mobile) mobilenet_v3 from torchvision import models
model = models.mobilenet_v3_small(weights='DEFAULT')
스마트폰/임베디드용. 속도가 매우 빠름.
효율 (Efficient) efficientnet_b0 from torchvision import models
model = models.efficientnet_b0(weights='DEFAULT')
가성비 모델. 적은 연산으로 높은 정확도.
고밀도 (DenseNet) densenet121 from torchvision import models
model = models.densenet121(weights='DEFAULT')
층간 연결이 촘촘함. 세밀한 특징 추출에 유리.
클래식 (VGG) vgg16 from torchvision import models
model = models.vgg16(weights='DEFAULT')
구조가 직관적이나 파라미터가 많아 무거움.
최신 (ViT) vit_b_16 from torchvision import models
model = models.vit_b_16(weights='DEFAULT')
Transformer 구조 적용. 대용량 데이터에 강함.

💡 팁: 최신 PyTorch 환경에서는 pretrained=True 대신 weights='DEFAULT'를 사용하는 것이 표준입니다.

In [27]:
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.
In [ ]:
# =====================================
# 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}')

In [14]:
# =====================================
# 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 실행 → 테스트 데이터 평가

In [22]:
# =====================================
# 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)
No description has been provided for this image
No description has been provided for this image
In [25]:
# 요약 출력은 터미널에만 (그래프와 별개)
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
--------------------------------------------------------------------------------
In [28]:
# =====================================
# 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
No description has been provided for this image
📈 클래스별 성능 분석
======================================================================
              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
No description has been provided for this image
======================================================================

🎯 최종 평가 요약
======================================================================
총 테스트 샘플: 2,500개
정답 개수: 2,480개
오답 개수: 20개

📊 최종 테스트 정확도: 0.9920 (99.20%)

저장된 파일:
  - confusion_matrix.png (혼동 행렬)
  - prediction_confidence.png (예측 확률 분포)
======================================================================

✅ 모든 평가가 완료되었습니다! 🎉
In [29]:
# =====================================
# 💡 오답 노트: 틀린 예측들만 보기
# =====================================

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
No description has been provided for this image
======================================================================