관리 메뉴

Bull

[ML] 최적화 (Optimization) | study book 본문

Artificial Intelligence/Machine Learning

[ML] 최적화 (Optimization) | study book

Bull_ 2024. 8. 12. 20:07

최적화 (Optimization)

최적화란 목적함수와 결과값의 오차를 최적화하는 변수를 찾는 알고리즘이다. 손실 함수를 통해 최적의 오차를 찾을 수 있다.

선형회귀 그래프

예를 들어, 어떤 데이터를 통해 $y=ax+b$ 라는 1차함수를 예측했다고 해보자. 가장 최적의 선형회귀 그래프를 찾는 공식은 정해져 선형회귀 그래프가 아닌 경우도 분명 존재한다. 여기서 $a$를 가중치 $b$를 편향이라고 한다. 최적의 오차를 모른다고 가정했을 때 이 $b$는 그대로 두고 $a$를 $a$의 값에 대해서 오차와 가중치에 대한 그래프가 나온다. $b$를 건드리지 않는 이유가 궁금했지만 책에 설명은 나오지 않는다. 추측으로는 $b$도 건드리긴 하는데 책의 간결성을 위해 $a$ 값에 대해서만 설명한 게 아닐까 싶다. 정확한 내용은 아니다.

오차/가중치 그래프

선형회귀 $y=ax+b$에 대한 선형회귀 그래프를 우리는 공식을 모른다는 가정하에 가중치인 $a$의 값에 대해 조정에 따라 오차가 변하는 그래프가 나온다. 이 그래프는 $y=x^2$과 유사한 형태로 형성이 되었다. 가중치가 $x$이고 오차가 $y$인데 이 그래프의 기울기가 0일수록 오차는 줄어든다. 수학적인 얘기는 생략하겠다.

왜 하필 그 그래프인가?

$y=x^2$ 그래프에 대한 내용을 설명하기 위한 것이 아니다. 최적화 그래프를 설명하기 위함으로 선형함수 $y=ax+b$에서 $a$를 가중치로 설정하고 가중치 변화에 따른 오차를 설명하기 위해 그런 그래프가 나타난 것이다. 손실함수와 가중치/오차 그래프는 같은 것이 아니다. 가중치/오차 그래프는 손실함수를 통해서 가중치 값을 조정하는 것이다. 자세한 설명은 이후 경사하강법을 통해 해보겠다.

경사 하강법 (Gradient Descent)

경사하강법은 함수의 기울기가 낮은 곳으로 계속 이동시켜 극값에 도달할 때 까지 반복하는 알고리즘이다.
$$ W_{i+1}=W_i-\alpha \nabla f(W_i) $$
우리는 $W$를 가중치로 설정한다. 이 가중치는 이전에 $y=ax+b$ 에서 $a$와 같다고 말할 수 있다.
$$ \hat{Y_i} = W_i \times x + b_i $$
$$ MSE(W,b) = \frac{1}{n} \sum^n_{i=1}(Y_i-\hat{Y_i})^2 $$
여기서 $f$를 손실함수로 사용할 수 있다. 기존에 있던 W에서 $\alpha$(학습률) 만큼 곱해서 미세한 조정을 하는 것이다. $W$는 하나의 원소가 아니라 함수이다. $W$를 이전의 $y=x^2$으로 생각해보라. 이 함수는 원래 $y=x^2$ 는 아니지만 그 함수에 가까워질수록 실제 $y=x^2$ 와의 오차가 줄어든다.
$$ W_{i+1}=W_i-\alpha \frac{\partial }{\partial W}MSE(W,b) $$
$$ = W_i -\alpha \times E[(\hat{y_i}-Y_i) \times x] $$
공식을 생략했지만 $y=ax+b$에서 $a$를 가중치라 둔 상태로 편미분을 진행하였다. 여기서 $a$를 가중치로 생각했지만 $b$에 대한 편미분을 진행도 하여 최적의 가중치인 $b$ 도 찾아야 한다. 여기서 업데이트되는 $W_i$에 대한 값을 구하기 위해 MSE를 사용했고 가중치를 $a$ 로 두었다. 그러면 어떤 것을 찾아야하는 지가 관건이다. 공식에서 보이 듯 $E[\hat{Y_i}-Y_i] 이 최적이 되는 W에서 업데이트를 멈출 수 있다. (오차가 0에 가깝도록)


이제 오차가 0이 되는 부분을 인터넷에서 자주 볼 수 있는 경사 하강법이 적용된 그래프로 나타낼 수 있는 것이다. 학습률에 따라 가중치 업데이트가 급격하게 변하기도 느슨하게 변하기도 한다. $\alpha$가 곱해져 있기 때문에 오차만큼 더해지거나 빼지기 때문이다. 여기서 학습률 크기에 따라 오차가 발산할수도 진동(발산)할 수도 너무 느리게 업데이트되기도 한다.


또다른 최적화 문제로는 찾아낸 최솟값이 진짜 최솟값이 아니라 다른 극솟값일 수 있다. 또한 3차원에서는 안장점이 존재한다. 이 안장점은 극솟값도 극댓값도 동시에 될 수 있기때문에 안장점이라고 불린다. 즉 최소/최대라 할 수 없는데 기울기가 0이 되는 부분이 존재한다.

단순 선형 회귀: numpy

import numpy as np

x = np.array(
    [[1], [2], [3], [4], [5], [6], [7], [8], [9], [10],
    [11], [12], [13], [14], [15], [16], [17], [18], [19], [20],
    [21], [22], [23], [24], [25], [26], [27], [28], [29], [30]]
)
y = np.array(
    [[0.94], [1.98], [2.88], [3.92], [3.96], [4.55], [5.64], [6.3], [7.44], [9.1],
    [8.46], [9.5], [10.67], [11.16], [14], [11.83], [14.4], [14.25], [16.2], [16.32],
    [17.46], [19.8], [18], [21.34], [22], [22.5], [24.57], [26.04], [21.6], [28.8]]
)
weight = 0.0 # 가중치 초기값
bias = 0.0 # 편향 초기값
learning_rate = 0.005 #학습률(알파)
for epoch in range(10000):
    # y=ax+b
    y_hat = weight * x + bias

    # MSE
    cost = ((y - y_hat) ** 2).mean()
    # a를 가중치로 하였을 때 경사하강법 공식에 MSE를 적용하고 a에 편미분
    weight = weight - learning_rate * ((y_hat - y) * x).mean()
    # b를 가중치로 하였을 때 경사하강법 공식에 MSE를 적용하고 b에 편미분
    bias = bias - learning_rate * (y_hat - y).mean()

    if (epoch + 1) % 1000 == 0:
        print(f"Epoch : {epoch+1:4d}, Weight : {weight:.3f}, Bias : {bias:.3f}, Cost : {cost:.3f}")

설명은 주석으로 간단하게 해놨다. 업데이트 부분에 공식이 정해져있어서 헷갈릴 수 있는데 $y=ax+b$ 을 기준으로 $a$,$b$에 대한 각각의 경사하강법 공식으로 편미분을 진행한 것이다.

# 결과
Epoch : 1000, Weight : 0.872, Bias : -0.290, Cost : 1.377
Epoch : 2000, Weight : 0.877, Bias : -0.391, Cost : 1.373
Epoch : 3000, Weight : 0.878, Bias : -0.422, Cost : 1.372
Epoch : 4000, Weight : 0.879, Bias : -0.432, Cost : 1.372
Epoch : 5000, Weight : 0.879, Bias : -0.435, Cost : 1.372
Epoch : 6000, Weight : 0.879, Bias : -0.436, Cost : 1.372
Epoch : 7000, Weight : 0.879, Bias : -0.436, Cost : 1.372
Epoch : 8000, Weight : 0.879, Bias : -0.436, Cost : 1.372
Epoch : 9000, Weight : 0.879, Bias : -0.436, Cost : 1.372
Epoch : 10000, Weight : 0.879, Bias : -0.436, Cost : 1.372

에폭은 순전파와 역전파 과정을 을 1회 통과한 것을 말한다. 에폭이 적을 수록 과소적합, 에폭이 많을 수록 과대 적합이 일어난다.


우리는 x,y 데이터 30개를 한 번에 학습을 하였다. 이런 데이터를 10개씩 3부분으로 나눠서도 학습할 수 있다. 이러한 과정을 배치크기로 나눈다고 한다. 30개의 데이터를 10개로 3세트로 나눠버리면 CPU나 GPU 부담을 줄이고 계산량을 늘린다. 반대로 30개를 한 번에 학습해버리면 계산량은 줄지만 CPU나 GPU가 한 번에 연산하기가 어렵다.


6000회 쯤에서 $a$ 와 $b$ 둘 다 변하지 않는다. 따라서 학습횟수(에폭)을 적절히 해주어도 충분한 학습이 될 수 있기 때문에 이러한 하이퍼 파라미터를 적절히 조절해주어야 한다.


학습률을 0.005 보다 0.001 일 때 오차가 더 천천히 감소한다. 학습률이 낮을 때의 경사하강법 그림과 비슷하다.


만약 $a$ 와 $b$의 초기값을 100000으로 해준다고 하면 처음부터 매우 큰 값으로 오차가 발생하기 때문에 많은 학습을 요구할 것이다. 이렇듯 하이퍼파라미터를 처음에 적절하게 선택해주어야 한다.

단순 선형 회귀: pytorch

파이토치는 기본적으로 우리가 넘파이에서 직접 구현했던 행위를 기존에 있는 기능(메소드)으로 제공해준다. 바로 torch.optim 이라는 메소드이다. 이것을 통해 다른 최적화 함수도 사용해보겠다.

import torch
from torch import optim

x = torch.FloatTensor([
    [1], [2], [3], [4], [5], [6], [7], [8], [9], [10],
    [11], [12], [13], [14], [15], [16], [17], [18], [19], [20],
    [21], [22], [23], [24], [25], [26], [27], [28], [29], [30]
])
y = torch.FloatTensor([
    [0.94], [1.98], [2.88], [3.92], [3.96], [4.55], [5.64], [6.3], [7.44], [9.1],
    [8.46], [9.5], [10.67], [11.16], [14], [11.83], [14.4], [14.25], [16.2], [16.32],
    [17.46], [19.8], [18], [21.34], [22], [22.5], [24.57], [26.04], [21.6], [28.8]
])

# 크기가 1인 0 // weight = 0 인데 파이토치를 사용하기 위해 정의를 특이하게 함
weight = torch.zeros(1, requires_grad=True)
bias = torch.zeros(1, requires_grad=True)
learning_rate = 0.001

# SGD(Stochastic Gradient Desent) 라는 확률적 경사 하강법
optimizer = optim.SGD([weight, bias], lr=learning_rate)

for epoch in range(10000):
    # 가설: y=ax+b
    hypothesis = weight * x + bias
    # MSE
    cost = torch.mean((hypothesis - y) ** 2)

    # optimizer에 포함된 매개변수들의 기울기를 0으로 초기화
    optimizer.zero_grad()
    # 역전파
    cost.backward()
    # 업데이트
    optimizer.step()

    if (epoch + 1) % 1000 == 0:
        print(f"Epoch : {epoch+1:4d}, Weight : {weight.item():.3f}, Bias : {bias.item():.3f}, Cost : {cost:.3f}")

SGD는 모든 데이터를 사용하지 않고 일부 데이터만 사용한다. 즉 미니 배치 형태로 전체 데이터를 N 등분하는 것이다. 여기서 zero_grad 메소드가 나는 이해가 안됐는데 책 설명에는 다음과 같이 나와있다. 텐서의 기울기는 grad 변수에 누적해서 더해지기 때문에 각 학습마다 초기화를 해주어야한다. 그러니까 weight = x 가 아닌 weight += x 구조로 저장된다. 이것은 수학적인 문제보다는 pytorch 설계의 문제인 듯하다. 상황에 따라 zero_grad 를 하지 않고 누적해야 하는 상황도 있기 때문에 zero_grad로 초기화 해주는 메소드를 따로 만들어 놓은 듯 싶다.

 

학습 결과는 numpy로 하였을 때와 유사하게 출력된다.

신경망 패키지

import torch
from torch import nn
from torch import optim

x = torch.FloatTensor([
    [1], [2], [3], [4], [5], [6], [7], [8], [9], [10],
    [11], [12], [13], [14], [15], [16], [17], [18], [19], [20],
    [21], [22], [23], [24], [25], [26], [27], [28], [29], [30]
])
y = torch.FloatTensor([
    [0.94], [1.98], [2.88], [3.92], [3.96], [4.55], [5.64], [6.3], [7.44], [9.1],
    [8.46], [9.5], [10.67], [11.16], [14], [11.83], [14.4], [14.25], [16.2], [16.32],
    [17.46], [19.8], [18], [21.34], [22], [22.5], [24.57], [26.04], [21.6], [28.8]
])

# weight = torch.zeros(1, requires_grad=True)
# bias = torch.zeros(1, requires_grad=True)
model = nn.Linear(1, 1)

criterion = nn.MSELoss()
optimizer = optim.SGD(model.parameters(), lr=0.001

for epoch in range(10000):
    output = model(x)
    cost = criterion(output, y)

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

    if (epoch + 1) % 1000 == 0:
        print(f"Epoch : {epoch+1:4d}, Model : {list(model.parameters())}, Cost : {cost:.3f}")

nn.Linear(1, 1)은 입력층 1개, 출력 층 1개로 선형 변환을 쉽게 해주는 파이토치의 신경망 패키지 메소드이다. nn.Linear(1, 1)의 위에 달린 주석과 같은 역할울 한다. nn.MSELoss()를 통해 비용함수 기능을 도와주는 패키지 메소드이다. 파이토치의 클래스에 적용했으므로 이 메소드를 통해 비용함수를 정의하면 간결한 코드로 정의가 가능하다.

 

참고 자료

https://product.kyobobook.co.kr/detail/S000209621433

 

파이토치 트랜스포머를 활용한 자연어 처리와 컴퓨터비전 심층학습 | 윤대희 - 교보문고

파이토치 트랜스포머를 활용한 자연어 처리와 컴퓨터비전 심층학습 | 트랜스포머는 딥러닝 분야에서 성능이 우수한 모델로 현대 인공지능 분야의 핵심 기술입니다. 트랜스포머와 비전 트랜스

product.kyobobook.co.kr