1. 데이터에 대한 이해(Data Definition)
이번 챕터에서 선형 회귀를 위해 사용할 예제는 공부한 시간과 점수에 대한 상관관계입니다.
1. 훈련 데이터셋과 테스트 데이터셋
어떤 학생이 1시간 공부를 했더니 2점, 다른 학생이 2시간 공부를 했더니 4점, 또 다른 학생이 3시간을 공부했더니 6점을 맞았습니다. 그렇다면, 내가 4시간을 공부한다면 몇 점을 맞을 수 있을까요?
이 질문에 대답하기 위해서 1시간, 2시간, 3시간을 공부했을 때 각각 2점, 4점, 6점이 나왔다는 앞서 나온 정보를 이용해야 합니다. 이때 예측을 위해 사용하는 데이터를 훈련 데이터셋(training dataset)이라고 합니다. 학습이 끝난 후, 이 모델이 얼마나 잘 작동하는지 판별하는 데이터셋을 테스트 데이터셋(test dataset)이라고 합니다.
2. 훈련 데이터셋의 구성
앞서 텐서에 대해서 배웠는데, 모델을 학습시키기 위한 데이터는 파이토치의 텐서의 형태(torch.tensor)를 가지고 있어야 합니다. 그리고 입력과 출력을 각기 다른 텐서에 저장할 필요가 있습니다. 이때 보편적으로 입력은 x, 출력은 y를 사용하여 표기합니다.
여기서 x_train은 공부한 시간, y_train은 그에 맵핑되는 점수를 의미합니다.
x_train = torch.FloatTensor([[1], [2], [3]])
y_train = torch.FloatTensor([[2], [4], [6]])
이제 모델의 가설을 세워보겠습니다.
2. 가설(Hypothesis) 수립
머신 러닝에서 식을 세울때 이 식을 가설(Hypothesis)라고 합니다. 보통 머신 러닝에서 가설은 임의로 추측해서 세워보는 식일수도 있고, 경험적으로 알고 있는 식일 수도 있습니다. 그리고 맞는 가설이 아니라고 판단되면 계속 수정해나가게 되는 식이기도 합니다.
선형 회귀의 가설은 이미 널리 알려져있으므로 고민할 필요가 없습니다. 선형 회귀란 학습 데이터와 가장 잘 맞는 하나의 직선을 찾는 일입니다. 이때 선형 회귀의 가설(직선의 방정식)은 아래와 같은 형식을 가집니다.
- 직선의 방정식 링크 : https://mathbang.net/443
3. 비용 함수(Cost function)에 대한 이해
앞으로 딥 러닝을 학습하면서 인터넷에서 이런 용어들을 본다면, 전부 같은 용어로 생각하면 되겠습니다.
비용 함수(cost function) = 손실 함수(loss function) = 오차 함수(error function) = 목적 함수(objective function)
특히 비용 함수와 손실 함수란 용어는 기억해두는 것이 좋습니다.
비용 함수에 대해서 이해하기 위해서 여기서만 잠깐 새로운 예제를 사용해보겠습니다.
어떤 4개의 훈련 데이터가 있고, 이를 2차원 그래프에 4개의 점으로 표현한 상태라고 하겠습니다.
지금 목표는 4개의 점을 가장 잘 표현하는 직선을 그리는 일입니다. 임의로 3개의 직선을 그려보겠습니다.
위의 그림은 서로 다른 W와 b의 값에 따라서 천차만별로 그려진 3개의 직선의 모습을 보여줍니다. 이 3개의 직선 중에서 4개의 점을 가장 잘 반영한 직선은 어떤 직선인가요? 검은색 직선이라고 말하는 사람도 있을 것이고, 잘 모르겠다고 말하는 사람도 있을 것입니다. 검은색 직선이라고 말하는 사람은 검은색 직선이 가장 4개의 점에 가깝게 지나가는 느낌을 받고 있기 때문입니다.
하지만 수학에서 느낌이라는 표현을 사용하는 것은 아무런 의미도 없습니다. 어떤 직선이 가장 적절한 직선인지를 수학적인 근거를 대서 표현할 수 있어야 합니다. 그래서 오차(error)라는 개념을 도입하겠습니다.
위 그림은 임의로 그려진 주황색 선에 대해서 각 실제값(4개의 점)과 직선의 예측값(동일한 �값에서의 직선의 �값)에 대한 값의 차이를 빨간색 화살표 ↕로 표현한 것입니다. 각 실제값과 각 예측값과의 차이고, 이를 각 실제값에서의 오차라고 말할 수 있습니다. 이 직선의 예측값들과 실제값들과의 총 오차(total error)는 어떻게 구할까요? 직관적으로 생각하기에 모든 오차를 다 더하면 될 것 같습니다. 각 오차를 전부 더해봅시다.
단순히 오차 = 실제값 - 예측값으로 계산한 경우 오차값이 음수가 나오는 경우가 생깁니다. 그래서 이를 모두 더한 경우에 양수와 음수가 골고루 섞여서 정확안 오차의 크기를 측정할 수 없습니다. 그래서 오차를 그냥 전부 더하는 것이 아니라, 각 오차들을 제곱해준 뒤에 전부 더하는 방식을 사용합니다.
4. 옵티마이저 - 경사 하강법(Gradient Descent)
이제 앞서 정의한 비용 함수(Cost Function)의 값을 최소로 하는 W와 b를 찾는 방법에 대해서 배울 차례입니다. 이때 사용되는 것이 옵티마이저(Optimizer) 알고리즘입니다. 최적화 알고리즘이라고도 부릅니다. 그리고 이 옵티마이저 알고리즘을 통해 적절한 W와 b를 찾아내는 과정을 머신 러닝에서 학습(training)이라고 부릅니다. 여기서는 가장 기본적인 옵티마이저 알고리즘인 경사 하강법(Gradient Descent)에 대해서 배웁니다.
cost가 최소화가 되는 지점은 접선의 기울기가 0이 되는 지점이며, 또한 미분값이 0이 되는 지점입니다. 경사 하강법의 아이디어는 비용 함수(Cost function)를 미분하여 현재 W에서의 접선의 기울기를 구하고, 접선의 기울기가 낮은 방향으로 W의 값을 변경하는 작업을 반복하는 것에 있습니다.
기울기가 음수일 때와 양수일 때 어떻게 w값이 조정되는지 보겠습니다.
- 기울기가 음수일 때 : w의 값이 증가
기울기가 음수면 w의 값이 증가하는데 이는 결과적으로 접선의 기울기가 0인 방향으로 W의 값이 조정됩니다.
만약, 접선의 기울기가 양수라면 위의 수식은 아래와 같이 표현할 수 있습니다.
- 기울기가 양수일 때 : w의 값이 감소
기울기가 양수면 w의 값이 감소하게 되는데 이는 결과적으로 기울기가 0인 방향으로 w의 값이 조정됩니다. 즉, 아래의 수식은 접선의 기울기가 음수거나, 양수일 때 모두 접선의 기울기가 0인 방향으로 w의 값을 조정합니다.
2차함수를 보면 정확히 W, b를 구할 수 있을 것은데 왜 그렇게 구하지 않고 경사하강법을 사용할까요? -> 로스함수내에서 나오는 식은 매우 복잡하므로 W값이 무엇이라고 딱 구해지기 어렵기 때문입니다.
그렇다면 여기서 학습률(learning rate)이라고 말하는 a는 어떤 의미를 가질까요? 학습률 a은 W의 값을 변경할 때, 얼마나 크게 변경할지를 결정합니다. 또는 W를 그래프의 한 점으로보고 접선의 기울기가 0일 때까지 경사를 따라 내려간다는 관점에서는 얼마나 큰 폭으로 이동할지를 결정합니다. 직관적으로 생각하기에 학습률 a의 값을 무작정 크게 하면 접선의 기울기가 최소값이 되는 W를 빠르게 찾을 수 있을 것같지만 그렇지 않습니다.
위의 그림은 학습률 a가 지나치게 높은 값을 가질 때, 접선의 기울기가 0이 되는 W를 찾아가는 것이 아니라 cost의 값이 발산하는 상황을 보여줍니다. 반대로 학습률 a가 지나치게 낮은 값을 가지면 학습 속도가 느려지므로 적당한 a의 값을 찾아내는 것도 중요합니다.
지금까지는 b는 배제시키고 최적의 W를 찾아내는 것에만 초점을 맞추어 경사 하강법의 원리에 대해서 배웠는데, 실제 경사 하강법은 W와 b에 대해서 동시에 경사 하강법을 수행하면서 최적의 W와 b의 값을 찾아갑니다.
- 가설, 비용 함수, 옵티마이저는 머신 러닝 분야에서 사용되는 포괄적 개념입니다. 풀고자하는 각 문제에 따라 가설, 비용 함수, 옵티마이저는 전부 다를 수 있으며 선형 회귀에 가장 적합한 비용 함수는 평균 제곱 오차, 옵티마이저는 경사 하강법입니다.
이제 가설, 비용 함수, 옵티마이저에 대해서 학습하였으니 파이토치로 구현해보겠습니다.
4. 파이토치로 선형 회귀 구현하기
우선 실습을 위해 파이토치의 도구들을 임포트하는 기본 셋팅을 진행합니다.
1. 기본 셋팅
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
# 현재 실습하고 있는 파이썬 코드를 재실행해도 다음에도 같은 결과가 나오도록 랜덤 시드(random seed)를 줍니다.
torch.manual_seed(1)
실습을 위한 기본적인 셋팅이 끝났습니다. 이제 훈련 데이터인 x_train과 y_train을 선언합니다.
2. 변수 선언
x_train = torch.FloatTensor([[1], [2], [3]])
y_train = torch.FloatTensor([[2], [4], [6]])
x_train과 x_train의 크기(shape)를 출력해보겠습니다.
print(x_train)
print(x_train.shape)
tensor([[1.],
[2.],
[3.]])
torch.Size([3, 1])
x_train의 값이 출력되고, x_train의 크기가 (3 × 1)임을 알 수 있습니다.
y_train과 y_train의 크기(shape)를 출력해보겠습니다.
print(y_train)
print(y_train.shape)
tensor([[2.],
[4.],
[6.]])
torch.Size([3, 1])
y_train의 값이 출력되고, y_train의 크기가 (3 × 1)임을 알 수 있습니다.
3. 가중치와 편향의 초기화
선형 회귀란 학습 데이터와 가장 잘 맞는 하나의 직선을 찾는 일입니다.
선형 회귀의 목표는 가장 잘 맞는 직선을 정의하는 W와 b의 값을 찾는 것입니다.
우선 가중치 W를 0으로 초기화하고, 이 값을 출력해보겠습니다.
# 가중치 W를 0으로 초기화하고 학습을 통해 값이 변경되는 변수임을 명시함.
W = torch.zeros(1, requires_grad=True)
# 가중치 W를 출력
print(W)
tensor([0.], requires_grad=True)
가중치 W가 0으로 초기화되어있으므로 0이 출력된 것을 확인할 수 있습니다. 위에서 requires_grad=True가 인자로 주어진 것을 확인할 수 있습니다. 이는 이 변수는 학습을 통해 계속 값이 변경되는 변수임을 의미합니다.
마찬가지로 편향 b도 0으로 초기화하고, 학습을 통해 값이 변경되는 변수임을 명시합니다.
b = torch.zeros(1, requires_grad=True)
print(b)
tensor([0.], requires_grad=True)
현재 가중치 W와 b 둘 다 0이므로 현 직선의 방정식은 다음과 같습니다.
y=0×x+0
지금 상태에선 에 어떤 값이 들어가도 가설은 0을 예측하게 됩니다. 즉, 아직 적절한 �와 �의 값이 아닙니다.
4. 가설 세우기
파이토치 코드 상으로 직선의 방정식에 해당되는 가설을 선언합니다.
H(x) = Wx + b
hypothesis = x_train * W + b
print(hypothesis)
5. 비용 함수 선언하기
파이토치 코드 상으로 선형 회귀의 비용 함수에 해당되는 평균 제곱 오차를 선언합니다.
# 앞서 배운 torch.mean으로 평균을 구한다.
cost = torch.mean((hypothesis - y_train) ** 2)
print(cost)
tensor(18.6667, grad_fn=<MeanBackward1>)
6. 경사 하강법 구현하기
이제 경사 하강법을 구현합니다. 아래의 'SGD'는 경사 하강법의 일종입니다. lr은 학습률(learning rate)를 의미합니다.
학습 대상인 W와 b가 SGD의 입력이 됩니다.
optimizer = optim.SGD([W, b], lr=0.01)
optimizer.zero_grad()를 실행하므로서 미분을 통해 얻은 기울기를 0으로 초기화합니다. 기울기를 초기화해야만 새로운 가중치 편향에 대해서 새로운 기울기를 구할 수 있습니다. 그 다음 cost.backward() 함수를 호출하면 가중치 W와 편향 b에 대한 기울기가 계산됩니다. 그 다음 경사 하강법 최적화 함수 opimizer의 .step() 함수를 호출하여 인수로 들어갔던 W와 b에서 리턴되는 변수들의 기울기에 학습률(learining rate) 0.01을 곱하여 빼줌으로서 업데이트합니다.
- optimizer = optim.SGD([W, b], lr=0.01) 이거를 쓰면 W랑 b가 저절로 학습되는 것 인가요?
네, 맞습니다. optim.SGD는 경사 하강법(Stochastic Gradient Descent) 최적화를 수행하는 PyTorch의 내장 최적화 도구입니다. 여기서 W와 b는 최적화해야 하는 모델의 파라미터입니다.
optim.SGD를 사용하면 경사 하강법 알고리즘이 비용 함수의 기울기를 계산하고, 이를 사용하여 파라미터들을 조정하여 비용을 최소화합니다. 이를 위해 lr(learning rate, 학습률)과 같은 하이퍼파라미터를 설정하여 경사 하강법의 갱신 스텝의 크기를 조절할 수 있습니다.
따라서 optimizer = optim.SGD([W, b], lr=0.01)를 사용하면 W와 b가 학습되며, 반복적으로 경사 하강법을 사용하여 비용 함수를 최소화하는 방향으로 업데이트됩니다.
# gradient를 0으로 초기화
optimizer.zero_grad()
# 비용 함수를 미분하여 gradient 계산
cost.backward()
# W와 b를 업데이트
optimizer.step()
- optimizer.zero_grad(): 이 메서드는 모델의 파라미터에 대한 기울기를 0으로 초기화합니다. 파이토치에서는 기울기가 누적되기 때문에, 각 배치(batch)마다 기울기를 새로 계산하기 전에 이 메서드를 호출하여 이전에 계산된 기울기를 지워야 합니다. -> 아래에 자세한 설명
- cost.backward(): 이 메서드는 비용 함수를 파라미터로 미분하고, 각 파라미터에 대한 기울기를 계산합니다. 이때, 자동 미분 기능을 사용하여 각 파라미터의 grad 속성에 기울기를 저장합니다. -> 이 기울기가 SGD에서 사용되는 기울기.
- optimizer.step(): 이 메서드는 경사 하강법을 사용하여 파라미터(W, b)를 업데이트합니다. 이 때, 파라미터의 기울기와 학습률(learning rate)을 곱하여 업데이트를 수행합니다. 이후, 업데이트된 값으로 파라미터를 갱신합니다. -> 위에서 나온 기울기를 사용해 .step()에서 W, b를 업데이트
- requires_grad=True와 backward()에 대한 정리는 자동 미분(Autograd) 챕터에 별도 정리하였습니다.
7. 전체 코드
# 데이터
x_train = torch.FloatTensor([[1], [2], [3]])
y_train = torch.FloatTensor([[2], [4], [6]])
# 모델 초기화
W = torch.zeros(1, requires_grad=True)
b = torch.zeros(1, requires_grad=True)
# optimizer 설정
optimizer = optim.SGD([W, b], lr=0.01)
nb_epochs = 1999 # 원하는만큼 경사 하강법을 반복
for epoch in range(nb_epochs + 1):
# H(x) 계산
hypothesis = x_train * W + b
# cost 계산
cost = torch.mean((hypothesis - y_train) ** 2)
# cost로 H(x) 개선
optimizer.zero_grad()
cost.backward()
optimizer.step()
# 100번마다 로그 출력
if epoch % 100 == 0:
print('Epoch {:4d}/{} W: {:.3f}, b: {:.3f} Cost: {:.6f}'.format(
epoch, nb_epochs, W.item(), b.item(), cost.item()
))
- 초기화: 먼저 최적화할 파라미터들을 초기화합니다. 이 경우 W와 b가 주어진 것으로 가정합니다.
- 전진 전파(Forward Propagation): 주어진 입력 데이터를 모델에 전달하여 예측값을 계산합니다. 이 예측값은 비용 함수의 입력으로 사용됩니다.
- 비용 함수 계산: 예측값과 실제값을 비교하여 비용 함수의 값을 계산합니다. 여기서는 평균 제곱 오차(MSE) 등의 비용 함수를 사용할 수 있습니다.
- 역전파(Backpropagation): 비용 함수의 값으로부터 파라미터들의 기울기(gradient)를 계산합니다. 이는 자동 미분(autograd) 기능을 사용하여 자동으로 수행됩니다.
- 파라미터 업데이트: 경사 하강법 알고리즘을 사용하여 파라미터를 업데이트합니다. 이 때, 파라미터들은 현재 값에서 학습률(learning rate)과 기울기를 곱한 값을 빼는 방식으로 업데이트됩니다.
- 반복(Iterations): 위의 과정을 지정된 반복 횟수(epoch)나 다른 종료 조건을 만족할 때까지 반복합니다. 이를 통해 모델이 학습 데이터에 더 적합해지도록 파라미터들이 조정됩니다.
결과적으로 훈련 과정에서 W와 b는 훈련 데이터와 잘 맞는 직선을 표현하기 위한 적절한 값으로 변화해갑니다.
Epoch 0/2000 W: 0.187, b: 0.080 Cost: 18.666666
Epoch 100/2000 W: 1.746, b: 0.578 Cost: 0.048171
Epoch 200/2000 W: 1.800, b: 0.454 Cost: 0.029767
Epoch 300/2000 W: 1.843, b: 0.357 Cost: 0.018394
Epoch 400/2000 W: 1.876, b: 0.281 Cost: 0.011366
Epoch 500/2000 W: 1.903, b: 0.221 Cost: 0.007024
Epoch 600/2000 W: 1.924, b: 0.174 Cost: 0.004340
Epoch 700/2000 W: 1.940, b: 0.136 Cost: 0.002682
Epoch 800/2000 W: 1.953, b: 0.107 Cost: 0.001657
Epoch 900/2000 W: 1.963, b: 0.084 Cost: 0.001024
Epoch 1000/2000 W: 1.971, b: 0.066 Cost: 0.000633
Epoch 1100/2000 W: 1.977, b: 0.052 Cost: 0.000391
Epoch 1200/2000 W: 1.982, b: 0.041 Cost: 0.000242
Epoch 1300/2000 W: 1.986, b: 0.032 Cost: 0.000149
Epoch 1400/2000 W: 1.989, b: 0.025 Cost: 0.000092
Epoch 1500/2000 W: 1.991, b: 0.020 Cost: 0.000057
Epoch 1600/2000 W: 1.993, b: 0.016 Cost: 0.000035
Epoch 1700/2000 W: 1.995, b: 0.012 Cost: 0.000022
Epoch 1800/2000 W: 1.996, b: 0.010 Cost: 0.000013
Epoch 1900/2000 W: 1.997, b: 0.008 Cost: 0.000008
Epoch 2000/2000 W: 1.997, b: 0.006 Cost: 0.000005
에포크(Epoch)는 전체 훈련 데이터가 학습에 한 번 사용된 주기를 말합니다.
이번 실습의 경우 2,000번을 수행했습니다.
최종 훈련 결과를 보면 최적의 기울기 W는 2에 가깝고, b는 0에 가까운 것을 볼 수 있습니다.
현재 훈련 데이터가 x_train은 [[1], [2], [3]]이고 y_train은 [[2], [4], [6]]인 것을 감안하면
실제 정답은 W가 2이고, b가 0인 H(x)=2x이므로 거의 정답을 찾은 셈입니다.
5. optimizer.zero_grad()가 필요한 이유
파이토치는 미분을 통해 얻은 기울기를 이전에 계산된 기울기 값에 누적시키는 특징이 있습니다. 예를 들어봅시다.
import torch
w = torch.tensor(2.0, requires_grad=True)
nb_epochs = 20
for epoch in range(nb_epochs + 1):
z = 2*w
z.backward()
print('수식을 w로 미분한 값 : {}'.format(w.grad))
수식을 w로 미분한 값 : 2.0
수식을 w로 미분한 값 : 4.0
수식을 w로 미분한 값 : 6.0
수식을 w로 미분한 값 : 8.0
수식을 w로 미분한 값 : 10.0
수식을 w로 미분한 값 : 12.0
수식을 w로 미분한 값 : 14.0
수식을 w로 미분한 값 : 16.0
수식을 w로 미분한 값 : 18.0
수식을 w로 미분한 값 : 20.0
수식을 w로 미분한 값 : 22.0
수식을 w로 미분한 값 : 24.0
수식을 w로 미분한 값 : 26.0
수식을 w로 미분한 값 : 28.0
수식을 w로 미분한 값 : 30.0
수식을 w로 미분한 값 : 32.0
수식을 w로 미분한 값 : 34.0
수식을 w로 미분한 값 : 36.0
수식을 w로 미분한 값 : 38.0
수식을 w로 미분한 값 : 40.0
수식을 w로 미분한 값 : 42.0
계속해서 미분값인 2가 누적되는 것을 볼 수 있습니다. 그렇기 때문에 optimizer.zero_grad()를 통해 미분값을 계속 0으로 초기화시켜줘야 합니다.
6. torch.manual_seed()를 하는 이유
torch.manual_seed()를 사용한 프로그램의 결과는 다른 컴퓨터에서 실행시켜도 동일한 결과를 얻을 수 있습니다. 그 이유는 torch.manual_seed()는 난수 발생 순서와 값을 동일하게 보장해준다는 특징때문입니다. 우선 랜덤 시드가 3일 때 두 번 난수를 발생시켜보고, 다른 랜덤 시드를 사용한 후에 다시 랜덤 시드를 3을 사용한다면 난수 발생값이 동일하게 나오는지 보겠습니다.
import torch
torch.manual_seed(3)
print('랜덤 시드가 3일 때')
for i in range(1,3):
print(torch.rand(1))
랜덤 시드가 3일 때
tensor([0.0043])
tensor([0.1056])
랜덤 시드가 3일때 두 개의 난수를 발생시켰더니 0.0043과 0.1056이 나옵니다. 이제 랜덤 시드값을 바꿔봅시다.
torch.manual_seed(5)
print('랜덤 시드가 5일 때')
for i in range(1,3):
print(torch.rand(1))
랜덤 시드가 5일 때
tensor([0.8303])
tensor([0.1261])
0.8303과 0.1261이 나옵니다. 이제 다시 랜덤 시드값을 3으로 돌려보겠습니다. 이렇게 하면 프로그램을 다시 처음부터 실행한 것처럼 난수 발생 순서가 초기화됩니다.
torch.manual_seed(3)
print('랜덤 시드가 다시 3일 때')
for i in range(1,3):
print(torch.rand(1))
랜덤 시드가 다시 3일 때
tensor([0.0043])
tensor([0.1056])
다시 동일하게 0.0043과 0.1056이 나옵니다.
- 텐서에는 requires_grad라는 속성이 있습니다. 이것을 True로 설정하면 자동 미분 기능이 적용됩니다. 선형 회귀부터 신경망과 같은 복잡한 구조에서 파라미터들이 모두 이 기능이 적용됩니다. requires_grad = True가 적용된 텐서에 연산을 하면, 계산 그래프가 생성되며 backward 함수를 호출하면 그래프로부터 자동으로 미분이 계산됩니다.
참고한 사이트 : https://wikidocs.net/53560
'AI > ML' 카테고리의 다른 글
[ML] 미니 배치와 데이터 로드(Mini Batch and Data Load) (0) | 2024.03.28 |
---|---|
[ML] nn.Module로 구현하는 선형 회귀 (0) | 2024.03.26 |
[pytorch] 파이토치 입문 (0) | 2024.03.23 |
[ML] 다중 선형 회귀(Multivariable Linear regression) (1) | 2024.03.23 |
[ML] 자동 미분(Autograd) (0) | 2024.03.22 |