Link

실습: SGD 적용하기

데이터 준비

import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt

from sklearn.preprocessing import StandardScaler
from sklearn.datasets import fetch_california_housing

앞서와 같이 필요한 라이브러리를 불러오고 데이터셋을 로딩합니다. 이번 실습에서 사용할 데이터셋은 캘리포니아 하우징 데이터셋입니다. 집에 대한 정보들이 주어졌을 때, 평균 집 값을 나타내는 Target 컬럼을 맞춰야 합니다.

california = fetch_california_housing()

df = pd.DataFrame(california.data, columns=california.feature_names)
df["Target"] = california.target
df.tail()

약 20600개의 샘플로 이루어진 데이터셋이며, 다음 그림과 같이 Target 컬럼을 포함하여 9개의 컬럼으로 이루어져있습니다. 앞서와 차이점을 발견하셨나요? 이제까지 다룬 데이터셋은 대부분 샘플의 숫자가 그렇게 많지 않았을 것입니다. 이전까지는 SGD의 개념을 배우기 전이었기 때문에, 미니배치를 구성할 때 전체 데이터셋의 샘플들을 한꺼번에 집어넣어 학습을 진행하였습니다. 즉, 이전 방식에서는 배치사이즈가 20640인 상태에서 SGD를 수행해야 한다고 보면 됩니다. 따라서 지금과 같은 데이터셋의 크기라면 이전 방식대로 진행했을 때 메모리가 모자랄 가능성이 높습니다.

데이터의 분포를 파악하기 위해서 1000개만 임의 추출하여 페어플랏pair plot을 그려보도록 합니다.

sns.pairplot(df.sample(1000))
plt.show()

일부 봉우리가 여러개인 멀티모달multimodal 분포들도 보이지만 일괄적으로 표준 스케일링standard scaling을 적용하도록 하겠습니다. 그림에는 잘 보이지 않겠지만 각 컬럼의 데이터들은 평균이 0이고 표준편차가 1인 분포의 형태로 바뀌었을 것입니다. 다음 코드에서 정답 컬럼을 제외하고 스케일링을 적용하는 것도 확인바랍니다.

scaler = StandardScaler()
scaler.fit(df.values[:, :-1])
df.values[:, :-1] = scaler.transform(df.values[:, :-1])

sns.pairplot(df.sample(1000))
plt.show()

학습 코드 구현

데이터가 준비되었으니 모델의 학습을 진행하는 코드를 짜보도록 하겠습니다. 필요한 파이토치 모듈들을 불러옵니다.

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

그리고 앞서 정제된 데이터를 파이토치 텐서로 변환하고, 그 크기를 확인합니다.

data = torch.from_numpy(df.values).float()

print(data.shape)
torch.Size([20640, 9])

이제 입력 데이터와 출력 데이터를 분리하여 각각 x와 y에 저장합니다.

x = data[:, :-1]
y = data[:, -1:]

print(x.shape, y.shape)
torch.Size([20640, 8]) torch.Size([20640, 1])

학습에 필요한 셋팅 값들을 지정합니다. 모델은 전체 데이터셋의 모든 샘플을 최대 4천번 학습할 것입니다. 배치사이즈는 256으로 지정하고, 학습률은 0.01로 학습합니다.

n_epochs = 4000
batch_size = 256
print_interval = 200
learning_rate = 1e-2

이전에 배웠던 것처럼 nn.Sequential 클래스를 활용하여 심층신경망을 구성합니다. nn.Sequential을 선언할 때, 선형 계층 nn.Linear와 활성함수 nn.LeakyReLU를 선언하여 넣어줍니다. 주의할 점은 첫 번째 선형 계층과 마지막 선형 계층은 실제 데이터셋 텐서 x의 크기(8)와 y의 크기(1)를 입출력 크기로 갖도록 정해주었다는 것입니다. 또한 내부의 선형 계층들은 서로 입출력 크기가 호환되도록 되어 있다는 점에도 주목하기 바랍니다.

model = nn.Sequential(
    nn.Linear(x.size(-1), 6),
    nn.LeakyReLU(),
    nn.Linear(6, 5),
    nn.LeakyReLU(),
    nn.Linear(5, 4),
    nn.LeakyReLU(),
    nn.Linear(4, 3),
    nn.LeakyReLU(),
    nn.Linear(3, y.size(-1)),
)

print(model)

모델을 프린트하면 앞서 선언한 것과 같이 선형 계층과 활성함수가 잘 들어있는 것을 확인할 수 있습니다.

Sequential(
  (0): Linear(in_features=8, out_features=6, bias=True)
  (1): LeakyReLU(negative_slope=0.01)
  (2): Linear(in_features=6, out_features=5, bias=True)
  (3): LeakyReLU(negative_slope=0.01)
  (4): Linear(in_features=5, out_features=4, bias=True)
  (5): LeakyReLU(negative_slope=0.01)
  (6): Linear(in_features=4, out_features=3, bias=True)
  (7): LeakyReLU(negative_slope=0.01)
  (8): Linear(in_features=3, out_features=1, bias=True)
)

앞서 생성한 모델 객체의 파라미터를 학습시킬 옵티마이저optimizer를 생성합니다. 옵티마이저의 클래스가 SGD인 이유를 이제 독자들도 이해할 수 있겠지요.

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

이제 학습에 필요한 객체들이 준비되었습니다. 본격적으로 반복문 루프를 돌며 학습을 진행해보도록 하겠습니다. 기존에는 가장 바깥에 for 반복문 한 개만 있던 것을 기억할 것입니다. 잘 기억이 나지 않는다면 이전 챕터의 실습 코드와 함께 비교해보는 것도 좋습니다. 이번 실습 코드에는 for 반복문이 한 개 더 추가되었습니다. 바깥 쪽 for 반복문은 정해진 최대 에포크 수 만큼 반복을 수행하여, 모델이 데이터셋을 n_epochs 만큼 반복해서 학습할 수 있도록 합니다. 안 쪽 for 반복문은 미니배치에 대해서 피드포워딩feed-forwarding과 역전파back-propagation, 그리고 경사하강gradient descent을 수행합니다.

따라서 안 쪽 for 반복문 앞을 보면 매 에포크마다 데이터셋을 랜덤하게 섞어(셔플링shuffling)주고 미니배치로 나누는 것을 볼 수 있습니다. 이때 중요한 점은 입력 텐서 x와 출력 텐서 y를 각각 따로 셔플링을 수행하는 것이 아니라, 함께 동일하게 섞어주어야 한다는 것입니다. 만약 따로 섞어주게 된다면 x와 y의 관계가 끊어지게되어 아무 의미없는 노이즈로 가득찬 데이터가 될 것입니다.

이 과정을 좀 더 자세히 살펴보면 randperm 함수를 통해서 새롭게 섞어줄 데이터셋의 인덱스 순서를 정합니다. 그리고 index_select 함수를 통해서 이 임의의 순서로 섞인 인덱스 순서대로 데이터셋을 섞어줍니다. 마지막으로 split 함수를 활용하여 원하는 배치사이즈로 텐서를 나누어주면 미니배치를 만드는 작업이 끝납니다.

안 쪽 for 반복문은 전체 데이터셋 대신에 미니배치 데이터를 모델에 학습시킨다는 점이 다를 뿐, 앞서 챕터들에서 보았던 코드들과 동일합니다. 하나 추가된 점은 y_hat이라는 빈 리스트를 만들어, 미니배치마다 y_hat_i 변수에 피드포워딩 결과가 나오면 y_hat에 차례대로 저장합니다. 그리고 마지막 에포크가 끝나면 이 y_hat 리스트를 파이토치 cat 함수를 활용하여 이어붙여 하나의 텐서로 만든 후, 실제 정답과 비교합니다.

# |x| = (total_size, input_dim)
# |y| = (total_size, output_dim)

for i in range(n_epochs):
    # Shuffle the index to feed-forward.
    indices = torch.randperm(x.size(0))
    x_ = torch.index_select(x, dim=0, index=indices)
    y_ = torch.index_select(y, dim=0, index=indices)
    
    x_ = x_.split(batch_size, dim=0)
    y_ = y_.split(batch_size, dim=0)
    # |x_[i]| = (batch_size, input_dim)
    # |y_[i]| = (batch_size, output_dim)
    
    y_hat = []
    total_loss = 0
    
    for x_i, y_i in zip(x_, y_):
        # |x_i| = |x_[i]|
        # |y_i| = |y_[i]|
        y_hat_i = model(x_i)
        loss = F.mse_loss(y_hat_i, y_i)

        optimizer.zero_grad()
        loss.backward()

        optimizer.step()
        
        total_loss += float(loss) # This is very important to prevent memory leak.
        y_hat += [y_hat_i]

    total_loss = total_loss / len(x_)
    if (i + 1) % print_interval == 0:
        print('Epoch %d: loss=%.4e' % (i + 1, total_loss))
    
y_hat = torch.cat(y_hat, dim=0)
y = torch.cat(y_, dim=0)
# |y_hat| = (total_size, output_dim)
# |y| = (total_size, output_dim)

앞의 코드에서 loss 변수에 담긴 손실 값 텐서를 float 타입캐스팅type casting을 통해 단순 float 타입으로 변환하여 train_loss 변수에 더하는 것을 볼 수 있습니다. 이 부분도 매우 중요한 부분이기에 코드 상에 주석을 붙여 놓았고, 한번 더 짚고 넘어가고자 합니다. 타입캐스팅 이전의 loss 변수는 파이토치 텐서 타입으로 그래디언트를 가지고 있고, 파이토치의 AutoGrad 동작 원리에 의해서 loss 변수가 계산될 때까지 활용된 파이토치 텐서 변수들이 loss 변수에 줄줄이 엮여 있습니다. 따라서 만약 float 타입캐스팅이 없다면 total_loss도 파이토치 텐서가 될 것이고, 이 total_loss 변수는 해당 에포크의 모든 loss 변수를 엮고 있을 것입니다. 결과적으로 total_loss가 메모리에서 없어지지 않는다면 loss 변수와 그에 엮인 텐서 변수들 모두 아직 참조 중인 상태이므로 파이썬 가비지컬렉터garbage collector에 의해서 메모리에서 해제되지 않습니다. 즉, 메모리 누수memory leak가 발생하게 됩니다. 더욱이 추후 실습에서처럼 손실 곡선을 그려보기 위해서 total_loss 변수를 따로 또 저장하기라도 한다면 학습이 끝날때까지 학습에 사용된 대부분의 파이토치 텐서 변수들이 메모리에서 해제되지 않는 최악의 상황이 발생할 수도 있습니다. 그러므로 앞서와 같은 상황에서는 float 타입캐스팅 또는 detach 함수를 통해 AutoGrad를 위해 연결된 그래프를 잘라내는 작업이 필요합니다.

학습을 진행하였을 때, 출력되는 손실 값은 다음과 같습니다. 0.3이라는 생각보다 큰 손실값을 보여주고 있습니다만, 독자분들이 학습률 등의 다른 설정 값 튜닝을 통해서 좀 더 낮춰볼 수 있을 겁니다.

Epoch 200: loss=3.3620e-01
Epoch 400: loss=3.1224e-01
Epoch 600: loss=3.1349e-01
Epoch 800: loss=3.1115e-01
Epoch 1000: loss=3.1101e-01
Epoch 1200: loss=3.0942e-01
Epoch 1400: loss=3.0967e-01
Epoch 1600: loss=3.0942e-01
Epoch 1800: loss=3.0878e-01
Epoch 2000: loss=3.0931e-01
Epoch 2200: loss=3.0866e-01
Epoch 2400: loss=3.0938e-01
Epoch 2600: loss=3.0830e-01
Epoch 2800: loss=3.0768e-01
Epoch 3000: loss=3.0786e-01
Epoch 3200: loss=3.0829e-01
Epoch 3400: loss=3.0780e-01
Epoch 3600: loss=3.0782e-01
Epoch 3800: loss=3.0736e-01
Epoch 4000: loss=3.0732e-01

결과 확인

마찬가지로 결과를 페어플랏을 통해 확인해보면, 조금 넓게 퍼져있긴하지만, 대체로 중앙을 통과하는 대각선 주변으로 점들이 분포하고 있는 것을 볼 수 있을 것입니다.

df = pd.DataFrame(torch.cat([y, y_hat], dim=1).detach().numpy(),
                  columns=["y", "y_hat"])

sns.pairplot(df, height=5)
plt.show()