논문 리뷰

AlexNet 아키텍처 직접 구현 및 CPU vs GPU 실험 (정확도가 다르게 나온 이유?)

Solbi Lee 2025. 8. 4. 17:17

1. 목표 

AlexNet 직접 구현 

torchvision.models의 AlexNet을 사용하지 않고, 논문 구조를 참고해 직접 CNN 모델을 구현 (Optimizer은 Adam으로 변경)

사용할 데이터 셋은 kaggle의 포트홀 데이터 선택 

 

CPU vs GPU 학습 성능 비교 실험

동일한 조건으로 CPU와 GPU 각각에서 학습 진행 

학습 시간과 정확도를 표 또는 그래프로 비교 분석 

 


1. AlexNet 직접 구현 

# kaggle API를 통해 데이터셋 불러오기 

from google.colab import files
files.upload()  # kaggle.json 업로드 창 표시
!mkdir -p ~/.kaggle
!cp kaggle.json ~/.kaggle/
!chmod 600 ~/.kaggle/kaggle.json

!kaggle datasets download atulyakumar98/pothole-detection-dataset

!unzip -q pothole-detection-dataset.zip

 

import os, glob, shutil
import torch
import torch.optim as optim
import matplotlib.pyplot as plt
import torch.nn as nn
import torch.nn.functional as F

from PIL import Image
from sklearn.model_selection import train_test_split
from torchvision import transforms, datasets
from torchvision.transforms import transforms
from torchvision.datasets import ImageFolder
from torch.utils.data import DataLoader
from torch.utils.data import DataLoader, Dataset
from torch.optim.lr_scheduler import StepLR

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
device

 

categories = ['normal', 'potholes']
train_ratio = 0.8
random_state = 42
split_root = 'data_split' # 데이터를 분리해 저장할 폴더의 경로 지정

# train과 test 폴더 생성
for phase in ['train','test']:
    for cls in categories:
        # 경로 생성 : data_split/train/normal, data_split/test/potholes 등의 폴더 생성
        our_dir = os.path.join(split_root, phase, cls)
        os.makedirs(our_dir, exist_ok=True) # 폴더가 없다면 새로 생성 (있으면 무시)

# 각각 카테고리별 이미지 분리 작업 시작
for cls in categories:
    # 현재 카테고리 폴더 내에 있는 모든 이미지 파일의 경로 목록 가져옴
    src_paths = glob.glob(os.path.join(cls, '*'))

    # train_ratio 비율로 train/test 나눔
    train_paths, test_paths = train_test_split(
        src_paths,
        train_size=train_ratio,
        random_state=random_state,
        shuffle=True
    )

    # 이미지 파일을 각각의 목적지 폴더로 복사 (학습용 이미지)
    for src in train_paths:
        # 복사할 목적지 경로 지정 (data_split/train/카테고리명/파일명)
        dst = os.path.join(split_root, 'train', cls, os.path.basename(src))
        shutil.copy2(src, dst) # 실제 파일 복사 작업 수행

    # 이미지 파일을 각각의 목적지 폴더로 복사 (테스트용 이미지)
    for src in test_paths:
        # 복사할 목적지 경로 지정 (data_split/test/카테고리명/파일명)
        dst = os.path.join(split_root, 'test', cls, os.path.basename(src))
        shutil.copy2(src, dst) # 실제 파일 복사 작업 수행

print(" data_split 아래에 train/test 분류가 완료되었습니다!")

- 폴더를 만들어서, train/test 데이터 분리 

 

for phase in ['train', 'test']:
    for cls in categories:
        pattern = os.path.join(split_root, phase, cls, '*')
        count = len(glob.glob(pattern))
        print(f"{phase:5s} / {cls:8s} : {count} images")
        
=================

train / normal   : 281 images
train / potholes : 263 images
test  / normal   : 71 images
test  / potholes : 66 images


# 약간의 차이는 있지만 클래스 불균형이라고 보기엔 위험이 크지 않을 정도라고 판단됨.

 

데이터 전처리 (Transform) 정의, DataLoader 생성 

# 1) 전처리(transform) 정의
data_transforms = {
    'train': transforms.Compose([
        transforms.Resize((224, 224)),               # 모든 이미지를 224×224 크기로 통일
        transforms.RandomHorizontalFlip(),           # 랜덤 좌우 반전 (데이터 다양성 ↑)
        transforms.ToTensor(),                       # [0,255] 픽셀 → [0.0,1.0] 텐서
        transforms.Normalize(                        # RGB 채널별 평균/표준편차로 정규화
            mean=[0.485, 0.456, 0.406],
            std=[0.229, 0.224, 0.225]
        )
    ]),
    'test': transforms.Compose([
        transforms.Resize((224, 224)),
        transforms.ToTensor(),
        transforms.Normalize(
            mean=[0.485, 0.456, 0.406],
            std=[0.229, 0.224, 0.225]
        )
    ])
}

# 2) ImageFolder로 데이터셋 생성
image_datasets = {
    phase: ImageFolder(
        root=os.path.join(split_root, phase),
        transform=data_transforms[phase]
    )
    for phase in ['train', 'test']
}

# 3) DataLoader 생성 (미니배치 단위로 자동으로 로드)
dataloaders = {
    phase: DataLoader(
        dataset=image_datasets[phase],
        batch_size=32,              # 배치 크기: 한 번에 32장씩
        shuffle=(phase == 'train')  # train일 때만 섞어서 로드
    )
    for phase in ['train', 'test']
}

# 4) 데이터셋 크기 및 클래스 이름 출력
dataset_sizes = {phase: len(image_datasets[phase]) for phase in ['train', 'test']}
class_names     = image_datasets['train'].classes

print(f"Train size: {dataset_sizes['train']} images")
print(f"Test  size: {dataset_sizes['test']} images")
print("Classes:", class_names)

 

AlexNet 모델 직접 구현하기 

class AlexNetCustom(nn.Module):
    def __init__(self, num_classes=2):
        super(AlexNetCustom, self).__init__()
        # 1) 특징 추출부 (feature extractor)
        self.features = nn.Sequential(
            # --- 1st conv block ---
            nn.Conv2d(3, 64, kernel_size=11, stride=4, padding=2),  # 224→55
            nn.ReLU(inplace=True),
            nn.LocalResponseNorm(size=5, alpha=1e-4, beta=0.75, k=2),
            nn.MaxPool2d(kernel_size=3, stride=2),                   # 55→27

            # --- 2nd conv block ---
            nn.Conv2d(64, 192, kernel_size=5, padding=2),            # 27→27
            nn.ReLU(inplace=True),
            nn.LocalResponseNorm(size=5, alpha=1e-4, beta=0.75, k=2),
            nn.MaxPool2d(kernel_size=3, stride=2),                   # 27→13

            # --- 3rd,4th,5th conv blocks ---
            nn.Conv2d(192, 384, kernel_size=3, padding=1),           # 13→13
            nn.ReLU(inplace=True),
            nn.Conv2d(384, 256, kernel_size=3, padding=1),           # 13→13
            nn.ReLU(inplace=True),
            nn.Conv2d(256, 256, kernel_size=3, padding=1),           # 13→13
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=3, stride=2)                    # 13→6
        )

        # 2) 분류부 (classifier)
        self.classifier = nn.Sequential(
            nn.Dropout(p=0.5),
            nn.Linear(256 * 6 * 6, 4096),  # 풀링 후 feature 크기 flatten
            nn.ReLU(inplace=True),
            nn.Dropout(p=0.5),
            nn.Linear(4096, 4096),
            nn.ReLU(inplace=True),
            nn.Linear(4096, num_classes)   # 최종 클래스 수 (normal/potholes)
        )

    def forward(self, x):
        x = self.features(x)                   # 컨볼루션 + 풀링
        x = x.view(x.size(0), 256 * 6 * 6)     # 4D → 2D (batch, features)
        x = self.classifier(x)                 # 완전연결 + 드롭아웃
        return x

# 모델 생성 및 장치 할당
model = AlexNetCustom(num_classes=len(class_names)).to(device)
print(model)

 

손실함수와 옵티마이저 설정 

# 1) 손실 함수 정의
# CrossEntropyLoss는 분류 문제에서 가장 널리 쓰이는 loss.
# 내부적으로 Softmax + Log + NLLLoss(음의 로그 우도 손실)을 한 번에 계산합니다.
criterion = nn.CrossEntropyLoss()

# 2) 옵티마이저 Adam 설정 
optimizer = optim.Adam(
    model.parameters(),   # 모델의 모든 학습 가능한 파라미터
    lr=0.001,             # 학습률: 보통 SGD보다 작은 값으로 시작
    betas=(0.9, 0.999),   # (β1, β2) 과거 그래디언트 평균을 구할 때 쓰는 지수 가중치
    eps=1e-08,            # 수치 불안정성 방지를 위한 작은 상수
    weight_decay=1e-4     # L2 규제(옵션): 과적합 방지
)

# 3) (선택) 스케줄러: 10 에폭마다 학습률을 0.1배로 줄임
scheduler = StepLR(optimizer, step_size=10, gamma=0.1)

print("설정 완료!")

옵티마이저 변경 이유 

- 논문 재현성 : 원본에서는 SGD with Momentum을 사용했으나, 현재 예제에서는 빠른 수렴과 실험 편의성을 위해 Adam을 선택했습니다. 

 

학습 루프 작성 

def train_model(model, dataloaders, criterion, optimizer, scheduler, num_epochs=10):
    since = time.time()

    # 전체 에폭 반복
    for epoch in range(1, num_epochs+1):
        print(f"\n=== Epoch {epoch}/{num_epochs} ===")

        # Train / Test 두 단계로 나눠서 처리
        for phase in ['train', 'test']:
            if phase == 'train':
                model.train()   # 학습 모드 (Dropout, BatchNorm 활성)
            else:
                model.eval()    # 평가 모드 (Dropout, BatchNorm 비활성)

            running_loss = 0.0
            running_corrects = 0

            # 배치 단위로 데이터 가져오기
            for inputs, labels in dataloaders[phase]:
                inputs = inputs.to(device)
                labels = labels.to(device)

                optimizer.zero_grad()   # 이전 배치의 gradient 초기화

                # 순전파(forward)
                with torch.set_grad_enabled(phase == 'train'):
                    outputs = model(inputs)                     # 모델 예측
                    _, preds = torch.max(outputs, 1)           # 가장 높은 점수 클래스 선택
                    loss = criterion(outputs, labels)           # 손실 계산

                    # 학습 단계일 때만 역전파
                    if phase == 'train':
                        loss.backward()                         # gradient 계산
                        optimizer.step()                        # 파라미터 업데이트

                # 통계 누적
                running_loss     += loss.item() * inputs.size(0)
                running_corrects += torch.sum(preds == labels.data)

            # 에폭 끝난 후 통계 계산
            epoch_loss = running_loss / dataset_sizes[phase]
            epoch_acc  = running_corrects.double() / dataset_sizes[phase]

            print(f"{phase.capitalize()}  Loss: {epoch_loss:.4f}  Acc: {epoch_acc:.4f}")

            # 학습 모드 끝나면 스케줄러 스텝
            if phase == 'train':
                scheduler.step()

    total_time = time.time() - since
    print(f"\nTraining complete in {total_time//60:.0f}m {total_time%60:.0f}s")

# 실제 학습 실행
train_model(
    model, 
    dataloaders, 
    criterion, 
    optimizer, 
    scheduler, 
    num_epochs=10
)
# 결과 

=== Epoch 1/10 ===
Train  Loss: 0.8163  Acc: 0.4724
Test  Loss: 0.6903  Acc: 0.5182

=== Epoch 2/10 ===
Train  Loss: 0.6833  Acc: 0.5184
Test  Loss: 0.6322  Acc: 0.5547

=== Epoch 3/10 ===
Train  Loss: 0.5826  Acc: 0.6893
Test  Loss: 0.4432  Acc: 0.8248

=== Epoch 4/10 ===
Train  Loss: 0.5230  Acc: 0.7812
Test  Loss: 0.4866  Acc: 0.8248

=== Epoch 5/10 ===
Train  Loss: 0.5071  Acc: 0.7665
Test  Loss: 0.3267  Acc: 0.8832

=== Epoch 6/10 ===
Train  Loss: 0.3998  Acc: 0.8217
Test  Loss: 0.3245  Acc: 0.8905

=== Epoch 7/10 ===
Train  Loss: 0.4125  Acc: 0.8107
Test  Loss: 0.3769  Acc: 0.8467

=== Epoch 8/10 ===
Train  Loss: 0.5042  Acc: 0.7868
Test  Loss: 0.5413  Acc: 0.8029

=== Epoch 9/10 ===
Train  Loss: 0.4376  Acc: 0.8051
Test  Loss: 0.3190  Acc: 0.8540

=== Epoch 10/10 ===
Train  Loss: 0.3661  Acc: 0.8474
Test  Loss: 0.3232  Acc: 0.8686

Training complete in 2m 37s

2. CPU vs GPU 성능 비교 실험 

같은 모델, 데이터, 하이퍼파라미터로 device만 바꿔가며 학습 시간과 최종 정확도를 기록하고 그래프로 비교 

하드웨어에 따른 성능 차이를 확인해본다. 

 

문제 발견 

Colab 런타임을 GPU 모드로 설정한 뒤 CPU와 GPU를 실행했더니 

- GPU: 78초, 87.6%

- CPU: 352초, 75.2%

=> 학습 시간 차이는 예상했지만, CPU쪽 정확도도 같아야 할 텐데 왜 낮게 나왔을까? 

 

원인 분석 및 가설 수립 

1. 초기 가중치 불일치 

run_experiment() 안에서 매번 AlexNetCustom() 을 호출하면 매 실험마다 모델 가중치가 랜덤하게 다시 초기화 되기 때문에

결과적으로 GPU 실험과 CPU 실험이 서로 다른 출발점에서 학습을 시작하게 돼 최종 정확도도 달라졌을 것이다. 

 

2. 랜덤 요소 통제 미흡 

DataLoader(shuffle=True) 와 RandomHorizontalFlip(), Dropout(p=0.5) 등 학습 과정 곳곳에 난수 기반 동작이 있어. 

배치 순서, 증강 패턴, 드롭아웃 패턴이 모두 실험마다 달라졌을 것이다. 

 

가설 : 난수 시드를 고정하고, 베이스 모델을 한 번만 초기화 한 뒤 복제본을 사용한다면 비슷한 정확도가 나올 것이다. 

 

# 난수 시드 고정 

import random, numpy as np, torch

def set_seed(seed=123):
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)
    torch.cuda.manual_seed_all(seed)
    torch.backends.cudnn.deterministic = True
    torch.backends.cudnn.benchmark     = False

set_seed(123)  # 실험 전 단 한 번만 호출
# 베이스 모델 한 번만 초기화 -> 복제본 사용 

# 1) 시드 고정 후 베이스 모델 생성
base_model = AlexNetCustom(num_classes=2)

# 2) 매 실험 동일 가중치 복제
import copy

def run_experiment(device, num_epochs=5):
    model = copy.deepcopy(base_model).to(device)
    # ...optimizer, train_model 호출 등 동일...
# DataLoader 난수 제어 

g = torch.Generator()
g.manual_seed(123)
train_loader = DataLoader(
    train_dataset, 
    batch_size=32, 
    shuffle=True, 
    generator=g
)

 

+  데이터 증강·드롭아웃 시드 동기화 : torch.manual_seed(123) 를 for epoch 앞에 둬서 매 에폭마다 동일한 증강/드롭아웃 패턴이 적용되도록 함. 

 

재 실험 결과 

- GPU 학습시간 : 76.5초, 정확도 82.48% 

- CPU 학습시간 : 350.1초, 정확도 85.40% 

 

위 모든 통제를 적용하고 다시 실험한 결과, 최종 정확도가 거의 비슷하게 나오는 것을 확인 

하드웨어 차이는 학습 시간에만 영향을 주고, 모델 성능 자체에는 직접적으로 영향을 주지 않을 것이다. 

 

코드 

# train_model() 내부에서 항상 전역변수 device로만 입출력을 옮기기 때문에, CPU 실험을 하려 해도
# 입력은 GPU 텐서가 되고, 모델 가중치는 CPU 텐서가 되어 타입이 달라져 버림 

# 해결책: train_model에 사용할 장치를 인자로 넘기기
# train_model 정의를 바꿔, device를 파라미터로 받도록 수정
# 내부의 inputs.to(device) · labels.to(device)를 이 인자로 바꿔 줍니다
# run_experiment에서 CPU·GPU 각각의 디바이스 이름을 함께 넘겨 줍니다


def train_model(model, dataloaders, criterion, optimizer, scheduler,
                num_epochs=10, device_name='cuda'):
    since = time.time()

    for epoch in range(1, num_epochs+1):
        print(f"\n=== Epoch {epoch}/{num_epochs} ===")

        for phase in ['train', 'test']:
            if phase == 'train':
                model.train()
            else:
                model.eval()

            running_loss = 0.0
            running_corrects = 0

            for inputs, labels in dataloaders[phase]:
                # ← 여기를 global device 대신 인자로 받은 device_name 사용
                inputs  = inputs.to(device_name)
                labels  = labels.to(device_name)

                optimizer.zero_grad()
                with torch.set_grad_enabled(phase == 'train'):
                    outputs = model(inputs)
                    _, preds = torch.max(outputs, 1)
                    loss = criterion(outputs, labels)

                    if phase == 'train':
                        loss.backward()
                        optimizer.step()

                running_loss     += loss.item() * inputs.size(0)
                running_corrects += torch.sum(preds == labels.data)

            epoch_loss = running_loss / dataset_sizes[phase]
            epoch_acc  = running_corrects.double() / dataset_sizes[phase]
            print(f"{phase.capitalize()}  Loss: {epoch_loss:.4f}  Acc: {epoch_acc:.4f}")

            if phase == 'train':
                scheduler.step()

    total_time = time.time() - since
    print(f"\nTraining complete in {total_time//60:.0f}m {total_time%60:.0f}s")
    return total_time
import random, numpy as np, torch

def set_seed(seed=42):
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)
    torch.cuda.manual_seed_all(seed)
    # 결정론적 연산을 위해 (속도 저하 감수)
    torch.backends.cudnn.deterministic = True
    torch.backends.cudnn.benchmark     = False

# 실험 전 한 번만 호출
set_seed(123)
import copy

# 난수 시드 고정 후, 한 번만 베이스 모델 생성
base_model = AlexNetCustom(num_classes=len(class_names))

def run_experiment(device_name, num_epochs=10):
    # ① 베이스 모델을 복제 → **동일한 초기 가중치** 사용
    model_exp = copy.deepcopy(base_model).to(device_name)

    # ② 옵티마이저·스케줄러·손실 함수 설정
    optimizer_exp = optim.Adam(model_exp.parameters(), lr=0.001, weight_decay=1e-4)
    scheduler_exp = StepLR(optimizer_exp, step_size=10, gamma=0.1)
    criterion_exp = nn.CrossEntropyLoss()

    # ③ 학습
    elapsed = train_model(
        model_exp,
        dataloaders,
        criterion_exp,
        optimizer_exp,
        scheduler_exp,
        num_epochs=num_epochs,
        device_name=device_name
    )

    # ④ 테스트 정확도 측정 (기존과 동일)
    model_exp.eval()
    correct = total = 0
    with torch.no_grad():
        for inputs, labels in dataloaders['test']:
            inputs, labels = inputs.to(device_name), labels.to(device_name)
            outputs = model_exp(inputs)
            _, preds = torch.max(outputs, 1)
            correct += (preds == labels).sum().item()
            total   += labels.size(0)
    test_acc = correct / total

    return elapsed, test_acc
# GPU 사용 가능 여부 확인
print("CUDA available:", torch.cuda.is_available())

# 실험 실행 (에폭 수는 필요에 따라 조정)
time_gpu, acc_gpu = run_experiment('cuda' if torch.cuda.is_available() else 'cpu', num_epochs=5)
torch.cuda.empty_cache() # GPU 캐시 정리 (오류 방지)
time_cpu, acc_cpu = run_experiment('cpu', num_epochs=5)

# 결과 출력
print(f"\n=== CPU vs GPU 비교 결과 ===")
print(f"GPU 학습 시간: {time_gpu:.1f}s, 테스트 정확도: {acc_gpu*100:.2f}%")
print(f"CPU 학습 시간: {time_cpu:.1f}s, 테스트 정확도: {acc_cpu*100:.2f}%")
CUDA available: True

=== Epoch 1/5 ===
Train  Loss: 1.0218  Acc: 0.4982
Test  Loss: 0.6924  Acc: 0.5182

=== Epoch 2/5 ===
Train  Loss: 0.6905  Acc: 0.5441
Test  Loss: 0.7415  Acc: 0.4818

=== Epoch 3/5 ===
Train  Loss: 0.6935  Acc: 0.5147
Test  Loss: 0.6550  Acc: 0.5255

=== Epoch 4/5 ===
Train  Loss: 0.6482  Acc: 0.6140
Test  Loss: 0.7083  Acc: 0.5182

=== Epoch 5/5 ===
Train  Loss: 0.6087  Acc: 0.6636
Test  Loss: 0.4394  Acc: 0.8248

Training complete in 1m 16s

=== Epoch 1/5 ===
Train  Loss: 0.7428  Acc: 0.5184
Test  Loss: 0.6667  Acc: 0.5328

=== Epoch 2/5 ===
Train  Loss: 0.6699  Acc: 0.6048
Test  Loss: 0.6850  Acc: 0.7518

=== Epoch 3/5 ===
Train  Loss: 0.6275  Acc: 0.6581
Test  Loss: 0.4089  Acc: 0.8321

=== Epoch 4/5 ===
Train  Loss: 0.4656  Acc: 0.7684
Test  Loss: 0.3207  Acc: 0.8467

=== Epoch 5/5 ===
Train  Loss: 0.4604  Acc: 0.7904
Test  Loss: 0.4101  Acc: 0.8540

Training complete in 5m 50s

=== CPU vs GPU 비교 결과 ===
GPU 학습 시간: 76.5s, 테스트 정확도: 82.48%
CPU 학습 시간: 350.1s, 테스트 정확도: 85.40%