[Deep Learning] MNIST로 알아보는 Learning Rate의 영향

개굴이·약 15시간 전

멋쟁이 사자처럼 NLP

목록 보기
12/13

[PyTorch] MNIST MLP 학습과 Learning Rate 비교 실험

이번 실습에서는 PyTorch를 이용하여 MNIST 숫자 이미지 분류 모델을 구현하고,
SGD Optimizer의 Learning Rate가 모델의 학습 성능에 어떤 영향을 미치는지 비교하였다.

비교한 Learning Rate는 다음과 같다.

  • 0.001
  • 0.01
  • 0.1

모델 구조와 학습 조건은 동일하게 유지하고 Learning Rate만 변경하여 Accuracy와 Loss를 비교하였다.


1. 라이브러리 불러오기

import random, os, math
import numpy as np
import torch

from torch import nn
from torch.utils.data import DataLoader
from torchvision import datasets, transforms

import torch.nn.functional as F
from torch.optim import SGD

import matplotlib.pyplot as plt
import pandas as pd

2. Random Seed 및 Device 설정

실험 결과의 재현성을 높이기 위해 Random 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

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

print(device)

cuDNN 설정

torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False

cuDNN은 NVIDIA GPU에서 딥러닝 연산을 빠르게 수행하기 위한 라이브러리이다.

위 설정은 실행 속도보다는 실험 결과의 재현성을 높이는 데 목적이 있다.


3. MNIST 데이터 준비

MNIST 이미지를 Tensor로 변환한 후 평균과 표준편차를 이용하여 정규화하였다.

transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.1307,), (0.3081,))
])

정규화 식은 다음과 같다.

[
x' = \frac{x - \mu}{\sigma}
]

여기서는

  • 평균: 0.1307
  • 표준편차: 0.3081

을 사용하였다.


Dataset 생성

train_ds = datasets.MNIST(
    root="./data",
    train=True,
    download=True,
    transform=transform
)

test_ds = datasets.MNIST(
    root="./data",
    train=False,
    download=True,
    transform=transform
)

MNIST 데이터셋은 다음과 같이 구성되어 있다.

데이터개수
Train60,000
Test10,000

DataLoader 생성

BATCH_SIZE = 128

train_loader = DataLoader(
    train_ds,
    batch_size=BATCH_SIZE,
    shuffle=True,
    num_workers=2,
    pin_memory=True
)

test_loader = DataLoader(
    test_ds,
    batch_size=512,
    shuffle=False,
    num_workers=2,
    pin_memory=True
)

학습 데이터는 순서에 따른 영향을 줄이기 위해 shuffle=True로 설정하였다.


4. MLP 모델 구현

MNIST 이미지는 28 × 28 크기의 흑백 이미지이다.

따라서 입력 데이터를 Flatten하면

[
28 \times 28 = 784
]

개의 입력값을 갖는다.

모델 구조는 다음과 같다.

Input
28 × 28
   ↓
Flatten
   ↓
784
   ↓
Linear
   ↓
512
   ↓
ReLU
   ↓
Linear
   ↓
256
   ↓
ReLU
   ↓
Linear
   ↓
10

코드는 다음과 같다.

class MLP(nn.Module):
    def __init__(self, dropout_p=0.0):
        super().__init__()

        self.flatten = nn.Flatten()

        self.fc1 = nn.Linear(28 * 28, 512)
        self.fc2 = nn.Linear(512, 256)
        self.fc3 = nn.Linear(256, 10)

        self.drop = nn.Dropout(p=dropout_p)
        self.p = dropout_p

    def forward(self, x):

        x = self.flatten(x)

        x = F.relu(self.fc1(x))

        if self.p > 0:
            x = self.drop(x)

        x = F.relu(self.fc2(x))

        if self.p > 0:
            x = self.drop(x)

        x = self.fc3(x)

        return x

마지막 출력층에는 Softmax를 적용하지 않았다.

이후 사용하는

F.cross_entropy(logits, y)

가 logits를 직접 입력으로 받기 때문이다.


5. Accuracy 계산

def accuracy_from_logits(logits, y):

    pred = logits.argmax(dim=1)

    return (
        (pred == y)
        .float()
        .mean()
        .item()
    )

argmax(dim=1)을 이용하여 10개의 출력 중 가장 큰 값을 가진 클래스를 모델의 예측값으로 사용하였다.


6. 모델 평가 함수

평가 단계에서는 Gradient를 계산할 필요가 없으므로 torch.no_grad()를 사용한다.

@torch.no_grad()
def evaluate(model, loader):

    model.eval()

    total_loss = 0.0
    total_acc = 0.0
    total_cnt = 0

    for x, y in loader:

        x = x.to(device)
        y = y.to(device)

        logits = model(x)

        loss = F.cross_entropy(logits, y)

        acc = accuracy_from_logits(logits, y)

        bs = x.size(0)

        total_cnt += bs
        total_loss += loss.item() * bs
        total_acc += acc * bs

    return (
        total_loss / total_cnt,
        total_acc / total_cnt
    )

평가에서는

model.eval()

을 사용하여 Dropout 등의 학습 전용 동작을 비활성화하였다.


7. Training Loop

def train_one(
    model,
    optimizer,
    epochs=6,
    clip_maxnorm=None
):

    hist = {
        "epoch": [],
        "train_loss": [],
        "train_acc": [],
        "val_loss": [],
        "val_acc": []
    }

    for ep in range(1, epochs + 1):

        model.train()

        t_loss = 0.0
        t_acc = 0.0
        t_cnt = 0

        for x, y in train_loader:

            x = x.to(device)
            y = y.to(device)

            # Forward
            logits = model(x)

            loss = F.cross_entropy(logits, y)

            # 기존 Gradient 제거
            optimizer.zero_grad(set_to_none=True)

            # Backpropagation
            loss.backward()

            # Gradient Clipping
            if clip_maxnorm is not None:
                torch.nn.utils.clip_grad_norm_(
                    model.parameters(),
                    clip_maxnorm
                )

            # Parameter Update
            optimizer.step()

            bs = x.size(0)

            t_cnt += bs
            t_loss += loss.item() * bs
            t_acc += (
                accuracy_from_logits(logits, y) * bs
            )

        vl, va = evaluate(
            model,
            test_loader
        )

        hist["epoch"].append(ep)
        hist["train_loss"].append(t_loss / t_cnt)
        hist["train_acc"].append(t_acc / t_cnt)

        hist["val_loss"].append(vl)
        hist["val_acc"].append(va)

        print(
            f"Epoch {ep:02d} | "
            f"train_loss={t_loss/t_cnt:.4f} "
            f"acc={t_acc/t_cnt:.4f} | "
            f"val_loss={vl:.4f} "
            f"acc={va:.4f}"
        )

    return hist

핵심적인 학습 과정은 다음 세 단계이다.

optimizer.zero_grad()
loss.backward()
optimizer.step()

즉,

Forward
   ↓
Loss 계산
   ↓
Gradient 초기화
   ↓
Backpropagation
   ↓
Parameter Update

순서로 진행된다.


8. 학습 결과 시각화

def plot_curves(histories, title_prefix=""):

    # Validation Accuracy
    plt.figure(figsize=(7, 5))

    for name, h in histories.items():

        plt.plot(
            h["epoch"],
            h["val_acc"],
            label=name
        )

    plt.title(
        f"{title_prefix} - Validation Accuracy"
    )

    plt.xlabel("Epoch")
    plt.ylabel("Acc")

    plt.legend()
    plt.grid(True)

    plt.show()

    # Validation Loss
    plt.figure(figsize=(7, 5))

    for name, h in histories.items():

        plt.plot(
            h["epoch"],
            h["val_loss"],
            label=name
        )

    plt.title(
        f"{title_prefix} - Validation Loss"
    )

    plt.xlabel("Epoch")
    plt.ylabel("Loss")

    plt.legend()
    plt.grid(True)

    plt.show()

9. 실험 설계

이번 실험에서는 Learning Rate가 모델 성능에 미치는 영향을 확인하였다.

독립 변수

Learning Rate

0.001
0.01
0.1

통제 변수

다음 조건은 동일하게 유지하였다.

  • MNIST Dataset
  • 데이터 전처리 방식
  • MLP 구조
  • SGD Optimizer
  • Batch Size
  • Epoch 수
  • Cross Entropy Loss
  • Dropout 미사용
  • Learning Rate Scheduler 미사용

종속 변수

다음 값을 관찰하였다.

  • Accuracy
  • Loss
  • Epoch에 따른 수렴 속도

실험 목적

Learning Rate에 따라 모델의 학습 속도와 최종 성능이 어떻게 달라지는지 확인하고 가장 적절한 Learning Rate를 찾는다.


10. Learning Rate Sweep

EPOCHS = 6

LR_LIST = [
    1e-3,
    1e-2,
    1e-1
]

histories = {}
summary = []

for lr in LR_LIST:

    name = f"lr : {lr}"

    print(f"\n{name}")

    model = MLP(
        dropout_p=0.0
    ).to(device)

    optim = SGD(
        model.parameters(),
        lr=lr
    )

    hist = train_one(
        model,
        optim,
        epochs=EPOCHS
    )

    histories[name] = hist

    summary.append({
        "name": name,
        "final_val_acc":
            hist["val_acc"][-1],
        "final_val_loss":
            hist["val_loss"][-1]
    })

Learning Rate 이외에는 동일한 조건을 사용하였다.


11. 결과 그래프

plot_curves(
    histories,
    title_prefix=
    "LR Sweep (SGD, no dropout/scheduler)"
)

Validation Accuracy

여기에 Validation Accuracy 그래프 이미지 삽입

Validation Loss

여기에 Validation Loss 그래프 이미지 삽입


12. 최종 결과

summary_df = (
    pd.DataFrame(summary)
    .sort_values(
        by="final_val_acc",
        ascending=False
    )
    .reset_index(drop=True)
)

summary_df

실험 결과는 다음과 같았다.

Learning RateFinal AccuracyFinal Loss
0.10.97980.067430
0.010.93650.214619
0.0010.85080.650666

백분율로 표현하면 다음과 같다.

Learning RateAccuracy
0.197.98%
0.0193.65%
0.00185.08%

13. LR = 0.001

결과는 다음과 같다.

Epoch 01
val_loss = 2.0897
val_acc  = 0.5515

Epoch 02
val_loss = 1.7781
val_acc  = 0.7175

Epoch 03
val_loss = 1.3503
val_acc  = 0.7682

Epoch 04
val_loss = 0.9932
val_acc  = 0.8076

Epoch 05
val_loss = 0.7784
val_acc  = 0.8333

Epoch 06
val_loss = 0.6507
val_acc  = 0.8508

Accuracy가 지속적으로 상승하고 Loss 역시 계속 감소하였다.

즉 학습에 실패한 것은 아니지만 Learning Rate가 작기 때문에 Weight의 업데이트 크기가 작고, 결과적으로 6 Epoch 내에서 충분히 수렴하지 못한 것으로 해석할 수 있다.


14. LR = 0.01

Epoch 01
val_loss = 0.4407
val_acc  = 0.8852

Epoch 02
val_loss = 0.3228
val_acc  = 0.9097

Epoch 03
val_loss = 0.2796
val_acc  = 0.9208

Epoch 04
val_loss = 0.2544
val_acc  = 0.9265

Epoch 05
val_loss = 0.2343
val_acc  = 0.9299

Epoch 06
val_loss = 0.2146
val_acc  = 0.9365

LR=0.001보다 훨씬 빠르게 수렴하였다.

Accuracy와 Loss 모두 안정적으로 개선되었지만 LR=0.1보다는 학습 속도와 최종 성능이 낮았다.


15. LR = 0.1

Epoch 01
val_loss = 0.1701
val_acc  = 0.9479

Epoch 02
val_loss = 0.1175
val_acc  = 0.9648

Epoch 03
val_loss = 0.0907
val_acc  = 0.9717

Epoch 04
val_loss = 0.0839
val_acc  = 0.9737

Epoch 05
val_loss = 0.0664
val_acc  = 0.9800

Epoch 06
val_loss = 0.0674
val_acc  = 0.9798

세 Learning Rate 중 가장 빠르게 수렴하였다.

특히 Epoch 5에서

Accuracy = 98.00%
Loss     = 0.0664

를 기록하였다.

최종 Epoch의 Accuracy는 97.98%였다.


16. Epoch 5와 Epoch 6 비교

LR=0.1의 결과를 자세히 보면 다음과 같다.

EpochTrain AccuracyValidation AccuracyValidation Loss
598.57%98.00%0.0664
698.91%97.98%0.0674

Epoch 6에서는 Training Accuracy가 계속 증가하였다.

하지만 Validation Accuracy는

98.00%
→
97.98%

로 아주 조금 감소하였다.

Validation Loss 역시

0.0664
→
0.0674

로 소폭 증가하였다.

이는 학습 데이터의 성능은 계속 좋아지는 반면 평가 데이터의 성능 향상은 멈추기 시작했다는 의미이다.

따라서 약한 Overfitting의 시작 가능성을 확인할 수 있다.

다만 차이가 매우 작기 때문에 심각한 Overfitting으로 보기는 어렵다.


17. Learning Rate에 따른 차이

SGD에서 Weight Update는 기본적으로 다음 식을 따른다.

[

w_{new}

w_{old}

\eta \nabla L
]

여기서 (\eta)가 Learning Rate이다.

Learning Rate가 작으면 Weight가 조금씩 변경된다.

LR이 너무 작음
↓
업데이트 폭이 작음
↓
안정적
↓
하지만 수렴이 느림

반대로 적절하게 큰 Learning Rate에서는

적절한 LR
↓
업데이트 폭 증가
↓
빠른 Loss 감소
↓
빠른 수렴

이 가능하다.

이번 실험에서는 0.1이 이 역할을 가장 잘 수행하였다.


18. 실험 결과 분석

Learning Rate별 최종 Accuracy는 다음과 같다.

LR = 0.001
→ 85.08%

LR = 0.01
→ 93.65%

LR = 0.1
→ 97.98%

최종 Loss는 다음과 같다.

LR = 0.001
→ 0.6507

LR = 0.01
→ 0.2146

LR = 0.1
→ 0.0674

따라서 이번 실험 범위에서는

0.1 > 0.01 > 0.001

순서로 좋은 결과가 나타났다.


19. 결론

본 실험에서는 SGD Optimizer의 Learning Rate를 0.001, 0.01, 0.1로 변경하여 동일한 MLP 모델을 6 Epoch 동안 학습하였다.

실험 결과 Learning Rate가 0.001일 때 최종 Accuracy는 85.08%, 0.01일 때 93.65%, 0.1일 때 97.98%로 나타났다.

또한 최종 Loss는 각각 0.6507, 0.2146, 0.0674로 나타났다.

따라서 비교한 세 조건 중에서는 Learning Rate 0.1이 가장 빠른 수렴 속도와 가장 높은 분류 성능을 나타냈다.

특히 LR=0.1의 Epoch 5에서 98.00%의 Accuracy를 기록하여 가장 높은 평가 성능을 보였다.

다만 Epoch 6에서는 Training Accuracy가 증가하는 반면 평가 Accuracy가 소폭 감소하고 Loss가 증가하여, 추가 학습 시 Overfitting이 발생할 가능성도 확인할 수 있었다.

결론적으로 이번 실험 조건에서는

Learning Rate = 0.1

이 가장 적절한 값으로 판단된다.


20. 실험하면서 확인한 점

이번 실습을 통해 다음 내용을 확인하였다.

  1. Learning Rate는 모델의 수렴 속도에 큰 영향을 준다.
  2. Learning Rate가 너무 작으면 안정적이지만 제한된 Epoch 내에서는 충분히 학습되지 않을 수 있다.
  3. 단순히 Training Accuracy만 보는 것보다 평가 Accuracy와 Loss를 함께 확인해야 한다.
  4. 마지막 Epoch의 결과가 항상 가장 좋은 결과인 것은 아니다.
  5. Hyperparameter를 비교할 때는 다른 조건을 동일하게 유지해야 한다.
  6. Learning Rate는 모델 구조 및 Optimizer와 함께 고려해야 한다.

참고: Validation과 Test의 구분

이번 코드에서는

vl, va = evaluate(model, test_loader)

를 사용하고 있기 때문에 실제로는 MNIST Test Dataset을 매 Epoch 평가하고 있다.

코드에서는 편의상

val_loss
val_acc

라는 이름을 사용했지만 엄밀한 실험에서는 다음과 같이 분리하는 것이 좋다.

Training Set
     ↓
모델 학습

Validation Set
     ↓
Hyperparameter 선택

Test Set
     ↓
최종 모델 성능 평가

즉 실제 연구나 엄밀한 모델 평가에서는 Train / Validation / Test를 별도로 구성하는 것이 적절하다.


참고: pin_memory Warning

CPU 환경에서 다음과 같은 Warning이 발생할 수 있다.

'pin_memory' argument is set as true
but no accelerator is found

이는 GPU가 없는 상태에서

pin_memory=True

로 설정했기 때문이다.

에러는 아니며 학습 자체에는 문제가 없다.

CUDA 사용 여부에 따라 설정하려면 다음과 같이 작성할 수 있다.

PIN_MEMORY = torch.cuda.is_available()

그리고

train_loader = DataLoader(
    train_ds,
    batch_size=BATCH_SIZE,
    shuffle=True,
    num_workers=2,
    pin_memory=PIN_MEMORY
)

처럼 사용하면 된다.

0개의 댓글