기계학습/밑바닥딥러닝3 오독오독 씹기

1. Auto Gradient(자동미분): 변수/ 함수/ 수치미분/ 역전파

H_erb Salt 2020. 12. 1. 20:57
step1

DeZero

  • 해당 내용의 오리지널 프레임워크
  • 해당 내용은 DeZero를 60단계로 나누어, 조금씩 완성하도록 구성함

1.1 Variable?

- 상자에 데이터를 넣는 그림에서, 상자의 역할이 변수
- 상자와 데이터는 별개
- 상자에는 데이터가 들어감(대입 or 할당)
- 상자 속을 들여다보면 데이터를 알 수 있음(참조)

1.2 Variable class 구현

- 파이썬에서는 클래스의 첫 글자 이름을 보통 대문자로 함(PEP8)
- Variable 클래스가 상자가 되도록 구현
In [1]:
class Variable:
    def __init__(self, data):
        self.data = data
  • 초기화 함수 __init__에 주어진 인수를 인스턴스 변수 data에 대입함.
  • 간단한 코드지만, 이를 통해 Variable 클래스를 상자로 사용할 수 있음.
  • 실제 데이터가 Variable의 data에 보관되기 때문.
In [2]:
import numpy as np

data = np.array(1.0)
x = Variable(data)
print(x.data)
1.0
  • 위 예에서 상자에 넣는 데이터로는 넘파이의 다차원 배열을 사용함
  • 이 때 x는 Variable 인스턴스이며, 실제 데이터는 x 안에 담겨 있음.
  • 즉, x는 데이터 자체가 아니라 데이터를 담은 상자가 됨
  • 머신러닝 시스템은 기본 데이터 구조로 '다차원 배열'을 사용함.
  • 넘파이의 다차원 배열 클래스는 numpy.ndarray이며, np.ndarray로 줄임
  • 넘파이 배열은 np.array함수를 이용해 생성할 수 있음
In [3]:
x.data = np.array(2.0)
print(x.data)
2.0
  • x.data = ... 형태로 쓰면 새로운 데이터가 대입 됨. 이제 Variable 클래스를 상자로 사용할 수 있게 됨

2. 변수를 낳는 함수

  • 앞 단계에서 Variable 클래스를 상자로 사용할 수 있게 함.
  • 하지만 지금 이대로는 그냥 상자!
  • 이를 단순한 상자에서 마법의 상자로 바꾸는 장치가 필요한데, 이 열쇠가 '함수'

2.1 Fucntion?

  • 어떤 변수로부터 다른 변수로의 대응 관계를 정한 것 f(x) = y -> x를 f에 입력해서 y가 출력

2.2 Fucntion class 구현

  • Variable 인스턴스를 변수로 다룰 수 있는 함수를 Function 클래스로 구현
  • 주의할 점은 두 가지
    • Function 클래스는 Variable 인스턴스를 입력받아 Variable 인스턴스를 출력
    • Variable 인스턴스의 실제 데이터는 인스턴스 변수인 data에 있음
In [4]:
class Function:
    def __call__(self, input):
        x = input.data # 데이터를 꺼냄
        y = x ** 2 # 실제 계산
        output = Variable(y) # Variable 형태로 되돌림
        
        return output
  • __call__ 메서드의 인수 input은 Variable 인스턴스라고 가정
    • __call__은 파이썬의 특수 메서드
    • 이 메서드를 정의하면 f = Function()형태로 함수의 인스턴스를 변수 f에 대입해두고, 나중에 f(...) 형태로 __call__ 메서드를 호출할 수 있음
  • 따라서 실제 데이터는 input.data에 존재
  • 데이터를 꺼낸 후 원하는 계산을 하고, 결과를 Variable이라는 '상자'에 담아 돌려줌

2.3 Function 클래스 이용

  • Function 클래스를 실제로 사용.
  • Variable 인스턴스인 x를 fucntion 인스턴스인 f에 입력해보자
In [5]:
x = Variable(np.array(10))
f = Function()
y = f(x)

print(type(y))
print(y.data)
<class '__main__.Variable'>
100
  • 이와 같이 Variable과 Funcion을 연계할 수 있음
  • 실행 결과를 보면 y의 클래스는 Variable이며, 데이터는 y.data에 저장되어 있음을 알 수 있음
  • 그런데 방금 구현한 Function 클래스는 용도가 '입력값의 제곱'으로 고정된 함수
  • 따라서 Square라는 명확한 이름이 더 어울림
  • 앞으로 Sin, Exp라는 다양한 함수가 필요하다는 점을 고려하면 Function 클래스는 기반 클래스로 두고 DeZero의 모든 함수가 공통적으로 제공하는 기능만 담아두는 것이 좋을듯
  • DeZero함수는 다음 두 사항을 충족하도록 구현함
    • Function 클래스는 기반 클래스로서, 모든 함수에 공통되는 기능을 구현함
    • 구체적인 함수는 Function 클래스를 상송한 클래스에서 구현함
  • 이를 위해 Function 클래스를 다음처럼 수정함
In [6]:
class Function:
    def __call__(self, input):
        x = input.data
        y = self.forward(x) # 구체적인 계산은 forward 메서드에서 함
        output = Variable(y)
        
        return output
    
    def forward(self, x):
        raise NotImplementedError()
  • __call__을 살짝 수정하고, forward라는 메서드를 추가함
  • __call__ 메서드는 'Variable에서 데이터 찾기'와 '계산 결과를 Variable에 포장하기' 라는 두 가지 일을 함
  • 그리고 그 사이의 구체적인 계산은 forward 메서드를 호출하여 수행함
  • 마지막으로 forward 메서드의 구체적인 로직은 하위 클래스에서 구현함
  • Function 클래스의 forward메서드에서 예외를 발생시킴
  • 이렇게 해두면 Function 클래스의 forward 메서드를 직접 호출한 사람에게 이 메서드는 상속하여 구현해야 한다는 사실을 말해줄 수 있음
  • 이어서 Function 클래스를 상속하여 입력값을 제곱하는 클래스를 구현
  • 클래스 이름은 Square라고 짓고 아래와 같이 구현
In [7]:
class Square(Function):
    def forward(self, x):
        return x ** 2
  • Square 클래스는 Function 클래스를 상속하기 때문에 __call__ 메서드는 그대로 계승됨
  • 따라서 forward 메서드에 구체적인 계산 로직을 작성해 넣는 것만으로도 구현은 끝
  • 실제로 확인
In [8]:
x = Variable(np.array(10))
f = Square()
y = f(x)

print(type(y))
print(y.data)
<class '__main__.Variable'>
100

3. 함수 연결

  • 지금까지 DeZero의 '변수'와 '함수'를 만들고, 2단계에서 Square라는 제곱 계산용 함수 클래스를 구현함
  • 이번 단계에서는 또 다른 함수를 구현하고 여러 함수를 조합해 계산할 수 있게 만듦

3.1 Exp 함수 구현

  • $ y = e^x$ 라는 계산하는 함수를 구현
In [10]:
class Exp(Function):
    def forward(self, x):
        return np.exp(x)
  • Square 클래스와 마찬가지로 Function 클래스를 상속한 다음 forward 메서드에서 원하는 계산을 구현함
  • Square 클래스와의 차이는 forward 메서드의 내용이 x ** 2에서 np.exp(x)로 변경된 것

3.2 함수 연결

  • Function 클래스의 __call__ 메서드는 입력과 출력이 모두 Variable 인스턴스이므로 자연스럽게 DeZero 함수들을 연이어 사용할 수 있음
  • $y=(e^{x^2})^2$ 이라는 계산을 예로 들면 아래와 같음
In [13]:
A = Square()
B = Exp()
C = Square()

x = Variable(np.array(0.5))
a = A(x)
b = B(a)
y = C(b)

print(y.data)
1.648721270700128
  • 3개의 함수 A,B,C를 연이어 적용함
  • 중요한 점은 4개의 변수 x,a,b,y가 모두 Variable 인스턴스라는 것. Function 클래스의 __call__ 메서드의 입출력이 Variable 인스턴스로 통일되어있는 덕분
  • 이처럼 여러 함수로 구성된 함수를 합성함수라고 함
  • 계산그래프를 이용하면 각 변수에 대한 미분을 효율적으로 계산할 수 있음
  • 변수별 미분을 계산하는 알고리즘이 역전파
  • 다음 단계부터 역전파를 구현할 수 있도록 DeZero 확장

4. 수치 미분

  • 지금까지 Variable 클래스와 Function 클래스를 구현함
  • 이 클래스를 구현한 이유는 미분을 자동으로 계산하기 위함
  • 본격적인 구현에 앞서 이번 단계에서 미분이 무엇인지 복습하고 수치 미분이라는 간단한 방법으로 미분을 계산
  • 그런 다음 5단계에서 수치 미분을 대신하는 더 효율적인 알고리즘(역전파)를 구현

4.1 미분

  • pass

4.2 수치 미분 구현

  • 컴퓨터는 극한을 취급할 수 없어 극한과 비슷한 값으로 대체함
  • 예를 들어, h=0.0001(1e-4)과 같은 작은 값을 이용하여 미분 식을 계산함
  • 이런 미세한 차이를 이용하여 함수의 변화량을 구하는 방법을 수치 미분이라고 함
  • 수치 미분은 작은 값을 사용하여 실제 미분을 근사함. 따라서, 어쩔 수 없이 오차가 포함됨
  • 이 근사오차를 줄이는 방법으로 '중앙 차분(Centered difference)'가 있음
  • 중앙차분은 f(x)와 f(x+h)의 차이(전진차분)를 구하는 대신 f(x-h)와 f(x+h)의 차이를 구하는 것
  • 중앙차분에서의 직선의 기울기는 $\frac{f(x+h)-f(x-h)}{2h}$ (분모가 2h임에 주의)
  • 전진차분보다 중앙차분이 실제 미분값에 가깝다는 사실은 테일러 급수를 이용해 증명할 수 있음
  • 중앙차분을 이용하여 수치미분을 계산하는 함수를 numerical_diff(f, x, eps=1e-4)라는 이름으로 구현
    • 첫 번째 인수 f: 미분의 대상이 되는 함수, 앞에서 구현한 Function의 인스턴스
    • 두 번째 인수 x: 미분을 계산하는 변수. Variable의 인스턴스
    • 세 번째 인수 eps: 작은 값, 디폴트는 1e-4
In [14]:
def numerical_diff(f, x, eps=1e-4):
    x0 = Variable(x.data - eps)
    x1 = Variable(x.data + eps)
    y0 = f(x0)
    y1 = f(x1)

    return (y1.data - y0.data) / (2 * eps)
  • 실제 데이터는 Variable의 인스턴스 변수인 data에 들어있다는 것만 주의하면 특별히 조심할 점은 없어 보임
  • 3단계에서 구현한 Square 클래스를 대상으로 미분
In [15]:
f = Square()
x = Variable(np.array(2.0))
dy = numerical_diff(f, x)
print(dy)
4.000000000004
  • 이렇게 함수 $y=x^2$ 에서 x=2.0 일때 수치 미분한 결과를 구함
  • 약간의 오차가 있지만 거의 비슷한 값
  • 미분을 해석학적으로 계산할 수 도 있음. 해석적으로 계산한다는 뜻은 수식 변형만으로 답을 유도한다는 것

4.3 합성 함수의 미분

  • 지금까지는 $y=x^2$ 이라는 단순한 함수를 다룸
  • 이어서 합성함수를 미분. $y=(e^{x^2})^2$ 이라는 계산에 대한 미분을 계산
In [16]:
def f(x):
    A = Square()
    B = Exp()
    C = Square()

    return C(B(A(x)))

x = Variable(np.array(0.5))
dy = numerical_diff(f, x)
print(dy)
3.2974426293330694
  • 위 코드는 일련의 계산을 f라는 함수로 정리함
  • 파이썬에서는 함수도 객체이므로 다른 함수에 인수로 전달될 수 있음
  • 실제로 앞의 코드에서는 numerical_diff 함수에 함수 f를 전달함
  • 이상으로 미분을 '자동으로' 계산하는데 성공함
  • 원하는 계산을 파이썬 코드로 표현한 다음(위 예에서는 f로 정의) 미분하라고 프로그램에 요청
  • 위 방식으로 복잡한 함수라도 미분을 자동으로 계산 가능
  • 이러한 방식으로 함수의 종류를 늘려가면 미분이 가능한 함수라면 어떠한 함수도 미분이 가능함
  • 하지만 수치 미분에는 문제가 있다!

4.3 수치 미분의 문제점

  • 수치 미분의 결과에는 오차가 포함됨
  • 대부분의 경우 오차는 매우 작지만 어떤 계산이냐에 따라 커질수도 있음
  • 또한, 계산량이 많다는게 더 심각한 문제. 변수가 여러 개인 계산을 미분할 경우 변수 각각을 미분해야 하기 때문
  • 신경망에서는 매개변수를 수백만 개 이상 사용하는 일도 많으므로 이 모두를 수치 미분으로 구하는 것은 비현실적
  • 그래서 등장한 것이 역전파(backpropagation)
  • 수치미분은 구현하기 쉽고 거의 정확한 값을 얻지만, 역전파는 복잡한 알고리즘이므로 구현하면 버그가 섞여 들어가기 쉬움
  • 따라서 역전파를 정확히 구현했는지 확인하기 위해 수치 미분의 결과를 이용하곤 함
  • 이를 기울기 확인(gradient check)라고 하는데, 단순히 수치 미분의 결과와 역전파의 결과를 비교하는 것.

5. 역전파 이론

  • 수치 미분을 이용해 미분을 계산할 수 있게 되었지만, 수치미분은 계산 비용과 정확도 면에서 문제가 있음
  • 역전파를 이용하면 미분을 효율적으로 계산할 수 있고, 결괏값의 오차도 작음

5.1 연쇄법칙(Chain Rule)

  • 역전파를 이해하는 열쇠는 연쇄법칙(Chain Rule)
  • 연쇄법칙을 따르면 합성함수의 미분은 구성 함수 각각을 미분한 후 곱한것과 같음

이후 내용 pass

In [ ]: