🐱🐶 CNN으로 고양이 vs 강아지 분류하기¶

목표: 합성곱 신경망(CNN)을 처음부터 만들어서 실제 사진 데이터로 학습시켜봅니다.

데이터: Kaggle Dogs vs. Cats (25,000장)

기대 성능: 약 70-75% 정확도 (기본 CNN으로는 이 정도가 한계)

📦 1. 환경 설정 및 라이브러리 임포트¶

In [1]:
import setup_env
--------------------------------------------------------------------------------
=== Hardware Acceleration ===
PyTorch version: 2.9.0a0+145a3a7bda.nv25.10
Using NVIDIA GPU (CUDA)
   CUDA version: 13.0
   GPU name: NVIDIA GeForce RTX 5070 Ti
   GPU count: 1
   Total GPU memory: 15.92 GB
   Allocated memory: 0.00 GB
   Free memory: 15.92 GB
Device: cuda

=== Matplotlib Settings ===
✅ Font: NanumGothic

=== System Info ===
OS: Ubuntu 24.04.3 LTS (Noble Numbat)
    Kernel: 6.6.87.2-microsoft-standard-WSL2
Architecture: x86_64
Python: 3.12.3
Working directory: /workspace/ai-deeplearning/tutorial

=== Library Versions ===
NumPy: 2.1.0
Pandas: 3.0.0
Matplotlib: 3.10.7
Scikit-learn: 1.7.2
OpenCV: Not installed → !pip install -q opencv-python
Pillow: 12.0.0
Seaborn: 0.13.2
TensorFlow: Not installed → !pip install -q tensorflow
Transformers: 4.40.1
TorchVision: 0.24.0a0+094e7af5

=== Environment setup completed ===
--------------------------------------------------------------------------------

=== Visualizing Test Plot (Wide View) ===
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 ===
--------------------------------------------------------------------------------
연도 모델 Top-1 정확도 Top-5 정확도 설명
2010 SVM 52.9% 71.8% CNN 이전 전통적 머신러닝 방식
2012 AlexNet 62.5% 84.7% 최초 딥러닝 CNN 우승, GPU 활용, 딥러닝 혁명의 시작
2013 ZFNet 64.4% 88.8% AlexNet 개선, NYU 연구팀 개발
2014 GoogLeNet 68.7% 93.3% 구글 개발, 22층 깊은 구조
2015 ResNet 78.6% 96.4% MS 개발, 152층, 인간 초월
2016 SENet 계열 82.7% 96.6% 채널별 중요도 학습
2017 SENet 82.7% 97.7% 마지막 대회 - 38개 참가팀 중 29개 팀이 Top-5 95% 이상 달성, 벤치마크 종료
기준 인간 ~75% 94.9% Karpathy 실험 기준

📥 2. 데이터셋 다운로드 및 준비¶

Kaggle API를 사용하여 Dogs vs. Cats 데이터를 다운로드합니다.

In [4]:
import os
import zipfile
import shutil
import requests
from tqdm import tqdm
from sklearn.model_selection import train_test_split

# 공통 경로 설정
base_dir = './data_dogs_cats'
zip_path = 'cats_and_dogs_25k.zip'
url = "https://download.microsoft.com/download/3/E/1/3E1C3F21-ECDB-4869-8368-6DEBA77B919F/kagglecatsanddogs_5340.zip"

print("✅ 설정 완료")
✅ 설정 완료
In [5]:
# 파일이 없을 때만 다운로드 실행
if not os.path.exists(zip_path):
    print("🚀 25,000장 전체 데이터셋 다운로드 시작 (약 800MB)...")
    response = requests.get(url, stream=True)
    with open(zip_path, "wb") as f:
        for data in tqdm(response.iter_content(chunk_size=1024)):
            f.write(data)
    print("✅ 다운로드 완료")
else:
    print("📂 이미 데이터셋 압축 파일이 존재합니다. 다운로드를 건너뜁니다.")
📂 이미 데이터셋 압축 파일이 존재합니다. 다운로드를 건너뜁니다.
In [6]:
if not os.path.exists(base_dir):
    print("📦 7:2:1 비율로 데이터 분할 및 폴더 정리 시작...")
    
    # 임시 압축 해제
    with zipfile.ZipFile(zip_path, 'r') as zip_ref:
        zip_ref.extractall('./temp_raw')

    # train/val/test 폴더 생성
    for split in ['train', 'val', 'test']:
        for label in ['dog', 'cat']:
            os.makedirs(os.path.join(base_dir, split, label), exist_ok=True)

    # 원본 파일 리스트 추출 (Dog, Cat 각각)
    dog_src = './temp_raw/PetImages/Dog'
    cat_src = './temp_raw/PetImages/Cat'
    
    dogs = [os.path.join(dog_src, f) for f in os.listdir(dog_src) if f.endswith('.jpg')]
    cats = [os.path.join(cat_src, f) for f in os.listdir(cat_src) if f.endswith('.jpg')]

    # --- 7:2:1 분할 로직 ---
    # 1. 70%(Train) vs 30%(나머지)
    train_dogs, rest_dogs = train_test_split(dogs, test_size=0.3, random_state=42)
    train_cats, rest_cats = train_test_split(cats, test_size=0.3, random_state=42)

    # 2. 남은 30% 중 2/3(전체의 20%)는 Val, 1/3(전체의 10%)는 Test
    val_dogs, test_dogs = train_test_split(rest_dogs, test_size=1/3, random_state=42)
    val_cats, test_cats = train_test_split(rest_cats, test_size=1/3, random_state=42)

    # 파일 복사 함수 (정상 파일만)
    def copy_valid_files(files, target_path):
        count = 0
        for f in files:
            try:
                if os.path.getsize(f) > 0: # 0바이트 파일 걸러내기
                    shutil.copy(f, target_path)
                    count += 1
            except:
                pass
        return count

    # 실제 복사 실행
    print("🚚 파일을 폴더로 복사 중...")
    copy_valid_files(train_dogs, os.path.join(base_dir, 'train', 'dog'))
    copy_valid_files(train_cats, os.path.join(base_dir, 'train', 'cat'))
    copy_valid_files(val_dogs, os.path.join(base_dir, 'val', 'dog'))
    copy_valid_files(val_cats, os.path.join(base_dir, 'val', 'cat'))
    copy_valid_files(test_dogs, os.path.join(base_dir, 'test', 'dog'))
    copy_valid_files(test_cats, os.path.join(base_dir, 'test', 'cat'))

    shutil.rmtree('./temp_raw')
    print("✨ 모든 작업 완료!")
else:
    print(f"✅ '{base_dir}' 폴더가 이미 존재하여 작업을 건너뜁니다.")

# 최종 개수 확인
for s in ['train', 'val', 'test']:
    for l in ['dog', 'cat']:
        p = os.path.join(base_dir, s, l)
        print(f"📊 {s}/{l}: {len(os.listdir(p))}장")
✅ './data_dogs_cats' 폴더가 이미 존재하여 작업을 건너뜁니다.
📊 train/dog: 8749장
📊 train/cat: 8749장
📊 val/dog: 2500장
📊 val/cat: 2500장
📊 test/dog: 1250장
📊 test/cat: 1250장

🗂️ 3. 데이터 분할: Train / Validation / Test¶

전체 25,000장을 다음과 같이 분할합니다:

  • Train: 70% (17,500장) - 모델 학습용
  • Validation: 15% (3,750장) - 하이퍼파라미터 튜닝 및 조기종료
  • Test: 15% (3,750장) - 최종 성능 평가

각 클래스(고양이/강아지)별로 균등하게 분할합니다.

🖼️ 4. 데이터 시각화¶

실제 이미지가 어떻게 생겼는지 확인해봅시다.

In [7]:
# 수정 시작: D2Coding 폰트 적용 및 이모지 제거로 한글 깨짐 해결
def show_sample_images(data_dir, n_samples=8):
    """
    각 클래스에서 n_samples//2 개씩 샘플 이미지를 보여줍니다.
    """
    import matplotlib.pyplot as plt
    from PIL import Image
    import pathlib
    import matplotlib.font_manager as fm

    # 1. D2Coding 폰트 설정 (경로 확인 필수)
    font_path = '/usr/share/fonts/truetype/d2coding/D2Coding-Ver1.3.2-20180524.ttf'
    if os.path.exists(font_path):
        font_prop = fm.FontProperties(fname=font_path)
        plt.rc('font', family='D2Coding')
    
    # data_dir을 Path 객체로 변환
    data_dir = pathlib.Path(data_dir)
    
    fig, axes = plt.subplots(2, n_samples//2, figsize=(15, 6))
    
    # 2. 고양이 샘플
    cat_dir = data_dir / 'cat'
    cat_files = list(cat_dir.glob('*.jpg'))[:n_samples//2]
    
    for i, img_path in enumerate(cat_files):
        img = Image.open(img_path)
        axes[0, i].imshow(img)
        axes[0, i].set_title(f'Cat #{i+1}', fontsize=10)
        axes[0, i].axis('off')
    
    # 3. 강아지 샘플
    dog_dir = data_dir / 'dog'
    dog_files = list(dog_dir.glob('*.jpg'))[:n_samples//2]
    
    for i, img_path in enumerate(dog_files):
        img = Image.open(img_path)
        axes[1, i].imshow(img)
        axes[1, i].set_title(f'Dog #{i+1}', fontsize=10)
        axes[1, i].axis('off')
    
    # 4. 제목 설정 (이모지는 폰트에서 지원하지 않으므로 텍스트만 사용)
    plt.suptitle('고양이(Cat) vs 강아지(Dog) 샘플 이미지', fontsize=14, fontweight='bold')
    plt.tight_layout()
    plt.show()

# 실행
train_path = os.path.join(base_dir, 'train')
show_sample_images(train_path, n_samples=8)
# 수정 종료
No description has been provided for this image
In [8]:
# 수정 시작: Path 처리 및 D2Coding 폰트 명시적 적용
import pathlib

# 1. train_dir을 Path 객체로 확실히 변환
if isinstance(train_path, str):
    train_dir_path = pathlib.Path(train_path)
else:
    train_dir_path = train_path

print('📏 이미지 크기 분석 중... (샘플 100장)')

# glob 패턴 수정 (하위 폴더의 모든 jpg 찾기)
sample_files = list(train_dir_path.glob('*/*.jpg'))[:100]
widths = []
heights = []

for img_path in tqdm(sample_files):
    img = Image.open(img_path)
    widths.append(img.width)
    heights.append(img.height)

# 2. 시각화 (D2Coding 폰트 설정 포함)
import matplotlib.font_manager as fm
font_path = '/usr/share/fonts/truetype/d2coding/D2Coding-Ver1.3.2-20180524.ttf'
font_prop = fm.FontProperties(fname=font_path)

fig, axes = plt.subplots(1, 2, figsize=(12, 4))

# 너비 히스토그램
axes[0].hist(widths, bins=20, alpha=0.7, color='skyblue', edgecolor='black')
axes[0].set_xlabel('Width (pixels)', fontproperties=font_prop)
axes[0].set_ylabel('Frequency', fontproperties=font_prop)
axes[0].set_title('이미지 너비 분포', fontproperties=font_prop, fontsize=12)
axes[0].axvline(np.mean(widths), color='red', linestyle='--', label=f'평균: {np.mean(widths):.0f}px')
axes[0].legend(prop=font_prop)

# 높이 히스토그램
axes[1].hist(heights, bins=20, alpha=0.7, color='lightcoral', edgecolor='black')
axes[1].set_xlabel('Height (pixels)', fontproperties=font_prop)
axes[1].set_ylabel('Frequency', fontproperties=font_prop)
axes[1].set_title('이미지 높이 분포', fontproperties=font_prop, fontsize=12)
axes[1].axvline(np.mean(heights), color='red', linestyle='--', label=f'평균: {np.mean(heights):.0f}px')
axes[1].legend(prop=font_prop)

plt.tight_layout()
plt.show()

print(f'\n평균 크기: {np.mean(widths):.0f} × {np.mean(heights):.0f} pixels')
print(f'최소 크기: {np.min(widths):.0f} × {np.min(heights):.0f} pixels')
print(f'최대 크기: {np.max(widths):.0f} × {np.max(heights):.0f} pixels')
print('\n💡 모든 이미지를 224×224로 리사이즈하여 학습합니다.')
# 수정 종료
📏 이미지 크기 분석 중... (샘플 100장)
100%|██████████| 100/100 [00:00<00:00, 774.77it/s]
No description has been provided for this image
평균 크기: 394 × 359 pixels
최소 크기: 141 × 120 pixels
최대 크기: 500 × 500 pixels

💡 모든 이미지를 224×224로 리사이즈하여 학습합니다.

🔄 5. 데이터 전처리 및 증강 (Data Augmentation)¶

이미지를 모델에 입력하기 전에 전처리가 필요합니다:

  1. 리사이즈: 모든 이미지를 224×224로 통일
  2. 정규화: ImageNet 평균/표준편차로 정규화 (전이학습 대비)
  3. 증강 (Train only): 좌우반전, 회전 등으로 데이터 다양성 확보
In [9]:
# ImageNet 통계값 (RGB 각 채널별)
IMAGENET_MEAN = [0.485, 0.456, 0.406]
IMAGENET_STD = [0.229, 0.224, 0.225]

# 이미지 크기
IMG_SIZE = 224

# 훈련 데이터 전처리 (Data Augmentation 포함)
train_transform = transforms.Compose([
    transforms.Resize((IMG_SIZE, IMG_SIZE)),      # 224×224로 리사이즈
    transforms.RandomHorizontalFlip(p=0.5),       # 50% 확률로 좌우 반전
    transforms.RandomRotation(degrees=15),        # ±15도 랜덤 회전
    transforms.ColorJitter(                       # 색상 변화
        brightness=0.2,                           # 밝기 ±20%
        contrast=0.2,                             # 대비 ±20%
        saturation=0.2,                           # 채도 ±20%
        hue=0.1                                   # 색조 ±10%
    ),
    transforms.ToTensor(),                        # PIL Image → Tensor (0~1)
    transforms.Normalize(                         # 정규화
        mean=IMAGENET_MEAN,
        std=IMAGENET_STD
    )
])

# 검증/테스트 데이터 전처리 (Augmentation 없음)
val_test_transform = transforms.Compose([
    transforms.Resize((IMG_SIZE, IMG_SIZE)),
    transforms.ToTensor(),
    transforms.Normalize(
        mean=IMAGENET_MEAN,
        std=IMAGENET_STD
    )
])

print('✅ 데이터 전처리 파이프라인 정의 완료!')
print(f'   이미지 크기: {IMG_SIZE}×{IMG_SIZE}')
print(f'   정규화: ImageNet 평균/표준편차 사용')
✅ 데이터 전처리 파이프라인 정의 완료!
   이미지 크기: 224×224
   정규화: ImageNet 평균/표준편차 사용
In [10]:
# 수정 시작: 경로 변수명 통일 및 문자열 변환
# 위에서 정의한 base_dir을 기준으로 경로를 다시 한번 확실히 잡아줍니다.

train_path = os.path.join(base_dir, 'train')
val_path = os.path.join(base_dir, 'val')
test_path = os.path.join(base_dir, 'test')

train_dataset = datasets.ImageFolder(
    root=str(train_path), 
    transform=train_transform
)

val_dataset = datasets.ImageFolder(
    root=str(val_path),  # 변수명이 val_path로 정의되어 있어야 합니다.
    transform=val_test_transform
)

test_dataset = datasets.ImageFolder(
    root=str(test_path), 
    transform=val_test_transform
)

print('✅ Dataset 생성 완료!')
print(f'   Train: {len(train_dataset):,}장')
print(f'   Val: {len(val_dataset):,}장')
print(f'   Test: {len(test_dataset):,}장')
print(f'\n   클래스: {train_dataset.classes}')
print(f'   클래스 인덱스: {train_dataset.class_to_idx}')
# 수정 종료
✅ Dataset 생성 완료!
   Train: 17,498장
   Val: 5,000장
   Test: 2,500장

   클래스: ['cat', 'dog']
   클래스 인덱스: {'cat': 0, 'dog': 1}
In [11]:
# DataLoader 생성
BATCH_SIZE = 128  # GPU 메모리에 맞게 조절 가능 (16, 32, 64 등)
NUM_WORKERS = 4  # CPU 코어 수에 맞게 조절

train_loader = DataLoader(
    train_dataset,
    batch_size=BATCH_SIZE,
    shuffle=True,           # 훈련 시 섞기
    num_workers=NUM_WORKERS,
    persistent_workers=True,
    pin_memory=True        # GPU 사용 시 속도 향상
)

val_loader = DataLoader(
    val_dataset,
    batch_size=BATCH_SIZE,
    shuffle=False,          # 검증 시 순서 유지
    num_workers=NUM_WORKERS,
    persistent_workers=True,
    pin_memory=True
)

test_loader = DataLoader(
    test_dataset,
    batch_size=BATCH_SIZE,
    shuffle=False,
    num_workers=NUM_WORKERS,
    persistent_workers=True,
    pin_memory=True
)

print('✅ DataLoader 생성 완료!')
print(f'   배치 크기: {BATCH_SIZE}')
print(f'   Train 배치 수: {len(train_loader)}')
print(f'   Val 배치 수: {len(val_loader)}')
print(f'   Test 배치 수: {len(test_loader)}')
✅ DataLoader 생성 완료!
   배치 크기: 128
   Train 배치 수: 137
   Val 배치 수: 40
   Test 배치 수: 20
In [12]:
# Augmentation 효과 시각화
def show_augmented_images(dataset, idx=0, n_augmentations=8):
    """
    같은 이미지에 대해 여러 번 augmentation을 적용한 결과를 보여줍니다.
    """
    # 원본 이미지 경로 가져오기
    img_path, label = dataset.samples[idx]
    original_img = Image.open(img_path)
    
    fig, axes = plt.subplots(2, 4, figsize=(15, 7))
    axes = axes.ravel()
    
    for i in range(n_augmentations):
        # Transform 적용
        img_tensor = dataset.transform(original_img)
        
        # 정규화 역변환 (시각화용)
        img_numpy = img_tensor.numpy().transpose(1, 2, 0)
        img_numpy = img_numpy * np.array(IMAGENET_STD) + np.array(IMAGENET_MEAN)
        img_numpy = np.clip(img_numpy, 0, 1)
        
        axes[i].imshow(img_numpy)
        axes[i].set_title(f'Augmentation #{i+1}', fontsize=10)
        axes[i].axis('off')
    
    class_name = 'Cat' if label == 0 else 'Dog'
    plt.suptitle(f'Data Augmentation 효과 ({class_name})', fontsize=14, fontweight='bold')
    plt.tight_layout()
    plt.show()

# 고양이 이미지로 augmentation 효과 확인
show_augmented_images(train_dataset, idx=0, n_augmentations=8)
No description has been provided for this image

🧠 6. CNN 모델 정의¶

처음부터 간단한 CNN을 만들어봅시다.

구조:

  • Conv Layer 1: 3 → 32 channels
  • Conv Layer 2: 32 → 64 channels
  • Conv Layer 3: 64 → 128 channels
  • Conv Layer 4: 128 → 256 channels
  • Fully Connected: 256 → 2 (cats vs dogs)
In [13]:
class SimpleCNN(nn.Module):
    """
    간단한 CNN 모델 (4개의 합성곱층 + 2개의 완전연결층)
    """
    def __init__(self, num_classes=2):
        super(SimpleCNN, self).__init__()
        
        # ============ 특징 추출부 (Feature Extraction) ============
        
        # Conv Block 1: 224x224x3 → 112x112x32
        self.conv1 = nn.Sequential(
            nn.Conv2d(3, 32, kernel_size=3, padding=1),  # 3채널(RGB) → 32채널
            nn.BatchNorm2d(32),                          # 배치 정규화
            nn.ReLU(inplace=True),                       # 활성화 함수
            nn.MaxPool2d(kernel_size=2, stride=2)        # 크기 1/2로 축소
        )
        
        # Conv Block 2: 112x112x32 → 56x56x64
        self.conv2 = nn.Sequential(
            nn.Conv2d(32, 64, kernel_size=3, padding=1),
            nn.BatchNorm2d(64),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=2, stride=2)
        )
        
        # Conv Block 3: 56x56x64 → 28x28x128
        self.conv3 = nn.Sequential(
            nn.Conv2d(64, 128, kernel_size=3, padding=1),
            nn.BatchNorm2d(128),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=2, stride=2)
        )
        
        # Conv Block 4: 28x28x128 → 14x14x256
        self.conv4 = nn.Sequential(
            nn.Conv2d(128, 256, kernel_size=3, padding=1),
            nn.BatchNorm2d(256),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=2, stride=2)
        )
        
        # ============ 분류부 (Classification) ============
        
        # Global Average Pooling: 14x14x256 → 1x1x256
        self.global_avg_pool = nn.AdaptiveAvgPool2d((1, 1))
        
        # Fully Connected Layers
        self.classifier = nn.Sequential(
            nn.Dropout(0.5),                    # 과적합 방지 (50% 드롭)
            nn.Linear(256, 128),                # 256 → 128
            nn.ReLU(inplace=True),
            nn.Dropout(0.5),
            nn.Linear(128, num_classes)         # 128 → 2 (cats vs dogs)
        )
    
    def forward(self, x):
        # 특징 추출
        x = self.conv1(x)      # 224 → 112
        x = self.conv2(x)      # 112 → 56
        x = self.conv3(x)      # 56 → 28
        x = self.conv4(x)      # 28 → 14
        
        # Global Average Pooling
        x = self.global_avg_pool(x)  # 14x14x256 → 1x1x256
        x = x.view(x.size(0), -1)    # Flatten: (batch, 256)
        
        # 분류
        x = self.classifier(x)
        
        return x
In [14]:
from torchsummary import summary

# 모델을 GPU(또는 CPU)로 이동
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = SimpleCNN().to(device)

# 시각화 (입력 사이즈: 3채널, 224x224)
summary(model, input_size=(3, 224, 224))
----------------------------------------------------------------
        Layer (type)               Output Shape         Param #
================================================================
            Conv2d-1         [-1, 32, 224, 224]             896
       BatchNorm2d-2         [-1, 32, 224, 224]              64
              ReLU-3         [-1, 32, 224, 224]               0
         MaxPool2d-4         [-1, 32, 112, 112]               0
            Conv2d-5         [-1, 64, 112, 112]          18,496
       BatchNorm2d-6         [-1, 64, 112, 112]             128
              ReLU-7         [-1, 64, 112, 112]               0
         MaxPool2d-8           [-1, 64, 56, 56]               0
            Conv2d-9          [-1, 128, 56, 56]          73,856
      BatchNorm2d-10          [-1, 128, 56, 56]             256
             ReLU-11          [-1, 128, 56, 56]               0
        MaxPool2d-12          [-1, 128, 28, 28]               0
           Conv2d-13          [-1, 256, 28, 28]         295,168
      BatchNorm2d-14          [-1, 256, 28, 28]             512
             ReLU-15          [-1, 256, 28, 28]               0
        MaxPool2d-16          [-1, 256, 14, 14]               0
AdaptiveAvgPool2d-17            [-1, 256, 1, 1]               0
          Dropout-18                  [-1, 256]               0
           Linear-19                  [-1, 128]          32,896
             ReLU-20                  [-1, 128]               0
          Dropout-21                  [-1, 128]               0
           Linear-22                    [-1, 2]             258
================================================================
Total params: 422,530
Trainable params: 422,530
Non-trainable params: 0
----------------------------------------------------------------
Input size (MB): 0.57
Forward/backward pass size (MB): 74.66
Params size (MB): 1.61
Estimated Total Size (MB): 76.84
----------------------------------------------------------------
In [15]:
# 모델 파라미터 수 계산
def count_parameters(model):
    """
    모델의 학습 가능한 파라미터 수를 계산합니다.
    """
    return sum(p.numel() for p in model.parameters() if p.requires_grad)

n_params = count_parameters(model)
print(f'📊 총 파라미터 수: {n_params:,}개 ({n_params/1e6:.2f}M)')

# 레이어별 파라미터 수
print('\n레이어별 파라미터:')
for name, param in model.named_parameters():
    if param.requires_grad:
        print(f'  {name:30} : {param.numel():>10,}')
📊 총 파라미터 수: 422,530개 (0.42M)

레이어별 파라미터:
  conv1.0.weight                 :        864
  conv1.0.bias                   :         32
  conv1.1.weight                 :         32
  conv1.1.bias                   :         32
  conv2.0.weight                 :     18,432
  conv2.0.bias                   :         64
  conv2.1.weight                 :         64
  conv2.1.bias                   :         64
  conv3.0.weight                 :     73,728
  conv3.0.bias                   :        128
  conv3.1.weight                 :        128
  conv3.1.bias                   :        128
  conv4.0.weight                 :    294,912
  conv4.0.bias                   :        256
  conv4.1.weight                 :        256
  conv4.1.bias                   :        256
  classifier.1.weight            :     32,768
  classifier.1.bias              :        128
  classifier.4.weight            :        256
  classifier.4.bias              :          2
In [16]:
# 모델 입력/출력 테스트
dummy_input = torch.randn(1, 3, 224, 224).to(device)
output = model(dummy_input)

print(f'입력 크기: {dummy_input.shape}')
print(f'출력 크기: {output.shape}')
print(f'\n출력값 (로짓): {output}')
print(f'출력값 (확률): {torch.softmax(output, dim=1)}')
입력 크기: torch.Size([1, 3, 224, 224])
출력 크기: torch.Size([1, 2])

출력값 (로짓): tensor([[ 0.1315, -0.2536]], device='cuda:0', grad_fn=<AddmmBackward0>)
출력값 (확률): tensor([[0.5951, 0.4049]], device='cuda:0', grad_fn=<SoftmaxBackward0>)

⚙️ 7. 학습 설정¶

손실 함수, 옵티마이저, 학습률 스케줄러를 설정합니다.

In [17]:
# 수정 시작: 필수 라이브러리 임포트 추가 (nn, optim)
import torch.nn as nn
import torch.optim as optim

# 하이퍼파라미터 설정
EPOCHS = 100                 # 에포크 수
LEARNING_RATE = 0.001       # 초기 학습률
WEIGHT_DECAY = 1e-4         # L2 정규화 (과적합 방지)

# 손실 함수 (Cross Entropy Loss)
# model이 미리 정의되어 있어야 합니다.
criterion = nn.CrossEntropyLoss()

# 옵티마이저 (Adam)
optimizer = optim.Adam(
    model.parameters(),
    lr=LEARNING_RATE,
    weight_decay=WEIGHT_DECAY
)

# 학습률 스케줄러 (ReduceLROnPlateau)
# (주의) 최신 버전에서는 verbose=True 대신 아래와 같이 로그를 확인합니다.
scheduler = optim.lr_scheduler.ReduceLROnPlateau(
    optimizer,
    mode='min',           # loss를 최소화
    factor=0.5,           # 학습률을 1/2로 감소
    patience=3            # 3 epoch 동안 개선 없으면 감소
)

print('✅ 학습 설정 완료!')
print(f'   Epochs: {EPOCHS}')
print(f'   Learning Rate: {LEARNING_RATE}')
print(f'   Weight Decay: {WEIGHT_DECAY}')
print(f'   Optimizer: Adam')
print(f'   Scheduler: ReduceLROnPlateau')
# 수정 종료
✅ 학습 설정 완료!
   Epochs: 100
   Learning Rate: 0.001
   Weight Decay: 0.0001
   Optimizer: Adam
   Scheduler: ReduceLROnPlateau

🏋️ 8. 학습 루프¶

이제 모델을 학습시켜봅시다!

In [18]:
# 학습 기록용 딕셔너리
history = {
    'train_loss': [],
    'train_acc': [],
    'val_loss': [],
    'val_acc': [],
    'lr': []
}

# Best model 저장용
best_val_acc = 0.0
best_model_path = 'best_cnn_model.pth'
In [19]:
def train_one_epoch(model, dataloader, criterion, optimizer, device):
    """
    1 epoch 동안 모델을 학습시킵니다.
    
    Returns:
        avg_loss: 평균 손실
        avg_acc: 평균 정확도
    """
    model.train()  # 학습 모드
    
    running_loss = 0.0
    running_corrects = 0
    total_samples = 0
    
    # 진행률 표시
    pbar = tqdm(dataloader, desc='Training', leave=False)
    
    for inputs, labels in pbar:
        # GPU로 데이터 이동
        inputs = inputs.to(device)
        labels = labels.to(device)
        
        # 그래디언트 초기화
        optimizer.zero_grad()
        
        # 순전파
        outputs = model(inputs)
        loss = criterion(outputs, labels)
        
        # 역전파
        loss.backward()
        
        # 가중치 업데이트
        optimizer.step()
        
        # 통계 업데이트
        batch_size = inputs.size(0)
        running_loss += loss.item() * batch_size
        
        _, preds = torch.max(outputs, 1)
        running_corrects += torch.sum(preds == labels.data).item()
        total_samples += batch_size
        
        # 진행률 업데이트
        pbar.set_postfix({
            'loss': f'{loss.item():.4f}',
            'acc': f'{running_corrects/total_samples:.4f}'
        })
    
    # 평균 계산
    avg_loss = running_loss / total_samples
    avg_acc = running_corrects / total_samples
    
    return avg_loss, avg_acc
In [20]:
def validate(model, dataloader, criterion, device):
    """
    검증 데이터로 모델을 평가합니다.
    
    Returns:
        avg_loss: 평균 손실
        avg_acc: 평균 정확도
    """
    model.eval()  # 평가 모드
    
    running_loss = 0.0
    running_corrects = 0
    total_samples = 0
    
    # 그래디언트 계산 비활성화 (메모리 절약)
    with torch.no_grad():
        pbar = tqdm(dataloader, desc='Validation', leave=False)
        
        for inputs, labels in pbar:
            inputs = inputs.to(device)
            labels = labels.to(device)
            
            # 순전파만 수행
            outputs = model(inputs)
            loss = criterion(outputs, labels)
            
            # 통계 업데이트
            batch_size = inputs.size(0)
            running_loss += loss.item() * batch_size
            
            _, preds = torch.max(outputs, 1)
            running_corrects += torch.sum(preds == labels.data).item()
            total_samples += batch_size
    
    # 평균 계산
    avg_loss = running_loss / total_samples
    avg_acc = running_corrects / total_samples
    
    return avg_loss, avg_acc
In [21]:
# 학습 시작!
print('\n' + '='*80)
print('🏋️ 학습 시작!')
print('='*80 + '\n')

import time
start_time = time.time()

for epoch in range(EPOCHS):
    epoch_start_time = time.time()
    
    # ========== 학습 ==========
    train_loss, train_acc = train_one_epoch(model, train_loader, criterion, optimizer, device)
    
    # ========== 검증 ==========
    val_loss, val_acc = validate(model, val_loader, criterion, device)
    
    # ========== 학습률 업데이트 ==========
    scheduler.step(val_loss)
    current_lr = optimizer.param_groups[0]['lr']
    
    # ========== 기록 저장 ==========
    history['train_loss'].append(train_loss)
    history['train_acc'].append(train_acc)
    history['val_loss'].append(val_loss)
    history['val_acc'].append(val_acc)
    history['lr'].append(current_lr)
    
    # ========== Best Model 저장 ==========
    if val_acc > best_val_acc:
        best_val_acc = val_acc
        torch.save(model.state_dict(), best_model_path)
        print(f'\n✨ Best model saved! (Val Acc: {val_acc:.4f})')
    
    # ========== 진행 상황 출력 ==========
    epoch_time = time.time() - epoch_start_time
    print(f'Epoch [{epoch+1:3d}/{EPOCHS}] {epoch_time:5.1f}s | Train Loss: {train_loss:.4f} Acc: {train_acc:.2%} | Val Loss: {val_loss:.4f} Acc: {val_acc:.2%} | LR: {current_lr:.6f}')

# 학습 종료
total_time = time.time() - start_time
print(f'\n✅ 학습 완료! (총 소요 시간: {total_time/60:.1f}분)')
print(f'🏆 Best Validation Accuracy: {best_val_acc:.4f}')
================================================================================
🏋️ 학습 시작!
================================================================================

                                                                                    
✨ Best model saved! (Val Acc: 0.6062)
Epoch [  1/100]  33.4s | Train Loss: 0.6560 Acc: 60.61% | Val Loss: 0.6446 Acc: 60.62% | LR: 0.001000
                                                                                    
✨ Best model saved! (Val Acc: 0.6666)
Epoch [  2/100]  33.8s | Train Loss: 0.6225 Acc: 65.01% | Val Loss: 0.6037 Acc: 66.66% | LR: 0.001000
                                                                                    
✨ Best model saved! (Val Acc: 0.7180)
Epoch [  3/100]  33.2s | Train Loss: 0.5973 Acc: 68.29% | Val Loss: 0.5572 Acc: 71.80% | LR: 0.001000
                                                                                    
✨ Best model saved! (Val Acc: 0.7394)
Epoch [  4/100]  33.3s | Train Loss: 0.5771 Acc: 70.03% | Val Loss: 0.5352 Acc: 73.94% | LR: 0.001000
                                                                                    
Epoch [  5/100]  33.1s | Train Loss: 0.5604 Acc: 71.53% | Val Loss: 0.5531 Acc: 72.20% | LR: 0.001000
                                                                                    
Epoch [  6/100]  33.4s | Train Loss: 0.5463 Acc: 72.70% | Val Loss: 0.5970 Acc: 67.46% | LR: 0.001000
                                                                                    
✨ Best model saved! (Val Acc: 0.7590)
Epoch [  7/100]  33.2s | Train Loss: 0.5347 Acc: 73.97% | Val Loss: 0.4972 Acc: 75.90% | LR: 0.001000
                                                                                    
Epoch [  8/100]  33.2s | Train Loss: 0.5234 Acc: 74.61% | Val Loss: 0.6635 Acc: 63.68% | LR: 0.001000
                                                                                    
Epoch [  9/100]  33.4s | Train Loss: 0.5025 Acc: 76.08% | Val Loss: 0.5907 Acc: 70.22% | LR: 0.001000
                                                                                    
Epoch [ 10/100]  33.4s | Train Loss: 0.4922 Acc: 76.99% | Val Loss: 0.5334 Acc: 74.80% | LR: 0.001000
                                                                                    
✨ Best model saved! (Val Acc: 0.8082)
Epoch [ 11/100]  33.5s | Train Loss: 0.4825 Acc: 77.59% | Val Loss: 0.4172 Acc: 80.82% | LR: 0.001000
                                                                                    
Epoch [ 12/100]  33.9s | Train Loss: 0.4671 Acc: 78.67% | Val Loss: 0.9471 Acc: 58.72% | LR: 0.001000
                                                                                    
Epoch [ 13/100]  33.8s | Train Loss: 0.4528 Acc: 79.30% | Val Loss: 0.4343 Acc: 79.90% | LR: 0.001000
                                                                                    
Epoch [ 14/100]  33.4s | Train Loss: 0.4418 Acc: 79.88% | Val Loss: 0.4888 Acc: 75.96% | LR: 0.001000
                                                                                    
✨ Best model saved! (Val Acc: 0.8426)
Epoch [ 15/100]  33.6s | Train Loss: 0.4207 Acc: 81.35% | Val Loss: 0.3667 Acc: 84.26% | LR: 0.001000
                                                                                    
Epoch [ 16/100]  35.2s | Train Loss: 0.4013 Acc: 82.48% | Val Loss: 0.4736 Acc: 77.38% | LR: 0.001000
                                                                                    
Epoch [ 17/100]  33.3s | Train Loss: 0.3930 Acc: 82.52% | Val Loss: 0.5427 Acc: 72.76% | LR: 0.001000
                                                                                    
Epoch [ 18/100]  33.4s | Train Loss: 0.3813 Acc: 83.64% | Val Loss: 0.4292 Acc: 79.72% | LR: 0.001000
                                                                                    
Epoch [ 19/100]  33.6s | Train Loss: 0.3734 Acc: 83.90% | Val Loss: 0.6209 Acc: 69.68% | LR: 0.000500
                                                                                    
Epoch [ 20/100]  33.2s | Train Loss: 0.3331 Acc: 85.86% | Val Loss: 0.4815 Acc: 77.86% | LR: 0.000500
                                                                                    
Epoch [ 21/100]  33.5s | Train Loss: 0.3227 Acc: 86.41% | Val Loss: 0.4658 Acc: 80.74% | LR: 0.000500
                                                                                    
✨ Best model saved! (Val Acc: 0.8876)
Epoch [ 22/100]  33.6s | Train Loss: 0.3166 Acc: 86.81% | Val Loss: 0.2618 Acc: 88.76% | LR: 0.000500
                                                                                    
Epoch [ 23/100]  33.1s | Train Loss: 0.3056 Acc: 86.96% | Val Loss: 0.6734 Acc: 72.34% | LR: 0.000500
                                                                                    
Epoch [ 24/100]  33.3s | Train Loss: 0.2990 Acc: 87.37% | Val Loss: 0.2847 Acc: 88.16% | LR: 0.000500
                                                                                    
Epoch [ 25/100]  33.7s | Train Loss: 0.2902 Acc: 88.03% | Val Loss: 0.5639 Acc: 76.66% | LR: 0.000500
                                                                                    
✨ Best model saved! (Val Acc: 0.9020)
Epoch [ 26/100]  33.2s | Train Loss: 0.2863 Acc: 88.50% | Val Loss: 0.2412 Acc: 90.20% | LR: 0.000500
                                                                                    
Epoch [ 27/100]  33.5s | Train Loss: 0.2830 Acc: 88.54% | Val Loss: 0.5341 Acc: 75.08% | LR: 0.000500
                                                                                    
Epoch [ 28/100]  33.6s | Train Loss: 0.2765 Acc: 88.55% | Val Loss: 0.2575 Acc: 89.80% | LR: 0.000500
                                                                                    
Epoch [ 29/100]  33.4s | Train Loss: 0.2764 Acc: 88.27% | Val Loss: 0.2889 Acc: 87.80% | LR: 0.000500
                                                                                    
Epoch [ 30/100]  33.5s | Train Loss: 0.2648 Acc: 89.11% | Val Loss: 0.3468 Acc: 84.84% | LR: 0.000250
                                                                                    
Epoch [ 31/100]  33.2s | Train Loss: 0.2489 Acc: 89.88% | Val Loss: 0.4285 Acc: 81.56% | LR: 0.000250
                                                                                    
✨ Best model saved! (Val Acc: 0.9056)
Epoch [ 32/100]  33.4s | Train Loss: 0.2374 Acc: 90.28% | Val Loss: 0.2289 Acc: 90.56% | LR: 0.000250
                                                                                    
✨ Best model saved! (Val Acc: 0.9186)
Epoch [ 33/100]  33.4s | Train Loss: 0.2406 Acc: 90.27% | Val Loss: 0.2054 Acc: 91.86% | LR: 0.000250
                                                                                    
Epoch [ 34/100]  35.2s | Train Loss: 0.2387 Acc: 90.32% | Val Loss: 0.3527 Acc: 86.00% | LR: 0.000250
                                                                                    
Epoch [ 35/100]  33.2s | Train Loss: 0.2303 Acc: 90.82% | Val Loss: 0.2124 Acc: 91.50% | LR: 0.000250
                                                                                    
Epoch [ 36/100]  33.4s | Train Loss: 0.2334 Acc: 90.59% | Val Loss: 0.2198 Acc: 90.92% | LR: 0.000250
                                                                                    
Epoch [ 37/100]  33.4s | Train Loss: 0.2262 Acc: 90.91% | Val Loss: 0.2467 Acc: 89.50% | LR: 0.000125
                                                                                    
✨ Best model saved! (Val Acc: 0.9268)
Epoch [ 38/100]  33.4s | Train Loss: 0.2103 Acc: 91.38% | Val Loss: 0.1869 Acc: 92.68% | LR: 0.000125
                                                                                    
Epoch [ 39/100]  33.6s | Train Loss: 0.2056 Acc: 91.87% | Val Loss: 0.1967 Acc: 91.86% | LR: 0.000125
                                                                                    
Epoch [ 40/100]  33.2s | Train Loss: 0.2073 Acc: 91.52% | Val Loss: 0.3175 Acc: 87.10% | LR: 0.000125
                                                                                    
Epoch [ 41/100]  33.2s | Train Loss: 0.2088 Acc: 91.83% | Val Loss: 0.1872 Acc: 92.50% | LR: 0.000125
                                                                                    
Epoch [ 42/100]  33.2s | Train Loss: 0.2055 Acc: 91.74% | Val Loss: 0.2490 Acc: 89.86% | LR: 0.000063
                                                                                    
Epoch [ 43/100]  33.3s | Train Loss: 0.1983 Acc: 91.99% | Val Loss: 0.1942 Acc: 92.26% | LR: 0.000063
                                                                                    
✨ Best model saved! (Val Acc: 0.9292)
Epoch [ 44/100]  33.4s | Train Loss: 0.1922 Acc: 92.40% | Val Loss: 0.1779 Acc: 92.92% | LR: 0.000063
                                                                                    
✨ Best model saved! (Val Acc: 0.9296)
Epoch [ 45/100]  33.5s | Train Loss: 0.1912 Acc: 92.39% | Val Loss: 0.1820 Acc: 92.96% | LR: 0.000063
                                                                                    
Epoch [ 46/100]  33.5s | Train Loss: 0.1946 Acc: 92.27% | Val Loss: 0.1937 Acc: 92.20% | LR: 0.000063
                                                                                    
Epoch [ 47/100]  33.4s | Train Loss: 0.1909 Acc: 92.36% | Val Loss: 0.1836 Acc: 92.48% | LR: 0.000063
                                                                                    
✨ Best model saved! (Val Acc: 0.9308)
Epoch [ 48/100]  33.3s | Train Loss: 0.1890 Acc: 92.51% | Val Loss: 0.1751 Acc: 93.08% | LR: 0.000063
                                                                                    
Epoch [ 49/100]  34.2s | Train Loss: 0.1884 Acc: 92.47% | Val Loss: 0.1764 Acc: 93.04% | LR: 0.000063
                                                                                    
✨ Best model saved! (Val Acc: 0.9326)
Epoch [ 50/100]  33.3s | Train Loss: 0.1899 Acc: 92.47% | Val Loss: 0.1700 Acc: 93.26% | LR: 0.000063
                                                                                    
Epoch [ 51/100]  33.3s | Train Loss: 0.1843 Acc: 92.59% | Val Loss: 0.1721 Acc: 93.24% | LR: 0.000063
                                                                                    
Epoch [ 52/100]  34.3s | Train Loss: 0.1903 Acc: 92.59% | Val Loss: 0.1705 Acc: 93.12% | LR: 0.000063
                                                                                    
Epoch [ 53/100]  34.3s | Train Loss: 0.1874 Acc: 92.46% | Val Loss: 0.1806 Acc: 93.20% | LR: 0.000063
                                                                                    
Epoch [ 54/100]  33.5s | Train Loss: 0.1869 Acc: 92.47% | Val Loss: 0.2037 Acc: 91.44% | LR: 0.000031
                                                                                    
✨ Best model saved! (Val Acc: 0.9330)
Epoch [ 55/100]  33.8s | Train Loss: 0.1828 Acc: 92.68% | Val Loss: 0.1716 Acc: 93.30% | LR: 0.000031
                                                                                    
Epoch [ 56/100]  33.3s | Train Loss: 0.1844 Acc: 92.73% | Val Loss: 0.1725 Acc: 93.06% | LR: 0.000031
                                                                                    
✨ Best model saved! (Val Acc: 0.9338)
Epoch [ 57/100]  33.2s | Train Loss: 0.1836 Acc: 92.47% | Val Loss: 0.1724 Acc: 93.38% | LR: 0.000031
                                                                                    
Epoch [ 58/100]  33.6s | Train Loss: 0.1809 Acc: 92.75% | Val Loss: 0.1781 Acc: 93.00% | LR: 0.000016
                                                                                    
Epoch [ 59/100]  33.6s | Train Loss: 0.1783 Acc: 93.00% | Val Loss: 0.1700 Acc: 93.30% | LR: 0.000016
                                                                                    
✨ Best model saved! (Val Acc: 0.9346)
Epoch [ 60/100]  33.8s | Train Loss: 0.1791 Acc: 92.87% | Val Loss: 0.1697 Acc: 93.46% | LR: 0.000016
                                                                                    
Epoch [ 61/100]  33.3s | Train Loss: 0.1757 Acc: 93.03% | Val Loss: 0.1697 Acc: 93.34% | LR: 0.000016
                                                                                    
Epoch [ 62/100]  33.4s | Train Loss: 0.1748 Acc: 93.05% | Val Loss: 0.1732 Acc: 93.14% | LR: 0.000016
                                                                                    
Epoch [ 63/100]  33.6s | Train Loss: 0.1782 Acc: 93.00% | Val Loss: 0.1687 Acc: 93.36% | LR: 0.000016
                                                                                    
Epoch [ 64/100]  33.9s | Train Loss: 0.1766 Acc: 93.04% | Val Loss: 0.1713 Acc: 93.44% | LR: 0.000016
                                                                                    
Epoch [ 65/100]  33.7s | Train Loss: 0.1777 Acc: 92.96% | Val Loss: 0.1708 Acc: 93.30% | LR: 0.000016
                                                                                    
Epoch [ 66/100]  33.9s | Train Loss: 0.1739 Acc: 93.16% | Val Loss: 0.1692 Acc: 93.38% | LR: 0.000016
                                                                                    
Epoch [ 67/100]  33.8s | Train Loss: 0.1794 Acc: 92.83% | Val Loss: 0.1688 Acc: 93.30% | LR: 0.000008
                                                                                    
Epoch [ 68/100]  33.3s | Train Loss: 0.1729 Acc: 93.05% | Val Loss: 0.1692 Acc: 93.40% | LR: 0.000008
                                                                                    
Epoch [ 69/100]  36.3s | Train Loss: 0.1784 Acc: 92.95% | Val Loss: 0.1681 Acc: 93.40% | LR: 0.000008
                                                                                    
Epoch [ 70/100]  33.3s | Train Loss: 0.1783 Acc: 92.99% | Val Loss: 0.1699 Acc: 93.30% | LR: 0.000008
                                                                                    
✨ Best model saved! (Val Acc: 0.9350)
Epoch [ 71/100]  33.5s | Train Loss: 0.1739 Acc: 93.18% | Val Loss: 0.1678 Acc: 93.50% | LR: 0.000008
                                                                                    
Epoch [ 72/100]  33.9s | Train Loss: 0.1786 Acc: 92.85% | Val Loss: 0.1701 Acc: 93.32% | LR: 0.000008
                                                                                    
Epoch [ 73/100]  34.1s | Train Loss: 0.1706 Acc: 93.31% | Val Loss: 0.1684 Acc: 93.42% | LR: 0.000008
                                                                                    
Epoch [ 74/100]  33.4s | Train Loss: 0.1789 Acc: 92.83% | Val Loss: 0.1675 Acc: 93.46% | LR: 0.000008
                                                                                    
Epoch [ 75/100]  34.2s | Train Loss: 0.1781 Acc: 92.98% | Val Loss: 0.1680 Acc: 93.44% | LR: 0.000008
                                                                                    
Epoch [ 76/100]  33.6s | Train Loss: 0.1768 Acc: 92.88% | Val Loss: 0.1684 Acc: 93.48% | LR: 0.000008
                                                                                    
✨ Best model saved! (Val Acc: 0.9352)
Epoch [ 77/100]  33.5s | Train Loss: 0.1755 Acc: 93.09% | Val Loss: 0.1671 Acc: 93.52% | LR: 0.000008
                                                                                    
Epoch [ 78/100]  33.8s | Train Loss: 0.1765 Acc: 93.18% | Val Loss: 0.1674 Acc: 93.48% | LR: 0.000008
                                                                                    
Epoch [ 79/100]  33.8s | Train Loss: 0.1749 Acc: 93.20% | Val Loss: 0.1679 Acc: 93.32% | LR: 0.000008
                                                                                    
Epoch [ 80/100]  33.6s | Train Loss: 0.1736 Acc: 93.13% | Val Loss: 0.1677 Acc: 93.42% | LR: 0.000008
                                                                                    
Epoch [ 81/100]  33.2s | Train Loss: 0.1739 Acc: 93.02% | Val Loss: 0.1698 Acc: 93.22% | LR: 0.000004
                                                                                    
Epoch [ 82/100]  33.6s | Train Loss: 0.1775 Acc: 93.07% | Val Loss: 0.1671 Acc: 93.52% | LR: 0.000004
                                                                                    
Epoch [ 83/100]  33.3s | Train Loss: 0.1733 Acc: 93.06% | Val Loss: 0.1679 Acc: 93.44% | LR: 0.000004
                                                                                    
Epoch [ 84/100]  33.5s | Train Loss: 0.1741 Acc: 93.12% | Val Loss: 0.1677 Acc: 93.44% | LR: 0.000004
                                                                                    
Epoch [ 85/100]  35.6s | Train Loss: 0.1733 Acc: 93.24% | Val Loss: 0.1678 Acc: 93.42% | LR: 0.000002
                                                                                    
Epoch [ 86/100]  33.8s | Train Loss: 0.1745 Acc: 93.20% | Val Loss: 0.1671 Acc: 93.50% | LR: 0.000002
                                                                                    
✨ Best model saved! (Val Acc: 0.9354)
Epoch [ 87/100]  33.8s | Train Loss: 0.1752 Acc: 93.10% | Val Loss: 0.1664 Acc: 93.54% | LR: 0.000002
                                                                                    
Epoch [ 88/100]  33.6s | Train Loss: 0.1712 Acc: 93.28% | Val Loss: 0.1669 Acc: 93.38% | LR: 0.000002
                                                                                    
Epoch [ 89/100]  34.1s | Train Loss: 0.1724 Acc: 93.15% | Val Loss: 0.1667 Acc: 93.50% | LR: 0.000002
                                                                                    
Epoch [ 90/100]  33.6s | Train Loss: 0.1742 Acc: 92.98% | Val Loss: 0.1663 Acc: 93.44% | LR: 0.000002
                                                                                    
✨ Best model saved! (Val Acc: 0.9356)
Epoch [ 91/100]  33.9s | Train Loss: 0.1707 Acc: 93.21% | Val Loss: 0.1670 Acc: 93.56% | LR: 0.000002
                                                                                    
Epoch [ 92/100]  33.2s | Train Loss: 0.1739 Acc: 93.00% | Val Loss: 0.1670 Acc: 93.54% | LR: 0.000002
                                                                                    
Epoch [ 93/100]  33.6s | Train Loss: 0.1773 Acc: 92.94% | Val Loss: 0.1673 Acc: 93.26% | LR: 0.000002
                                                                                    
Epoch [ 94/100]  33.2s | Train Loss: 0.1705 Acc: 93.12% | Val Loss: 0.1672 Acc: 93.54% | LR: 0.000001
                                                                                    
Epoch [ 95/100]  34.1s | Train Loss: 0.1734 Acc: 93.02% | Val Loss: 0.1679 Acc: 93.36% | LR: 0.000001
                                                                                    
Epoch [ 96/100]  33.3s | Train Loss: 0.1697 Acc: 93.35% | Val Loss: 0.1665 Acc: 93.46% | LR: 0.000001
                                                                                    
Epoch [ 97/100]  34.3s | Train Loss: 0.1752 Acc: 93.14% | Val Loss: 0.1669 Acc: 93.48% | LR: 0.000001
                                                                                    
Epoch [ 98/100]  35.1s | Train Loss: 0.1688 Acc: 93.25% | Val Loss: 0.1667 Acc: 93.54% | LR: 0.000000
                                                                                    
Epoch [ 99/100]  32.8s | Train Loss: 0.1731 Acc: 93.14% | Val Loss: 0.1666 Acc: 93.48% | LR: 0.000000
                                                                                    
Epoch [100/100]  34.2s | Train Loss: 0.1712 Acc: 93.20% | Val Loss: 0.1665 Acc: 93.54% | LR: 0.000000

✅ 학습 완료! (총 소요 시간: 56.0분)
🏆 Best Validation Accuracy: 0.9356

📊 9. 학습 결과 시각화¶

In [24]:
# ===== Loss 곡선 =====
plt.figure(figsize=(12, 5))
plt.plot(history['train_loss'], label='Train Loss', 
         color='#1f77b4', marker='o', markersize=4, linewidth=2.5, alpha=0.8)
plt.plot(history['val_loss'], label='Val Loss', 
         color='#ff7f0e', marker='s', markersize=4, linewidth=2.5, alpha=0.8)
plt.xlabel('Epoch', fontsize=13)
plt.ylabel('Loss', fontsize=13)
plt.title('📉 Loss Curve (학습/검증 손실)', fontsize=15, fontweight='bold', pad=15)
plt.legend(fontsize=12, loc='upper right', framealpha=0.9)
plt.grid(True, alpha=0.3, linestyle='--', linewidth=0.7)
plt.xlim(0, len(history['train_loss'])-1)
plt.tight_layout()
plt.show()

# ===== Accuracy 곡선 =====
plt.figure(figsize=(12, 5))
plt.plot(history['train_acc'], label='Train Acc', 
         color='#1f77b4', marker='o', markersize=4, linewidth=2.5, alpha=0.8)
plt.plot(history['val_acc'], label='Val Acc', 
         color='#ff7f0e', marker='s', markersize=4, linewidth=2.5, alpha=0.8)
plt.xlabel('Epoch', fontsize=13)
plt.ylabel('Accuracy', fontsize=13)
plt.title('📈 Accuracy Curve (학습/검증 정확도)', fontsize=15, fontweight='bold', pad=15)
plt.legend(fontsize=12, loc='lower right', framealpha=0.9)
plt.grid(True, alpha=0.3, linestyle='--', linewidth=0.7)
plt.xlim(0, len(history['train_acc'])-1)
plt.ylim(0, 1.05)
plt.tight_layout()
plt.show()

# 최종 성능 요약
print('\n' + '='*70)
print('📊 최종 성능 요약')
print('='*70)
print(f'Final Train Loss: {history["train_loss"][-1]:.4f}')
print(f'Final Train Acc:  {history["train_acc"][-1]:.2%}')
print(f'Final Val Loss:   {history["val_loss"][-1]:.4f}')
print(f'Final Val Acc:    {history["val_acc"][-1]:.2%}')
print(f'Best Val Acc:     {best_val_acc:.2%}')
print('='*70)
No description has been provided for this image
No description has been provided for this image
======================================================================
📊 최종 성능 요약
======================================================================
Final Train Loss: 0.1712
Final Train Acc:  93.20%
Final Val Loss:   0.1665
Final Val Acc:    93.54%
Best Val Acc:     93.56%
======================================================================
In [22]:
# Loss 그래프
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# Loss 곡선
axes[0].plot(history['train_loss'], label='Train Loss', marker='o', linewidth=2)
axes[0].plot(history['val_loss'], label='Val Loss', marker='s', linewidth=2)
axes[0].set_xlabel('Epoch', fontsize=12)
axes[0].set_ylabel('Loss', fontsize=12)
axes[0].set_title('📉 Loss Curve', fontsize=14, fontweight='bold')
axes[0].legend(fontsize=11)
axes[0].grid(True, alpha=0.3)

# Accuracy 곡선
axes[1].plot(history['train_acc'], label='Train Acc', marker='o', linewidth=2)
axes[1].plot(history['val_acc'], label='Val Acc', marker='s', linewidth=2)
axes[1].set_xlabel('Epoch', fontsize=12)
axes[1].set_ylabel('Accuracy', fontsize=12)
axes[1].set_title('📈 Accuracy Curve', fontsize=14, fontweight='bold')
axes[1].legend(fontsize=11)
axes[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

# 최종 성능 요약
print('\n' + '='*60)
print('📊 최종 성능 요약')
print('='*60)
print(f'Final Train Loss: {history["train_loss"][-1]:.4f}')
print(f'Final Train Acc:  {history["train_acc"][-1]:.4f}')
print(f'Final Val Loss:   {history["val_loss"][-1]:.4f}')
print(f'Final Val Acc:    {history["val_acc"][-1]:.4f}')
print(f'Best Val Acc:     {best_val_acc:.4f}')
print('='*60)
No description has been provided for this image
============================================================
📊 최종 성능 요약
============================================================
Final Train Loss: 0.1712
Final Train Acc:  0.9320
Final Val Loss:   0.1665
Final Val Acc:    0.9354
Best Val Acc:     0.9356
============================================================
In [26]:
# 학습률 변화 시각화
plt.figure(figsize=(12, 5))
plt.plot(history['lr'], marker='o', markersize=4, linewidth=2.5, color='red', alpha=0.8)
plt.xlabel('Epoch', fontsize=13)
plt.ylabel('Learning Rate', fontsize=13)
plt.title('⚙️ Learning Rate Schedule', fontsize=15, fontweight='bold', pad=15)
plt.grid(True, alpha=0.3, linestyle='--', linewidth=0.7)
plt.yscale('log')
plt.xlim(0, len(history['lr'])-1)
plt.tight_layout()
plt.show()
No description has been provided for this image

🎯 10. 테스트 세트 평가¶

In [27]:
# Best model 로드
model.load_state_dict(torch.load(best_model_path))
print(f'✅ Best model 로드 완료! ({best_model_path})')

# 테스트 세트 평가
test_loss, test_acc = validate(model, test_loader, criterion, device)

print('\n' + '='*60)
print('🎯 Test Set 최종 성능')
print('='*60)
print(f'Test Loss: {test_loss:.4f}')
print(f'Test Acc:  {test_acc:.4f} ({test_acc*100:.2f}%)')
print('='*60)
✅ Best model 로드 완료! (best_cnn_model.pth)
Validation:   0%|          | 0/20 [00:00<?, ?it/s]
                                                           
============================================================
🎯 Test Set 최종 성능
============================================================
Test Loss: 0.1718
Test Acc:  0.9316 (93.16%)
============================================================

In [28]:
# Confusion Matrix 계산
from sklearn.metrics import confusion_matrix, classification_report

# 전체 예측 수집
all_preds = []
all_labels = []

model.eval()
with torch.no_grad():
    for inputs, labels in tqdm(test_loader, desc='Collecting predictions'):
        inputs = inputs.to(device)
        outputs = model(inputs)
        _, preds = torch.max(outputs, 1)
        
        all_preds.extend(preds.cpu().numpy())
        all_labels.extend(labels.numpy())

# Confusion Matrix
cm = confusion_matrix(all_labels, all_preds)

# 시각화
plt.figure(figsize=(8, 6))
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', 
            xticklabels=['Cats', 'Dogs'],
            yticklabels=['Cats', 'Dogs'],
            cbar_kws={'label': 'Count'})
plt.xlabel('Predicted', fontsize=12)
plt.ylabel('Actual', fontsize=12)
plt.title('🔍 Confusion Matrix', fontsize=14, fontweight='bold')
plt.tight_layout()
plt.show()

# Classification Report
print('\n📋 Classification Report:')
print(classification_report(all_labels, all_preds, 
                          target_names=['Cats', 'Dogs'],
                          digits=4))
Collecting predictions: 100%|██████████| 20/20 [00:02<00:00,  8.27it/s]
No description has been provided for this image
📋 Classification Report:
              precision    recall  f1-score   support

        Cats     0.9319    0.9312    0.9316      1250
        Dogs     0.9313    0.9320    0.9316      1250

    accuracy                         0.9316      2500
   macro avg     0.9316    0.9316    0.9316      2500
weighted avg     0.9316    0.9316    0.9316      2500

🖼️ 11. 예측 결과 시각화¶

In [29]:
def show_predictions(model, dataset, device, n_samples=12):
    """
    랜덤 샘플에 대한 예측 결과를 시각화합니다.
    """
    model.eval()
    
    # 랜덤 인덱스 선택
    indices = np.random.choice(len(dataset), n_samples, replace=False)
    
    fig, axes = plt.subplots(3, 4, figsize=(16, 12))
    axes = axes.ravel()
    
    class_names = ['🐱 Cat', '🐶 Dog']
    
    for i, idx in enumerate(indices):
        # 이미지와 레이블 가져오기
        img_tensor, true_label = dataset[idx]
        
        # 예측
        with torch.no_grad():
            img_tensor = img_tensor.unsqueeze(0).to(device)
            output = model(img_tensor)
            probabilities = torch.softmax(output, dim=1)
            pred_label = torch.argmax(probabilities, dim=1).item()
            confidence = probabilities[0, pred_label].item()
        
        # 이미지 역정규화 (시각화용)
        img_numpy = img_tensor.cpu().squeeze(0).numpy().transpose(1, 2, 0)
        img_numpy = img_numpy * np.array(IMAGENET_STD) + np.array(IMAGENET_MEAN)
        img_numpy = np.clip(img_numpy, 0, 1)
        
        # 시각화
        axes[i].imshow(img_numpy)
        
        # 제목 (정답 여부에 따라 색상 변경)
        is_correct = pred_label == true_label
        title_color = 'green' if is_correct else 'red'
        title = f'True: {class_names[true_label]}\n'
        title += f'Pred: {class_names[pred_label]} ({confidence*100:.1f}%)'
        
        axes[i].set_title(title, fontsize=10, color=title_color, fontweight='bold')
        axes[i].axis('off')
    
    plt.suptitle('🎯 예측 결과 (초록=정답, 빨강=오답)', fontsize=16, fontweight='bold')
    plt.tight_layout()
    plt.show()

# 테스트 세트에서 예측 결과 시각화
show_predictions(model, test_dataset, device, n_samples=12)
No description has been provided for this image
In [31]:
# 오답 노트: 틀린 예측들만 보기
def show_wrong_predictions(model, dataset, device, n_samples=20):
    """
    틀린 예측들만 골라서 보여줍니다.
    """
    model.eval()
    
    wrong_indices = []
    
    # 틀린 예측 찾기
    print('🔍 틀린 예측 찾는 중...')
    for idx in tqdm(range(len(dataset))):
        img_tensor, true_label = dataset[idx]
        
        with torch.no_grad():
            img_tensor = img_tensor.unsqueeze(0).to(device)
            output = model(img_tensor)
            pred_label = torch.argmax(output, dim=1).item()
            
            if pred_label != true_label:
                wrong_indices.append(idx)
        
        if len(wrong_indices) >= n_samples:
            break
    
    if len(wrong_indices) == 0:
        print('🎉 완벽합니다! 틀린 예측이 없습니다!')
        return
    
    print(f'\n❌ 총 {len(wrong_indices)}개의 오답을 찾았습니다.\n')
    
    # 시각화
    n_cols = 5
    n_rows = (len(wrong_indices) + n_cols - 1) // n_cols
    fig, axes = plt.subplots(n_rows, n_cols, figsize=(20, 4*n_rows))
    axes = axes.ravel()
    
    class_names = ['🐱 Cat', '🐶 Dog']
    
    for i, idx in enumerate(wrong_indices[:n_samples]):
        img_tensor, true_label = dataset[idx]
        
        with torch.no_grad():
            img_tensor_batch = img_tensor.unsqueeze(0).to(device)
            output = model(img_tensor_batch)
            probabilities = torch.softmax(output, dim=1)
            pred_label = torch.argmax(probabilities, dim=1).item()
            confidence = probabilities[0, pred_label].item()
        
        # 이미지 역정규화
        img_numpy = img_tensor.numpy().transpose(1, 2, 0)
        img_numpy = img_numpy * np.array(IMAGENET_STD) + np.array(IMAGENET_MEAN)
        img_numpy = np.clip(img_numpy, 0, 1)
        
        axes[i].imshow(img_numpy)
        title = f'True: {class_names[true_label]}\n'
        title += f'Pred: {class_names[pred_label]} ({confidence*100:.1f}%)'
        axes[i].set_title(title, fontsize=10, color='red', fontweight='bold')
        axes[i].axis('off')
    
    # 빈 칸 제거
    for i in range(len(wrong_indices), len(axes)):
        axes[i].axis('off')
    
    plt.suptitle('❌ 오답 노트: 틀린 예측들', fontsize=16, fontweight='bold', color='red')
    plt.tight_layout()
    plt.show()

show_wrong_predictions(model, test_dataset, device, n_samples=20)
🔍 틀린 예측 찾는 중...
 12%|█▏        | 300/2500 [00:02<00:16, 136.14it/s]
❌ 총 20개의 오답을 찾았습니다.

No description has been provided for this image