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

CIFAR-10이 MNIST보다 더 어려운 이유
→ CIFAR-10은 흑백이 아니라 컬러(3채널) 이미지이다. 따라서 한 픽셀마다 단순 밝기 하나가 아니라, RGB 정보를 함께 처리해야 하기에 모델 입장에서는 고려해야 할 정보량이 더 많아진다. 또, 0~9 숫자의 경우엔 숫자 형태 자체가 클래스마다 확연히 달라 모양 패턴이 비교적 단순하지만, CIFAR-10의 경우, 개/고양이, 자동차/트럭 등 형태가 유사한 클래스가 존재하며, 같은 클래스도 정면/측면/위에서 본 각도 등 다양한 형태로 존재하며, 객체의 종류가 더 다양하고, 배경 변화도 존재한다. 또, 입력 차원(Input Dimension)도 더 크고, 분류 경계(Decision Boundary)도 더 복잡하다. 이러한 차이 때문에 MNIST보다 CIFAR-10이 더 어렵다.
데이터 정규화란 입력 데이터의 값 범위를 일정한 스케일(scale)로 조정하는 과정을 의미한다. CIFAR-10의 데이터(이미지)를 예로 들면, 원본 픽셀 값은 RGB 3채널에 맞춘 0~255의 데이터로 이루어져 있다. 이때 정규화를 통해 -1~1 또는 0~1 과 같이 작은 스케일로 통일시키는 것이다.
그렇다면 왜 정규화가 필요할까? 정규화를 거치는 이유는 다음과 같다.
데이터 증강이란, 기존 데이터에 변환을 가해서 새로운 학습 데이터를 만들어내는 것을 의미한다. 모델은 학습 데이터에서 본 패턴만을 학습하지만, 현실 데이터는 학습 데이터와 완전히 같지 않다.
예를 들어, 학습 데이터의 고양이 이미지가 정면에 밝은 배경이라면, 실제 고양이 이미지는 측면일 수도 있고, 배경이 어두울 수도 있고, 일부가 잘린 구도일 수도 있다. 이때, 모델은 현실의 고양이는 한 번도 본 적 없는 데이터처럼 느끼게 된다. 따라서, 같은 이미지에서 다양한 변형을 만들어냄으로써, 적은 데이터로도 다양한 상황을 경험한 것처럼 학습시킬 수 있게 된다.
간단하게 정리하면, “모델이 외울 수 없도록 데이터를 다양하게 만들어 과적합을 막고, 현실의 다양한 상황에 대응할 수 있게 하는 것”이 데이터 증강이다.

주로 사용되는 증강 기법
RandomHorizontalFlip : 이미지를 n% 확률로 좌우반전한다. (default : 0.5)
RandomCrop : 이미지 주변에 패딩을 추가한 뒤 랜덤한 위치에서 원래 크기로 잘라낸다.
RandomResizedCrop : crop 후 주어진 크기만큼 Resize
ColorJitter : 밝기, 대비, 채도, 색조를 랜덤하게 변화시킨다.
torchvision.transforms.ColorJitter(brightness=0.5, contrast=0.5, saturation=0.5, hue=0.5)RandomRotation : 이미지를 랜덤한 각도로 회전시킨다.
RandomVerticalFlip(상하반전)도 있지만, 뒤집힌 자동차나 비행기는 현실에서 거의 없으므로 오히려 잘못된 패턴을 학습시킬 수 있어 사용하지 않는다.
# 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 : 증강은 기본적으로 랜덤이다. 검증 / 테스트 셋에 증강을 적용하면, 이미지가 어떻게 잘렸는지, 좌우반전은 진행되었는지 등 매번 다른 이미지가 들어가서 성능 측정이 일관되지 않는다. 모델이 좋아진 것인지 운이 좋아 특정 데이터만 편향적으로 입력된 것인지 알 수 없어지기 때문이다. 따라서 동일한 입력으로 성능을 정확하게 측정하기 위해 증강을 적용하지 않는다.
CIFAR-10 학습 데이터 50,000장을 다음과 같이 나눈다.
# 같은 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는 데이터를 미니배치 단위로 묶어서 모델에 공급해주는 역할을 한다.
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)}") # 검증 배치 수 확인
정규화가 적용된 이미지를 그대로 시각화하면 색상이 원본과 다르게 보일 수 있다. 이는 모델 입력을 위해 픽셀값의 분포를 조정하는 과정에서, 사람이 일반적으로 인식하는 이미지의 값 범위가 바뀌기 때문이다. 원본 이미지는 보통 0~255의 정수값 또는 0~1 범위의 실수값으로 표현되지만, 정규화 이후에는 각 채널 값이 평균과 표준편차를 기준으로 변환되어 음수나 1보다 큰 값을 포함할 수 있다.
정규화는 일반적으로 다음과 같이 수행된다.
이렇게 변환된 텐서는 모델 학습에는 적합하지만, 시각화에는 적합하지 않다. 예를 들어 RGB 이미지의 각 채널 값이 정규화된 상태로 남아 있으면, 원래의 색 비율이 사람이 기대하는 범위와 달라져 색감이 이상하게 표현될 수 있다. 또한 음수값이 포함된 경우 matplotlib와 같은 시각화 라이브러리에서 값이 자동으로 잘리거나(clipping), 예상치 못한 색으로 출력될 수 있다.
따라서 정규화된 이미지를 사람이 해석 가능한 형태로 보기 위해서는 역정규화(denormalization) 과정이 필요하다. 역정규화는 다음 식으로 수행된다.
이 과정을 거치면 정규화 이전의 스케일로 값을 되돌릴 수 있다. 엄밀히 말하면, 이는 항상 0~255로 직접 복원된다기보다 정규화 직전의 값 범위로 되돌리는 과정이다. 예를 들어 ToTensor() 이후 정규화를 수행했다면, 역정규화 결과는 보통 0~1 범위에 가까운 값으로 복원되며, 이 상태가 matplotlib에서 올바르게 시각화되기 적절하다.
정규화된 텐서를 시각화할 때는 보통 다음 순서를 따른다.
텐서를 CPU로 옮긴다.
정규화를 되돌리는 역정규화(denormalize) 과정을 거친다.
HWC 형태로 바꾸어 출력한다.
(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) # 샘플 배치 시각화 실행
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의 구조적 특성은 계산 효율성을 높일 뿐만 아니라, 불필요한 파라미터 증가를 억제하여 일반화 성능을 향상시키는 데 중요한 역할을 한다.
- 컨볼루션 신경망 : 정보 추출/축소를 반복하며 최종적으로 주어진 이미지를 기반으로 핵심 특징을 학습함.
- 컨볼루션 : 지역적 특징 감지 및 추출. 레이어가 깊어질수록 추상적/복잡한 특징 추출
- 풀링 : 이미지 압축. 위치 변화에 강건한 특징을 보이게 해줌.



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

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은 co-adaptation(신경망에서 특정 뉴런들이 서로 강하게 의존되는 현상)을 방지하는 효과가 있다. 특정 뉴런이 특정 feature만 잘 잡아내서 다른 뉴런이 그 feature를 잡아내지 않고, 다른 역할만 하면 뉴런들이 독립적으로 일반화된 표현을 학습하지 못 하는 현상이 발생할 수 있는데, Dropout은 이를 방지할 수 있고, 이를 통해, 각 뉴런이 다른 뉴런에 과도하게 의존하지 않고 자신만의 독립적 표현을 학습할 수 있다.
이러한 Dropout은 보통 epoch 단위가 아니라 iteration 단위, 즉 배치가 들어올 때마다 새롭게 적용된다. 이렇게 하는 이유는 매 배치마다 서로 다른 뉴런 조합을 사용하도록 하여, 모델이 특정 노드나 특정 연결에 과도하게 의존하는 것을 막기 위해서이다. 만약 동일한 dropout mask를 오랫동안 고정하면, 일부 뉴런 조합에 편향된 학습이 일어날 수 있다. 따라서 iteration마다 dropout을 새로 적용함으로써 일반화 성능을 높일 수 있다.

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이다.
가장 기본적인 CNN 구조로 다음과 같이 레이어가 구성되어 있다.
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은 학습 안정성이 더 좋아지고 / 과적합 완화에 도움이 될 수 있으며 / 마지막 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) # 개선 모델 특징맵 크기 흐름 출력

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()을 명확히 구분하여 사용하는 것이 중요하다.
학습 루프를 그대로 따라가면 다음과 같다.
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)를 초기화하는 단계
output = model(images) : 순전파(forward propagation) 단계
loss = criterion(outputs, labels) : 모델의 예측과 실제 정답을 비교하여 손실(loss)을 계산
loss.backward() : 역전파(backpropagation) 단계
optimizer.step() : 계산된 gradient를 바탕으로 실제 파라미터를 업데이트하는 단계
위 과정이 배치마다 반복되고, 전체 train_dataset을 한 번 다 돌면 1 epoch가 끝난다. CIFAR-10을 기준으로 하면, 45,000장 / 128 배치 크기 = 352 배치가 1 epoch가 된다.
다중 클래스 분류에서 사용하는 손실 함수로, 다음과 같은 두 가지 연산을 내부적으로 합친다.
모델은 먼저 로짓(logit) 을 출력하고
그 로짓을 Softmax 로 확률처럼 바꾸고
그 확률에 log를 취한 다음
정답 클래스의 값만 골라서 손실을 계산한다 (Negative Log Likelihood / NLL Loss)
결국 모델이 정답 클래스를 높은 확률로 예측할 수록 loss가 0에 가까워지고, 틀릴 수록 loss가 커지는 손실 함수이다.
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은 단순히 현재 gradient만 사용하는 것이 아니라, 기울기의 1차 모멘텀과 2차 모멘텀을 함께 추적한다. 여기서 1차 모멘텀은 gradient의 이동 평균으로, 기울기가 여러 step 동안 비슷한 방향으로 계속 나타나면 그 방향으로 더 안정적으로 이동하도록 돕는다. 직관적으로 말하면, 같은 방향의 gradient가 반복될 때 업데이트에 일종의 관성을 부여하여 더 빠르고 부드럽게 수렴하도록 만드는 역할을 한다. 2차 모멘텀은 gradient 제곱값의 이동 평균으로, gradient의 크기가 큰 파라미터에 대해서는 업데이트 크기를 줄이고, gradient의 크기가 작은 파라미터에 대해서는 상대적으로 더 큰 업데이트가 가능하도록 조정한다. 따라서 Adam은 파라미터마다 적응적으로 학습률을 조정하는 방식이라고 볼 수 있다. 이로 인해 SGD보다 학습이 더 안정적이고, 하이퍼파라미터 튜닝 부담도 비교적 적은 편이다.
Adam은 Optimizer로 많이 사용하던 SGD의 문제를 해결하기 위해 기울기에 1차 모멘텀과 2차 모멘텀을 추적하며 다음을 계산한다.
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를 함께 적절히 설정하는 것이 중요하다.
우선 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를 시각화한 자료이다.

위 그림을 행별로 살펴보겠다.
# 모델을 어떻게 학습시키고, 학습이 잘 되고 있는지 어떻게 평가하는가!
# 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 # 전체 학습 이력 반환
정확도란, 단순히 맞춘 개수를 전체 개수로 나눈 것을 의미하며, 전체에 대한 평균이다.
모델의 성능을 판단할 때 이 정확도와 손실을 가장 많이 확인하지만, 역으로 정확도가 전체 평균이기 때문에 클래스별 성능 차이가 숨겨질 가능성이 존재한다.
전체 정확도 : 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란, 모델이 각 클래스를 얼마나 맞추고 얼마나 틀렸는지를 한 눈에 확인할 수 있는 표(예측값과 실제값 사이 관계를 행렬 형태로 표현한 것)로, 아래와 같은 형태를 띄고 있다.

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 오분류는 모델이 두 클래스를 구분하는 특징을 충분히 학습하지 못하는 문제임을 확인할 수 있다.
이를 통해 추가 개선 방향으로, 특정 각도나 클로즈업 이미지에서 오분류가 많다면 해당 유형의 증강을 강화하는 식으로 대응할 수 있다.
우선 각 모델별 학습 결과를 확인해보면, 다음과 같다.

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보다 과적합이 훨씬 잘 억제되어 있음을 확인할 수 있다.
두 지표를 종합적으로 분석해 정리하면 다음과 같다.

# 재현성 고정을 위한 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}") # 메모리 고정 사용 여부 확인