Model 확장과 가중치값 변경을 통한 예측 정확도 향상

이전에 작성한 “PyTorch를 이용한 간단한 머신러닝”이라는 아래의 글에서는 은닉층이 없는 입력과 출력층으로만 구성된 모델을 사용했습니다. 그리고 가중치 및 편향값의 최적화를 위한 방법은 SGD, 즉 확률적 경사하강을 사용했습니다. 정확도는 대략 90%정도 나왔었습니다.

PyTorch를 이용한 간단한 머신러닝

이에 대해 은닉층을 2개 추가하고 매개변수의 최적화를 위한 방식을 SGD가 아닌 Adam을 사용하여 정확도를 향상시켜 보겠습니다. 은닉층이 추가 되었으므로 활성화 함수가 필요한데, 역전파에서 미분값 소실(Vanishing Gradient)이 발생할 가능성이 큰 시그모이드 함수가 아닌 ReLU 함수를 사용합니다. 즉, 모델은 다음과 같습니다.

위 모델을 구성하기 위한 PyTorch의 코드는 다음과 같습니다.

linear1 = torch.nn.Linear(784, 256, bias=True).to(device)
linear2 = torch.nn.Linear(256, 256, bias=True).to(device)
linear3 = torch.nn.Linear(256, 10, bias=True).to(device)
relu = torch.nn.ReLU()
model = torch.nn.Sequential(linear1, relu, linear2, relu, linear3).to(device)

그리고 매개변수에 대한 최적화 방법을 Adam을 사용하므로 이에 대한 코드는 아래와 같구요. 각 최적화 방식이 어떤식으로 작동하는지 시각적으로 확인할 수 있는 유용한 사이트인 http://www.denizyuret.com/2015/03/alec-radfords-animations-for.html을 참고하시기 바랍니다.

optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

기존의 소스코드에서 위의 변경된 부분이 반영된 전체 코드는 아래와 같습니다.

import torch
import torchvision

batch_size = 1000

mnist_train = torchvision.datasets.MNIST(root="MNIST_data/", train=True, transform=torchvision.transforms.ToTensor(), download=True)
mnist_test = torchvision.datasets.MNIST(root="MNIST_data/", train=False, transform=torchvision.transforms.ToTensor(), download=True)
data_loader = torch.utils.data.DataLoader(mnist_train, batch_size=batch_size, shuffle=True, drop_last=True)

device = torch.device("cuda:0")

linear1 = torch.nn.Linear(784, 256, bias=True).to(device)
linear2 = torch.nn.Linear(256, 256, bias=True).to(device)
linear3 = torch.nn.Linear(256, 10, bias=True).to(device)
relu = torch.nn.ReLU()
model = torch.nn.Sequential(linear1, relu, linear2, relu, linear3).to(device)

loss = torch.nn.CrossEntropyLoss().to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

total_batch = len(data_loader)
training_epochs = 15

for epoch in range(training_epochs):
    total_cost = 0

    for X, Y in data_loader:
        X = X.view(-1, 28 * 28).to(device)
        Y = Y.to(device)
        
        hypothesis = model(X)
        cost = loss(hypothesis, Y)

        optimizer.zero_grad()
        cost.backward()
        optimizer.step()

        total_cost += cost 

    avg_cost = total_cost / total_batch
    print("Epoch:", "%03d" % (epoch+1), "cost =", "{:.9f}".format(avg_cost))

with torch.no_grad():
    X_test = mnist_test.data.view(-1, 28 * 28).float().to(device)
    Y_test = mnist_test.targets.to(device)
    prediction = model(X_test)
    correct_prediction = torch.argmax(prediction, 1) == Y_test
    accuracy = correct_prediction.float().mean()
    print("Accuracy: ", accuracy.item())

최적화 기법마다 학습률의 값은 다릅니다. 물론 에폭에 대한 반복수도 달라질 수 있습니다. 이러한 모델의 구성과 하이퍼 파라메터인 학습률과 반복 에폭수 등은 AI 전문가가 상황에 따라 결정해야 합니다. 결과적으로 위와 같은 모델의 확장과 최적화 방법의 변경 등을 통한 정확도는 약 97%로 출력되는데, 기존의 90%에서 대폭 향상된 것을 알 수 있습니다.

PyTorch를 이용한 간단한 머신러닝

머신러닝을 위한 라이브러리 중 파이토치를 이용한 기계학습을 정리해 봅니다. 학습의 주제는 손글씨로 써진 숫자 인식입니다. 먼저 학습을 위한 데이터가 필요한데요. MNIST 데이터를 사용합니다. MNIST는 아래의 그림처럼 테스트 데이터로 60,000개의 손글씨 이미지와 각 이미지에 해당하는 숫자가 무엇인지를 나타내는 60,000개의 라벨값이 있습니다. 또한 이 학습 데이터를 이용해 학습된 모델을 테스트하기 위한 테스트로 손글씨 이미지와 라벨 데이터가 각각 10,000개씩 존재합니다. 이미지 한장의 크기는 28×28 픽셀입니다.

코드는 PyTorch와 MNIST에서 숫자 이미지를 가져오기 위한 라이브러리를 사용하기 위해 아래처럼 import 문으로 시작합니다.

import torch
import torchvision

데이터를 통한 훈련을 위해 한번에 60,000개씩 훈련해도 되지만, 학습의 효율과 메모리 사용을 줄이기 위해 Mini-Batch 방식을 이용합니다. 여기서는 미니배치의 크기로 1000을 사용합니다. 그리고 MNIST로부터 훈련 데이터와 테스트 테이터를 다운로드하고 다운로드된 데이터로부터 미니배치만큼 데이터를 로딩하기 위해 다음과 같은 코드를 추가합니다.

batch_size = 1000

mnist_train = torchvision.datasets.MNIST(root="MNIST_data/", train=True, transform=torchvision.transforms.ToTensor(), download=True)
mnist_test = torchvision.datasets.MNIST(root="MNIST_data/", train=False, transform=torchvision.transforms.ToTensor(), download=True)

data_loader = torch.utils.data.DataLoader(mnist_train, batch_size=batch_size, shuffle=True, drop_last=True)

머신러닝은 CPU 보다 행렬 연산에 최적화된 GPU를 통해 수행하는 것이 효율적입니다. 즉, Tensor를 GPU에 올려 연산을 수행한다는 의미입니다. 이를 위해 아래의 코드를 추가합니다.

device = torch.device("cuda:0")

이제 손글씨 이미지를 입력하고, 이 이미지가 의미하는 것이 무엇인지를 학습시키기 위한 모델을 정의합니다. 이미지 한장은 28×28 크기이므로 이를 범용적인 입력 데이터로써 받기 위해 각 화소값을 입력값으로 합니다. 즉, 입력값은 28×28인 784개이고, 출력값은 해당 이미지가 어떤 숫자인지에 대한 0~9까지의 확률값이므로 총 10 개입니다. 이를 위한 신경망 모델을 아래처럼 정의합니다. 아래의 코드가 신경망 모델에 대한 구성 코드입니다. 참고로 여기서 사용하는 신경망은 입력층과 출력층으로만 구성되므로 매우 단순합니다. bias=True라는 것은 가중치 외에도 편향값도 사용한다는 의미입니다.

linear = torch.nn.Linear(784, 10, bias=True).to(device)

각 훈련은 손실값만큼 가중치와 편향값을 최적의 값으로 보정하게 됩니다. 이때 손실값으로 Cross Entroy Error를 사용합니다. 이와 함께 출력층에서는 사용하는 활성화함수로는 Softmax 함수를 사용합니다. 파이토치에서는 이를 위해 torch.nn.CrossEntropyLoss를 제공하는데, 이 클래스는 내부적으로 Softmax와 Cross Entroy Error 둘 다 적용해 줍니다. 그리고 훈련에 대한 손실값을 최소화하기 위해 최적의 가중치와 편향값을 찾기 위해 경사하강법을 사용하는데, Hyper-Parameter인 학습율을 0.1로 정했습니다. 이에 대한 코드는 다음과 같습니다.

loss = torch.nn.CrossEntropyLoss().to(device)
SDG = torch.optim.SGD(linear.parameters(), lr=0.1)

아래는 1 Epoch(미니배치로 전체 훈련 데이터 처리 단위)에서 몇번의 미니배치가 반복되는지, 그리고 몇번의 Epoch 만큼 훈련할 것인지에 대한 각각의 변수에 대한 코드입니다.

total_batch = len(data_loader) # 60 = 60000 / 1000 (total / batch_size)
training_epochs = 10

아래는 훈련에 대한 코드입니다.

for epoch in range(training_epochs):
    total_cost = 0

    for X, Y in data_loader:
        X = X.view(-1, 28 * 28).to(device)
        Y = Y.to(device)
        
        hypothesis = linear(X)
        cost = loss(hypothesis, Y)

        SDG.zero_grad()
        cost.backward()
        SDG.step()

        total_cost += cost 

    avg_cost = total_cost / total_batch
    print("Epoch:", "%03d" % (epoch+1), "cost =", "{:.9f}".format(avg_cost))

위의 코드에서 5번은 (1000, 1, 28, 28) 크기의 텐서를 (1000, 784) 크기의 텐서로 변경해 줍니다. 6번 코드는 이미지 데이터에 대한 라벨값인데, One-Hot 인코딩이 아닌 0~9까지의 값으로 이미지에 대한 의미를 나타냅니다. 8번과 9번은 각각 입력 이미지에 대한 추정값을 얻고, 추정값과 라벨값인 참값 사이의 오차값을 계산합니다. 11번~13번은 오차역전파기법을 이용하여 가중치와 편향값을 보정하는 코드입니다. 18번은 1 에폭마다 손실값이 얼마나 나오는지를 확인하게 되는데, 옳바른 학습이라면 이 손실값은 큰 그림에서 봤을때 점차적으로 줄어들어야 합니다. 아래는 이러한 손실값을 에폭의 반복에 대해 표현한 그래프입니다.

마지막으로 아래의 코드는 위의 훈련을 통해 얻어진 가중치값과 편향값을 테스트 데이터에 적용해 얼마만큼의 정확도가 나오는지 확인하는 코드입니다.

with torch.no_grad():
    X_test = mnist_test.data.view(-1, 28 * 28).float().to(device)
    Y_test = mnist_test.targets.to(device)
    prediction = linear(X_test)
    correct_prediction = torch.argmax(prediction, 1) == Y_test
    accuracy = correct_prediction.float().mean()
    print("Accuracy: ", accuracy.item())

실제 이 코드를 통해 학습해 보면 대략 90% 정도의 정확도를 얻을 수 있습니다. 높은 정확도라고 할수는 없지만, 단순이 이미지의 화소값을 특징으로 일렬로 구성한, 즉 이미지라는 2차원적인 개념을 전혀 고려하지 않고 얻은 정확도라는 점에서 상당이 인상적인데요. 하지만 90%라는 정확도를 개선하기 위해 이미지라는 2차원적인 개념까지 고려하고 반영한 CNN을 이용하면 정확도를 99% 이상으로 올릴 수 있게 됩니다. 99%의 정확도는 인간의 평균 정확도를 넘어선 값입니다.

손실함수(Loss Function)

손실함수는 비용함수(Cost Function)라고도 합니다. 손실에는 그만큼의 비용이 발생한다는 개념에서 말입니다. 손실함수가 왜 필요한지부터 파악하기 위해 다음과 같은 데이터가 있다고 합시다.

t = [0, 0, 0, 0.5, 0.5, 0, 0, 0, 0, 0]

총 10개 값으로, 어떤 입력값이 0~9중 어떤 값인지를 나타내는 확률값 입니다. 첫번째 값이 0일때의 확률이고, 두번째가 1일때의 확률입니다. 즉, 위의 값은 3일때와 4일때의 확률이 각각 50%인 셈입니다.

위의 데이터는 항상 옳은 경우의 실제값라고 합시다. 이제.. 아래의 데이터는 계산된, 즉 예측된 데이터입니다.

y = [0.01, 0.01, 0.1, 0.3, 0.33, 0.04, 0.02, 0.05, 0.01, 0.1]

위의 예측 데이터 역시 0~9중 어떤 값일 거라는 계산된 확률값으로써 0일 확률은 1%, 2일 확률은 10%, 3일 확률은 30%라고 예측하고 있습니다. 그럼, 이 예측값와 실제값에 대한 오차는 어떻게 계산할 수 있을까요? 바로 이 오차가 손실함수의 값이 됩니다.

손실함수는 흔히 평균제곱오차(Mean Squared Error, MSE)와 교차 엔트로피 오차(Cross Entropy Error, CEE)가 사용됩니다.

평균 제곱 오차(Mean Squared Error, MSE)

MSE의 공식은 다음과 같습니다.

    $$E=\frac{1}{2}{\displaystyle\sum_{i=1}^{n} {(y_{i}-t_{i})^{2}}}$$

앞서 언급한 실제값 t와 예측값 y에 대한 평균제곱오차의 손실값은 아래의 파이선 코드를 통해 얻을 수 있습니다.

import numpy as np

def MSE(y, t):
    return 0.5 * np.sum((y-t)**2)

t = np.array([0, 0, 0, 0.5, 0.5, 0, 0, 0, 0, 0])
y = np.array([0.01, 0.01, 0.1, 0.3, 0.33, 0.04, 0.02, 0.05, 0.01, 0.1])

print(MSE(t,y))

위의 출력값으로써 손실값은 0.04685 입니다. 그럼 동일한 t에 대해 상대적으로 잘못 예측한 상황의 y값을 아래처럼 얻었다고 가정합시다.

y = [0.3, 0.01, 0.1, 0.01, 0.04, 0.02, 0.05, 0.33, 0.01, 0.1]

위에 대한 손실값은 0.33685 입니다. 즉 손실값이 예상했던 것처럼 상대적으로 큽니다.

교차 엔트로피 오차(Cross Entropy Error, CEE)

CEE의 수식은 다음과 같습니다.

    $$E=-\displaystyle\sum_{i=1}^{n} {t_{i}\log{y_i}}$$

위이 식에서 log는 밑이 e인 자연로그입니다. CEE의 이해를 위해 자연로그에 대한 그래프를 시각화해 보는 코드는 다음과 같습니다.

import numpy as np
import matplotlib.pyplot as plt
 
x = np.arange(0, 1, 0.01)
y = np.log(x)
 
plt.plot(x, y)
plt.show()

그래프는 다음과 같습니다.

가로축은 정답일 확률이고, y 축은 손실값에 -1을 곱한 값입니다. 가로축값이 1, 즉 정답이 확률이 100%일때 손실값은 0이 되고, 정답이 확률이 낮아 질수록 손실값은 무한대로 커지게 됩니다.

이제 앞서 언급한 실제값 t와 예측값 y에 대한 교차 엔트로피 오차, CEE를 얻는 파이선 코드는 다음과 같습니다.

import numpy as np

def CEE(y, t):
    delta = 1e-10
    return -np.sum(t*np.log(y+delta))

t = np.array([0, 0, 0, 0.5, 0.5, 0, 0, 0, 0, 0])
y0 = [0, 0, 0, 0.5, 0.5, 0, 0, 0, 0, 0]
y1 = [0.01, 0.01, 0.1, 0.3, 0.33, 0.04, 0.02, 0.05, 0.01, 0.1]
y2 = np.array([0.3, 0.01, 0.1, 0.01, 0.04, 0.02, 0.05, 0.33, 0.01, 0.1])

print(CEE(t,y0)) # 0.6931471803599453
print(CEE(t,y1)) # 8.265472039806522
print(CEE(t,y2)) # 21.21844021456322

보시는 것처럼 실제값에서 예측값이 멀어질 수록 손실값이 커지는 것을 알 수 있습니다.

활성화 함수(Activation Function)

활성화함수는 입력값이 특정 뉴런에서 처리되어 결과값을 생성할때 적용되는 함수입니다. 활성화 함수로 이 글에서는 3가지를 언급하는데 첫째는 계단함수, 둘째는 시그모이드 함수, 셋째는 ReLU 함수입니다. 각 활성화 함수의 수식과 그래프를 살펴보면 다음과 같습니다.

시그모이드 함수(Sigmoid Function)

    $$h(x)=\frac{1}{1+e^{-x}}$$

위의 식을 그래프로 시각화하기 위한 코드는 아래와 같습니다.

import numpy as np
import matplotlib.pylab as plt

def sigmoid(x):
    return 1 / (1 + np.exp(-x))

x = np.arange(-10.0, 10, 0.1)
y = sigmoid(x)

plt.plot(x, y)
plt.show()

결과 그래프는 아래와 같습니다.

계단함수(Step Function)

    $$h(x)=\begin{cases}     0  & \quad (x \leq 0)\\     1  & \quad (x > 0)   \end{cases}$$

위의 식을 그래프로 시각화하기 위한 코드는 아래와 같습니다.

import numpy as np
import matplotlib.pylab as plt

def step(x):
    return np.array(x > 0, dtype=np.int)

x = np.arange(-10.0, 10, 0.1)
y = step(x)

plt.plot(x, y)
plt.show()

결과 그래프는 아래와 같습니다.

ReLU

    $$h(x)=\begin{cases}     0  & \quad (x \leq 0)\\     x  & \quad (x > 0)   \end{cases}$$

위의 식을 그래프로 시각화하기 위한 코드는 아래와 같습니다.

import numpy as np
import matplotlib.pylab as plt

def ReLU(x):
    return np.maximum(0, x)

x = np.arange(-10.0, 10, 0.1)
y = ReLU(x)

plt.plot(x, y)
plt.show()

결과 그래프는 아래와 같습니다.

Softmax

모델의 마지막 구성인 출력층에서 입력 데이터가 어떤 클래스로 분류되는지에 대한 확률값으로써 사용되는 활성화 함수로 식은 다음과 같습니다.

    $$y_{k}=\frac{\exp(a_{k})}{\displaystyle\sum_{i=1}^{n} {\exp(a_{i})}}$$

결과적으로 각 출력값들의 합은 1로써 각 출력값을 확률로 해석할 수 있습니다.

쌍곡탄젠트(Hyperbolic Tangent)

활성화 함수로 사용되는 신경망은 대표적으로 RNN입니다. RNN은 순환신경망(Recurrent Network Network)입니다. 수식은 아래와 같습니다.

    $$tanh(x)=\frac{e^{x}-e^{-x}}{e^{x}+e^{-x}}$$

그래프로 그려보면 다음과 같습니다.

신경망에서의 활성화함수는 각 뉴런계층의 값을 그 다음 뉴런계층으로 전달할때 비선형성을 부여해주게 됩니다. 즉, 활성화함수는 반드시 비선형홤수여야 하며 손실값의 최소화를 위한 경사하강법을 위해 반드시 미분 가능한 함수여야 합니다.