파이토치 딥러닝 마스터_5장

코넬·2023년 3월 29일
0

ComputerVision_Pytorch

목록 보기
5/10
post-thumbnail

학습 기법 배우기

5장에서는...

  • 데이터로부터 알고리즘이 학습하는 방법
  • 미분과 경사 하강을 사용한 파라미터 추정이라는 관점으로 학습에 대해 재구성해보기
  • 간단한 학습 알고리즘 훑어보기
  • 학습을 돕는 파이토치의 자동미분에 대해 알아볼겁니다 !

📮 데이터 과학의 핸드북

옛날에 케플러가 6년동안 이뤄낸 업적이 데이터 과학의 핸드북이라 나도 적어봤다.

  1. 동료 브라헤로부터 좋은 데이터를 많이 얻었고,
  2. 수상한 점을 느껴서 이를 가시화해보려고 시도하였으며,
  3. 데이터에 가장 잘 부합할 만한 최대한 단순한 모델을 골라서
  4. 데이터를 쪼개 일부만 사용하고 나머지는 검증을 위해 남겨뒀고
  5. 잠정적으로 타원에 대한 이심률과 크기를 정하고 이 모델이 관찰 결과와 맞을 때까지 반복하였으며,
  6. 별도의 관찰을 통해 자신의 모델을 검증한 후,
  7. 의심스러운 부분을 되돌아보았다.

이 교재에서 우리가 관심있어하는 모델은 특정한 범위에 한정된 작업을 수행하기 위해 만들어진 모델이 아닌 입출력 쌍을 활용한 다양한 유사 작업에 대해 스스로를 최적화하기 위해 자동으로 적응하는 모델이다. 즉, 특정 작업에 대한 데이터로 학습한 일반화된 모델 이다.

학습 알고리즘의 동작 방식을 이해하고, 복잡한 모델까지 다뤄보자 !

🧐 학습은 파라미터 추정에 불과하다.

그냥 딥러닝을 배우면 정말 잘 아는 과정이 이 제목이다. 입력 및 입력에 대응하는 출력인 실측 자료와 가중치 초깃값이 주어졌을 때, 모델에 입력 데이터가 들어가고 실측값과 출력 결괏값을 비교하여 오차를 계산한다. 그리고 모델의 파라미터(가중치)를 최적화하기 위해 가중치를 오차값에 따라 일정 단위만큼(gradient) 변경한다. 이 변경값은 합성 함수(역방향 전달)의 미분값을 연속으로 계산하는 규칙(chain rule)을 통해 정해진다. 이렇게 하면 오차가 줄어드는 방향으로 가중치값이 조정된다.

자, 그럼 말로만 흐름 설명하지 말고 직접 적용해볼까~?

🌡️ 온도 문제 풀기

우리가 선호하는 단위를 사용해 온도계를 읽고 일치하는 온도를 기록한 데이터셋을 만든 후, 학습을 위한 모델을 하나 골라 오차가 충분히 낮아질 때까지 반복적으로 가중치를 조절하여 마지막에 우리가 이해하는 온도 단위로 새로운 눈금을 해석할 수 있도록 만들어보자.

1. 데이터 수집

%matplotlib inline
import numpy as np
import torch
torch.set_printoptions(edgeitems=2, linewidth=75)

t_c = [0.5,  14.0, 15.0, 28.0, 11.0,  8.0,  3.0, -4.0,  6.0, 13.0, 21.0]
t_u = [35.7, 55.9, 58.2, 81.9, 56.3, 48.9, 33.9, 21.8, 48.4, 60.4, 68.4]
t_c = torch.tensor(t_c)
t_u = torch.tensor(t_u)

자, 여기서 t_c값은 섭씨 온도 값이고 t_u 값은 우리가 모르는 단위의 값이다.
노이즈가 조금 있지만 일정한 패턴이 있음을 그래프로 뽑아보면 확인할 수 있다.

2. 선형 모델을 골라 시도해보자.

가중치와 편향값을 w와 b 로 부르고, 식을 작성해보면
t_c = w * t_u + b

가 되는데, 우리는 모델의 파라미터인 w와 b를 데이터 기반으로 추정해야한다. 이로써 모델을 통해 t_u로 표기한 알 수 없는 온도값을 섭씨 단위로 측정한 실측 값에 최대한 가깝도록 만들면 된다.

즉, 우리는 알 수 없는 파라미터를 가진 모델이 있고, 이 파라미터의 값을 잘 추정하여 측정된 값과 예측값인 출력값 사이의 오차가 최대한 작아지게 만든다.

오차 측정을 어떻게 할 지 구체적으로 정의해야하는데, 이를 손실 함수(loss function) 이라고 한다. 이렇게 손실 함수의 값이 최소인 지점에서 w와 b를 찾는 과정을 최적화 과정 이라고 한다.

3. 손실을 줄이기 위한 방안

손실 함수는 일반적으로 훈련 샘플로부터 기대하는 출력값과 모델이 샘플에 대해 실제 출력한 값 사이의 차이를 계산한다.
지금 풀고 있는 문제에 대입해보면 t_p - t_c 의 차이겠죵?

  1. 여기서 손실 함수는 t_p가 t_c보다 크든 작든 간에 항상 양수의 차이가 나오게 하여 t_p가 t_c로 맞춰가는데 사용할 수 있게 해야한다.

  2. 손실 함수는 훈련 샘플로부터 나오는 오차 중 어떤 경우를 우선하여 보완해나갈 것인지를 결정하고, 파라미터 조정은 손실값이 적은 샘플의 출력을 변경하기보다 가중치가 큰 샘플을 우선적으로 보정하게된다.

  3. 두 손실 함수 모두 최소 오차는 0이고 어느 방향으로든 예측값이 실제값보다 커지면 오차도 단조적으로 증가한다. 그래프의 상승 기울기 역시 최솟값으로부터 단조적으로 증가하기 때문에 둘다 볼록(convex)하다고 할 수 있다. 모델이 선형이므로 w와 b로 만들어진 손실 함수 역시 볼록하다는 것 !

손실이 모델 파라미터의 볼록함수인 경우는 특수한 알고리즘을 통해 매우 효과적으로 최솟값을 찾을 수 있기 때문에 다루기가 매우 좋다.

여기서 ! 차이를 절댓값으로 구한 경우와 차이를 제곱한 경우의 비교를 해보자면,

  • 절댓값을 사용한 경우는 우리가 수렴하고자 하는 겨웅에 미분값을 정의할 수 없다.
  • 제곱을 사용한 차이가 절댓값을 사용한 차이보다 잘못된 결과에 더 많은 불이익을 준다는 사실도 존재한다.
  • 차이를 제곱한 경우는 이런 식으로 오차 보정에 우선순위를 주도록 동작한다는 사실!

항상 선형적으로 나오는줄은 알았는데, 이런 의미가 있는줄은 몰랐지? 지금 짚어넘고 가야한다 !!

3-2. 파이토치로 마저 풀어보기 !

def model(t_u, w, b):
    return w * t_u + b
    
def loss_fn(t_p, t_c):
   	squared_diffs = (t_p - t_c)**2
    return squared_diffs.mean()

위의 손실함수 정의는 요소 단위로 값의 차이를 제곱한 텐서를 만든 후 텐서의 모든 요소에 대한 평균을 구하여 스칼라 값을 만들어내는 손실 함수이다. 이를 평균 제곱 손실(mean square error) 이라고 한다.

이제 파라미터를 초기화하고 모델을 호출해보자.

w = torch.ones(())
b = torch.zeros(())

t_p = model(t_u, w, b)
t_p

#result
tensor([35.7000, 55.9000, 58.2000, 81.9000, 56.3000, 48.9000, 33.9000,
        21.8000, 48.4000, 60.4000, 68.4000])
loss = loss_fn(t_p, t_c)
loss

#result
tensor(1763.8848)

요기에서는 모델과 손실을 구현해봤고, 예제의 핵심인 손실이 최소일 때의 w와 b를 어떻게 추정할 것인가에 대해 문제까지 확인해봤다.

⛰️ 경사를 따라 내려가기 : 경사 하강법

경사하강법(gradient descent) : 파라미터 관점에서 손실 함수를 최적화하는 방식.

w와 b 의 값을 조절하여 그 사이 손실값이 얼마나 변하는지 확인하면 된다. w와 b 값에서 특정 단위만큼 w가 증가하였을 때, 손실이 변하게 만드는데, 값이 줄어들면 w를 늘려 손실을 최소화하고 값이 늘어나면 w를 줄여 손실을 최소화하는 방식이다.

w의 경우, 손실의 변화 비율에 비례하여 영향을 많이 미치는 파라미터를 변경한다.
이를 얼만큼 바꿔갈지에 대한 스케일링 비율을 나타내는 변수가 learning rate 이다.

delta = 0.1

loss_rate_of_change_w = \
    (loss_fn(model(t_u, w + delta, b), t_c) - 
     loss_fn(model(t_u, w - delta, b), t_c)) / (2.0 * delta)
     
learning_rate = 1e-2

w = w - learning_rate * loss_rate_of_change_w

#편향(bias)
loss_rate_of_change_b = \
    (loss_fn(model(t_u, w, b + delta), t_c) - 
     loss_fn(model(t_u, w, b - delta), t_c)) / (2.0 * delta)

b = b - learning_rate * loss_rate_of_change_b

경사 하강에서 기본적인 파라미터 조정이 끝났다. 이렇게 평가를 반복함으로써 주어진 데이터에 대해 손실 계산값이 최소로 떨어지는 최적의 파라미터 값으로 수렴하게 된다.

그렇다면 경사 하강법의 원리는 무엇일까?

경사하강법은 파라미터에 대해 손실 함수를 미분하는 것과 동일하다. w,b 이렇게 두개 이상의 파라미터를 가진 모델에서 각 파라미터에 대한 손실 함수의 편미분을 구하고, 이 편미분 값들을 미분 벡터에 넣으면 기울기(gradient) 가 나오게 된다.

파라미터에 대한 손실 함수의 미분을 계산하려면, 연쇄 규칙을 적용하여 입력에 대한 손실 함수의 미분을 계산하여 파라미터에 대한 모델의 미분을 곱할 수 있다.

d loss_fn / dw = (d loss_fn / d t_p) * (d t_p / d w)

위의 식과 연결지어, 선형 함수이면서 손실함수는 제곱의 합인 미분식을 만들어보자.

def loss_fn(t_p, t_c):
    squared_diffs = (t_p - t_c)**2
    return squared_diffs.mean()
    
def dloss_fn(t_p, t_c):
    dsq_diffs = 2 * (t_p - t_c) / t_p.size(0)  # <1>
    return dsq_diffs
    
#경사 함수 정의하기

def grad_fn(t_u, t_c, t_p, w, b):
    dloss_dtp = dloss_fn(t_p, t_c)
    dloss_dw = dloss_dtp * dmodel_dw(t_u, w, b)
    dloss_db = dloss_dtp * dmodel_db(t_u, w, b)
    return torch.stack([dloss_dw.sum(), dloss_db.sum()])

경사 함수의 경우, 모두를 합쳐 w와 b에 대한 손실값의 미분을 반환하는 함수를 만들면된다.

 ▽L=(Lw,Lb)=(Lmmw,Lmmb)\displaystyle\ ▽L \displaystyle \displaystyle= (\frac{∂L}{∂w} , \frac{∂L}{∂b}) = (\frac{∂L}{∂m}*\frac{∂m}{∂w},\frac{∂L}{∂m}*\frac{∂m}{∂b})

자, 이제 함수를 완료하였으니 파라미터를 최적화하기 위하여 반복해 w,b를 조정해보자.

def training_loop(n_epochs, learning_rate, params, t_u, t_c):
    for epoch in range(1, n_epochs + 1):
        w, b = params

        t_p = model(t_u, w, b) 
        loss = loss_fn(t_p, t_c)
        grad = grad_fn(t_u, t_c, t_p, w, b)  

        params = params - learning_rate * grad

        print('Epoch %d, Loss %f' % (epoch, float(loss)))
            
    return params
    
training_loop(
    n_epochs = 100, 
    learning_rate = 1e-4, 
    params = torch.tensor([1.0, 0.0]), 
    t_u = t_u, 
    t_c = t_c)

여기서 주의해야할 점은, 과도한 훈련을 막기 위하여 학습률 조정 을 꼭 하고 진행해야한다는 점 ! 우리가 지금 하고 있는 과정들을 통틀어 하이퍼파라미터 튜닝(haperparameter tuning) 이라고 부른다.

실제로 코드 작성을 해봤으니 파이토치에서 제공하는 자동미분을 써보자.

🎢 파이토치 자동미분 : 역전파 진행하기

파이토치 텐서 의 경우, 자신이 어디로부터 왔는지, 어느 텐서에서 어떤 연산을 수행하여 만들어진 텐서인지 기억하고 있으며, 자연스럽게 미분 최초 입력까지 연쇄적으로 적용하여 올라갈 수 있다. 이것을 자동미분이라고하고, 우리는 코드 작성만 할 줄 알면 된다.

아래의 코드에서 requires_grad=True 인자는, params 에 가해지는 연산의 결과로 만들어지는 모든 텐서를 이은 전체 트리를 기록하라고 파이토치에게 요청한다. 미분값은 params 텐서의 grad 속성 으로 자동 기록된다.

params = torch.tensor([1.0, 0.0], requires_grad=True)

params.grad is None

loss = loss_fn(model(t_u, *params), t_c)
loss.backward()

params.grad

if params.grad is not None:
    params.grad.zero_()
    
def training_loop(n_epochs, learning_rate, params, t_u, t_c):
    for epoch in range(1, n_epochs + 1):
        if params.grad is not None:  
            params.grad.zero_()
        
        t_p = model(t_u, *params) 
        loss = loss_fn(t_p, t_c)
        loss.backward()
        
        with torch.no_grad():  
            params -= learning_rate * params.grad

        if epoch % 500 == 0:
            print('Epoch %d, Loss %f' % (epoch, float(loss)))
            
    return params
    
training_loop(
    n_epochs = 5000, 
    learning_rate = 1e-2, 
    params = torch.tensor([1.0, 0.0], requires_grad=True), 
    t_u = t_un,
    t_c = t_c)

미분 함수 누적의 경우, 파이토치는 연쇄적으로 연결된 함수들을 거쳐 손실에 대한 미분을 계산하고 그 값을 텐서의 grad 속성에 누적한다.

여기서 항상 코드를 짤 때 왜 기울기를 초기화하고 진행해야하는지에 대한 이유가 등장한다. 말단 노드의 기울기 값이 이전 반복문 수행 시 계산 되었던 기존 값에 누적되어 부정확한 기울기값을 초래하기 때문이다.

옵티마이저 골라쓰기

torch 에는 optim 서브 모듈이 존재하는데, 여기서 다양한 최적화 알고리즘 구현 클래스를 찾아볼 수 있다.

모든 옵티마이저 생성자는 첫 번째 입력으로 파라미터 리스트를 받는다. 옵티마이저에 전달된 파라미터는 옵티마이저 객체 내부에 유지되고, 값을 조정하고 grad 속성에 접근할 때 사용된다.

기본적으로 모든 옵티마이저는 zero_gradstep 이라는 두 가지 메소드를 제공한다. zero_grad 는 옵티마이저 생성자에 전달됐던 파라미터의 모든 grad 속성값을 0으로 만든다. step 은 옵티마이저별로 구현된 최적화 전략에 따라 파라미터 값을 조정한다.

경사 하강 옵티마이저

params = torch.tensor([1.0, 0.0], requires_grad=True)
learning_rate = 1e-2
optimizer = optim.SGD([params], lr=learning_rate) 

확률적 경사 하강법(SGD : stochastic gradient descent) 이란, 일반적인 경사 하강법과 완전히 동일하다. SGD의 기울기는 미니 배치(mini batch) 라고 불리는 여러 샘플 중 임의로 뽑은 일부에 대해 평균을 계산하여 얻기 때문에, 확률적(stochastic) 이라는 단어를 사용한다.

def training_loop(n_epochs, optimizer, params, t_u, t_c):
    for epoch in range(1, n_epochs + 1):
        t_p = model(t_u, *params) 
        loss = loss_fn(t_p, t_c)
        
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

        if epoch % 500 == 0:
            print('Epoch %d, Loss %f' % (epoch, float(loss)))
            
    return params

위의 함수를 들여다보면, 이제는 직접 건들지 않아도 step 호출을 통해 params 가 알아서 조정되고, 옵티마이저는 params.grad 를 보고 params 에서 grad에 learning_rate를 곱한 것만큼 빼서 조정을 진행한다.

다른 옵티마이저 학습해보기


Adam (Adaptive Moment Esimation) 은 Momentum 와 RMSProp 두가지를 섞어 쓴 알고리즘이다. 즉, 진행하던 속도에 관성을 주고, 최근 경로의 곡면의 변화량에 따른 적응적 학습률을 갖은 알고리즘이다.매우 넓은 범위의 아키덱처를 가진 서로 다른 신경망에서 잘 작동한다는 것이 증명되어,일반적 알고리즘에 현재 가장 많이 사용되고 있다.

출처 : 파이토치 딥러닝 마스터

profile
어서오세요.

0개의 댓글