Pytorch 모델 학습

인화·2026년 6월 17일

Deep Learning

목록 보기
5/10

1. CIFAR-10 데이터셋과 데이터 분할의 의미

1.1 CIFAR-10 소개

CIFAR-10은 컴퓨터 비전 입문에서 가장 널리 쓰이는 이미지 분류 데이터셋이다.

  • 총 10개 클래스 (airplane, automobile, bird, cat, deer, dog, frog, horse, ship, truck)
  • 학습 데이터 : 50,000장 / 테스트 데이터 : 10,000장
  • 이미지 크기 : 32 × 32
  • 채널 수 : 3 (RGB)

CIFAR-10이 MNIST보다 더 어려운 이유
→ CIFAR-10은 흑백이 아니라 컬러(3채널) 이미지이다. 따라서 한 픽셀마다 단순 밝기 하나가 아니라, RGB 정보를 함께 처리해야 하기에 모델 입장에서는 고려해야 할 정보량이 더 많아진다. 또, 0~9 숫자의 경우엔 숫자 형태 자체가 클래스마다 확연히 달라 모양 패턴이 비교적 단순하지만, CIFAR-10의 경우, 개/고양이, 자동차/트럭 등 형태가 유사한 클래스가 존재하며, 같은 클래스도 정면/측면/위에서 본 각도 등 다양한 형태로 존재하며, 객체의 종류가 더 다양하고, 배경 변화도 존재한다. 또, 입력 차원(Input Dimension)도 더 크고, 분류 경계(Decision Boundary)도 더 복잡하다. 이러한 차이 때문에 MNIST보다 CIFAR-10이 더 어렵다.

1.2 데이터 전처리와 증강

데이터셋의 구분

  • Training set : 모델을 학습할 때 사용하는 데이터
  • Vaildation set : 모델 학습 하이퍼파라미터 최적화와 과적합 방지, 모델 학습 중 모델 일반화 성능 검증을 위해 활용되는 데이터 (학습 중 모델 성능을 점검하는 데이터)
    • Validation set은 학습 중에 모델이 잘 학습되고 있는지를 확인할 기준 역할을 한다. 이때, 확인 절차에 Training set을 사용하지 않고 Validation set을 사용하는 이유는, Training set을 활용해 정확도나 손실 등의 결과를 확인하고 분석하면 해당 데이터를 통해 얻은 결과가 학습 데이터를 잘 외워서 나온 것인지(과적합), 높은 일반화 성능을 지녀 새로운 데이터를 잘 맞출 수 있는 것인지(높은 일반화 성능 모델)를 확인할 수 없기 때문이다.
    • 다시 말해, 검증에 Training set을 활용하면 모델에 과적합(Overfitting)이 발생하고 있는지 알아채기 어렵다. 따라서 학습에 활용되지 않은 Validation set을 활용하며, 해당 데이터를 통해 얻은 Val Loss가 올라가면 과적합 신호로 판단할 수 있다.
    • 이러한 Validation set은 설정한 에폭마다 성능을 점검하여 모델 선택, 학습률/배치 크기/드랍아웃 비율과 같은 하이퍼파라미터 튜닝에 활용한다.
  • Test set : 실제 평가 시 사용되는 데이터로 최종적으로 모델 성능을 평가하는 데이터이므로 학습에 관여하지 않음. (최종 성능을 한 번 평가하는 데이터)
    • 테스트 데이터는 최종 평가용으로만 활용하고, 모델 학습 과정에는 절대 관여해선 안 된다. 테스트 데이터를 검증 데이터로 활용하지 않는 이유도 이 이유에서이다. 모델 개발 과정에서 테스트 데이터를 검증 용도로 사용하면, 모델이 그 데이터를 직접적으로 학습하지 않았더라도 하이퍼파라미터 최적화나 모델 구조 선택에 간접적으로 활용되어 실제 새로운 데이터에 대한 일반화 성능을 왜곡한다.
    • 테스트 데이터는 반드시 모델이 본 적 없는 완전히 새로운 데이터를 가정하고 평가해야 한다.

정규화 (Normalization)

데이터 정규화란 입력 데이터의 값 범위일정한 스케일(scale)로 조정하는 과정을 의미한다. CIFAR-10의 데이터(이미지)를 예로 들면, 원본 픽셀 값은 RGB 3채널에 맞춘 0~255의 데이터로 이루어져 있다. 이때 정규화를 통해 -1~1 또는 0~1 과 같이 작은 스케일로 통일시키는 것이다.

그렇다면 왜 정규화가 필요할까? 정규화를 거치는 이유는 다음과 같다.

  • 학습 안정성
    • 단순 픽셀 값을 기준으로 255의 입력과 1 입력은 기울기의 크기가 매우 커지거나 작아진다. 정규화 작업을 진행하면 기울기 크기가 일정해져, 학습이 안정적으로 진행된다.
  • 학습 속도
    • 값 범위가 맞춰져 있으면, Loss 곡면이 균형을 이뤄, 최적점을 더 빠르게 찾아간다. loss 곡면이 둥글지 않고 찌그러진 타원형과 같은 형태를 띄고 있으면 최적점을 찾기 힘들어 학습 속도가 느려지게 된다.
  • 특정 채널이 학습을 지배하는 것을 방지
    • RGB 3채널에서 R 채널 값이 유독 크면, 모델이 R 채널에만 크게 의존하게 될 수 있다. 따라서 채널별로 평균과 표준편차를 맞추어, 모든 채널이 균등하게 학습에 기여하도록 한다.
  • 활성화 함수의 효율
    • sigmoid, tanh와 같은 활성화 함수는, 함수 수식의 특성 상 입력값이 크면 포화 현상으로 인해 기울기가 0에 수렴하는 기울기 소실 문제가 발생할 가능성이 매우 높다. 이 때 입력 값을 정규화하면 활성화 함수가 기울기가 살아있는 구간에서 동작하게 된다.

데이터 증강 (Data Augmentation)

데이터 증강이란, 기존 데이터에 변환을 가해서 새로운 학습 데이터를 만들어내는 것을 의미한다. 모델은 학습 데이터에서 본 패턴만을 학습하지만, 현실 데이터는 학습 데이터와 완전히 같지 않다.

예를 들어, 학습 데이터의 고양이 이미지가 정면에 밝은 배경이라면, 실제 고양이 이미지는 측면일 수도 있고, 배경이 어두울 수도 있고, 일부가 잘린 구도일 수도 있다. 이때, 모델은 현실의 고양이는 한 번도 본 적 없는 데이터처럼 느끼게 된다. 따라서, 같은 이미지에서 다양한 변형을 만들어냄으로써, 적은 데이터로도 다양한 상황을 경험한 것처럼 학습시킬 수 있게 된다.

간단하게 정리하면, “모델이 외울 수 없도록 데이터를 다양하게 만들어 과적합을 막고, 현실의 다양한 상황에 대응할 수 있게 하는 것”이 데이터 증강이다.

주로 사용되는 증강 기법

  • RandomHorizontalFlip : 이미지를 n% 확률로 좌우반전한다. (default : 0.5)

    • 좌우 반전을 통해 모델이 방향 변화에 덜 민감해지도록 함.
  • RandomCrop : 이미지 주변에 패딩을 추가한 뒤 랜덤한 위치에서 원래 크기로 잘라낸다.

    • 가장자리에 padding을 준 뒤 랜덤하게 잘라 위치 변화에 강인해지도록 함
    • crop의 크기가 이미지 크기보다 크면 에러 발생
  • RandomResizedCrop : crop 후 주어진 크기만큼 Resize

    • crop의 크기가 이미지 크기보다 커도 Resize하기에 오류가 발생하지 않음
  • ColorJitter : 밝기, 대비, 채도, 색조를 랜덤하게 변화시킨다.

    • (ex) torchvision.transforms.ColorJitter(brightness=0.5, contrast=0.5, saturation=0.5, hue=0.5)
  • RandomRotation : 이미지를 랜덤한 각도로 회전시킨다.

    • 각도는 (-degree, +degree) 사이만큼 회전함.

    RandomVerticalFlip(상하반전)도 있지만, 뒤집힌 자동차나 비행기는 현실에서 거의 없으므로 오히려 잘못된 패턴을 학습시킬 수 있어 사용하지 않는다.

참고 : https://blog.joonas.io/193#randomresizedcrop

# CIFAR-10에서 자주 쓰이는 평균 / 표준편차 (CIFAR-10 표준 정규화 통계값)
mean = (0.4914, 0.4822, 0.4465) # 채널별 평균값
std = (0.2470, 0.2435, 0.2616) # 채널별 표준편차값

# 학습용 데이터 전처리 (Train)
# 학습 단계 전용 전처리 및 증강 파이프라인
# transforms.Compose() 여러 단계의 변환이 필요할 때 여러 단계를 묶는 역할
train_transform = transforms.Compose([
		# 이미지 주변에 4픽셀 패딩을 주고, 32*32 크기 크롭 (주변 패딩 후 32*32 임의 절단 증강)
    transforms.RandomCrop(32, padding=4),
    transforms.RandomHorizontalFlip(), # 좌우 반전 증강
    transforms.ToTensor(), # PIL 이미지를 [0, 1] 텐서로 변환
    # 정규화 : (x' = (x - mean) / std)
    # (스케일 맞춰줌 + 학습 과정에서 Gradient 표면이 일관적이지 않은 문제 해결
    # // 중요중요 Normalize)
    transforms.Normalize(mean, std) # 채널별 정규화 수행
])

# 평가용 데이터 전처리 (Validation / Test)
# 평가용 데이터에는 augmentation이 없음
# -> 모델 성능을 공정하게 평가해야 하기 때문
# 검증 및 테스트 단계 전용 전처리 파이프라인
eval_transform = transforms.Compose([
    transforms.ToTensor(), # 텐서 변환만 수행
    # 입력 기준 맞추기 위해 여기서도 정규화
    transforms.Normalize(mean, std) # 학습과 동일 통계 기준 정규화
])

print("Train transform : ") # 학습 파이프라인 확인용 출력
print(train_transform) # 학습 전처리 상세 구성 출력
print() # 출력 가독성 확보용 빈 줄
print("Eval transform : ") # 평가 파이프라인 확인용 출력
print(eval_transform) # 평가 전처리 상세 구성 출력

Train에는 데이터 증강을 적용하지만, Validation/Test에서는 데이터 증강을 적용하지 않는 이유
****각 데이터셋의 목적이 다르기 때문이다.

  • train set : 데이터 증강을 적용하며, 모델이 다양한 번형을 경험하게 해, 일반화 능력을 키우는 것을 목적으로 매 에폭마다 같은 이미지가 조금씩 다르게 변형되어 들어오므로 모델이 특정 패턴을 외우기 어려워진다.
  • validaion / test set : 증강은 기본적으로 랜덤이다. 검증 / 테스트 셋에 증강을 적용하면, 이미지가 어떻게 잘렸는지, 좌우반전은 진행되었는지 등 매번 다른 이미지가 들어가서 성능 측정이 일관되지 않는다. 모델이 좋아진 것인지 운이 좋아 특정 데이터만 편향적으로 입력된 것인지 알 수 없어지기 때문이다. 따라서 동일한 입력으로 성능을 정확하게 측정하기 위해 증강을 적용하지 않는다.

1.3 데이터셋 분할과 DataLoader

데이터셋 분할

CIFAR-10 학습 데이터 50,000장을 다음과 같이 나눈다.

  • train : 45,000 장
  • validation : 5,000 장
  • test : 10,000 장
  • train용 데이터셋은 데이터 증강이 적용된 파이프라인을 적용하고 validation, test용 데이터셋은 데이터 증강이 적용되지 않은 파이프라인을 적용한다.
  • train, validation 셋은 train=True가 적용되어 있고, test 셋은 False로 설정되어 있다. train 옵션은 학습/테스트 방식을 설정하는 것이 아닌, 단순히 어느 파일을 열지 지정하는 파일 선택자로, 실제 CIFAR-10 데이터셋에 포함되어 있는 구조 중 50,000장에 포함되어 있는 데이터를 읽어오겠다는 의미를 가진다.
  • train 데이터셋은 CIFAR-10에서 따로 제공하고 있으므로, 우리는 validation 데이터셋을 train 데이터셋의 일부를 슬라이싱해 사용하는 형태로 진행하기 때문에 다음과 같은 설정을 사용한다.
# 같은 CIFAR-10 학습 데이터를
# - train용 (증강 적용)
# - validation용 (증강 미적용)
# 으로 따로 준비합니다 (차이점 기억 - Train / Test Data 분리 & 용도)
# 동일 CIFAR-10 학습 원본에서 train용 증강 버전과 validation용 비증강 버전 분리 구성 목적

# train용 (증강 적용) / 학습용 증강 데이터셋 생성부
full_train_aug = torchvision.datasets.CIFAR10(
    root = DATA_ROOT, # 데이터 저장 경로
    train=True, # 학습 원본 사용 설정
    download=True, # 미존재 시 자동 다운로드 설정
    transform=train_transform # 학습용 증강 파이프라인 적용
)

# validation용 (증강 미적용) / 검증용 비증강 데이터셋 생성부
# 검증 데이터 - 평가용 전처리 적용 (중요)
# 테스트용과 동일하게 성능 평가에 활용하기 때문에 검증 데이터에는 transform이 적용되면 안 됨.
full_train_eval = torchvision.datasets.CIFAR10(
    root=DATA_ROOT, # 데이터 저장 경로
    train=True, # 동일 학습 원본 사용 설정
    download=True, # 미존재 시 자동 다운로드 설정
    transform=eval_transform # 평가용 전처리만 적용
)

# test용 (증강 미적용) / 최종 성능 측정용 테스트 세트 생성부
test_dataset = torchvision.datasets.CIFAR10(
    root=DATA_ROOT, # 데이터 저장 경로
    train=False, # 공식 테스트 세트 사용 설정
    download=True, # 미존재 시 자동 다운로드 설정
    transform=eval_transform # 평가용 전처리 적용
)

num_total = len(full_train_aug) # 전체 학습 데이터 개수
# torch.randperm(n) : 0 ~ (n-1) 사이의 값이 랜덤하게 들어있는 tensor를 출력
# torch.Generator() : 난수 생성 알고리즘의 상태 확인 객체
# 0부터 49999까지 인덱스를 랜덤하게 섞음
indices = torch.randperm(num_total, generator=torch.Generator().manual_seed(SEED)
					.tolist() # 시드 고정 셔플 인덱스 생성

# train / validation 인덱스 분리
train_indices = indices[:-VAL_SIZE] # 앞부분을 학습 인덱스로 사용
val_indices = indices[-VAL_SIZE:] # 뒷부분을 검증 인덱스로 사용

# Subset() : 특정 조건에 맞는 데이터나 변수 추출
# 실제 train / validation dataset 생성
train_dataset = Subset(full_train_aug, train_indices) # 증강 적용 학습 부분집합
val_dataset = Subset(full_train_eval, val_indices) # 비증강 적용 학습 부분집합

print(f"Total train data : {num_total}") # 전체 학습 원본 개수 확인
print(f"Train Subset : {len(train_dataset)}") # 학습 부분집합 개수 확인
print(f"Validation Subset : {len(val_dataset)}") # 검증 부분집합 개수 확인
print(f"Test Dataset : {len(test_dataset)}") # 테스트 세트 개수 확인

DataLoader

DataLoader는 데이터를 미니배치 단위로 묶어서 모델에 공급해주는 역할을 한다.

  • shuffle=True : 매 Epoch마다 데이터 순서를 섞음
  • batch_size : 한 번에 몇 장씩 학습할지 결정
  • pin_memory=True : GPU 사용 시 전송 효율 개선에 도움
# 훈련용 DataLoader (학습용 DataLoader 구성부)
train_loader = DataLoader(
    train_dataset, # 학습용 subset (학습 데이터셋 입력)
    batch_size=BATCH_SIZE, # 미니배치 크기 설정
    # 학습에 사용되는 게 또 다시 사용될 수 있어서 shuffle이 iteration이 아닌 epoch 단위로 사용
    shuffle=True, # 매 epoch마다 셔플 적용 설정
    num_workers=NUM_WORKERS, # 데이터를 미리 읽어오는 worker 수 (배치 로딩 병렬 수)
    pin_memory=PIN_MEMORY # GPU 전송 가속용 메모리 고정 여부
)

# 검증용 DataLoader 구성부
val_loader = DataLoader(
    val_dataset, # 검증용 subset (검증 데이터셋 입력)
    batch_size=BATCH_SIZE, # 미니배치 크기 동일 적용
    shuffle=False, # 검증 시 순서 고정 설정 (인덱스 위치 같아야 하니까 shuffle 안함)
    num_workers=NUM_WORKERS, # 배치 로딩 병렬 수
    pin_memory=PIN_MEMORY # GPU 전송 가속 설정
)

# 테스트용 DataLoader 구성부
test_loader = DataLoader(
    test_dataset, # 테스트용 subset (테스트 데이터셋 입력)
    batch_size=BATCH_SIZE, # 미니배치 크기 동일 적용
    shuffle=True, # 검증 시 순서 고정 설정
    num_workers=NUM_WORKERS, # 배치 로딩 병렬 수
    pin_memory=PIN_MEMORY # GPU 전송 가속 설정
)

print(f"Number of train batches : {len(train_loader)}") # 학습 배치 수 확인
print(f"Number of val batches : {len(val_loader)}") # 검증 배치 수 확인

1.4 데이터 시각화

정규화가 적용된 이미지를 그대로 시각화하면 색상이 원본과 다르게 보일 수 있다. 이는 모델 입력을 위해 픽셀값의 분포를 조정하는 과정에서, 사람이 일반적으로 인식하는 이미지의 값 범위가 바뀌기 때문이다. 원본 이미지는 보통 0~255의 정수값 또는 0~1 범위의 실수값으로 표현되지만, 정규화 이후에는 각 채널 값이 평균과 표준편차를 기준으로 변환되어 음수나 1보다 큰 값을 포함할 수 있다.

정규화는 일반적으로 다음과 같이 수행된다.

xnorm=xμσx_{norm} = \frac{x-\mu}{σ}

이렇게 변환된 텐서는 모델 학습에는 적합하지만, 시각화에는 적합하지 않다. 예를 들어 RGB 이미지의 각 채널 값이 정규화된 상태로 남아 있으면, 원래의 색 비율이 사람이 기대하는 범위와 달라져 색감이 이상하게 표현될 수 있다. 또한 음수값이 포함된 경우 matplotlib와 같은 시각화 라이브러리에서 값이 자동으로 잘리거나(clipping), 예상치 못한 색으로 출력될 수 있다.

따라서 정규화된 이미지를 사람이 해석 가능한 형태로 보기 위해서는 역정규화(denormalization) 과정이 필요하다. 역정규화는 다음 식으로 수행된다.

x=xnormσ+μx = x_{norm}⋅σ+μ

이 과정을 거치면 정규화 이전의 스케일로 값을 되돌릴 수 있다. 엄밀히 말하면, 이는 항상 0~255로 직접 복원된다기보다 정규화 직전의 값 범위로 되돌리는 과정이다. 예를 들어 ToTensor() 이후 정규화를 수행했다면, 역정규화 결과는 보통 0~1 범위에 가까운 값으로 복원되며, 이 상태가 matplotlib에서 올바르게 시각화되기 적절하다.

정규화된 텐서를 시각화할 때는 보통 다음 순서를 따른다.

  1. 텐서를 CPU로 옮긴다.

    • 학습 중 텐서는 GPU에 올라가 있을 수 있다. matplotlib 같은 시각화 라이브러리는 CPU의 numpy 배열만 처리할 수 있으므로, 텐서를 CPU로 옮겨야 한다.
  2. 정규화를 되돌리는 역정규화(denormalize) 과정을 거친다.

    • 정규화가 진행된 각 채널의 스케일을 원본 RGB 스케일 0~255에 맞게 역정규화 과정을 거친다.
  3. HWC 형태로 바꾸어 출력한다.

    • PyTorch의 이미지 텐서는 일반적으로 (C, H, W) 형식을 사용하지만, matplotlib(H, W, C) 형식을 기대한다. 따라서 permute() 등을 이용해 차원 순서를 바꿔주어야 정상적으로 이미지가 출력된다.

    즉, 정규화는 모델 학습을 위한 전처리이고, 역정규화는 사람이 이미지를 올바르게 해석하기 위한 복원 과정이라고 볼 수 있다. 따라서 학습용 텐서를 그대로 시각화하기보다는, 반드시 역정규화와 차원 변환을 거친 뒤 출력해야 원본에 가까운 색상과 형태를 확인할 수 있다.

# 정규화된 이미지를 다시 원래 색감에 가깝게 되돌리는 함
def denormalize(images : torch.Tensor, mean : Tuple[float, float, float], 
						std : Tuple[float, float, float]) -> torch.Tensor: # 시각화용 역정규화 함수
  # (batch_size, channels, height, width) 배추 ㅋ ㅋ
  # 평균과 표준편차 -> channels 단위니까
  # 평균 텐서 브로드캐스팅 형태 변환
  mean_tensor = torch.tensor(mean, device=images.device).view(1, 3, 1, 1)
  # 표준편차 텐서 브로드캐스팅 형태 변환
  std_tensor = torch.tensor(std, device=images.device).view(1, 3, 1, 1)
  # x' = (x - mean) / std (정규화)
  # x = x' * std + mean (복원)
  return images * std_tensor + mean_tensor # 정규화 이전 스케일 복원

def show_batch(images: torch.Tensor, labels : torch.Tensor,
								class_names: List[str], n: int=8, cols: int=4): # 배치 시각화 함수
	# 앞 n개만 선택 후 CPU 이동 및 역정규화 범위 제한
  images = denormalize(images[:n].cpu(), mean, std).clamp(0, 1)
  labels = labels[:n].cpu() # 시각화 대상 라벨만 CPU 이동

  rows = int(np.ceil(n / cols)) # 필요한 행 개수 계산
  plt.figure(figsize=(3 * cols, 3 * rows)) # 서브플롯 크기 설정

  for i in range(n): # 개별 이미지 반복 출력 루프
    plt.subplot(rows, cols, i + 1) # 현재 이미지 서브플롯 위치 지정
    img = images[i].permute(1, 2, 0).numpy() # [C, H, W] -> [H, W, C] 변환 후 NumPy 변환
    plt.imshow(img) # 이미지 출력
    plt.title(class_names[labels[i].item()]) # 정답 클래스명 제목 표시
    plt.axis("off") # 축 제거 설정

  plt.tight_layout() # 서브플롯 간 간격 자동 조정
  plt.show() # 최종 그림 출력

images, labels = next(iter(train_loader)) # 첫 학습 배치 추출
show_batch(images, labels, class_names, n=8, cols=4) # 샘플 배치 시각화 실행

2. BaselineCNN과 ImprovedCNN 구조 비교

2.1 CNN

CNN은 이미지 처리/인식을 위한 신경망이다. 시각적인 데이터 처리를 위한 딥러닝의 한 형태로, 사물 형태나 특징/위치 변화에 강인해 이미지/비디오 인식에 주로 사용된다. 이러한 CNN은 기존의 DNN과는 달리 이미지 그 자체를 입력으로 받는다. 즉, 기존의 DNN처럼 이미지를 flatten시켜서 입력으로 받을 필요 없이, 이미지 그대로를 입력으로 받을 수 있다. 그렇기에 이미지 자체의 공간적/지역적 정보를 유지한 채로 연산을 진행할 수 있다.

CNN은 Feature Learning(특징 학습) 단계와 Classifier Learning(분류기 학습) 단계로 구성되며, 입력층에서 컨볼루션 연산을 통해 특징을 뽑아내는 Feature map이 존재하고, Feature map에 풀링 연산을 적용해 입력 차원의 차원을 줄인다. 이를 통해 데이터를 간소화하고, 필요한 정보만을 유지하며, 오버피팅을 방지하여 계산 효율을 높일 수 있다. 이러한 컨볼루션과 풀링 레이어는 여러 번 되풀이된다. 그리고 신경망의 맨 끝에는 Fully Connected Layer가 존재해 추출된 특징을 기반으로 물체를 인식하고 분류할 수 있다.

전반적인 과정을 다시 살펴보면 다음과 같다. 입력으론 이미지 그 자체가 입력되고, Convolutional layer에선 필터를 통해 이미지의 지역적 특징을 잡아내 이를 Feature map으로 출력한다. 이후, Feature map에 비선형성을 도입해서 복잡한 패턴을 학습할 수 있도록 도움을 주기 위한 Activation function을 적용한 뒤, 이미지의 중요한 특징만을 남기고 공간 크기를 줄이는 Pooling layer를 거친다.

여기서 중요한 것은, 각 Convolutional layer의 입력이 원본 이미지가 아닌, 이전 레이어의 출력 결과라는 것이다. 즉, 추출된 특징들을 바탕으로 새로운 특징들을 계속 학습해 나간다. 우리가 Pooling layer를 사용해야 하는 이유는 여기에 있다. 이미지의 중요한 특징만을 남기고 크기를 줄이지 않으면, 연산량이 너무 많고, 이로 인해 overfitting이 발생할 우려가 있기 때문이다.

모든 Conv → Pooling 과정을 거친 후엔 Fully Connected Layer로 이동하는데, 이때 FC Layer로 이동하기 전, Flatten 과정을 거쳐 다차원의 Feature map을 1차원 벡터로 평탄화시키는 과정이 필요하다. 왜냐하면 FC Layer는 1차원 배열 형태로 평탄화된 행렬을 통해 이미지를 분류하는 데 사용되는 계층이기 때문이다. 그래서 Flatten 과정을 거친 후, FC Layer에서 입력으로 받은 1차원 벡터의 특징들을 바탕으로 활성화 함수나 softmax를 적용해 최종적인 분류를 결정한다.

이러한 과정을 거치면서 이미지의 중요한 특징들이 Feature map에 통합되고, 중요한 특징들을 파악해 나가면서 이미지가 어떤 클래스에 속하는지 찾아내는 과정이 CNN 모델의 전반적인 학습 과정이다.

컨볼루션 신경망은 층이 깊어질수록 점차 더 복잡하고 세밀한 패턴을 포착할 수 있다. 초기 층에서는 엣지나 코너와 같은 단순한 국소적 특징(simple cell)을 학습하고, 중간 층으로 갈수록 이러한 특징들이 조합되어 텍스처나 부분 구조를 인식하며, 최종적으로는 객체 전체를 나타내는 복잡한 고수준 특징(complex cell)을 학습하게 된다.

이러한 계층적 특징 학습은 Receptive Field와 밀접한 관련이 있다. Receptive Field란 컨볼루션 신경망에서 특정 출력 뉴런 하나에 영향을 미치는 입력 이미지 영역의 크기를 의미한다. 컨볼루션과 풀링 연산이 반복되면서 각 층의 뉴런이 참조하는 입력 영역은 점점 넓어지게 되고, 이에 따라 상위 층의 뉴런은 더 넓은 공간적 정보를 통합하여 바라보게 된다.

즉, 얕은 층의 뉴런은 이미지의 작은 국소 영역만을 관찰하는 반면, 네트워크가 깊어질수록 Receptive Field가 확장되어 점차 이미지의 큰 부분, 나아가서는 전체 이미지에 대한 전역적 정보를 포착할 수 있게 된다. 이러한 특성 덕분에 컨볼루션 신경망은 로컬한 특징에서부터 전역적인 의미 정보까지 단계적으로 학습할 수 있으며, 이는 이미지 분류·객체 인식과 같은 비전 문제에서 뛰어난 성능을 보인다.

또한 CNN은 하위 레이어의 유닛과 상위 레이어의 유닛이 모두 연결되지 않고 부분적으로만 연결되는 구조를 가지므로, 전체 네트워크의 복잡도가 낮아지고 과적합(overfitting)에 빠질 가능성이 줄어든다. 이러한 특성을 지역적 수용 영역(local receptive field)이라고 한다.

기존의 DNN은 모든 입력 뉴런이 다음 층의 모든 뉴런과 연결되는 Fully Connected Layer 구조를 사용하는 반면, CNN은 입력의 국소 영역에 대해서만 연산을 수행하는 희소 연결성(sparse connectivity)을 가진다. 이는 컨볼루션 필터가 슬라이딩 윈도우 방식으로 이미지 전체를 이동하며 연산을 수행하기 때문에 나타나는 구조적 특성이다.

구체적으로, 하나의 필터는 이미지를 작은 영역으로 나누어 각 영역에서 특정 패턴을 추출하며, 이 필터는 이미지의 모든 위치에 동일하게 적용된다. 이 과정에서 동일한 가중치가 전체 이미지에 공유(weight sharing)되므로, Fully Connected Layer에 비해 학습해야 할 파라미터 수가 크게 감소한다.

이러한 지역 연결성, 희소 연결성, 가중치 공유라는 CNN의 구조적 특성은 계산 효율성을 높일 뿐만 아니라, 불필요한 파라미터 증가를 억제하여 일반화 성능을 향상시키는 데 중요한 역할을 한다.

  • 컨볼루션 신경망 : 정보 추출/축소를 반복하며 최종적으로 주어진 이미지를 기반으로 핵심 특징을 학습함.
  • 컨볼루션 : 지역적 특징 감지 및 추출. 레이어가 깊어질수록 추상적/복잡한 특징 추출
  • 풀링 : 이미지 압축. 위치 변화에 강건한 특징을 보이게 해줌.

Convolution Layer

  • 이미지 처리에서 가장 중요한 연산은 합성곱(Convolution) 이다. 이는 필터(또는 커널)라는 작은 가중치 행렬을 이용해 이미지(또는 이전 레이어의 feature map)에 슬라이딩 연산을 수행하여 특징을 추출한다.
  • 커널 크기가 3x3이고, 입력 채널이 1, 출력 채널이 32라면, 각 필터는 (1,3,3) 크기의 가중치를 가진다. 이런 필터가 32개 있어(출력 채널 수 만큼) 다른 특징을 잡아내도록 학습된다.
  • PyTorch에서는 nn.Conv2d(in_channels, out_channels, kernel_size, stride, padding, …) 등을 통해 구현이 가능하다.

Pooling Layer

  • 풀링은 지역적 영역에서 최대값 또는 평균 등을 취해 특성맵 크기를 줄이고, 불변성을 높여준다.
  • 가장 흔한 방식 중 하나인 nn.MaxPool2d(kernel_size=2, stride=2)는 2x2 영역에서 최대값을 취해 가로 세로를 절반으로 줄인다.

FC Layer(Fully Connected)

  • 추출된 특징을 기반으로 최종 분류를 수행한다.
  • FC Layer에 진입하기 전 Flatten 과정을 거쳐 특징을 1차원으로 펼친 뒤 진입한다.

Padding & Stride

  • 패딩은 컨볼루션 전 후에 주변을 0과 같은 숫자로 둘러싸 입력 크기를 유지시켜주거나, 정보를 더 많이 보존하려는 목적으로 사용된다. 이를 위해 입력 이미지의 가장자리(테두리)에 특정 값을 지닌 픽셀을 추가하여 컨볼루션 연산 이후에도 출력 이미지와 원본 이미지 크기 크기가 같도록 한다. 작은 이미지나 경계값 주변에 중요한 정보를 지니는 경우, 패딩을 적용하지 않으면(valid 패딩) 경계값 주변의 픽셀 정보가 손실되므로 성능이 저하되는 문제점이 있다. 이를 해결하기 위해 등장한 것이 패딩이다. 패딩은 합성곱 연산 전에 입력 데이터 주변에 특정 값을 채우므로(same 패딩) 출력 데이터의 크기를 조정할 수 있고, 패딩을 통해 경계값을 확장할 수도 있다. 하지만 인위적인 픽셀 값 추가로 인하여 정보 왜곡이 발생할 수 있다는 문제점도 존재한다.
  • 스트라이드는 필터를 얼마나 건너뛰면서 이동할지 정하며 일반적으로 1 또는 2(혹은 커널 사이즈에 맞춰) 사용한다. (컨볼루션 연산에서 필터가 입력 데이터(이미지) 위에서 한 번에 이동하는 거리, 즉 커널을 적용하는 간격) → 패딩을 크게 하면 출력 데이터의 크기가 커지는 반면, 보폭을 크게 하면 출력 데이터의 크기는 작아진다.

CNN에서 Conv2d(in_channels=1, out_channels=32, kernel_size=3, padding=1) 형태로 컨볼루션 연산을 진행한다고 가정했을 때

  • 필터 1개의 가중치 수: 1 x 3 x 3 = 9 (in 채널 x 커널 높이 x 커널 너비)
  • bias 파라미터까지 합하면 필터 1개당 9+1 = 10
  • 필터가 32개이므로 총 파라미텨 = 32 x 10 = 320
  • 총 파라미터는 320개로 위와 같은 과정을 통해 계산이 가능하며, 풀링 레이어는 단순 연산을 진행하는 레이어로 학습 파라미터가 존재하지 않는다.

(Baseline / Improved 해석 전) 참고 사항

Batch Normalization

Batch Normalization은 신경망의 각 층에 들어가는 입력 분포를 정규화하여 학습을 안정화하는 기법이다. 딥러닝 모델을 학습할 때 각 층의 입력값 분포가 계속 변하면, 뒤쪽 층은 매번 달라지는 분포에 적응해야 하므로 학습이 불안정해질 수 있다. 이렇게 학습 중 각 층의 입력 분포가 계속 변하는 현상을 internal covariate shift라고 한다.

Batch Normalization은 미니배치 단위로 평균과 분산을 계산한 뒤, 각 feature를 평균이 0이고 분산이 1에 가까운 형태로 정규화한다. 하지만 단순히 정규화만 하면 표현력이 제한될 수 있기 때문에, 이후 학습 가능한 파라미터를 통해 다시 스케일과 이동을 적용한다. 즉, 입력 분포를 무조건 고정하는 것이 아니라, 학습에 유리한 방향으로 안정화하면서도 필요한 표현력은 유지할 수 있도록 설계된 것이다.

이 기법을 사용하는 가장 큰 이유는 학습을 더 빠르고 안정적으로 만들 수 있기 때문이다. 각 층이 받는 입력 분포가 지나치게 흔들리면 뒤쪽 층은 매번 달라지는 입력에 적응해야 하므로 학습이 어려워진다. Batch Normalization은 이러한 변화를 완화하여 gradient 흐름을 안정시키고, 경우에 따라 더 큰 learning rate를 사용할 수 있게 하여 수렴 속도를 높이는 데 도움을 준다. 또한 미니배치 통계를 사용한다는 특성 때문에 약한 regularization 효과도 가져와 과적합 완화에 긍정적인 영향을 줄 수 있다.

Batch Normalization은 학습 단계와 추론 단계에서 동작 방식이 다르다. 학습 시에는 현재 미니배치의 평균과 분산을 사용하여 정규화를 수행한다. 반면 추론 시에는 학습 과정에서 누적해 둔 running mean과 running variance를 사용한다. 이는 테스트 단계에서는 입력이 한 장씩 들어오거나 배치 크기가 일정하지 않을 수 있기 때문에, 학습 때처럼 현재 배치 통계를 그대로 사용하는 것이 적절하지 않기 때문이다.

CNN에서는 Batch Normalization을 보통 Convolution 연산 뒤, 활성화 함수인 ReLU 앞에 배치한다. 즉, 일반적으로는 Conv → BatchNorm → ReLU 순서로 많이 사용된다. 그 이유는 convolution 결과의 분포를 먼저 정규화한 뒤 활성화 함수를 적용하는 것이 더 자연스럽기 때문이다. 만약 ReLU를 먼저 적용하면 음수값이 0으로 잘리면서 원래 feature 분포가 달라지게 되고, 이후 Batch Normalization은 이미 변형된 분포를 기준으로 정규화를 수행하게 된다. 따라서 기본적인 구조에서는 ReLU 이전에 Batch Normalization을 두는 경우가 많다. 다만 실제 모델 설계에서는 항상 이 순서만 정답인 것은 아니며, 구조에 따라 순서를 다르게 했을 때 더 좋은 성능이 나오는 경우도 있다.

정리하면, Batch Normalization은 미니배치 단위의 평균과 분산을 이용해 feature 분포를 정규화하고, 학습 가능한 파라미터를 통해 표현력을 유지하면서 학습 안정성과 수렴 속도를 높이는 기법이다. 따라서 딥러닝 모델의 성능 향상뿐 아니라 학습 효율을 높이는 데에도 중요한 역할을 한다.

배치 : 한 번에 모델에 넣어서 같이 처리하는 데이터 묶음
Iteration : 배치 한 번을 처리하는 것
Epoch : 전체 학습 데이터를 다 보는

Dropout

드롭아웃이란, 학습 단계마다 무작위로 일부 뉴런을 비활성화하여 신경망이 특정 뉴런에만 과하게 의존하는 것을 방지하기 위한 기법을 의미한다. 이는 과적합을 줄이고 일반화 성능을 향상시키는 효과가 있는데, 그 이유는 학습 과정에서 특정 가중치/뉴런에 과하게 의존하지 않고, 다양한 경로를 학습할 수 있게 하기 때문이다.

다시 말해, Dropout은 co-adaptation(신경망에서 특정 뉴런들이 서로 강하게 의존되는 현상)을 방지하는 효과가 있다. 특정 뉴런이 특정 feature만 잘 잡아내서 다른 뉴런이 그 feature를 잡아내지 않고, 다른 역할만 하면 뉴런들이 독립적으로 일반화된 표현을 학습하지 못 하는 현상이 발생할 수 있는데, Dropout은 이를 방지할 수 있고, 이를 통해, 각 뉴런이 다른 뉴런에 과도하게 의존하지 않고 자신만의 독립적 표현을 학습할 수 있다.

이러한 Dropout은 보통 epoch 단위가 아니라 iteration 단위, 즉 배치가 들어올 때마다 새롭게 적용된다. 이렇게 하는 이유는 매 배치마다 서로 다른 뉴런 조합을 사용하도록 하여, 모델이 특정 노드나 특정 연결에 과도하게 의존하는 것을 막기 위해서이다. 만약 동일한 dropout mask를 오랫동안 고정하면, 일부 뉴런 조합에 편향된 학습이 일어날 수 있다. 따라서 iteration마다 dropout을 새로 적용함으로써 일반화 성능을 높일 수 있다.

Adaptive Average Pooling

Global Average Pooling은 각 채널의 전체 공간 영역(H×W)을 전부 평균내서, 채널마다 값 1개만 남기는 방식이다. Adaptive Average Pooling은 GAP의 특수한 경우로, Adaptive Average Pooling은 입력 feature map의 크기와 관계없이, 사용자가 지정한 출력 크기에 맞추어 평균 풀링을 수행하는 기법이다. 일반적인 Average Pooling은 kernel size와 stride를 직접 설정해야 하며, 그 결과 출력 크기가 입력 크기에 따라 달라진다. 반면 Adaptive Average Pooling은 출력 크기를 먼저 정하면, 이에 맞도록 내부적으로 pooling 영역을 자동으로 나누어 평균값을 계산한다.

이 방식의 장점은 입력 이미지 크기나 중간 feature map의 크기가 달라도, 최종적으로 항상 고정된 크기의 출력을 얻을 수 있다는 점이다. 따라서 CNN의 마지막 단계에서 feature map을 분류기(fully connected layer)에 넣기 전, 크기를 일관되게 맞추는 용도로 자주 사용된다.

특히 출력 크기를 (1, 1)로 설정한 경우, 각 채널의 전체 공간 영역 H×W를 모두 평균내어 채널당 하나의 값만 남기게 되는데, 이것이 바로 Global Average Pooling(GAP) 이다. 즉, GAP는 Adaptive Average Pooling의 특수한 형태라고 볼 수 있다.

예를 들어 입력 feature map의 크기가 (C,H,W)일 때, AdaptiveAvgPool2d((1,1))를 적용하면 출력은 (C,1,1)이 된다. 이 과정에서 각 채널은 하나의 대표값으로 요약되며, 공간적 정보는 압축되고 채널별 특징만 남게 된다.

정리하면, Adaptive Average Pooling은 출력 크기를 원하는 형태로 유연하게 조절할 수 있는 평균 풀링 방법이며, 그중 가장 대표적인 경우가 (1,1) 출력으로 각 채널을 하나의 값으로 요약하는 Global Average Pooling이다.

2.3 BaselineCNN vs Improved CNN

BaselineCNN

가장 기본적인 CNN 구조로 다음과 같이 레이어가 구성되어 있다.

  • Conv → ReLU
  • Conv → ReLU
  • MaxPool
  • Conv → ReLU
  • Conv → ReLU
  • MaxPool
  • Flatten → Linear → ReLU → Linear
from torch.nn.modules.pooling import MaxPool2d

# 컨볼루션 다음엔 반드시 ReLU가 따라옴
# Dense하게 안 된 얘한테 추가해주는 게 표현력 증가 (픽셀 정보 토대로 묶은 거에 비선형성 추가)
class BaselineCNN(nn.Module): # 기존 CNN 기준 모델 정의부
  def __init__(self, num_classes: int=10): # 출력 클래스 수 설정 가능 구조
    super().__init__() # nn.Module 초기화 호출

    self.features = nn.Sequential( # 합성곱 기반 특징 추출부
		    # 3채널 입력 -> 32채널 특징맵 변환, 해상도 유지
        nn.Conv2d(3, 32, kernel_size=3, padding=1),
        nn.ReLU(inplace=True), # 비선형성 부여용 활성화 함수

				# 32채널 -> 64채널 확장, 저수준 특징 심화
        nn.Conv2d(32, 64, kernel_size=3, padding=1),
        nn.ReLU(inplace=True), # 비선형 표현력 추가
				
				# 32*32 -> 16*16 다운 샘플링, 위치 불변성 일부 확보
        nn.MaxPool2d(kernel_size=2, stride=2),

				# 64채널 -> 128채널 확장, 중간 수준 특징 추출
        nn.Conv2d(64, 128, kernel_size=3, padding=1),
        nn.ReLU(inplace=True), # 비선형성 추가

        nn.Conv2d(128, 128, kernel_size=3, padding=1), # 128채널 유지, 특징 정교화 단계
        nn.ReLU(inplace=True), # 비선형성 추가

        nn.MaxPool2d(kernel_size=2, stride=2) # 16* 16 -> 8*8 다운 샘플링
    )

    self.classifier = nn.Sequential( # 분류기 구성부
        nn.Flatten(), # 3차원 특징맵을 1차원 벡터로 평탄화
        # Tensor shape : [B, C, H, W]
        **# 고차원 특징 -> 은닉 표현 압축 (이거 in_channel, out_channel 계산 가능해야 함)
        nn.Linear(128 * 8 * 8, 256),** 
        nn.ReLU(inplace=True), # 분류 전 비선형성 부여
        nn.Linear(256, num_classes) # 최종 클래스 로짓 출력
    )

  def forward(self, x): # 순전파 정의부
    x = self.features(x) # 특징 추출부 통과
    x = self.classifier(x) # 분류기 통과
    return x # 최종 로짓 반환

ImprovedCNN

보다 실질적인 개선 구조이다.

  • BatchNorm2d 추가 : BaselineCNN은 Conv → ReLU 구조이다. 이 구조는 Conv 출력값이 레이어를 거칠수록 분포가 점점 치우쳐져, 어떤 채널은 값이 매우 크고, 어떤 채널은 매우 작아지는 현상이 발생해 학습이 불안정해진다. ImprovedCNN은 이 사이에 BatchNorm을 추가해 Conv → BatchNorm → ReLU 구조로 변경했다. : BatchNorm은 미니배치 단위로 각 채널의 출력을 정규화하는 레이어로, 매 레이어마다 평균을 0으로, 표준편차로 1로 다시 맞춰준다. BatchNorm을 추가하면, 학습이 안정적으로 진행되고 학습률을 크게 써도 발산하지 않는다는 장점이 있으며, 자체적인 정규화 효과로 과적합이 억제되는 효과를 가진다.
  • Dropout 추가 : BaselineCNN의 분류기는 Flatten → Linear(1281288) → ReLU → Linear(256, 10) 구조로 이루어져 있다. 위 구조는, FC 레이어에서 특정 뉴런이 학습을 지배하게 되면, 그 뉴런에 과도하게 의존하는 과적합이 발생할 가능성이 높다.
    : ImprovedCNN은 Flatten 직후 Dropout을 추가해 다음과 같은 구조로 변경했다. Flatten → Dropout(p=0.3) → Linear(256, 10). 30%의 뉴런을 랜덤하게 꺼버려, 특정 뉴런 의존을 막고 다양한 경로로 학습을 강제하지만, model.eval() 시에는 자동으로 비활성화되며 모든 뉴런이 동작한다.
  • 마지막에 AdaptiveAvgPool2d((1,1)) 사용 : BaselienCNN은 마지막 MaxPool 과정 이후 특징 맵이 8x8 형태로 남는다. 여기서 Flatten을 하게 되면 128 128 8로 8192차원 벡터가되고, 이는 FC 레이어의 입력 크기가 8192로, 파라미터 수가 매우 많아진다는 것을 의미한다. 이렇게 되면, 입력 이미지의 크기가 바뀌면 FC 레이어의 크기도 바꿔야 한다. : ImpovedCNN은 마지막에 AdaptiveAvgPool2d((1,1))을 추가하는 구조로 변경했다. 이는 출력 크기를 고정하는 풀링 방식이며, 일반적인 풀링은 입력 크기에 따라 출력 크기가 달라지지만, 이는 입력크기에 상관없이 출력을 항상 지정한 크기(1,1)로 맞춰준다. Flatten 전에 AdaptiveAvgPooling을 통해 입력 크기에 상관 없이 항상 1x1로 압축하므로 FC 입력이 채널 수만 남고, 파라미터 수가 대폭 줄어들게 된다. 또한, MaxPool보다 정보 손실이 적다.
  • 분류기 구조를 더 간결하게 정리 : 위에서 설명했던 것과 같이, 기존 분류기에서는 8192 차원이 입력으로 들어와 파라미터 수가 많고, 중간 FC 레이어가 추가될수록 과적합 위험이 커진다. 이를 방지하기위해, AdaptiveAvgPool로 이미 256차원으로 압축된 특징이 들어오기 때문에, 중간 FC 레이어 없이 바로 분류가 가능해진다. 이런 구조는 파라미터 수가 줄고 구조가 단순해져 과적합을 방지해준다.

즉, ImprovedCNN은 학습 안정성이 더 좋아지고 / 과적합 완화에 도움이 될 수 있으며 / 마지막 feature map 크기에 덜 민감한 구조로 변경한 것임.

class improvedCNN(nn.Module): # 정규화/드롭아웃 포함 개선 모델 정의부
  def __init__(self, num_classes: int=10): # 출력 클래스 수 설정 가능 구조
    super().__init__() # nn.Module 초기화 호출

    self.features = nn.Sequential( # 개선형 특징 추출부
        nn.Conv2d(3, 32, kernel_size=3, padding=1), # 초기 저수준 특징 추출용 합성곱
        # BatchNorm -> internal covariate shift를 해결하기 위해 사용함.
        # internal covariate shift : 입력 데이터의 분포가 학습할 때와
        # 테스트할 때 다르게 나타나는 현상
        # 평균이 안 흔들리게 도와주는 것
        # BatchNorm이 컨볼루션 다음 나오는 이유
        # -> ReLU 다음 하면 0 이하는 버리니까 원래 데이터 분포와 달라짐
        # ReLU 때문에 내부 분포가 계속 변하면서 원래 분포에서 벗어나있는데
        # 이 상태에서 적용하면 이상해짐
        # 근데 실제적인 상황에서는 바꿨을 때 더 잘 나오기도 함.
        nn.BatchNorm2d(32), # 채널별 활성값 분포 안정화 계층
        nn.ReLU(inplace=True), # 비선형성 부여

        nn.Conv2d(32, 64, kernel_size=3, padding=1), # 채널 확장 단계
        nn.BatchNorm2d(64), # 학습 안정용 정규화
        nn.ReLU(inplace=True), # 비선형성 부여
        nn.MaxPool2d(kernel_size=2, stride=2), # 32*32 -> 16*16 다운샘플링

        nn.Conv2d(64, 128, kernel_size=3, padding=1), # 중간 수준 특징 확장 단계
        nn.BatchNorm2d(128), # 분포 안정화 계층
        nn.ReLU(inplace=True), # 비선형성 부여

        nn.Conv2d(128, 256, kernel_size=3, padding=1), # 고수준 특징 채널 확장 단계
        nn.BatchNorm2d(256), # 깊은 층 학습 안정화 목적
        nn.ReLU(inplace=True), # 비선형성 부여
        nn.MaxPool2d(kernel_size=2, stride=2), # 16*16 -> 8*8 다운샘플링

        nn.Conv2d(256, 256, kernel_size=3, padding=1), # 고수준 특징 재정련 단계
        nn.BatchNorm2d(256), # 마지막 특징 분포 안정화
        nn.ReLU(inplace=True), # 비선형성 부여

        # 전역 평균 풀링 수행 시 (1, 256, 8, 8) -> (1, 256, 1, 1)이 되므로
        # classifier 단에서 8*8*256이 아니라 Linear(256, num_classes)
        # CNN + FC(Fully Connected) Layer에서 classifier인 FC Layer를 없애기 위한 방법
        nn.AdaptiveAvgPool2d((1, 1)) # 공간 차원을 1*1로 압축하는 전역 평균 풀링
    )

    self.classifier = nn.Sequential( # 개선형 분류기 구성부
        nn.Flatten(), # [B, 256, 1, 1] -> [B, 256] 변환
        # 스케일이 큰 값이 우위를 가지는 걸 방지하기 위해 일부 뉴런을 꺼줌으로써
        # 다른 뉴런이 학습할 기회를 줌.
        **# epoch 단위보다는 Iteration 단위로 Dropout**
        # 배치 단위 학습 -> 그 배치에 의존하는 노드 발생하므로 특정 노드 강화하는 방식 학습
        # (더 편향적)
        nn.Dropout(p=0.3), # 과적합 완화용 일부 뉴런 비활성화
        nn.Linear(256, num_classes) # 최종 클래스 로짓 출력
    )

  def forward(self, x): # 순전파 정의부
    x = self.features(x) # 특징 추출부 통과
    x = self.classifier(x) # 분류기 통과
    return x # 최종 로짓 반환
baseline_model = BaselineCNN(num_classes=10) # 기준 모델 인스턴스 생성
improved_model = improvedCNN(num_classes=10) # 개선 모델 인스턴스 생성

dummy_input = torch.randn(4, 3, 32, 32) # 배치 크기 4인 가상 입력 텐서 생성

# (Batch, Num_classes)
# 기준 모델 출력 차원 확인
print("Baseline output shape : ", baseline_model(dummy_input).shape)
# 개선 모델 출력 차원 확인
print("Improved output shape : ", baseline_model(dummy_input).shape)
print() # 출력 가독성 확보용 빈 줄

# 층별 특징맵 크기 추적 함수
def inspect_feature_shapes(model : nn.Module, input_shape=(1, 3, 32, 32)):
  x = torch.randn(*input_shape) # 지정 크기 가상 입력 생성
  print(f"input shape : {tuple(x.shape)}") # 입력 크기 출력

  for layer in model.features: # 특징 추출부의 각 계층 순차 순회
    x = layer(x) # 현재 계층 통과 결과 갱신
    # 계층명과 출력 크기 동시 출력
    print(f"{layer.__class__.__name__:<20} -> {tuple(x.shape)}")

print("=== improvedCNN Feature Shape  Flow ===") # shape 흐름 분석 시작 안내
inspect_feature_shapes(improved_model) # 개선 모델 특징맵 크기 흐름 출력
  • 출력 shape 확인하기

3. 학습 루프와 최적화 설정 이해

model.train() / model.eval()

model.train()model.eval()모델의 학습 모드와 평가 모드를 전환하는 함수이다. 딥러닝 모델에는 학습할 때와 평가할 때 동작 방식이 달라져야 하는 레이어들이 존재하므로, 학습 단계와 검증·테스트 단계에서 이를 명시적으로 구분해주어야 한다. 이때 사용하는 것이 train()eval()이다.

model.train()을 호출하면 모델은 학습 모드로 전환되고, model.eval()을 호출하면 평가 모드로 전환된다. 학습 모드에서는 주로 forward 연산 이후 loss를 계산하고, backward를 통해 gradient를 구한 뒤 optimizer step으로 파라미터를 업데이트하는 흐름이 사용된다. 반면 평가 모드에서는 일반적으로 파라미터를 업데이트하지 않고, 검증 데이터나 테스트 데이터에 대해 loss나 accuracy 같은 성능 지표를 측정한다.

다만 여기서 주의해야 할 점은, model.eval()자동으로 gradient 계산을 꺼주는 것은 아니라는 점이다. eval()은 단지 특정 레이어들의 동작 방식을 평가용으로 바꾸는 역할을 한다. 따라서 평가 단계에서는 보통 with torch.no_grad():를 함께 사용하여 gradient 계산을 비활성화하고, 메모리 사용량과 연산량을 줄인다. 즉, train()eval()은 모델의 모드를 바꾸는 함수이고, gradient 계산 여부는 별도로 제어해야 한다.

또, train()eval() 에서 동작 방식이 다른 레이어는 두 가지가 존재한다.

  • Dropout : 학습 모드에서는 일부 뉴런을 랜덤하게 비활성화하여 특정 뉴런이나 연결에 과도하게 의존하는 것을 방지한다. 하지만 평가 모드에서는 더 이상 뉴런을 랜덤하게 끄지 않고, 모든 뉴런을 사용한 상태로 연산을 수행한다. 이는 평가 시마다 결과가 달라지는 것을 막고, 학습된 모델의 실제 성능을 안정적으로 측정하기 위함이다.

  • BatchNorm : 학습 모드에서는 현재 미니배치의 평균과 분산을 사용하여 feature를 정규화한다. 이 과정에서 동시에 running mean과 running variance도 업데이트된다. 반면 평가 모드에서는 현재 입력 배치의 통계를 사용하지 않고, 학습 과정에서 누적해둔 running mean과 running variance를 사용하여 정규화를 수행한다. 평가 시마다 입력 배치의 통계에 따라 결과가 달라지면 예측이 일관되지 않을 수 있으므로, 학습 중 축적한 통계를 사용하는 것이다.

    정리하면, model.train()model.eval()학습과 평가 상황에 맞게 모델 내부 레이어의 동작 방식을 전환하기 위해 반드시 필요한 설정이다. 특히 Dropout과 BatchNorm처럼 모드에 따라 동작이 달라지는 레이어가 포함된 경우, 이를 올바르게 설정하지 않으면 학습 성능이나 평가 결과가 왜곡될 수 있다. 따라서 학습 시에는 model.train(), 검증 및 테스트 시에는 model.eval()을 명확히 구분하여 사용하는 것이 중요하다.

zero_grad() → forward → loss 계산 → backward → step 흐름

학습 루프를 그대로 따라가면 다음과 같다.

optimizer.zero_grad()       # 1. 이전 기울기 초기화
outputs = model(images)     # 2. 순전파 (grad_fn 체인 생성)
loss = criterion(outputs, labels)  # 3. loss 계산
loss.backward()             # 4. 역전파 (기울기 계산)
optimizer.step()            # 5. 파라미터 업데이트
  • optimizer.zero_grad() : 이전 배치에서 계산된 기울기(gradient)를 초기화하는 단계

    • 리프 텐서의 .grad는 누적된다. 이전 배치에서 계산된 기울기가 남아있으면, 이번 배치의 기울기와 더해져서 잘못된 방향으로 업데이트된다. 그래서 매 배치 시작 전에 모든 파라미터의 .grad를 0으로 초기화해야 한다.
  • output = model(images) : 순전파(forward propagation) 단계

    • 순전파로, model(images)를 호출하면 내부적으로 forward()가 실행된다.
    • 이 과정에서 PyTorch는 각 연산마다 grad_fn을 체인으로 연결해, 계산 그래프를 만들어두고, 결과로 나오는 outputs는 로짓(logit)이다.
    • 로짓은 확률이 아닌 raw 점수로, 값이 클수록 그 클래스일 가능성이 높다고 모델이 판단한 것이다.
  • loss = criterion(outputs, labels) : 모델의 예측과 실제 정답을 비교하여 손실(loss)을 계산

    • 손실함수가 로짓을 받아 Loss를 계산한다. CrossEntropyLoss에서 모델이 한 클래스를 높게 예측할 수록 loss는 낮아지며, 배치 전체의 loss는 128개 샘플의 평균이다.
  • loss.backward() : 역전파(backpropagation) 단계

    • 역전파로, loss를 시작점으로 계산 그래프를 거꾸로 타고 올라가면서 각 파라미터에 대한 기울기를 계산한다.
    • Chain Rule에 의해, 각 레이어의 파라미터에 dloww/dw가 계산되어 .grad에 저장된다.
    • 이 때 기울기의 의미는 “이 파라미터를 조금 키우면 loss가 얼마나 변하는가”로, 양수면 파라미터를 줄여야 낮아지고 음수면 키워야 loss가 낮아진다.
  • optimizer.step() : 계산된 gradient를 바탕으로 실제 파라미터를 업데이트하는 단계

    • 계산된 기울기를 이용해 파라미터를 실제로 업데이트 하는 과정이다.
    • Adam을 기준으로 w = w - lr * w.grad 와 같은 수식을 통해 이루어지며, no_grad() 상태에서 진행되어 파라미터가 leaf 텐서로 유지된다.

    위 과정이 배치마다 반복되고, 전체 train_dataset을 한 번 다 돌면 1 epoch가 끝난다. CIFAR-10을 기준으로 하면, 45,000장 / 128 배치 크기 = 352 배치가 1 epoch가 된다.

Cross Entropy Loss의 의미

다중 클래스 분류에서 사용하는 손실 함수로, 다음과 같은 두 가지 연산을 내부적으로 합친다.

  • 모델은 먼저 로짓(logit) 을 출력하고

  • 그 로짓을 Softmax 로 확률처럼 바꾸고

  • 그 확률에 log를 취한 다음

  • 정답 클래스의 값만 골라서 손실을 계산한다 (Negative Log Likelihood / NLL Loss)

    결국 모델이 정답 클래스를 높은 확률로 예측할 수록 loss가 0에 가까워지고, 틀릴 수록 loss가 커지는 손실 함수이다.

Optimizer / Scheduler

Optimizer와 Scheduler는 딥러닝 모델의 학습 과정에서 서로 다른 역할을 담당한다. 먼저 Optimizer는 역전파를 통해 계산된 기울기(gradient)를 이용해 모델의 파라미터를 실제로 업데이트하는 역할을 한다. 즉, loss.backward() 단계에서 각 파라미터의 .grad에 저장된 기울기를 바탕으로, 손실이 줄어드는 방향으로 가중치를 조정하는 것이 Optimizer의 핵심 기능이다. 반면 Scheduler는 학습이 진행되는 동안 학습률(learning rate)을 자동으로 조정하는 역할을 한다. 학습률은 파라미터를 얼마나 크게 업데이트할지를 결정하는 중요한 하이퍼파라미터이므로, 학습 초반과 후반에 서로 다른 값으로 조절하면 더 효율적인 학습이 가능해진다.

일반적으로 학습 초반에는 비교적 큰 학습률을 사용하여 빠르게 최적점 근처로 이동하고, 학습이 어느 정도 진행된 이후에는 더 작은 학습률을 사용하여 세밀하게 파라미터를 조정하는 전략이 자주 사용된다. 이때 Scheduler가 이러한 학습률 변화를 자동으로 관리해준다. 예를 들어 StepLR은 일정 epoch마다 학습률을 특정 비율만큼 감소시키는 방식이다. 만약 초기 학습률이 0.0003이고, 5 epoch마다 0.5배씩 줄이도록 설정했다면, epoch 1~5에서는 0.000300, epoch 6~10에서는 0.000150, epoch 11~15에서는 0.000075와 같이 학습률이 단계적으로 감소하게 된다. 이러한 방식은 초반에는 빠른 수렴을 유도하고, 후반에는 보다 안정적이고 세밀한 최적화를 가능하게 한다. 반대로 Scheduler를 사용하지 않으면 학습률은 처음 설정한 값으로 고정되며, 학습 내내 같은 크기로 업데이트가 이루어진다.

Optimizer는 모델의 파라미터를 등록받아 이를 관리한다. 예를 들어 optimizer = optim.Adam(model.parameters(), lr=1e-3)와 같이 작성하면, model.parameters()에 포함된 모든 학습 가능한 파라미터를 Adam Optimizer가 관리하면서 업데이트하게 된다. 가장 기본적인 예로 SGD는 기울기를 이용해 파라미터를 단순하게 갱신한다. 직관적으로 보면 이는 w = w - lr * grad와 같은 형태에 가깝다. 즉, gradient가 양수이면 파라미터를 줄이고, gradient가 음수이면 파라미터를 키우는 방향으로 업데이트가 이루어진다. 그러나 SGD는 모든 파라미터에 동일한 학습률을 적용하기 때문에, 어떤 파라미터에는 너무 크게, 어떤 파라미터에는 너무 작게 업데이트가 이루어질 수 있다는 한계가 있다. 이러한 문제를 개선하기 위해 등장한 대표적인 Optimizer가 Adam이다.

optimizer_baseline = optim.Adam(baseline_model.parameters(), lr=1e-3)
optimizer_improved = optim.AdamW(improved_model.parameters(), lr=3e-4, weight_decay=1e-4)

scheduler_improved = optim.lr_scheduler.StepLR(
    optimizer_improved, step_size=5, gamma=0.5
) # 5 epoch마다 학습률을 0.5배로 줄임

코드 실행 결과에서 ImprovedCNN의 학습률 변화를 보면 다음과 같은 변화를 확인할 수 있다.

epoch 1~5: lr = 0.000300
epoch 6~10: lr = 0.000150 ← 0.5배
epoch 11~15: lr = 0.000075 ← 0.5배
...

이 과정에서 학습률(learning rate)을 0.5배씩 줄여주는 것이 scheduler의 역할이다.

초반에는 큰 학습률로 빠르게 수렴하고, 후반에는 작은 학습률로 세밀하게 조정하는 전략으로, BaselineCNN은 scheduler 없이 고정 학습률을 쓰기 때문에 학습률 그래프가 평평하게 나타난다.

Optimizer는 backward()로 계산된 기울기를 이용해서 파라미터를 실제로 업데이트 하는 역할을 한다.

optimizer = optim.Adam(model.parameters(), lr=1e-3)

model.parameters() 를 넘기는 것은, 해당 파라미터들을 관리해달라고 등록하는 것이다.

SGD로 예시를 들어보면, 가장 단순한 업데이트 수식으로

w = w- lr * w.grad

와 같은 형태로 업데이트를 진행하는데, 모든 파라미터에 동일한 학습률을 적용한다는 것이 문제가 되었다. 어떤 파라미터는 기울기가 크고 어떤 파라미터는 기울기가 작은데, 같은 학습률로 업데이트하면 효율이 떨어진다는 문제를 발견했다.

Adam / AdamW

Adam은 단순히 현재 gradient만 사용하는 것이 아니라, 기울기의 1차 모멘텀과 2차 모멘텀을 함께 추적한다. 여기서 1차 모멘텀은 gradient의 이동 평균으로, 기울기가 여러 step 동안 비슷한 방향으로 계속 나타나면 그 방향으로 더 안정적으로 이동하도록 돕는다. 직관적으로 말하면, 같은 방향의 gradient가 반복될 때 업데이트에 일종의 관성을 부여하여 더 빠르고 부드럽게 수렴하도록 만드는 역할을 한다. 2차 모멘텀은 gradient 제곱값의 이동 평균으로, gradient의 크기가 큰 파라미터에 대해서는 업데이트 크기를 줄이고, gradient의 크기가 작은 파라미터에 대해서는 상대적으로 더 큰 업데이트가 가능하도록 조정한다. 따라서 Adam은 파라미터마다 적응적으로 학습률을 조정하는 방식이라고 볼 수 있다. 이로 인해 SGD보다 학습이 더 안정적이고, 하이퍼파라미터 튜닝 부담도 비교적 적은 편이다.

Adam은 Optimizer로 많이 사용하던 SGD의 문제를 해결하기 위해 기울기에 1차 모멘텀과 2차 모멘텀을 추적하며 다음을 계산한다.

  • 1차 모멘텀(m) : 기울기의 이동 평균
  • 2차 모멘텀(v) : 기울기 제곱의 이동 평균

w = w - lr * m / (√v + ε)

위 수식과 1,2차 모멘텀을 함께 직관적으로 이야기하면 1차 모멘텀은 기울기가 계속 같은 방향이면 가속하게 되고, 2차 모멘텀은 기울기가 크면 학습률을 줄여서 안정화하는 역할을 한다.

즉, 파라미터마다 적응적으로 학습률이 조정되는 방식으로, 기울기가 작은 파라미터는 학습률이 커지고, 기울기가 큰 파라미터는 학습률이 작아진다.

AdamW는 Adam의 장점은 유지하면서, weight decay를 보다 올바르게 적용하도록 개선한 Optimizer이다. 여기서 weight decay는 파라미터 값이 지나치게 커지는 것을 억제하는 정규화 기법으로, 모델이 특정 feature에 과도하게 의존하거나 과적합되는 것을 완화하는 데 도움을 준다. 일반적인 Adam에서 weight decay를 적용하면, 정규화 항이 gradient에 섞여 들어가면서 Adam의 적응적 학습률 조정 과정과 함께 처리된다. 이 경우 weight decay가 원래 의도한 방식으로 작동하지 않고, 파라미터마다 다르게 약화되거나 왜곡될 수 있다. 반면 AdamW는 Adam에 의한 업데이트와 weight decay를 분리해서 적용한다. 즉, 먼저 Adam 방식으로 파라미터를 업데이트한 뒤, 별도로 weight decay를 적용하여 파라미터의 크기를 줄이는 방식이다. 이렇게 하면 정규화 효과가 더 명확하고 일관되게 작동하게 된다.

grad_with_decay = grad + weight_decay w
w = w - lr
m / (√v + ε) ← weight_decay가 모멘텀에 섞여버린다.

위 방식으로 진행할 경우, weight decay가 적응적 학습률 보정의 영향을 받아, 정규화 효과가 의도보다 약해지게 된다. AdamW는 여기에서 weight를 완전히 분리한 방식을 사용해, 결과적으로 정규화 효과가 더 정확하게 작동하게 된다.

w = w - lr m / (√v + ε) # Adam 업데이트
w = w - lr
weight_decay * w # weight decay 별도 적용

코드에서 확인해보면 다음과 같다.

# BaselineCNN: 기본 Adam
optimizer_baseline = optim.Adam(
    baseline_model.parameters(),
    lr=1e-3
)

# ImprovedCNN: AdamW + weight_decay
optimizer_improved = optim.AdamW(
    improved_model.parameters(),
    lr=3e-4,
    weight_decay=1e-4  # 파라미터 크기를 억제해서 과적합 방지
)

여기서 사용되는 weight_decay란, 파라미터 값 자체가 너무 커지지 않도록 억제하는 정규화 역할로, 파라미터가 크면 모델이 특정 특징에 과도하게 의존하는 경향을 보이는데 이를 방지하기 위해 사용한다.

정리하면, Optimizer는 기울기를 바탕으로 파라미터를 실제로 갱신하는 역할을 하고, Scheduler는 학습이 진행됨에 따라 학습률을 자동으로 조절하는 역할을 한다. 또한 Adam은 1차 및 2차 모멘텀을 이용해 파라미터별로 적응적인 업데이트를 수행하는 Optimizer이며, AdamW는 여기에 weight decay를 분리 적용하여 정규화 효과를 더 정확하게 반영하도록 만든 개선된 방식이다. 따라서 학습을 안정적으로 진행하고, 후반부에 더 세밀하게 최적화하며, 동시에 과적합을 줄이기 위해서는 Optimizer와 Scheduler를 함께 적절히 설정하는 것이 중요하다.

Best Validation

우선 fit_model 의 코드를 먼저 가져와서 보면,

if val_acc > best_val_acc:
    best_val_acc = val_acc
    best_state_dict = copy.deepcopy(model.state_dict())

# 학습이 종료된 후
model.load_state_dict(best_state_dict)

위와 같은 형식을 가지고 있다.

이렇게 best validation 모델을 별도로 저장하는 이유는, validation set을 사용해 검증을 진행할 때 Acc나 Loss가 갑자기 낮아지는 부분에서 과적합이 발생할 가능성이 매우 높다. 이는 결국 학습이 완전히 종료되었을 때의 모델이 항상 최선의 모델이 아니라는 것을 의미한다.

그렇기 때문에 val_acc가 가장 높았던 시점의 가중치를 deepcopy()로 저장해두었다가 학습이 끝나면 해당 모델을 복원한 후 최종 test를 진행한다.

주의사항

모델을 평가할 때에는 최종 정확도 하나만 보는 것보다, 학습이 어떤 경로를 거쳐 진행되었는지를 보는 습관이 가장 중요하다. 아래는 train / validation loss, train / validation accuracy / learning rate를 시각화한 자료이다.

위 그림을 행별로 살펴보겠다.

  • 1행(BaselineCNN) :
    • Loss 그래프 - train, val loss가 비슷한 속도로 함께 내려가고 있다. 둘 사이의 간격이 크지 않아, 현재까지는 과적합 징후가 거의 없는 상태임을 확인할 수 있지만 epoch 10에서도 두 곡선이 아직 수렴하지 않고 내려가는 중기기 때문에 학습이 더 필요한 상태임을 확인할 수 있다.
    • Accuracy 그래프 - train acc와 val acc가 거의 붙어서 함께 올라가고 있다. epoch 10 기준으로 약 81% 정도의 수준인데, 곡선이 아직 평탄해지지 않았으므로 epoch을 더 늘리면 성능이 더 오를 가능성이 존재한다.
    • Learning Rate 그래프 - Scheduler 없이 고정 학습률 0.001을 사용하고 있어 그래프가 완전히 평평하다.
  • 2행(ImprovedCNN) :
    • Loss 그래프 - 전반적으로 Loss값 자체가 BaselineCNN보다 더 높게 시작하고 높게 유지되고 있음을 확인할 수 있다. 이는, 아래 코드에서 확인할 수 있는 label_smoothing=0.1 옵션 때문으로, 정답 레이블을 1.0 대신 0.9로 처리하기 때문에 모델이 아무리 잘 맞혀도 loss가 일정 수준 아래로 내려가기 어렵기 때문이지, 숫자 자체가 크다고 해서 성능이 나쁘다는 것을 의미하지는 않는다. train loss와 val loss 간격이 매우 좁고, val loss가 train loss보다 오히려 낮은 구간도 존재한다. 이는 Dropout과 BatchNorm의 정규화 효과가 잘 작동하고 있음을 확인할 수 있는 것이며, Dropout이 학습 중에는 뉴런을 일부 끄기 때문에 train loss가 상대적으로 높게 측정되고, eval 시에는 모든 뉴런이 켜지면서 val loss가 더 낮아진다.
    • Accuracy 그래프 - epoch 12 기준으로, train과 val acc가 약 81% 수준으로 BaselineCNN과 비슷함을 확인할 수 있다.
    • Learning Rate 그래프 - StepLR scheduler가 작동하면서, 5 epoch마다 학습률이 절반으로 줄어드는 것을 확인할 수 있다.
  • 3행(With Rotation) :
    • Loss 그래프 - BaselineCNN과 구조는 같고, RandomRotation 증강만 추가된 경우다. 차이를 확인해보면, val loss가 train loss보다 일관되게 낮음을 확인할 수 있다. 이는 데이터 증강의 추가로 인해 학습 데이터가 더 어려워졌기 때문에 train loss가 상대적으로 높게 측정되는 것으로, 증강이 일종의 정규화 역할을 하고 있음을 확인할 수 있다.
    • Accuracy 그래프 - 이 그래프 또한 특이하게 val acc가 train acc보다 높다. train set에는 rotation 증강이 적용되어 더 어려운 데이터로 학습하지만, val set에는 증강 없이 평가하기 때문에 역전 현상이 발생했음을 확인할 수 있다. epoch 10 기준으로 val acc가 약 79% 정도인데, BaselineCNN보다 낮은 것을 확인할 수 있지만, epoch 수가 적어서 아직 수렴 전일 가능성이 높으므로 epoch 수를 늘려 확인해 볼 가치가 있다.
# 모델을 어떻게 학습시키고, 학습이 잘 되고 있는지 어떻게 평가하는가!
# train_one_epoch -> evaluate -> fit_model 반복
# train_one_epoch : 한 에폭 동안 학습 데이터로 모델을 실제로 업데이트하는 함수
# evaluate : 검증 데이터나 테스트 데이터로 성능 확인 함수
# fit_model : 여러 epoch을 반복하며 전체 학습 진행
# epoch : 학습 데이터 전체를 학습에 이용하는 것
def train_one_epoch(model, dataloader, criterion, optimizer, device): # 학습 1 epoch 수행 함수
  # 학습할 때랑 평가할 때 차이 파악하기
  model.train() # 모델을 학습 모드로 전환

  running_loss = 0.0 # 샘플 수 가중 손실 누적 변수
  correct = 0 # 정답 개수 누적 변수
  total = 0 # 전체 샘플 수 누적 변수

  # non_blocking=True로 하면 non_blocking=True로 줌 (빠르게 복사 위해)
  for images, labels in dataloader: # 미니배치 단위 반복 루프
    images = images.to(device, non_blocking=True) # 입력 이미지를 연산 장치로 이동
    labels = labels.to(device, non_blocking=True) # 정답 라벨을 연산 장치로 이동

    # Pytorch는 기본적으로 Gradient 안 덮어쓰고 누적함.
    # 이전 배치에서 구했던 기울기가 남아있는 상태에서 현재 기울기가 더해질 수 있는 상태
    # 따라서 반복할 때마다 기울기를 초기화해줘야 함.
    optimizer.zero_grad() # 이전 step 기울기 초기화

    # 로짓값은 확률 값은 아님 ! 크로스 엔트로피 쓰면 Softmax 이전에 로짓 값이 나옴
    # 로짓값은 각 클래스별 점수 값임
    outputs = model(images) # 순전파 결과 로짓(logit) 계산
    loss = criterion(outputs, labels) # 현재 배치 손실 계산

    loss.backward() # 역전파 수행 (모델의 각 가중치에 대해 gradient를 계산, 틀린 만큼 어떻게 고쳐야 할지를 피드백 받음)
    optimizer.step() # 파라미터 갱신(update) 수행

    # images.size(0) : 몇 개 들어있는지 계산
    # 이미지 배치에서 예를 들어 102개라고 했다 하면 2개가 남음 -> 마지막 배치 남아서
    # 각 배치 사이즈가 달라질 수 있으므로 배치 사이즈 계산을 위해 사용
    # (안 하면 마지막 배치가 2개뿐이어도, 20개짜리 배치와 똑같은 비중으로 들어갈 수 있음)
    running_loss += loss.item() * images.size(0) # 배치 크기 가중 손실 누적
    preds = outputs.argmax(dim=1) # 예측 클래스 인덱스 추출
    correct += (preds == labels).sum().item() # 정답 예측 개수 누적
    total += labels.size(0) # 전체 샘플 수 누적

  epoch_loss = running_loss / total # epoch 평균 손실 계산
  epoch_acc = correct / total # epoch 평균 정확도 계산
  return epoch_loss, epoch_acc # 학습 손실/정확도 반환

@torch.no_grad() # 평가 단계 기울기 계산 비활성화 데코레이터 (메모리 사용량이 적어지고, 속도가 빨라짐)
def evaluate(model, dataloader, criterion, device): # 검증/테스트 데이터에 대해서 모델 성능을 평가하는 함수
  model.eval() # 모델을 평가 모드로 전환 (Dropout이 꺼짐, BatchNorm은 평가 시 현재 배치의 통계가 아니라 저장된 통계를 사용해야 함.)

  running_loss = 0.0 # 샘플 수 가중 손실 "누적 변수"
  correct = 0 # 정답 개수 "누적 변수"
  total = 0 # 전체 샘플 수 "누적 변수"

  for images, labels in dataloader: # 미니배치 반복 루프
    images = images.to(device, non_blocking=True) # 입력 이미지를 연산 장치로 이동
    labels = labels.to(device, non_blocking=True) # 정답 라벨을 연산 장치로 이동

    outputs = model(images) # 순전파 결과 로짓 계산
    loss = criterion(outputs, labels) # 현재 배치 손실 계산 (배치별 loss 기억하기)

    running_loss += loss.item() * images.size(0) # 배치 크기 가중 손실 누적
    preds = outputs.argmax(dim=1) # 예측 클래스 인덱스 추출
    correct += (preds == labels).sum().item() # 정답 예측 개수 누적
    total += labels.size(0) # 전체 샘플 수 누적

  epoch_loss = running_loss / total # epoch 평균 손실 계산
  epoch_acc = correct / total # epoch 평균 정확도 계산
  return epoch_loss, epoch_acc # 평가 손실/정확도 반환

def fit_model(model, train_loader, val_loader, criterion, optimizer, device, epochs, scheduler=None): # 전체 학습과정을 관리하는 함수
  history = { # 학습 이력 저장용 사전
      "train_loss": [], # epoch별 학습 손실 기록
      "train_acc" : [], # epoch별 학습 정확도 기록
      "val_loss" : [], # epoch별 검증 손실 기록
      "val_acc" : [], # epoch별 검증 정확도 기록
      "lr" : [] # epoch별 학습률 기록
  }

  best_val_acc = -1.0 # 최고 검증 정확도 초기값
  best_state_dict = None # 최고 성능 가중치 저장 변수

  for epoch in range(epochs): # 1부터 epochs까지 반복
    start_time = time.time() # epoch 시작 시각 기록

    train_loss, train_acc = train_one_epoch(model, train_loader, criterion, optimizer, device) # 학습 1 epoch 수행
    val_loss, val_acc = evaluate(model, val_loader, criterion, device) # 검증 성능 계산

    if scheduler is not None: # 스케줄러 사용 여부 분기
      scheduler.step() # epoch 종료 후 학습률 갱신

    # optimizer가 사용 중인 learning rate를 뽑아오는 것
    # 에폭에 따른 학습률 변화를 보기 위해서!
    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) # 학습률 기록

    if val_acc > best_val_acc: # 최고 검증 정확도 갱신 여부 확인
      best_val_acc = val_acc # 최고 정확도 값 갱신
      # deepcopy를 쓰는 이유 -> 그냥 참조만 하게 되면 작업하면서 그 값이 바뀔 수 있으므로
      best_state_dict = copy.deepcopy(model.state_dict()) # 최고 성능 가중치 깊은 복사 저장. state_dict()는 모델의 학습 가능한 파라미터들을 담고 있는 딕셔너리

    elapsed = time.time() - start_time # epoch 소요 시간 계산
    print( # epoch별 핵심 지표 출력 구간
      f"Epoch [{epoch:02d}/{epochs:02d}] | "
      f"Train Loss: {train_loss:.4f}, Train Acc: {train_acc:.4f} | "
      f"Val Loss: {val_loss:.4f}, Val Acc: {val_acc:.4f} | "
      f"LR: {current_lr:.6f} | "
      f"Time: {elapsed:.1f}s"
    )

  if best_state_dict is not None: # 최고 성능 가중치 존재 여부 확인
    model.load_state_dict(best_state_dict) # 최고 검증 성능 시점 가중치 복원

  history["best_val_acc"] = best_val_acc # 최고 성능 가중치 존재 여부 확인
  print(f"Best Validation Accuracy : {best_val_acc:.4f}") # 최고 검증 성능 시점 가중치 복

  return history # 전체 학습 이력 반환

5. 성능 평가와 오류 분석

Accuracy(정확도)

정확도란, 단순히 맞춘 개수를 전체 개수로 나눈 것을 의미하며, 전체에 대한 평균이다.

모델의 성능을 판단할 때 이 정확도와 손실을 가장 많이 확인하지만, 역으로 정확도가 전체 평균이기 때문에 클래스별 성능 차이가 숨겨질 가능성이 존재한다.

전체 정확도 : 83.89%

cat: 74.30% ← 가장 낮음
dog: 75.20%
bird: 76.40%
...
ship: 92.20% ← 가장 높음
automobile: 91.30%

예제에서 일부 가져온 이 지표를 확인해보면, 전체 정확도는 83.89%인 반면, cat은 74%, ship은 92%로 두 클래스의 정확도 차이가 18%나 나고 있음을 확인할 수 있고, 단순히 전체 Acc 지표만 확인하면 이 차이를 알 수가 없다는 한계가 존재한다.

Confusion Matrix

Confusion Matrix란, 모델이 각 클래스를 얼마나 맞추고 얼마나 틀렸는지를 한 눈에 확인할 수 있는 표(예측값과 실제값 사이 관계를 행렬 형태로 표현한 것)로, 아래와 같은 형태를 띄고 있다.


S

행(세로)은 실제 정답을, 열(가로)는 모델이 예측한 값을 각 축으로 사용하며, 이를 종합해 대각선이 정답, 대각선 외의 값이 오분류이다. 이를 기준으로 모델이 어떤 클래스를 가장 잘 맞추고 어떤 클래스를 못 맞추는지 대각선 값이 큰 클래스와 작은 클래스를 확인함으로 알아낼 수 있다.

대각선을 제외한 값 중 가장 높은 값을 위주로 확인해보면, 어떤 클래스를 모델이 어떤 다른 클래스로 잘못 예측하고 있는 것인지 오분류가 가장 잦은 클래스 또한 확인할 수 있는 것이 Confusion Matrix이다.

클래스별 정확도 확인 및 오분류 사례 분석의 중요성

위에서 첨부한 이미지를 살펴보면 가장 잘 맞추는 클래스로는 ship / horse / automobile 임을, 가장 못 맞추는 클래스로는 cat / dog / deer이 있음을 확인할 수 있다. 이러한 클래스별 정확도를 확인하는 것은 모델 개선 방향을 알 수 있는 가장 큰 지표 중 하나이다.

가장 헷갈려 하는 쌍을 보면 cat ↔ dog 쌍이 존재하는데, 두 클래스 모두 털, 귀, 눈 과 같은 유사한 특징을 가지고 있음과 동시에 32x32라는 저해상도 환경이기 때문에 모델이 어려움을 가지고 있음을 예측할 수 있다.

모델의 성능 개선을 위해선 가장 낮은 클래스부터 집중해서 올리는 것이 가장 효율적이고, 어떤 쌍을 헷갈려 하는지 등을 함께 분석해야만 모델의 개선 방향을 잡을 수 있다.

우선 개선 방향을 확인했다면, 다음으론 구체적인 분석을 진행할 수 있다.

오분류 이미지를 확인해보면, 실제로 사람도 헷갈릴 만한 케이스들 또한 존재한다.

True: automobile → Pred: truck
True: dog → Pred: deer
True: horse → Pred: cat
True: airplane → Pred: horse

이런 분석이 중요한 이유 중 가장 중요한 것은 모델의 문제인 것인지 데이터의 문제인 것인지를 구분할 수 있다는 것이다. airplane → horse 오분류는 이미지 자체가 특이한 각도라서 데이터 문제에 가깝지만, cat ↔ dog 오분류는 모델이 두 클래스를 구분하는 특징을 충분히 학습하지 못하는 문제임을 확인할 수 있다.

이를 통해 추가 개선 방향으로, 특정 각도나 클로즈업 이미지에서 오분류가 많다면 해당 유형의 증강을 강화하는 식으로 대응할 수 있다.

Train / Validation 곡선을 통한 과적합 여부 판단

우선 각 모델별 학습 결과를 확인해보면, 다음과 같다.

BaselineCNN

epoch 27: train acc 0.8952 / val acc 0.8564
epoch 30: train acc 0.9005 / val acc 0.8424

epoch 27을 기준으로 이후 train acc는 계속 오르지만, val acc는 오히려 떨어지기 시작한다. 과적합이 시작된 신호로 확인할 수 있는 중요한 지표이기 때문에 Best Validation 가중치 저장이 중요하다는 것을 확인할 수 있고, epoch 27 시점의 가중치를 복원해서 최종 평가에 사용하게 된다.

ImprovedCNN

train acc와 val acc 간격이 전 구간에서 좁음
val acc가 train acc 보다 높은 구간도 존재

Dropout과 BatchNorm 덕분에 BaselineCNN보다 과적합이 훨씬 잘 억제되어 있음을 확인할 수 있다.

두 지표를 종합적으로 분석해 정리하면 다음과 같다.

참고사항

재현성 (Reproducibility)

  • 재현성 (Reproducibility) : 동일한 연구나 실험을 다른 조건(다른 연구자, 도구 등)에서 다시 수행했을 때 기존과 동일하거나 유사한 결과가 나오는 능력
  • 실험해서 나온 결과가 똑같이 실험해도(재실험해도) 재현되는 것을 보장하도록 가중치 초기값 설정의 랜덤성을 고정(하나의 값으로 설정)한다. 딥러닝 모델 훈련 과정에서 시드를 고정하지 않으면, 무작위성 때문에 결과를 재현하기 어렵기에 시드를 고정한다. 이렇게 시드를 고정하면 모델이 훈련될 때마다 동일한 초기값이 사용되어 결과 복제 및 비교가 쉬워진다.
# 재현성 고정을 위한 seed 고정 (재현성 통일용 시드 값)
SEED = 42 # 실험 반복 시 동일 결과 유도용 기준값

random.seed(SEED) # 파이썬 난수 시드 고정
np.random.seed(SEED) # Numpy 난수 시드 고정
torch.manual_seed(SEED) # CPU 텐서 난수 시드 고정
torch.cuda.manual_seed_all(SEED) # GPU 텐서 난수 시드 일괄 고정

# 재현성을 우선시하는 설정 (cuDNN (CUDA DNN GPU 가속화 라이브러리) 동작 방식 통일용 설정)
# 결정론적 연산 우선 설정 - torch.backends.cudnn.deterministic = True
torch.backends.cudnn.deterministic = True # 컨볼루션 연산에 결정론적 알고리즘만 사용
# 입력별 최적 커널 탐색 비활성화 설정 - torch.backends.cudnn.benchmark = False
torch.backends.cudnn.benchmark = False # 현재 환경에 가장 적합한 알고리즘을 선정해 수행하는 설정 끔

# GPU가 있으면 GPU를 사용하고, 없으면 CPU로 자동 전환
# 실행 장치 자동 선택
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print("Using device : ", device) # 실제 학습 장치 확인 목적

# 공통 설정 (학습 공통 하이퍼파라미터 영역)
DATA_ROOT = "./data" # 데이터 저장 경로
BATCH_SIZE = 128 # 미니배치 크기
VAL_SIZE = 5000 # 검증 세트 분리 개수
# dataset의 데이터를 gpu로 전송할 때 필요한 과정에 필요한 subprocess 수
# 0 -> 메인 프로세스가 직접 데이터를 읽음 / 2 -> 별도 작업자 두 명이 미리 데이터를 읽음
NUM_WORKERS = 2 if os.name != "nt" else 0 # 운영체제별 DataLoader 병렬 수 설정
# DRAM을 통하지 않고 바로 VRAN으로 데이터를 할당해 데이터 로드 속도 개선
PIN_MEMORY = device.type == "cuda" # CPU 가속용 pin_memory 사용 여부

print(f"BATCH_SIZE : {BATCH_SIZE}") # 배치 크기 확인 목적
print(f"VAL_SIZE : {VAL_SIZE}") # 검증 세트 크기 확인 목적
print(f"NUM_WORKERS : {NUM_WORKERS}") # 워커 수 확인 목적
print(f"PIN_MEMORY : {PIN_MEMORY}") # 메모리 고정 사용 여부 확인
profile
얼렁뚱땅 바보 학부생...

0개의 댓글