PyTorch는 우리가 신경망(neural network)를 생성하고 학습시키는 것을 도와주기 위해서 torch.nn, torch.optim, Dataset, DataLoader와 같은 잘 디자인된 모듈과 클래스를 제공합니다. 이들의 성능을 최대한 활용하고 여러분야의 문제에 맞게 커스터마이즈하기 위해서, 정확히 이들이 어떤 작업을 수행해야 하는지 이해할 필요가 있습니다. 우리는 먼저 이들 모델들로 부터 아무 피쳐도 사용하지 않고 MNIST 데이터 셋에 대해 기초적인 신경망을 학습시킬 것입니다. 우리는 처음에 가장 기초적인 PyTorch 텐서(tensor) 기능만을 사용할 것입니다. 그리고 우리는 점차적으로 torch.nn, torch.optim, Dataset, 또는 DataLoader로부터 한번에 하나씩 피쳐를 추가해가면서, 정확히 각 부분이 어떤 일을 하는지 그리고 이것이 어떻게 코드를 더 간결하고 유연하게 만드는지 보여드리겠습니다.
MNIST 데이터 준비
from pathlib import Path
import requests
DATA_PATH = Path("data")
PATH = DATA_PATH / "mnist"
PATH.mkdir(parents=True, exist_ok=True)
URL = "https://github.com/pytorch/tutorials/raw/main/_static/"
FILENAME = "mnist.pkl.gz"
if not (PATH / FILENAME).exists():
content = requests.get(URL + FILENAME).content
(PATH / FILENAME).open("wb").write(content)
일단 경로 설정을 담당하는 (python3 표준 라이브러리의 일부인) pathlib을 활용했고, requests를 이용해서 데이터셋을 다운로드 하였습니다. 위 데이터셋은 NumPy 배열 포멧이고, 데이터 직렬화하기 위한 python 전용 포맷 pickle을 이용하여 저장되어 있습니다.
import pickle
import gzip
with gzip.open((PATH / FILENAME).as_posix(), "rb") as f:
((x_train, y_train), (x_valid, y_valid), _) = pickle.load(f, encoding="latin-1")
각 이미지는 28 x 28 형태 이고, 784 (=28x28) 크기를 가진 하나의 행으로 저장되어 있습니다. 하나를 직접 뽑아서 살펴보겠습니다.
from matplotlib import pyplot
import numpy as np
pyplot.imshow(x_train[0].reshape((28, 28)), cmap="gray")
print(x_train.shape)
이제 PyTorch는 NumPy 배열 보다는 torch.tensor를 사용하므로, 우리는 데이터를 변환해 보도록 하겠습니다.
import torch
x_train, y_train, x_valid, y_valid = map(
torch.tensor, (x_train, y_train, x_valid, y_valid)
)
n, c = x_train.shape
print(x_train, y_train)
print(x_train.shape)
print(y_train.min(), y_train.max())
torch.nn 없이 밑바닥부터 신경망 만들기
PyTorch는 랜덤 또는 0으로만 이루어진 텐서를 생성하는 메소드를 제공하고, 우리는 간단한 선형 모델의 가중치(weights)와 절편(bias)를 생성하기 위해서 이것을 사용합니다. 이들은 일반적인 텐서에 매우 특별한 한가지가 추가된 것입니다, 또한 우리는 PyTorch에게 이들이 기울기(gradient)가 필요하다고 알려줍니다. 이를 통해 PyTorch는 텐서에 행해지는 모든 연산들을 기록하게 되고, 자동적으로 역전파(back-propagation)동안에 기울기를 계산할 수 있게 됩니다.
가중치에 대해서는 requires_grad를 초기화(initialization) 다음에 설정합니다. 왜냐하면 우리는 해당 단계가 기울기에 포함되는 것을 원치 않기 때문입니다. (PyTorch에서 _ 다음에 오는 메소드 이름은 연산이 인플레이스(in-place)로 수행되는 것을 의미합니다.) 그리고 우리는 Xavier initialization 기법을 이용하여 가중치를 초기화 합니다. ($1 / sqrt(n)$을 곱해서 초기화).
import math
weights = torch.randn(784, 10) / math.sqrt(784)
weights.requires_grad_()
bias = torch.zeros(10, requires_grad=True)
print(weights)
print(bias)
print(bias.shape)
PyTorch의 기울기를 자동으로 계산해주는 기능 덕분에, Python 표준 함수 (또는 호출 가능한 객체)를 모델로 사용할 수 있습니다. 그러므로 간단한 선형 모델을 만들기 위해서 단순한 행렬 곱셈과 브로드캐스트(broadcast)덧셈을 사용하여 보겠습니다. 또한, 우리는 활성화 함수(activation function)가 필요하므로, log_softmax를 구현하고 사용할 것ㅇ비니다. PyTorch에서 많은 사전 구현된 손실 함수(loss function), 활성화 함수들이 제공되지만, 일반적인 python을 사용하여 자신만의 함수를 쉽게 작성할 수 있습니다. PyTorch는 심지어 우리를 위해 빠른 GPU 또는 벡터화된 CPU코드를 만들어 줄 것입니다.
def log_softmax(x):
return x - x.exp().sum(-1).log().unsqueeze(-1)
def model(xb):
return log_softmax(xb @ weights + bias)
위에서, $@$기호는 행렬 곱셈(matrix multiplication) 연산을 나타냅니다. 우리는 하나의 배치(batch) 데이터(이 경우에는 64개의 이미지들)에 대하여 함수를 호출할 것입니다. 이 것은 하나의 포워드 전달(forward pass)입니다. 이 단계에서 우리는 무작위(random) 가중치로 시작했기 때문에 우리의 예측이 무작위 예측보다 전혀 나은점이 없을 것으로 기대합니다.
bs = 64 # 배치 크기
xb = x_train[0:bs] # x로부터 미니배치(mini-batch) 추출
preds = model(xb) # 예측
preds[0], preds.shape
print(preds[0], preds.shape)
그리고 보시다 싶이, preds 텐서(tensor)는 텐서 값 외에도, 또한 기울기 함수(gradient function)을 담고 있습니다. 우리는 나중에 이것을 역전파(backpropagation)을 위해 사용할 것입니다. 이제 손실함수(loss function)로 사용하기 위한 음의 로그 우도(negative log-likelihood)를 구현하겠습니다.
def nll(input, target):
return -input[range(target.shape[0]), target].mean()
loss_func = nll
그리고 우리의 무작위 모델에 대한 손실을 점검해보겠습니다. 이를 통해 우리는 나중에 역전파 이후에 개선이 있는지를 확인할 수 있게 됩니다.
✅ 파라미터 최적화 방법의 예시들!
Gradient Descent 기반 파라미터 최적화 (Parameter Optimization)
이는 Loss에 대한 파라미터의 편미분 값을 통해 learning rate를 따라서 파라미터를 최적화 해나가는 가장 기본적인 방법이라고 할 수있습ㅂ니다. 이에 대한 방법은 여러개가 있는데.
1-1) Batch Gradient Descent
이는 모든 DataSet을 하나의 batch로 보고 전체의 미분값을 평균하여 1epoch동안 update를 딱 한번 수행하는 방식입니다. 속도가 느리다는 단점이 있지만, 최적값을 찾을 수 있습니다.
for i in range(nb_epochs): params_grad = evaluate_gradient(loss_function, data, params) params = params - learning_rate * params_grad
1-2) Stochastic Gradient Descent (SGD)
SGD는 각 iteration에서 하나의 example만을 뽑아서 학습시키는 방법입니다. 이는 랜덤하게 n개의 데이터를 뽑아 update시키는 방법인 MGD방식 대신 SGD로 표현해서 사용하기도 합니다. 이는 속도를 훨씬 빠르게 한다는 장점이 있고, 아래와 같은 특징이 있습니다.
- for-loop을 돌기전 데이터 셋을 랜덤하게 Suffle할 필요가 있다.
- SGD방식은 BGD방식에 비해 최적화로 가는 과정에서 Noise가 많이 발생할 수 있다.
for i in range(nb_epochs): np.random.shuffle(data) for iter in range(start_iter, num_iter): params_grad = evaluate_gradient(loss_function, data[0], params) params = params - learning_rate * params_grad
1-3) Mini-Batch Gradient Descent
이는 미니배치 방식의 Gradient Descent 방식은 일정크기 n만큼의 데이터에 대해서 미분값을 평균하여 update하는 방식입니다. SGD에 비해 안정적이며, 속도도 BGD에 비해 빨라 대부분 많이 사용하는 방식입니다.
for i in range(nb_epochs): np.random.shuffle(data) for batch in get_batches(data, batch_size=50): params_grad = evaluate_gradient(loss_function, batch, params) params = params - learning_rate * params_grad
위의 예시에서는 MGD를 사용해서 코딩한 예시입니다. 그리고 나중에도 보겠지만, Momentum, AdaGrad,.. 다양한 최적화 방법이 있습니다. 차근차근 알아보겠습니다!
yb = y_train[0:bs]
print(loss_func(preds, yb))
또한 우리의 무작위 모델의 정확도를 점검해 보겠습니다. 그럼으로써 소닛ㄹ이 개선됨에 따라서 정확도가 개선되는지 확인할 수 있게 됩니다.
def accuracy(out, yb): # 매 예측마다, 가장 큰 값의 인덱스가 목표값(target value)와 동일하면 예측은 올바른 것이 된다.
preds = torch.argmax(out, dim=1)
return (preds == yb).float().mean()
print(accuracy(preds, yb))
그리고 이제 훈련 루프(training loop)를 실행할 수 있게 되었습니다. 이를 간단히 코드로 짜주겠습니다.
from IPython.core.debugger import set_trace
lr = 0.5 # 학습률 (learning rate)
epochs = 2 # 훈련에 사용할 에폭(epoch) 수
n = x_train.shape[0]
for epoch in range(epochs):
for i in range((n - 1) // bs + 1):
# set_trace()
start_i = i * bs
end_i = start_i + bs
xb = x_train[start_i: end_i]
yb = y_train[start_i: end_i]
pred = model(xb)
loss = loss_func(pred, yb)
loss.backward()
with torch.no_grad():
weights -= weights.grad * lr
bias -= bias.grad * lr
weights.grad.zero_()
bias.grad.zero_()
여기서 우리는 forward pass에서 계산한 기울기들을 이용해서 가중치와 절편을 업데이트 해줍니다. 우리는 이것을 torch.no_grad() 컨텍스트 매니저(context manager)내에서 실행합니다. 왜냐하면 이러한 실행이 다음 기울기의 계산에 기록되지 않기를 원하기 때문입니다. 그리고 우리는 기울기를 0으로 설정합니다. 그럼으로써 다음 루프(loop)에 준비할 수 있게 됩니다. 그렇지 않으면, 우리의 기울기들은 일어난 모든 연산의 누적 집계를 기록하게 됩니다. 즉 loss.backward()에서 계산된 값이 아니라, 기존 값에 기울기를 더하게 되는 것이죠
print(loss_func(model(xb), yb), accuracy(model(xb), yb))
우리는 은닉층(hidden layer)를 사용하지 않았기 때문에, 로지스틱 회귀(logistic regression)을 구현한 것이라고 할 수 있겠습니다.
그리고 위에서 우리는 Xavier Intialization을 사용했습니다. 여기서 간단히 가중치 초기화와 관련된 이야기를 조금해보겠습니다.
✅ 가중치 초기화 (Weight Intialization)
https://www.youtube.com/watch?v=LQ8Rm09jgAE
딥러닝에서 가중치를 잘못 설정할 경우 기울기 소실 문제나 표현력의 한계를 갖는 등 여러 문제를 야기하게 됩니다. 또한 딥러닝의 학습 문제가 non-convex이기 때문에 초기값을 잘못 설정할 경우 local-minimum에 수렴할 가능성이 커지게 됩니다.
1) 초기값을 모두 0으로 설정할 경우
실제로 0으로 가중치를 초기화 한다면 모든 뉴런들이 같은 값을 나타낼 것이고, 역전파 과정에서 각 가중치의 update가 동일하게 이루어질 것입니다. 이러한 update는 학습을 진행할 때도 계속 일어나며, 결국 제대로 학습하기 어려울 것입니다. 그리고 상수로 초기화 했을 경우도, 신경망에 대칭성(symmetry)이 생겨서 같은 계층의 모든 뉴런이 똑같이 작동하게 됩니다.
2) 활성화 함수로 sigmoid 사용시 정규 분포 사용 -> 가우시안 분포 초기화
sigmoid 함수는 input의 절댓값이 조금이라도 커지면 미분값이 소실되는 문제가 발생한다는 것을 쉽게 확인할 수 있습니다. 만약 N(0, 0.01) 가우시안 분포를 따르게 가중치를 랜덤하게 초기화 했다고 가정해 봅시다. 이 경우 가중치가 여러 layer를 지날 수록 점점 0에 가깝게 변합니다. 그 이유는 당연히 gradient vanishing 문제 때문에 입력 계층쪽으로 가중치가 잘 전달이 되지 않아서 발생하는 문제고, 이는 출력과 학습을 진행하지 못하고 초기화 했을때랑 비슷한 현상이 일어나는 것이라고 할 수 있습니다. 만약 N(0, 1) 가우시안 분포를 따르게 된다면, 이는 입력 데이터가 layer를 지나며 점점 0과 1에 수렴하는 것을 볼 수 있습니다.
위와같이 가중치가 작거나 큰 것이 모두 문제가 되었습니다. 가중치는 적어도 데이터의 크기를 줄이거나 늘리지 않는 값이여야 합니다. 즉 데이터가 계층을 통과하더라도 데이터의 크기를 유지해주는 가중치로 초기화해야 한다는 것입니다. 이 방법에 우리가 적용한 Xavier 초기화 방법이 있던 것입니다!
3) Xavier 초기화
Xavier initialization은 sigmoid 계열의 activation function을 사용할 때, 가중치를 초기화 하는 방법입니다.
torch.nn.functional 사용하기
이제 우리의 코드를 리팩토링(refactoring)해보겠습니다. 그럼으로써 이전과 동일하지만, PyTorch의 nn클래스의 장점을 활용해서 더 간결하고 유연하게 만들 것입니다. 지금부터 매 단계에서, 우리는 코드를 더 짧고, 이해하기 쉽고, 유연하게 만들어야 합니다.
처음이면서 우리의 코드를 짧게 만들기 가장 쉬운 단계는 직접 작성한 활성화, 손실 함수를 torch.nn.functional 의 함수로 대체하는 것입니다 (관례에 따라, 일반적으로 F 네임스피이스(namespace)를 통해 임포트(import)합니다). 이 모듈에는 torch.nn 라이브러리의 모든 함수가 포함되어 있습니다. (라이브러리의 다른 부분에는 클래스가 포함되어 있습니다.) 다양한 손실 및 활성화 함수 뿐만 아니라 , 폴링(pooling) 함수와 같이 신경망을 만드는데 편리한 몇 가지 함수도 여기에서 찾을 수 있을 겁니다. (컨볼루션(convolution) 연산, 선형(linear) 레이어, 등을 수행하는 함수도 있지만, 앞으로 보겠지만 일반적으로 라이브러리의 다른 부분을 사용하여 더 잘 처리할 수 있습니다.)
만약 우리가 음의 로그 우도 손실과 로그 소프트맥스 (log softmax) 활성화 함수를 사용하는 경우, PyTorch는 이 둘을 결합하는 단일 함수인 F.cross_entropy를 제공합니다. 따라서 모델에서 활성화 함수를 제거할 수도 있게 됩니다.
import torch.nn.functional as F
loss_func = F.cross_entropy
def model(xb):
return xb @ weights + bias
더이상 model 함수에서 log_softmax 를 호출하지 않고 있습니다. 손실과 정확도가 이전과 동일한지 확인해보겠습니다.
nn.Module 을 이용하여 리팩토링 하기
다음으로, 더 명확하고 간결한 훈련 루프를 위해 nn.Module 및 nn.Parameter를 사용합니다. 우리는 nn.Module (자체가 클래스이고 상태를 추적할 수 있는) 하위 클래스(subclass)를 만듭니다. 이 경우에는, 포워드(forward) 단계에 대한 가중치 절편, 그리고 메소드(method) 등을 유지하는 클래스를 만들고자 합니다. nn.Module은 우리가 사용할 몇 가지 속성(attribute)과 메소드를 ( .parameters()와 .zero_grad() 같은) 가지고 있습니다
from torch import nn
class Mnist_logistic(nn.Module):
def __init__(self):
super().__init__()
self.weights = nn.Parameter(torch.randn(784, 10) / math.sqrt(784)) # Xavier initialization
self.bias = nn.Parameter(torch.zeros(10))
def forward(self, xb):
return xb @ self.weights + self.bias
함수를 사용하는 대신에 이제는 오브젝트(object)를 사용하기 때문에, 먼저 모델을 인스턴스화(instantiate) 해야 합니다.
model = Mnist_logistic()
이제 우리는 이전과 동일한 방식으로 손실을 계산할 수 있습니다. 여기서 nn.Module 오브젝트들은 마치 함수처럼 사용됩니다. ( 즉, 이들은 호출가능하다는 소리입니다), 그러나 배후에서 PyTorch는 우리의 forward 메소드를 자동으로 호출합니다.
그리고 이전에는 훈련 루프를 위해 이름 별로 각 매개변수(parameter)의 값을 업데이트하고 다음과 같이 각 매개 변수에 대한 기울기들을 개별적으로 수동으로 0으로 제거해야 했습니다.
with torch.no_grad():
weights -= weights.grad * lr
bias -= bias.grad * lr
weights.grad.zero_()
bias.grad.zero_()
이제 우리는 model.parameters() 및 model.zero_grad() ( 모두 nn.Module 에 대해 PyTorch에 의해 정의됨)를 활용해서 이러한 단계를 더 간결하게 만들고, 특히 더 복잡한 모델에 대해서 일부 매개변수를 잊어 버리는 오류를 덜 발생시킬 수 있게됩니다.
with torch.no_grad():
for p in model.parameters(): p -= p.grad * lr
model.zero_grad()
이제 이것을 나중에 다시 실행할 수 있도록 fit 함수로 작은 훈련 루프를 감쌀 것입니다.
def fit():
for epoch in range(epochs):
for i in range((n - 1) // bs + 1):
start_i = i * bs
end_i = start_i + bs
xb = x_train[start_i:end_i]
yb = y_train[start_i:end_i]
pred = model(xb)
loss = loss_func(pred, yb)
loss.backward()
with torch.no_grad():
for p in model.parameters():
p -= p.grad * lr
model.zero_grad()
fit()
이제 손실이 줄어들었는지 다시 한번 확인해 보겠습니다.
nn.Linear 를 사용하여 리팩토링 하기
self.weights 및 self.bias를 수동으로 정의 및 초기화하고, xb @ self.weights + self.bias를 계산하는 대신에, 위의 모든 것을 해줄 PyTorch클래스인 nn.Linear를 선형 레이어로 사용합니다. PyTorch 에는 다양한 유형의 코드를 단순화 할 수 있는 미리 정의된 레이어가 있고 이는 또한 종종 기존 코드보다 속도를 빠르게 합니다.
class Mnist_Logistic(nn.Module):
def __init__(self):
super().__init__()
self.lin = nn.Linear(784, 10)
def forward(self, xb):
return self.lin(xb)
이도 이전과 같은 방식으로 모델을 인스턴스화하고 손실을 계산합니다.
우리는 여전히 이전과 동일한 fit 메소드를 사용할 수 있습니다.
torch.optim 을 이용하여 리팩토링 하기
PyTorch에는 다양한 최적화(optimization) 알고리즘을 가진 패키지인 torch.optim 도 있습니다. 각 매개변수를 수동으로 업데이트 하는 대신, 옵티마이저(optimizer)의 step 메소드를 사용하여 업데이트를 진행할 수 있습니다.
이렇게 하면 이전에 수동으로 코딩한 최적화 단계를 대체할 수 있습니다.
opt.step()
opt.zero_grad()
대신에 이렇게 말이죠. (optim.zero_grad())는 기울기를 0으로 재설정 해줍니다. 다음 미니 배치에 대한 기울기를 계산하기 전에 호출해야 합니다.
from torch import optim
def get_model():
model = Mnist_logistic()
return model, optim.SGD(model.parameters(), lr=lr)
model, opt = get_model()
print(loss_func(model(xb), yb))
for epoch in range(epochs):
for i in range((n - 1) // bs + 1):
start_i = i * bs
end_i = start_i + bs
xb = x_train[start_i:end_i]
yb = y_train[start_i:end_i]
pred = model(xb)
loss = loss_func(pred, yb)
loss.backward()
opt.step()
opt.zero_grad()
print(loss_func(model(xb), yb))
✅ learning rate,... nn.optim 과 관련된 이야기
2-1) Momentum
모멘텀을 사용하면, 물리학에서의 운동량처럼 오른쪽 방향으로 이동하면 점점 운동량이 누적되서 계속해서 늘어나는 것을 볼 수 있을겁니다. 따라서 모멘텀 방식을 따르면 그냥 생 SGD를 사용했을 때 보다 반복적인 위 아래로의 움직임은 줄어들 것입니다.
param_grad = evaluate_gradient(loss_function, data, params) v = mu * v - learning_rate * param_grad param = v + param
GD방식과는 달리 새로운 파라미터가 생기고 변수도 새로 생겼습니다. 만약 오른쪽 방향으로 가고 있던 중이라면, 오른쪽 방향으로의 미분값은 계속해서 v에 더해져서 더욱 큰 값을 갖게 되어 빠르게 이동할 수 있는 것입니다.
2-2) Nesterov 모멘텀 (Nesterov Momentum)
이는 Convec function에서 위 방법보다 이론적으로 더 뛰어나다.
3 Per-parameter adaptive learning rate 방법들
이때까지의 파라미터 최적화 방법들에서는 모든 파라미터에 대해 똑같은 학습 속도를 적용하였습니다. 그러나 여기에서는 각 파라미터에 대해 adaptive한 학습 속도를 적용합니다
ex:) AdaGrad, RMSProp, Adam
Dataset 을 이용하여 리팩토링 하기
PyTorch 에는 추상 Dataset 클래스가 있습니다. Dataset은 __len__ 함수 (Python의 표준 len 함수에 의해 호출됨) 및 __getitem__ 함수를 가진 어떤 것이라도 될 수 있으며 , 이 함수들은 인덱싱(indexing)하기 위한 방법으로 사용됩니다.
PyTorchdml TensorDataset은 텐서를 감싸는(wrapping) Dataset입니다. 길이와 인덱싱 방식을 정의함으로써 텐서의 첫 번째 차원을 따라 반복, 인덱싱 및 슬라이스(slice)하는 방법도 제공합니다. 이렇게하면 훈련 할 때 동일한 라인에서 독립(independent) 변수와 종속(dependent) 변수에 쉽게 액세스 할 수 있게 됩니다.
from torch.utils.data import TensorDataset
x_train 및 y_train 모두 하나의 Tensordataset 에 합쳐질 수 있습니다, 따라서 반복시키고 슬라이스 하기 편리합니다.
train_ds = TensorDataset(x_train, y_train)
이전에는 x 및 y 값의 미니 배치를 별도로 반복해야 했습니다.
xb = x_train[start_i:end_i]
yb = y_train[start_i:end_i]
이제 이 두 단계를 함께 수행할 수 있습니다.
xb,yb = train_ds[i*bs : i*bs+bs]
from torch.utils.data import TensorDataset
train_ds = TensorDataset(x_train, y_train)
for epoch in range(epochs):
for i in range((n - 1) // bs + 1):
xb, yb = train_ds[i * bs: i * bs + bs]
pred = model(xb)
loss = loss_func(pred, yb)
loss.backward()
opt.step()
opt.zero_grad()
print(loss_func(model(xb), yb))
DataLoader를 사용하여 리팩토링하기
PyTorch의 DataLoader는 배치 관리를 담당합니다. 모든 Dataset으로부터 DataLoader를 생성할 수 있습니다. DataLoader 는 배치들에 대해서 반복하기 쉽게 만들어줍니다. train_ds[i*bs: i*bs+bs] 를 사용하는 대신, DataLoader는 매 미니배치를 자동적으로 제공합니다.
from torch.utils.data import DataLoader
train_ds = TensorDataset(x_train, y_train)
train_dl = DataLoader(train_ds, batch_size=bs)
for epoch in range(epochs):
for xb, yb in train_dl:
pred = model(xb)
loss = loss_func(pred, yb)
loss.backward()
opt.step()
opt.zero_grad()
print(loss_func(model(xb), yb))
PyTorch의 nn.Module, nn.Parameter, Dataset 및 DataLoader 덕분에 이제 훈련 루프가 훨씬 더 작아지고 이해하기 쉬워졌ㅅ브니다. 이제 실제로 효과적인 모델을 만드는 데 필요한 기본 기능을 추가해보겠습니다.
검증(validation) 추가하기
섹션 1에서, 우리는 훈련 데이터에 사용하기 위해 합리적인 훈련 루프를 설정하려고 했습니다. 우리는 과적합(overfitting)을 확인하기 위해서 항상 검증 데이터셋(validation set)이 있어야 합니다.
훈련 데이터를 섞는(shuffling) 것은 배치와 과적합 사이의 상관관계를 방지하기 위해 중요합니다. 반면에, 검증 손실(validation loss)은 검증 데이터셋을 섞든 안섞든 동일합니다. 데이터를 섞는 것은 추가 시간이 걸리므로, 검증 데이터를 섞는 것은 의미가 없습니다.
검증 데이터셋에 대한 배치 크기는 학습 데이터셋 배치 크기의 2배를 사용할 것입니다. 이는 검증 데이터셋에 대해서는 역전파(backpropagation)가 필요하지 않으므로 메모리를 덜 사용하기 때문입니다. (기울기를 저장할 필요가 없음). 더 큰 배치 크기를 사용하여 손실을 더 빨리 계산하기 위해 아래와 같이 합니다.
train_ds = TensorDataset(x_train, y_train)
train_dl = DataLoader(train_ds, batch_size=bs, shuffle=True)
valid_ds = TensorDataset(x_valid, y_valid)
valid_dl = DataLoader(valid_ds, batch_size=bs * 2)
각 에폭이 끝날 때 검증 손실을 계산하고 프린트 하겠습니다.
(훈련 전에 항상 model.train() 을 호출하고, 추론(inference) 전에 model.eval() 을 호출합니다, 이는 nn.BatchNorm2d 및 nn.Dropout 과 같은 레이어에서 이러한 다른 단계(훈련, 추론) 에 대한 적절한 동작이 일어나게 하기 위함입니다.)
- 훈련 단계 (Training Phase)
- 모델의 파라미터를 업데이트 하는 과정
- 추론 단계 (Inference Phase 또는 Evaluation Phase)
- 이 단계에서는 학습된 모델을 사용하여 새로운 데이터의 라벨을 예측합니다. 이 과정은 학습된 모델의 성능을 평가하거나 실제 환경에서 모델을 사용하기 위해 실행됩니다. 이 단계에서는 오차 역전파나 경사하강법이 사용되지 않습니다.
model, opt = get_model()
for epoch in range(epochs):
model.train()
for xb, yb in train_dl:
pred = model(xb)
loss = loss_func(pred, yb)
loss.backward()
opt.step()
opt.zero_grad()
model.eval()
with torch.no_grad():
valid_loss = sum(loss_func(model(xb), yb) for xb, yb in valid_dl)
print(epoch, valid_loss / len(valid_dl))
여기서 model.train(), model.eval()은 각각 PyTorch nn.Module이 모델이 훈련 모드, 추론 모드로 동작하게 합니다. 위에서도 말했지만 model.eval()을 호출하면 이러한 모듈들이 추론 단걔에 맞는 동작을 수행하게 합니다. 예를 들어 dropout 모듈은 노드를 무작위로 제거하는 동작을 중지하고, 배치 정규화는 훈련 단계에서 학습된 파라미터를 사용(평균)하여 입력을 정규화합니다.
fit() 와 get_data() 생성하기
이제 우리는 우리만의 작은 리팩토링을 수행할 것입니다. 훈련 데이터셋과 검증 데이터셋 모두에 대한 손실을 계산하는 유사한 프로세스를 두 번 거치므로, 이를 하나의 배치에 대한 손실을 계산하는 자체를 함수 loss_batch로 만들어보겠습니다.
훈련 데이터셋에 대한 옵티마이저를 전달하고 이를 사용하여 역전파를 진행합니다. 검증 데이터셋의 경우 옵티마이저를 전달하지 않으므로 메소드가 역전파를 수행하지 않습니다.
def loss_batch(model, loss_func, xb, yb, opt=None):
loss = loss_func(model(xb), yb)
if opt is not None:
loss.backward()
opt.step()
opt.zero_grad()
return loss.item(), len(xb)
# fit 은 모델을 훈련하고 각 에폭에 대한 훈련 및 검증 손실을 계산하는 작업을 수행합니다.
def fit(epochs, model, loss_func, opt, train_dl, valid_dl):
for epoch in range(epochs):
model.train()
for xb, yb in train_dl:
loss_batch(model, loss_func, xb, yb, opt)
model.eval()
with torch.no_grad():
losses, nums = zip(
*[loss_batch(model, loss_func, xb, yb) for xb, yb in valid_dl],
)
val_loss = np.sum(np.multiply(losses, nums)) / np.sum(nums)
print(epoch, val_loss)
fit 은 모델을 훈련하고 각 에폭에 대한 훈련 및 검증 손실을 계산하는 작업을 진해합니다.
# get_data는 학습 및 검증 데이터셋에 대한 dataloader를 출력합니다.
def get_data(train_ds, valid_ds, bs):
return (
DataLoader(train_ds, batch_size=bs, shuffle=True),
DataLoader(valid_ds, batch_size=bs * 2),
)
get_data 는 학습 및 검증 데이터셋에 대한 dataloader를 출력합니다.
train_dl, valid_dl = get_data(train_ds, valid_ds, bs)
model, opt = get_model()
fit(epochs, model, loss_func, opt, train_dl, valid_dl)
이러한 기본 3줄의 코드를 사용하여 다양한 모델을 훈련할 수 있습니다. 컨볼루션 신경망(CNN)을 훈련하는 데 사용할 수 있을지 살펴보겠습니다!
CNN 으로 넘어가기
이제 3개의 컨볼루션 레이어로 신경망을 구축할 것입니다. 이전 섹션의 어떤 함수도 모델의 형식에 대해 가정하지 않았기 때문에, 별도의 수정없이 CNN을 학습하는 데 사용할 수 있습니다.
PyTorch의 사전정의된 Conv2d 클래스를 컨볼루션 레이어로 사용합니다. 3개의 컨볼루션 레이어로 CNN을 정의합니다. 각 컨볼루션 뒤에는 ReLU가 있습니다. 마지막으로 평균 폴링(average pooling)을 수행합니다. (view 는 PyTorch의 NumPy reshape 버전이라고 할 수 있겠습니다)
class Mnist_CNN(nn.Module):
def __init__(self):
super().__init__()
self.conv1 = nn.Conv2d(1, 16, kernel_size=3, stride=2, padding=1)
self.conv2 = nn.Conv2d(16, 16, kernel_size=3, stride=2, padding=1)
self.conv3 = nn.Conv2d(16, 10, kernel_size=3, stride=2, padding=1)
def forward(self, xb):
xb = xb.view(-1, 1, 28, 28)
xb = F.relu(self.conv1(xb))
xb = F.relu(self.conv2(xb))
xb = F.relu(self.conv3(xb))
xb = F.avg_pool2d(xb, 4)
return xb.view(-1, xb.size(1))
lr = 0.1
모멘텀(Momentum)은 이전 업데이트도 고려하고 일반적으로 더 빠른 훈련으로 이어지는 확률적 경사하강법(stochastic gradient descent) 의 변형입니다.
model = Mnist_CNN()
opt = optim.SGD(model.parameters(), lr=lr, momentum=0.9)
fit(epochs, model, loss_func, opt, train_dl, valid_dl)
✅ PyTorch Conv2d 에 대하여
1) torch.nn.Conv2d
torch.nn.Conv2d( in_channels, out_channels, kernel_size, stride=1, padding=0, dilation=1, groups=1, bias=True, padding_mode='zeros' )
여기서 사용되어야 하는 파라미터는 위와 같습니다. 여기서 입력해야 하는 파라미터는 in_channels, out_channels, kernel_size입니다. 먼저 위 함수는 input 값에 2d convolution 연산을 적용하는 함수입니다. input size는 $(N, C_{in}, H, W)$ 이고 output은 $(N, C_{out}, H_{out}, W_{out})$ 입니다. 여기서 $N$은 batch size, $C$는 채널의 수, $H, W$은 각각 height와 width를 나타냅니다.
여기서 dilation은 커널 가중치 사이에 공간을 두어서 적용하게 됩니다. 이를 통해 커널이 보이는 입력의 영역(receptive field)를 넓게 가져갈 수 있게 됩니다. 이는 주로 dilated convolution, atrous convolution에 사용됩니다. 그리고 groups는 입력 채널을 여러 그룹으로 나누고, 그 그룹에 독립적인 컨볼루션을 적용하는 데 사용됩니다. groups=in_channels로 설정하면 각 입력 채널이 자체 컨볼루션을 갖게 되어 depthwise convolution이 되는 것이죠!
nn.Sequential 사용하기
torch.nn 에는 코드를 간단히 사용할 수 있는 또 다른 편리한 클래스인 Sequential 이 있습니다. Sequential 객체는 그 안에 포함된 각 모듈을 순차적으로 실행합니다. 이것은 우리의 신경망을 작성하는 더 간단한 방법 중 하나입니다.
이를 활용하려면 주어진 함수에서 사용자정의 레이어(custom layer)를 쉽게 정의할 수 있어야 합니다. 예를 들어, PyTorch에는 view 레이어가 없으므로 우리의 신경망 용으로 만들어야 합니다. Lambda는 Sequential로 신경망을 정의할 때 사용할 수 있는 레이어를 생성할 것입니다.
신경망에서 "view"는 일반적으로 데이터의 차원을 바꾸거나 모양(shape)를 재구성하는데 사용되는 개념입니다. PyTorch에서는 텐서의 `.view()` 메소드를 이용해 텐서의 차원을 변경할 수 있습니다. 예를 들어 신경망을 통해 이미지를 처리할 때 종종 이러한 "view" 작업이 필요한데, 일반적으로는 이미지의 w x h x channel 이렇게 3차원 텐서로 표현되지만, 이를 완전 연결 계층(fully connected layer)에 전달하기 위해서는 1차원 텐서로 바꿔야 합니다.
import torch # 4x4의 2D 텐서 생성 x = torch.randn(4, 4) print(x.size()) # torch.Size([4, 4]) # .view() 메소드를 이용해 1D 텐서로 변경 x = x.view(-1) # -1은 나머지 차원을 알아서 조정하라는 의미 print(x.size()) # torch.Size([16])
class Lambda(nn.Module):
def __init__(self, func):
super().__init__()
self.func = func
def forward(self, x):
return self.func(x)
def preprocess(x):
return x.view(-1, 1, 28, 28)
Sequential 로 생성된 모듈은 간단하게 아래와 같습니다.
model = nn.Sequential(
Lambda(preprocess),
nn.Conv2d(1, 16, kernel_size=3, stride=2, padding=1),
nn.ReLU(),
nn.Conv2d(16, 16, kernel_size=3, stride=2, padding=1),
nn.ReLU(),
nn.Conv2d(16, 10, kernel_size=3, stride=2, padding=1),
nn.ReLU(),
nn.AvgPool2d(4),
Lambda(lambda x: x.view(x.size(0), -1)),
)
opt = optim.SGD(model.parameters(), lr=lr, momentum=0.9)
fit(epochs, model, loss_func, opt, train_dl, valid_dl)
DataLoader 감싸기
우리의 CNN은 상당히 간결하지만, MNIST에서만 작동합니다. 왜냐하면
- 입력이 28 * 28의 긴 벡터라고 가정합니다.
- 최종적으로 CNN 그리드 크기는 4 * 4 라고 가정합니다. (이것은 우리가 사용한 평균 폴링 커널 크기 때문입니다.)
이 두 가지 가정을 제거하여 모델이 만든 2d 단일 채널(channel) 이미지에서 작동하도록 하겠습니다. 먼저 초기 Lambda 레이어를 제거하고 데이터 전처리를 제네레이터(generator)로 이동시킬 수 있습니다. -> 그래서 초기에 차원을 바꿔주는 작업을 좀더 일반화 시킬 수 있다는 점이다.
def preprocess(x, y):
return x.view(-1, 1, 28, 28), y
class WrappedDataLoader:
def __init__(self, dl, func):
self.dl = dl
self.func = func
def __len__(self):
return len(self.dl)
def __iter__(self):
batches = iter(self.dl)
for b in batches:
yield (self.func(*b))
train_dl, valid_dl = get_data(train_ds, valid_ds, bs)
train_dl = WrappedDataLoader(train_dl, preprocess)
valid_dl = WrappedDataLoader(valid_dl, preprocess)
다음으로 nn.AvgPool2d 를 nn.AdaptiveAvgPool2d 로 대체하여 우리가 가진 입력 텐서가 아니라 원하는 출력 텐서의 크기를 정의할 수 있습니다. 결과적으로 우리 모델은 모든 크기의 입력과 함께 작동합니다.
model = nn.Sequential(
nn.Conv2d(1, 16, kernel_size=3, stride=2, padding=1),
nn.ReLU(),
nn.Conv2d(16, 16, kernel_size=3, stride=2, padding=1),
nn.ReLU(),
nn.Conv2d(16, 10, kernel_size=3, stride=2, padding=1),
nn.ReLU(),
nn.AdaptiveAvgPool2d(1),
Lambda(lambda x: x.view(x.size(0), -1)),
)
opt = optim.SGD(model.parameters(), lr=lr, momentum=0.9)
실행해 보겠습니다.
fit(epochs, model, loss_func, opt, train_dl, valid_dl)
GPU 사용하기
만약 우리가 운이 좋아서 CUDA 지원 GPU을 사용할 수 있다면, 코드 실행의 속도를 높일 수 있습니다. 먼저 GPU가 PyTorch에서 작동하는지 확인해야 합니다. ( 저는 런타임 설정을 CPU로 설정해놨음 ) 그래서 False가 나올 것임
dev = torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu")
그리고 위와같이 디바이스 오브젝트를 조건변수에 따라 다르게 생성했습니다. 이제 GPU로 배치를 옮기도록 preprocess 를 업데이트 해주겠습니다.
def preprocess(x, y):
return x.view(-1, 1, 28, 28).to(dev), y.to(dev)
train_dl, valid_dl = get_data(train_ds, valid_ds, bs)
train_dl = WrappedDataLoader(train_dl, preprocess)
valid_dl = WrappedDataLoader(valid_dl, preprocess)
마지막으로 모델을 GPU로 이동시킬 수 있습니다.
model.to(dev)
opt = optim.SGD(model.parameters(), lr=lr, momentum=0.9)
fit(epochs, model, loss_func, opt, train_dl, valid_dl)
이제 더 빨리 실행 될겁니다.