ML06. PyTorch로 MLP 구현방법, 비선형 분류

개요

이전 글 에서 "XOR 문제" 를 순수 numpy로 직접 순전파(Forward), 역전파(Back Propagation)를 구현해 풀어보았다.

가중치(W), 편향(b)의 업데이트 수식을 손으로 직접 유도하고 코드로 옮겨봤으니, 이제 머신러닝의 원리는 어느 정도 몸에 익었을 것이다.

이번 예제에서 사용할 데이터는 sklearn이 제공하는 "make_moons" 데이터셋인데, 이름 그대로 초승달 두 개가 서로 맞물린 모양의 데이터다.

아래 그림에서 빨강, 파랑점이 2차원 평면에 존재하고, 기계 학습을 통해 경계를 구분하는 모습이다.

[비선형 분류 예시, 출처 : scikit-learn]

PyTorch

이번 글에서는 실전에서 실제로 쓰이는 프레임워크인 PyTorch를 이용,

위 처럼 XOR보다 조금 더 복잡한 비선형 데이터를 분류해본다.


XOR은 점 4개였지만, 이번엔 200개의 점을 곡선 형태의 경계로 나눠야 한다.

직선 하나로는 어림도 없고, 심지어 XOR 처럼 은닉층 (hidden layer) 하나로도 완벽하게 나누기 쉽지 않다.


그래서 이번엔 은닉층을 2개 쌓은 신경망을 만들어본다.

(ML05. 정리에서 말했듯, 은닉층이 2개 이상이면 비로소 "Deep"하다고 부른다. 즉 오늘 만드는 모델이 이 블로그 시리즈 최초의 진짜 "딥러닝" 모델인 셈이다 😀)


또한 이번 글부터는 numpy로 손수 짰던 순전파, 역전파를 PyTorch의 'nn.Module', 'autograd', 'optimizer'가 대신 처리해준다.

원리는 똑같은데 코드는 훨씬 짧아진다는 것을 직접 느껴보자.


앞선 과정들에서 학습에 필요한 수식과 이론을 공부했으므로, PyTorch의 내부 동작을 짐작할 수 있을 것이다.

이제 PyTorch로 비선형 분류를 시작해보자.


1. 데이터셋 준비

from sklearn.datasets import make_moons
import numpy as np
import matplotlib.pyplot as plt

X, y = make_moons(n_samples=200, noise=0.2)
X = X.astype(np.float32)
y = y.reshape(-1, 1).astype(np.float32)
plt.scatter(X[:,0], X[:, 1], c=y[:, 0], cmap='coolwarm', alpha=0.7)
plt.show()

make_moons(n_samples=200, noise=0.2)

초승달 두 개가 서로 겹쳐진 형태의 2차원 데이터 200개를 생성


X

$(200, 2)$ 형태의 좌표값 (200개의 $x_1, x_2$)


y

$(200,)$ 형태의 정답 라벨 (0 또는 1, 어느 초승달에 속하는지)


noise=0.2

가우시안 노이즈의 표준편차값.

데이터에 약간의 무작위 잡음을 섞어서 완벽하게 깔끔한 곡선이 아니라 실제 데이터처럼 흩어지게.

값이 클수록 두 클래스가 더 많이 섞임

    [make_moons 결과]

    여기서 두 가지 변환이 눈에 띈다.

    X = X.astype(np.float32)
    y = y.reshape(-1, 1).astype(np.float32)
    


    왜 float32로 바꿀까?

    PyTorch의 신경망 연산은 기본적으로 float32 타입을 기대한다.

    sklearn이 만들어주는 X는 float64 (더블 정밀도)인데, 이걸 그대로 텐서로 바꾸면 나중에 모델의 가중치(float32)와 타입이 맞지 않아 에러가 난다.

    그래서 미리 float32로 맞춰준다.


    왜 reshape(-1, 1)을 할까?

    sklearn이 주는 y는 $(200,)$, 즉 1차원 벡터다.

    그런데 우리 모델의 출력은 $(200, 1)$ 형태, 즉 각 샘플마다 값 하나씩을 담은 2차원 행렬 형태로 나온다.

    손실함수(BCEWithLogitsLoss)에 넣을 때 모델 출력과 정답 라벨의 차원(shape)이 정확히 일치해야 하므로, y를 $(200,)$에서 $(200, 1)$로 바꿔주는 것.

    reshape의 -1은 "행차원은 알아서 계산해라"는 뜻이고, 1은 "열을 1개로 만들어라"는 뜻이다.

    시각화 코드에서 c=y[:, 0]으로 다시 1차원으로 꺼내는 이유도, y가 이미 $(200, 1)$ 형태로 바뀌었기 때문에 scatter (산점도 차트)의 색상(c) 인자에는 1차원 값이 필요해서다.


    위 산점도를 살펴보면 빨간색 점과 파란색 점이 각각 초승달 모양으로 서로 얽혀 있는 것을 확인할 수 있다.

    직선 하나로는 절대 이 두 그룹을 나눌 수 없다.


    2. PyTouch 텐서, 미니배치

    import torch
    import torch.nn as nn
    from torch.utils.data import TensorDataset, DataLoader
    
    X_tensor = torch.from_numpy(X)
    y_tensor = torch.from_numpy(y)
    tensor_ds = TensorDataset(X_tensor, y_tensor)
    tensor_dl = DataLoader(tensor_ds, batch_size=16, shuffle=True)
    

    torch.from_numpy(X)

    numpy 배열 X를 PyTorch가 계산할 수 있는 텐서(Tensor)로 변환.

    텐서는 numpy 배열과 거의 비슷하게 생겼지만, GPU 연산과 자동미분(autograd)을 지원한다는 차이가 있다.

    처음 공부하면 이 둘(numpy array, torch tensor)이 헷깔리는데 그냥 넘파일 배열을 토치에서 사용가능한 자료형 (data type) 으로 형변환 한다고 생각하자.


    TensorDataset(X_tensor, y_tensor)

    입력(X)과 정답(y)을 한 쌍으로 묶어주는 역할.

    즉 tensor_ds[0]을 하면 (X_tensor[0], y_tensor[0])이 튀어나오는 식이다.

    DataLoader는 이 데이터셋을 미니배치(mini-batch)로 잘라서 하나씩 꺼내주는 도구다.


    batch_size=16

    200개의 전체 데이터를 한 번에 학습에 쓰지 않고, 16개씩 잘라서 학습.

    즉 200개를 16개씩 나누면 약 13번의 배치로 나뉜다.

    (앞선 ML05에서는 데이터가 4개뿐이라 전체를 한 번에 학습, 실제 데이터는 수천~수백만개가 되기도 해서 이렇게 잘게 나눠 학습하는 방식(Mini-batch Gradient Descent)을 주로 사용)

    너무 큰 데이터셋은 한번에 메모리에 올릴 수도 없다.


    shuffle=True

    매 epoch(전체 데이터를 한 바퀴 도는 것)마다 데이터 순서를 무작위로 섞는다.

    데이터가 항상 같은 순서로 들어가면 모델이 순서 자체에 어떤 패턴이 있다고 착각(과적합)할 수 있어서, 학습시 섞어주는 것이 일반적이다.


    3. 모델 생성

    m,n = X_tensor.shape
    model = nn.Sequential(
        nn.Linear(n, 16),
        nn.ReLU(),    
        nn.Linear(16, 8),
        nn.ReLU(),    
        nn.Linear(8, 1)
    )
      

    nn.Sequential

    레이어를 순서대로 쭉 이어붙여주는 컨테이너다.

    데이터가 좌에서 우로 흘러가며 순전파가 이뤄진다.

    입력층($X$)    →    은닉층1($Z_1, A_1$)    →    은닉층2($Z_2, A_2$)    →    출력층($Z_3$)


    이 모델의 레이어를 차원을 따라가며 풀어보면 아래와 같다.

    $$
    \begin{aligned}
    X\;(m,2) &\rightarrow Z_1 \rightarrow A_1\;(m,16) \rightarrow Z_2 \\
              &\rightarrow A_2\;(m,8) \rightarrow Z_3 \rightarrow \hat{z}\;(m,1)
    \end{aligned}
    $$
    [ 클로드로 그린 다층신경망 구조 ]


    nn.Linear(n, 16)

    입력 2개($x_1, x_2$)를 받아 16개의 뉴런으로 보낸다.

    내부적으로 $Z_1 = X \cdot W_1 + b_1$ 을 계산하며, $W_1$의 차원은 $(2, 16)$이다.

    ML05에서 직접 만들었던 np.dot(X, W1) + b1과 완전히 같은 연산이다.


    nn.ReLU()

    활성화 함수. $A_1 = \text{ReLU}(Z_1)$


    nn.Linear(16, 8)

    두 번째 은닉층. $Z_2 = A_1 \cdot W_2 + b_2$, $W_2$의 차원은 $(16, 8)$


    nn.ReLU()

    $A_2 = \text{ReLU}(Z_2)$


    nn.Linear(8, 1)

    출력층. $\hat{z} = A_2 \cdot W_3 + b_3$, $W_3$의 차원은 $(8, 1)$


    여기서 출력이 $\hat{y}$가 아니라 $\hat{z}$인 것에 주목하자.

    출력층에 시그모이드가 빠져있다. 이유는 아래 손실함수 부분에서 설명한다.


    왜 Sigmoid 대신 ReLU를 쓸까?

    ML05 에서는 은닉층에도 시그모이드를 사용했다.

    하지만 실전에서는 은닉층의 활성화 함수로 보통 ReLU를 사용한다.

    $$
    \text{ReLU}(z) = \max(0, z) =
    \begin{cases}
    z & z > 0 \\
    0 & z \le 0
    \end{cases}
    $$


    시그모이드는 $z$가 아주 크거나 작을 때 그래프가 거의 평평해지는데,

    이 구간에서는 미분값(기울기)이 0에 가까워진다.

    층이 깊어질수록(은닉층이 많을수록) 역전파 과정에서 이 작은 기울기들이 계속 곱해지면서 점점 더 작아지고, 결국 앞쪽 층까지 오차가 거의 전달되지 않는 문제가 생긴다.

    이를 기울기 소실(Vanishing Gradient) 문제라고 한다.


    반면 ReLU는 $z>0$인 구간에서 미분값이 항상 1이라, 여러 층을 거쳐도 기울기가 잘 죽지 않는다.

    계산도 단순히 0보다 큰지 비교만 하면 되니 훨씬 빠르다.

    그래서 은닉층에는 ReLU, 출력층에는 문제 유형에 맞는 활성화 함수(이진분류라면 시그모이드)를 쓰는 것이 일반적인 조합이다.


    4. 손실함수 (BCELoss)

      criterion = nn.BCEWithLogitsLoss()
      

    PyTorch의 BCELoss 클래스를 사용한다.


    ML05에서는 아래 두 단계를 따로 계산했었다.

    1. 출력층에서 시그모이드를 통과시켜 확률 $\hat{y} = \sigma(z)$를 구하고

    2. Binary Cross Entropy로 손실을 계산

    $$
    L(y, \hat{y}) = -[y \log(\hat{y}) + (1 - y) \log(1 - \hat{y})]
    $$


    그런데 이번 모델의 마지막 레이어는 nn.Linear(8, 1)로 끝나고, 시그모이드가 없다.

    대신 BCEWithLogitsLoss 가 시그모이드와 BCE 계산을 한 번에 처리해준다.

    $$
    \begin{aligned}
    L(y,z) = -[y\log(\sigma(z)) \\
    \quad + (1-y)\log(1-\sigma(z))]
    \end{aligned}
    $$

    여기서 모델의 원본 출력값 $z$를 로짓(logit)이라고 부른다.

    굳이 시그모이드를 따로 빼서 계산하지 않고 로짓을 그대로 손실함수에 넘기는 이유는 수치 안정성 때문이다.


    $z$가 아주 큰 음수일 때 $\sigma(z)$는 0에 매우 가까워지고, $\log(0)$은 계산상 $-\infty$가 되어버려 값이 튀거나 nan이 나올 위험이 있다.

    토치의 BCEWithLogitsLoss는 내부적으로 로그와 지수 계산을 수학적으로 안정적인 형태로 재정리해서 계산하기 때문에 이런 문제가 훨씬 적다.


    즉 "모델은 확률이 아니라 로짓을 뱉고, 손실함수가 알아서 시그모이드까지 처리한다"는 것만 기억하면 된다.

    (그래서 나중에 예측할 때는 torch.sigmoid() 를 직접 한 번 더 씌워줘야 확률값을 얻을 수 있다)


    5. 옵티마이저, 경사하강법

      optimizer = torch.optim.SGD(model.parameters(), lr=0.002)
      

    PyTorch의 SGD 클래스를 사용한다.


    ML05에서 우리는 아래처럼 손으로 직접 가중치를 업데이트했다.

    $$
    W_2 = W_2 - \alpha \cdot A_1^T \cdot \delta_2
    $$


    torch.optim.SGD는 바로 이 과정을 자동화한 것이다.

    model.parameters()로 모델 안의 모든 $W$, $b$를 옵티마이저에게 넘겨주면,

    옵티마이저가 이 파라미터들을 학습률(lr) 만큼씩 경사하강법으로 업데이트해준다. 😀


    일반화하면 모든 파라미터 $\theta$에 대해

    $$
    \theta = \theta - \alpha \cdot \frac{\partial J}{\partial \theta}
    $$

    세상에... 파라미터만 해도 몇 개인데... 정말 고마운 친구다. 🥹


    여기서 $\alpha$가 lr(Learning Rate, 학습률)이고, $\frac{\partial J}{\partial \theta}$가 바로 역전파로 구해지는 기울기(gradient)다.


    참고로 우리 모델의 레이어별 파라미터(W, b) 수는 다음과 같다. (총 193) 

    [ 층별 W, b의 개수 ]

    6. 모델 학습

    epochs = 1000
    model.train()    
    
    for i in range(epochs):
        for x_batch, y_batch in tensor_dl:
            y_hat = model(x_batch)
            loss = criterion(y_hat, y_batch)
    
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
    
        if i%100==0:
            cost = loss.item()
            print(f'epoch={i}, cost={cost:.3f}')  
      


    model.train()

    모델을 "학습 모드"로 전환하는 코드다.

    (지금 쓴 Linear, ReLU만 있는 모델에서는 사실 큰 차이가 없지만, 나중에 배울 Dropout이나 BatchNorm 레이어가 들어가면 학습, 평가 모드에 따라 동작이 달라지므로 기본사용)


    for i in range(epochs)

    전체 데이터를 1000번 반복해서 학습한다는 뜻


    for x_batch, y_batch in tensor_dl

    DataLoader가 잘라준 미니배치(16개씩)를 하나씩 꺼내 학습한다는 뜻이다.


    각 배치마다 아래 네 단계가 반복된다.

    1. 순전파

    y_hat = model(x_batch)

    입력을 모델에 통과시켜 로짓(예측)을 얻는다.

    ML05의 z1 = X·W1+b1 → a1 → z2·W2+b2 전체 과정이 이 한 줄에 담겨있다.


    2. 손실 계산

    loss = criterion(y_hat, y_batch)

    예측과 정답의 차이(Cost)를 계산한다.


    3. 기울기 초기화

    optimizer.zero_grad()

    PyTorch는 기본적으로 기울기를 계속 누적, 매 배치마다 이전 기울기를 0으로 초기화.

    하지 않으면 이전 배치의 기울기가 계속 더해져 엉뚱한 값으로 학습.


    4. 역전파

    loss.backward()

    ML05에서 직접 유도했던 $\delta_2$, $\delta_1$, $\frac{\partial J}{\partial W}$ 계산을 PyTorch의 autograd가 자동으로 계산해 각 파라미터에 저장.


    5. 파라미터(W,b) 업데이트

    optimizer.step()

    계산된 기울기를 이용해 앞서 본 $\theta = \theta - \alpha \cdot \nabla \theta$ 를 실제로 수행.


    즉 ML05의 아래 4줄이

    W2 = W2 - lr * np.dot(a1.T, d2)
    
    b2 = b2 - lr * np.sum(d2, axis=0)
    
    W1 = W1 - lr * np.dot(X.T, d1)
    
    b1 = b1 - lr * np.sum(d1, axis=0)
      

    PyTorch에서는 loss.backward() + optimizer.step()

    단 두 줄로 압축된다.

    원리는 완전히 동일하고, 단지 미분과 업데이트를 프레임워크가 대신 해주는 것 뿐이다.


    if i % 100 == 0

    100 epoch마다 한 번씩 현재 Cost를 출력해서,

    학습이 잘 진행되고 있는지(Cost가 점점 줄어드는지) 확인하기 위한 코드.

    [학습진행, 비용감소 확인]


    7. 예측, 결정경계 시각화

    model.eval()
    with torch.no_grad():
        x_min, x_max = X[:, 0].min()-0.5, X[:, 0].max()+0.5
        y_min, y_max = X[:, 1].min()-0.5, X[:, 1].max()+0.5
    
        # gx(300,300) = 90,000, gy(300,300)
        gx, gy = np.meshgrid(
            np.linspace(x_min, x_max, 300),
            np.linspace(y_min, y_max, 300)
        )
    
        X_test = np.c_[gx.ravel(), gy.ravel()]
        X_tensor = torch.tensor(X_test, dtype=torch.float32)
    
        Z = model(X_tensor)
        A = torch.sigmoid(Z).numpy()
    
        A = A.reshape(gx.shape)
    
        plt.contourf(gx, gy, A, cmap='coolwarm', levels=50, alpha=0.5)
        plt.scatter(X[:, 0], X[:, 1], c=y[:, 0], cmap='coolwarm', s=30)
        plt.show()
      

    model.eval()

    모델을 "평가 모드"로 전환하는 코드


    with torch.no_grad()

    이 블록 안에서는 기울기를 계산하지 않겠다는 뜻이다.

    예측만 할 때는 역전파가 필요 없으므로, 기울기 계산을 꺼서 메모리와 연산량을 아낀다.


    meshgrid로 격자 만들기

    결정 경계(Decision Boundary)를 그리려면, 평면 전체를 촘촘한 격자로 나누고 "이 좌표는 어느 클래스에 속할 확률이 몇 %인가"를 전부 예측해야 한다.


    np.linspace(x_min, x_max, 300)

    x축을 300개의 점으로 쪼개고, y축도 마찬가지로 300개로 쪼갠다.


    np.meshgrid

    메쉬로 조합하면 $300 \times 300 = 90{,}000$개의 좌표(격자)가 만들어진다.

    gx, gy 각각의 shape는 $(300, 300)$이다. (화면을 300행, 300열로 나눈 좌표)


    np.c_[gx.ravel(), gy.ravel()]

    이 90,000개의 격자점을 (90000, 2) 형태의 좌표 목록으로 펼쳐서(ravel) 나란히 붙여준다(c_).

    이 좌표들을 모델에 통과시키면, 평면 위 모든 점에 대한 예측값을 한 번에 얻을 수 있다.

    즉, 화면의 좌표를 300X300 으로 만들고 학습된 모델에 넣어 예측값(0~1사이)을 구해 색상으로 표현.


    로짓 → 확률 변환

    Z = model(X_tensor)
    A = torch.sigmoid(Z).numpy()
      

    앞서 말했듯 모델의 출력 Z는 시그모이드를 거치지 않은 로짓이다.

    사람이 보기 좋은 0~1 사이 확률로 바꾸려면 여기서 직접 torch.sigmoid()를 한 번 더 씌워줘야 한다.

    $$
    A = \sigma(Z) = \frac{1}{1+e^{-Z}}
    $$


    A.reshape(gx.shape)

    (90000, 1)을 다시 $(300, 300)$ 형태로 되돌리면, 평면 위 각 좌표에 대응하는 확률 지도가 완성된다.


    시각화

    plt.contourf(gx, gy, A, ...)

    이 확률 지도를 등고선 형태의 색으로 채워서 그려준다.

    확률이 0.5보다 큰(즉 클래스 1에 가까운) 영역과 0.5보다 작은 영역이 색으로 구분되어, 모델이 학습한 곡선 형태의 결정 경계를 눈으로 확인할 수 있다.

    그 위에 실제 데이터 X 점(scatter)을 함께 찍어 얼마나 잘 분류했는지 비교한다.

    [학습된 모델의 결정경계 시각화]

    8. 전체 코드

    from sklearn.datasets import make_moons
    import numpy as np
    import matplotlib.pyplot as plt
    
    X, y = make_moons(n_samples=200, noise=0.2)
    X = X.astype(np.float32)
    y = y.reshape(-1, 1).astype(np.float32)
    plt.scatter(X[:,0], X[:, 1], c=y[:, 0], cmap='coolwarm', alpha=0.7)
    plt.show()
    
    import torch
    import torch.nn as nn
    from torch.utils.data import TensorDataset, DataLoader
    
    X_tensor = torch.from_numpy(X)
    y_tensor = torch.from_numpy(y)
    tensor_ds = TensorDataset(X_tensor, y_tensor)
    tensor_dl = DataLoader(tensor_ds, batch_size=16, shuffle=True)
    
    m,n = X_tensor.shape
    model = nn.Sequential(
        nn.Linear(n, 16),
        nn.ReLU(),    
        nn.Linear(16, 8),
        nn.ReLU(),    
        nn.Linear(8, 1)
    )
    
    criterion = nn.BCEWithLogitsLoss()
    optimizer = torch.optim.SGD(model.parameters(), lr=0.002)
    
    epochs = 1000
    model.train()    
    
    for i in range(epochs):
        for x_batch, y_batch in tensor_dl:
            y_hat = model(x_batch)
            loss = criterion(y_hat, y_batch)
    
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
    
        if i%100==0:
            cost = loss.item()
            print(f'epoch={i}, cost={cost:.3f}')
    
    
    # predict
    model.eval()
    with torch.no_grad():
        x_min, x_max = X[:, 0].min()-0.5, X[:, 0].max()+0.5
        y_min, y_max = X[:, 1].min()-0.5, X[:, 1].max()+0.5
    
        # gx(300,300) = 90,000, gy(300,300)
        gx, gy = np.meshgrid(
            np.linspace(x_min, x_max, 300),
            np.linspace(y_min, y_max, 300)
        )
    
        X_test = np.c_[gx.ravel(), gy.ravel()]
        X_tensor = torch.tensor(X_test, dtype=torch.float32)
    
        Z = model(X_tensor)
        A = torch.sigmoid(Z).numpy()
    
        A = A.reshape(gx.shape)
    
        plt.contourf(gx, gy, A, cmap='coolwarm', levels=50, alpha=0.5)
        plt.scatter(X[:, 0], X[:, 1], c=y[:, 0], cmap='coolwarm', s=30)
        plt.show()
        


    9. 정리

    • XOR을 numpy로 손수 구현했던 ML05와 달리, PyTorch의 nn.Sequential, autograd, optimizer로 훨씬 간결하게 구현
    • make_moons처럼 완전한 비선형 데이터는 더 깊은 신경망(은닉층 2개 이상)이 필요
    • 따라서, 이 모델이 이 시리즈 최초의 진짜 "딥러닝" 모델
    • 은닉층 활성화 함수는 시그모이드 대신 ReLU
    • 출력층에는 활성화 함수를 두지 않고 로짓을 그대로 출력
    • 이후 BCEWithLogitsLoss로 시그모이드 + BCE를 함께 진행
    • loss.backward() 한줄로 역전파 계산
    • DataLoader의 미니배치 학습은 전체 데이터를 한 번에 쓰는 방식보다 메모리 효율적, 실전 데이터(수만~수천만 개)에서는 사실상 필수.
    • 예측 시에는 model.eval() + torch.no_grad() 로 불필요한 기울기 계산 X
    • epochs, lr, batch_size, 은닉층의 뉴런 수를 직접 바꿔가며 결정 경계 변화 관찰.


    프레임워크는 반복적인 미분과 업데이트 계산을 대신해줄 뿐, 그 안에서 벌어지는 일은 지금까지 손으로 짜본 것과 동일하다는 사실을 기억하자.


    저도 공부하며 작성한 내용이라 틀린 부분이 있다면 알려주세요.

    감사합니다.

    댓글

    이 블로그의 인기 게시물

    Qt Designer 설치하기

    파이썬을 활용한 PID 제어기 GUI 구현

    MFC기반 화면 캡쳐앱 만들기