페이지

2022년 8월 6일 토요일

4.4 수치 미분의 문제점

 수치 미분의 결과에는 오차가 포함되어 있습니다. 대부분의 경우 오차는 매우 작지만 어떤 계산이냐에 따라 커질 수도 있습니다.

수치미분의 결과에 오차가 포함되기 쉬운 이유는 주로 '자릿수 누락' 때문입니다. 중앙차분 등 '차이'를 구하는 계산은 주로 크기가 비슷한 값들을 다루므로 계산 결과에서 자릿수 누락이 생겨 유효 자릿수가 줄어들 수 있습니다. 예를 들어 유효 자릿수가 4일 때 1.234 - 1.2333 이라는 계산(비슷한 값끼리의 뺄셈)을 생각해보죠. 계산 결과는 0.001 되어 유효 자릿수가 1로 줄어듭니다. 원래는 1.234.... - 1.233... = 0.001434..같은 결과였을지도 모르는데, 자릿수 누락 때문에 0.001이 됐다고 볼 수 있습니다. 이와 같은 원리 때문에 수치 미분을 이용하면 자릿수 누락이 발생하여 오차가 포함되기 쉽습니다.


수치 미분의 더 심각한 문제는 계산량이 많다는 점입니다.변수가 여러 개인 계산을 미분할 경우 변수 각각을 미분해야 하기 때문입니다. 신경망에서는 매개변수를 수백만개 시상 사용하는 것일도 아니므로 이 모두를 수치 미분으로 구하는 것은 현실적이지 않습니다. 그래서 등장한 것이 바로 역전파입니다. 다음 단계에서 드디어 역전파를 소개합니다.

덧붙여서, 수치 미분은 구현하기 쉽고 거의 정확한 값을 얻을 수 있습니다. 이에 비해 역전파는 복잡한 알고리즘이라서 구현하면서 버그가 섞여 들어ㅓ가기 쉽습니다. 그래서 역전파를 정확하게 구현했는지 확인하기 휘애 수치 미분의 별과를 이용하곤합니다. 이를 기울기 확인(gradient checking)이라고 하는데, 단순히 수치 미분 결과와 역전파의 결과를 비교하는 것입니다. 기술기 확인은 10단계에서 구현합니다.

4.3 합성 함수의 미분

 지금까지는 y = x ** 2 이라는 단순한 함수를 다뤘습니다. 이어서 합성 함수를 미분해 봅시다. y = (e ** x) ** 2 이라는 계산에 대한 미분 dy/dx를 계산할 것입니다. 코드는 다음과 같습니다.

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를 전달했습니다.

실행 결과를 보면 미분한 값이 3.297...입니다. x를 0.5에서 작은 값만큼 변화시키면 y는 작은 값의 3.297...배만큼 변한다는 의미죠.

이상에서 우리는 미분을 '자동으로'계산하는 데 성공했습니다. 원하는 계산을 파이썬 코드로 표현한 다음(앞의 예에서 함수 f로 정의) 미분해달라고 프로그램에 요구했습니다. 이 방식대로 하면 아무리 보=ㄱ잡하게 조립된 함수라도 미분을 자동으로 계산할 수 있습니다! 이제부터는 함수의 종류를 늘려가면서 어떠한 계산도 (미분 가능한 함수라면) 미분할 수 있습니다. 그러나 안타깝게도 수치 미분에는 문제가 있습니다.

4.2 수치 미분 구현

 그럼 미분을 정의한 [식4.1]에 따라 미분을 계산하는 코드를 구현해봅시다. 그런데 컴퓨터는 극한을 취급할 수 없으니 h를 극한과 비슷한 값으로 대체하겠습니다. 예를 들어 h = 0.0001(=1e-4)과 같은 매우 작은 값을 이용하여 [식 4.1]을 계산합니다. 이런 미세한 차이를 이용하여 함수의 변화량을 구하는 방법을 수치미분(numerical diffierentiation)이라고 합니다.

수치 미분은 작은 값을 사용하여 '진정한 미분'을 근사합니다. 따라서 값에 어쩔 수 없이 오차가 포함되는데, 이 근사 오차를 줄이는 방법으로는 '중아차분centered difference'이라는 게 있습니다.

중앙차분은 f(x)와 f(x + h)의 차이를 구하는 대신 f(x-h)와 f(x+h)의 차이를 구합니다. 그름으로 나타내면 [그림 4-2]의 파란 선에 해당하죠.


[그림 4-2]에서 보듯 x와 x + h지정에서의 기울기를 구하는 방법을 '전진차분(forward difference'이라 하고, x - h와 x + h에서의 기울기를 구하는 방법을 '중앙차분'이라 하는데, 중앙차분쪽이 상대적으로 오차가 작습니다. 책에 증명까지는 싣지 않았지만 직관적으로는 [그림4-2]에서 직선들의 기울기를 비교해보면 알 수 있습니다. 참고로 중앙차분에서 직선의 기울기는 (f(x+h) - f(x-h))/2h입니다(분모가 2h인 점에 주의하세요).

전진차분보다 중앙차분이 진정한 미분값이 가깝다는 사실은 테일러 급수(Tayler series)를 이용해 증명할 수 있습니다.


그러면 중앙차분을 이용하여 수치 미분을 계산하는 함수를 numerical_diff(f, x, eps=1e-4)라는 이름으로 구현해봅시다. 첫 번째 인수 f는 미분의 대상이 되는 함수이며, 앞에서 구현한 Function의 인스턴스입니다. 두 번째 인수 x는 미분을 계산하는 변수로, Variable인스턴스입니다. 마지막의 eps는 작은 값을 나타내며, 기본값은 1e-4입니다. 수치 미분은 다음과 같이 구현할 수 있습니다.

def numerical_diff(fxeps=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 클래스를 대상으로 미분해 보겠습니다.

f = Square()
x = Variable(np.array(2.0))
dy = numerical_diff(f, x)
print(dy)

4.000000000004

이렇게 함수 y = x ** 2에서 x = 2.0일때 수치 미분한 결과를 구했습니다. 오차가 없었다면 4.0이 나왔어야 하니, 이 결과는 거의 올바른 값이라고 할 수 있겠네요

4.1 미분이란

 미분이란 무엇일까요? 간단히 말하면 '변화율'을 뜻합니다. 예컨대 물체츼 시간에 따른 위치 변화율(위치의 미분)은 속도가 됩니다. 시간에 대한 속도 변화율(속도의 미분)은 가속도에 해당하죠. 이와 같이 미분은 변화율을 나타냅니다. 정확한 정의는 '극한으로 짧은 시간(순간)'에서의 변화량입니다. 수식으로 표현하면 f(x)라는 함수가 있을 때 미분은 다음 식으로 정의 됩니다.

f'(x) = lim (f(x + h) - f(x))/h   h->0

[식 4.1]의 lim(h->0)은 극한을 나타내며, h가 한없이 0에 근접한다는 뜻입니다. 여기서 [식 4.1]의 (f(x + h) - f(x))/h는 [그림 4-1]과 같이 두 점을 지나는 직선의 기울기 입니다.



[그림 4-1]에서 보듯 x와 x + h라는 두 점에서 함수 f(x)의 변화 비율은 (f(x +h) - f(x))/h입니다.

여기서 폭 h를 한없이 0에 가깝게 줄여 x의 변화 비율을 구하면 그 값이 바로 y=f(x)의 미분입니다. 또한 y=f(x)가 어떤 구간에서 미분 가능하다면 [식 4.1]은 해당 구간의 '모든 x' 에서 성립합니다. 따라서 [식 4.1]의 f'(x)도 함수이며, f(x)의 도함수라고 합니다.



STEP4 수치미분

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

머신러닝 외에도 많은 분야에서 미분을 사용합니다. 유체 역학, 금융 공학, 기상 시뮬레이션, 엔지니어링 설계 최적화 등 정말 많죠. 이런 다양한 분야에서 자동 미분계산 기능이 실제로 사용되고 있습니다.

3.2 함수 연결

 Function클래스의 __call__메서드는 입력과 출력이 모두 Variable 인스턴스이므로 자연스럽게 DeZero 함수들을 연이어 사용할 수 있습니다. y= (eX**2)**2 이라는 계산을 예로 생각해보죠. 

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 인스턴스로 통일되어 있는 덕분에 이와 같이 여러 함수를 연속하여 적용할 수 있는것이죠. 참고로 방금 한 계산은 [그림 3-1]과 같이 함수와 변수가 교대로 늘어선 계산 그래프로 표현할 수 있습니다.





[그림 3-1]과 같이 여러 함수를 순서대로 적용하여 만들어진 변환 전체를 하나의 큰 함수로 볼 수도 있습니다. 이처럼 여러 함수로 구성된 함수를 합성 함수(compuosite function)라고 합니다. 합성함수를 구성하는 각 함수의 계산은 간단하더라도, 연속으로 적용하면 더 복잡한 계산도 해낼 수 있다는 사실을 기억하세요.

그런데 일련의 계산을 '계산 그래프'로 보여드린 이유는 무엇일까요? 그 이우는 계산 그래프를 이용하면 각 변수에 대한 미분을 효율적으로 계산할 수 있기 때문이랍니다(정확하게는 그럴 준비가 됩니다). 그리고 변수별 미분을 계산하는 알고리즘이 바로 역전파입니다. 다음 단계부터는 역전파를 구현할 수 있도록 DeZero를 확장하겠습니다.

3.1 Exp 함수 구현

 운선 DeZero에 새로운 함수를 하나 구현하겠습니다. 바로 y=eX이라는 계산을 하는 함수입니다. 여기서 e는 자연로그의 밑(base of the natural logarithm)으로 구체적인 값은 2.718..입니다(오일러의 수(Euler's number)혹은 네이피어 상수(Napier's constant)라고도 합니다). 


class Exp(Function):
  def forward(selfx):
    return np.exp(x)

Square클래스와 마찬가지로 Function 클래스를 상속한 다음 forward 메서드에서 원하는 계산을 구현했습니다. Square클래스의 차이는 forward메서드의 내용이 x ** 2에서 np.exp(x)로 바꾼점입니다.