페이지

2022년 8월 14일 일요일

11.2 Add 클래스 구현

 이번 절에서는 Add클래스의 forward메서드를 구현합니다. 주의할 점은 인수와 반환값이 리스트(또는 튜플)여야 한다는 것입니다. 이 조건을 반영하여 다음 처럼 구현할 수 있습니다.

class Add(Function):
  def forward(selfxs):
    x0, x1 = xs
    y = x0 + x1
    return (y,)

Add 클래스의 인수는 변수가 두개 담긴 리스트입니다. 따라서 x0, x1 = xs형태로 리스트 xs에서 원소 두 개를 꺼냈습니다. 그런 다음 꺼낸 원소들을 사용하여 계산합니다. 결과를 반환할 때는 return(y,)형태로 튜플을 반환합니다(return y,처럼 괄호는 생략해도 됩니다). 수정한 Add클래스는 다음과 같이 사용할 수 있습니다.

xs = [Variable(np.array(2)), Variable(np.array(3))]   # 리스트로 준비
f = Add()
ys = f(xs)      # ys 튜플
y = ys[0]
print(y.data)

5

보시는 것처럼 2 + 3 = 5 계산을 DeZero로 재대로 처리할 수 있게 되었습니다. 입력을 리스트로 바꿔서 여러 개의 변수를 다룰 수 있게 하였고, 출력은 튜플로 바꿔서 역시 여러 개의 변수에 대응할 수 있게 했습니다. 이제 순전파에 한해서는 가변 길이 인수와 반환값에 대응할 수 있을 것입니다. 그런데 앞의 코드를 보면 다소 귀찮은 느낌이 듭니다. 왜냐하면 Add클래스를 사용하는 사람에게 입력 변수를 리스트에 담아 건네주라고 요구하거나 반환값으로 튜플을 받게 하는 것은 자연스럽지 않기 때문입니다. 그래서 다음 단계에서는 더 자연스러운 코드로 쓸 수 있도록 지금의 구현을 개선하겠습니다.

11.1 Function 클래스 수정

 가변 길이 입출력을 표현하려면 변수들을 리스트(또는 튜플)에 넣어 처리하면 편할 것 같습니다. 즉, Function 클래스는 지금까지처럼 '하나의 인수'만 받고 '하나의 값'만 반환하는 것이죠. 대신 인수와 반환값의 타입을 리스트로 바꾸고, 필요한 변수들을 이 리스트에 넣으면 됩니다.

파이썬의 리스트와 튜플은 여러 개의 데이터를 한 줄로 저장합니다. 리스트는 [1, 2, 3]과 같이 []로 묶고 튜플은 (1, 2, 3)과 같이 ()로 묶습니다. 리스트와 튜플의 주요 차이는 원소를 변경할 수 있는지 여부입니다. 튜플의 경우 한번 생성되면 원소를 변경할 수 없습니다. 옐르 들어  x = (1, 2, 3)으로 튜플을 생성한 후 에는 x[0]  = 4 등으로 덮어 쓸 수 없는 것이죠. 반면 리스트는 원소를 변경할 수 있습니다.


그러면 현재의 Function 클래스가 어떻게 구현되어 있는지부터 확인해 보죠.

class Function:
  def __call__(selfinput):
    x = input.data  #1
    y = self.forward(x) #2
    output = Variable(as_array(y))  #3
    output.set_creator(self)  #4
    self.input = input
    self.output = output
    return output
  
  def forward(selfx):
    raise NotImplementedError()
  
  def backward(selfgy):
    raise NotImplementedError()


Function의 __call__메서드는 Variable이라는 '상자'에서 실제 데이터를 꺼낸 다음 forward메서드에서 구체적인 계산을 합니다. 그리고 계산 결과를 Variable에 넣고 자신이 '창조자'라고 원산지를 표시합니다. 이상의 로직을 염두에 두고 __call__메서드의 인수와 반환값을 리스트로 바꿔보겠습니다.

 

class Function:
  def __call__(selfinputs):
    xs = [x.data for x in inputs]
    ys = self.forward(xs)
    outputs = [Variable(as_array(y)) for y in ys ]

    for output in outputs:
      output.set_creator(self)
    self.inputs = inputs
    self.outputs = outputs
    return outputs
  
  def forward(selfxs):
    raise NotImplementedError()

  def backward(selfgys):
    raise NotImplementedError()

인수와 반환값을 리스트로 변경했습니다. 변수를 리스트에 담아 취급한다는 점을 제외하고는 달라진 게 없습니다. 참고로 앞의 코드에서는 리스트를 생성할 때 리스트 내포(list comprehension)를 사용했습니다.


리스트 내포는 xs = [x.data for x in inputs] 형태로 사용합니다. 이 코드는 inputs 리스트의 각 원소 x에 대해 각각의 데이터(x.data)를 꺼내고, 꺼낸 원소들로 구성된 새로운 리스트를 만듭니다.


이상이 새로운 Function 클래스입니다. 이어서 새로운 Function클래스를 사용하여 구체적인 함수를 구현하겠습니다. 첫 번째는 덧셈을 해주는 Add클래스 차례입니다.

STEP 11가변 길이 인수(순전파 편)

 지금까지 우리는 함수에 입출력 변수가 하나씩인 경우만 생각해왔습니다. 예를 들어 y = square(x) 와 y = exp(x) 등은 입출력 변수가 하나씩입니다. 그러나 함수에 따라 여러 개의 변수를 입력받기도 합니다. [그림 11-1]의 '덧셈'과 '곱셈'이 대표적이죠.


출력이 여러 개인 함수도 있습니다. 옐르 들어 [그림 11-2]와 같은 함수가 있을 수 있겠죠.



이상을 고려하여 DeZero가 가변 길이 입출력을 처리할 수 있도록 확장하려 합니다. 가변 길이라고 함은 인수(또는 반환값)의 수가 달라질 수 있다는 뜻입니다. Function 클래스를 조금 수정하면 가변 길이 입출력에 대응 할 수 있을 것 같습니다.

제2고지 자연스러운 코드로

 우리 Dezero가 제1고지에 올라섰습니다(특정 계산으로 한정하면) 미분을 자동으로 계산할 수 있습니다. 예를 들어 제곱과 지수 함수 같은 함수 클래스로 구성된 (그리고 분기가 없는) 계산 그래프라면 backward 메서드를 호출하는 것만으로 미분값이 자동으로 구해집니다.

이제부터 두 번째 고지로 향합니다. 이번 고지의 주된 목적은 더 복잡한 계산도 가능하도록 현재의 DeZero를 확장하는 것입니ㅏㄷ. 정확하게는 입력을 여러 개 받는 함수나 출력이 여러 개인 함수도 처리할 수 있도록 DeZero의 기반을 수정할 것입니다. 또한 계산을 더 자연스러운 코드로 표현할 수 있도록, 예를 들어 +와 * 같은 연산자를 사용할 수 있도록 하겠습니다.

자동미분

 딥러닝 프레임워크의 중심에는 역전파가 있습니다. 역전파를 문헌에 따라 '자동 미분'이라고 부르기도 합니다. 하지만 '자동미분'이라는 용어는 (특히 학술 분야에서는) 더 제한적인 방법을 뜻하므로 주의해야 합니다. 이번 칼러에서는 자동미분에 대해 한 걸음 더 들어가 보겠습니다.

자동 미분을 문자 그래도 해석하면 '자동으로 미분을 계산하는 방법(기술)입니다. '자동으로'라 함은(사람이 아니라)컴퓨터가 미분을 계산한다는 뜻이죠. 정확히 말하면 어떤 계산(함수)을 코드로 구현하면 그 계산의 미분을 컴퓨터가 자동으로 계산해주는 시스템을 가리킵니다.

컴퓨터 프로그램에서 미분을 계산하는 방법은 크게 세 가지로 나눌수 있습니다. 첫 번째는 수치미분(numerical differentiation)입니다. 수치 미분은 4단계에서 구현한 것처럼 변수에 미세한 차이를 주어 일반적인 계산(순저파)을 2회 실시하고, 두 출력의 차이로부터 근사적으로 미분을 계산합니다. 수치 미분은 구현하기 쉽지만 출려에 오차가 포함되기 쉽고, 다량의 변수를 사용하는 함술르 다룰때는 계산 비용이 높다는 단점이 있습니다.

두 번째 방법은 기호 미분(symbolic differentiation)입니다. 기호 미분은 고등학교 수학에서 배운것처럼 미분 공식을 이용하여 계산하는 방법입니다. 입력도 '수식'이고 출력도'수식'입니다(수식은 트리 데이터 구조로 표현할 수 있습니다). Mathematica와 MATLAB등에서 이용하는 방법입니다.

기호 미분의 출력은 미분된 '식'(즉, 도함수)이며, 출력 시점에는 아무런 수치 계산도 수행되지 않습니다. 대신 도함수를 얻은 후 그체적인 값(예:x = 3.0)에서의 미분을 계산하는 식입니다.

기호 미분의 단점은 수식이 크게 부풀어 오르기 쉽다는 것입니다. 특히 최적화를 고려하지 않고 구현하면 수식이 곧바로 거대해집니다(수식이 '폭발'한다고 합니다). 그런데 딥러닝에서 취급하는 계산은 수많은 변수에 대한(수식이 아닌) 미분'값'을 효율적으로 구해야 합니다. 그래서 기호 미분보다 효율적인 방법이 필요합니다.

세 번째 방법은 자동 미분(automatic differentiation)입니다. 자동 미분은 연쇄 법칙을 사용하여 미분하는 방법으로, 어떤 함수를 프로그램으로 짜서 건네주면 그 미분을 효율적이고 정밀하게 계산할 수 있습니다. 역전파 방식도 자동 미분에 속합니다. 더 정확히 말하면 자동 미분은 크게 두 가지로 나눈 수 있습니다. 바로 'forward 모드'와 'reverse 모드'죠. 역전파는 후자인 'reverse 모드 자동 미분'에 해당합니다.

역전파(reverse 모드 자동 미분)는 미분 결과를 출력 쪽으로부터 입력 쪽으로 전달합니다. 반대로 forward모드 자동 미분은 입력 쪽으로부터 출력 쪽으로 전달합니다. 두 방법 모두 연쇄법칙을 사용하여 미분값을 계산하지만 그 '경로'가 다른 것이죠. 출력이 하나뿐이고, 그 하나의 출력변수를 미분하려면 reverse 모드 자동 미분이 적합합니다. 머신 러닝은 대부분 출력이 변수 하나로 모이지는 문제를 다루기 때문에 reverse 모드 자동 미분이 사용됩니다. 이러한 이유로 이책에서는 forward 모드 자동 미분에 대해서는 더 이상 설명하지 않으니, 관심 있는 분은 따로 참고 문헌[6]과 [7]을 참고하기 바랍니다.

지금까지의 내용을 정리하면 '컴퓨터 프로그램으로 미분을 계산하는 방법'은 [그림A-1]과 같이 나뉩니다.


[그림 A-1]에서 보듯 '자동 미분'은 컴퓨터로 미분을 계산하는 여러 방법 중 하나입니다. 딥러닝 프레임워크는 그 중에서도 'reverse 모드 자동 미분'을 구현해 사용합니다. 그러나 문헌에 따라 forward모드와 reverse모드를 구분하지 않고, 역전파를 가르켜 '자동미분'이라고 부르기도 합니다.


자동 미분은 학계에서 오랫동안 연구해온 분야입니다. 오랜 역사만큼이나 중요한 지식도 많이 쌓여 있지요. 하지만 아쉽게도 지금까지 머신러닝 분야와는 그다지 교류가 없었습니다. 최근 딥러닝 붐이 일면서 자동미분 분야에 대한 관심이 높아지는 추세이며, 머신러닝과 프로그래밍 언어 등의 분야와 자동 미분 분야의 새로운 교류가 꿈틀대고 있답니다.

10.4 테스트 정리

 DeZero 개발에 한정한다면 테스트에 관한 지식은 이 정도면 충분합니다. 여기서 배운 것만으로도 DeZero 테스트 코드를 작성하는 데 아무런 무리가 없을 겁니다. 다만 앞으로 이 책에서는 테스트에 대한 직접적인 설명은 생략하고 진행하겠습니다. 만약 테스트 코드가 필요하다고 느껴지면 스스로 추가해보기 바랍니다.

또한 테스트 파일들은 하나의 장소에 모아 관리하는 것이 일반적입니다. 이 책에서도 테스트코드는 tests 디렉터리에 모아뒸습니다(테스트요 편의 기능도 추가로 구현해뒀습니다). 관심있는 분은 해당 테스트 코드를 찾아보세요. 이번 단계에서 작성한 것과 비슷한 코드를 많이 발견할 수 있을 겁니다. 덧붙여서, 테스트 파일들은 다음 명령으로 한꺼번에 실행할 수 있습니다.

$python -m unittest discover tests

이와 같이 discover라는 하위 명령을 사용하면 discover다음에 지정한 디렉터리에서 테스트 파일이 있는지 검색합니다. 그리고 발견한 모든 파일을 실행하는 것이죠, 기본적으로는 지정한 디렉터리에서 이름이 test*.py 형태인 파일을 테스트 파일로 인식합니다(변경할 수 있습니다). 이것으로 test 디렉터리에 들어 있는 모든 테스트를 한번에 실행할 수 있습니다.

DeZero의 tests 디렉터리에는 정답을 체이너에게 묻는 테스트도 있습니다.예를 들어 시그모이드 함수를 테스트하는 경우 동일한 입력으로 DeZero와 Chainer 각각에 계산을 시키고 출력값이 거의 같은지 비교합니다.


또한 DeZero의 깃허브 저장소는 트래비스 CI라는 지속적 통합(continuous integration(CI))서비스와 연계해뒀습니다. DeZero의 깃허브 저장소에서 코드를 푸시(push)하고, 풀 리쿼스트(pull request)를 병합하고, 매시간 자동으로 테스트가 실행되도록 설정해좋은 것이죠, 테스트 결과에 문제가 있으면 메일 등으로 보고됩니다. 게다가 DeZero깃허브 저장소의 첫 화면에는 [그림 10-1]과 같이 로고 하단 왼쪽에 빌드 상태가 표시되게 해뒀습니다.

'build: passing'은 빌드 후 테스트까지 통과했다는 표시입니다(테스트에 실패하면 'build:failed'라는 배치가 표시됩니다). 이처럼 CI도구와 예계하면 소스 코드를 지속해서 테스트할 수 있습니다. 코드의 신뢰성을 유지하는 요령이죠. 

지금의 DeZero는 작은 소프트웨어이지만 앞으로 더 큰 소프트웨어로 성장시킬 것입니다. 여기에서 설명한 테스트 방식을 도입하면 성장 과정에서도 코드의 신뢰성을 꾸준히 유지할 수 있을 것입니다.

2022년 8월 13일 토요일

10.3 기울기 확인을 이용한 자동 테스트

 앞 절에서 역전파 테스트를 작성하여 미분의 기댓값을 손으로 계산해 입력했습니다. 사실 이부분을 자동화할 방법이 있습니다. 바로 기울기 확인(gradient checking)이라는 방법이죠, 기울기 확인이란 수치 미분으로 구한 결과와 역전파로 구한 결과를 비교하여 그 차이가 크면 역전파 구현에 문제가 있다고 판단하는 검증 기법입니다.

우리는 4단계에서 수치 미분을 구현했습니다. 수치 미분은 쉽게 구현할 수 있고 거의 정확한 미분값을 내줍니다. 따라서 수치미분의 결과와 비교하면 역전파를 정확히 구현했는지 검증할 수 있습니다.

기울기 확인은(기댓값을 몰라도) 입력값만 준비하면 되므로 테스트 효율을 높여줍니다. 그러니 우리 테스트에서도 기울기 확인을 이용하겠습니다. 4단계에서 구현한 numerical_diff 함수를 사용하면 되겠군요. 복습도 겸해서 이 함수의 코드까지 함깨 보여드리겠습니다.


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)

class SquareTest(unittest.TestCase):

  def test_forward(self):
    x = Variable(np.array(2.0))
    y = square(x)
    expected = np.array(4.0)
    self.assertEqual(y.data, expected)


  def test_backward(self):
    x = Variable(np.array(3.0))
    y = square(x)
    y.backward()
    expected = np.array(6.0)
    self.assertEqual(x.grad, expected)
  
  def test_gradient_check(self):
    x = Variable(np.random.rand(1)) # 무작위 입력값 생성
    y = square(x)
    y.backward()
    num_grad = numerical_diff(square, x)
    flg = np.allclose(x.grad, num_grad)
    self.assertTrue(flg)
    
if __name__ == '__main__':
    unittest.main(argv=['first-arg-is-ignored'], exit=False)


기울기 확인을 할 test_gradient_check메서드 안에서 무작위 입력값을 하나 생성합니다. 이어서 역전파로 미분값을 구하고, numerical_diff 함수를 사용해서 수치 미분으로도 계산해봅니다. 그런 다음 두 메서드로 각각 구한 값들이 거의 일치하는지 확인합니다. 이때 np.allclose라는 넘파이 함수를 이용합니다.

np.allclose(a,b)는 ndarray 인스턴스인 a 와 b의 값이 가까운지(close)판정합니다. 얼마나 가까워야 가까운 것인지는 np.allclose(a, b, rtol=1e-08)과 같이 인수 rtol과 atol로 지정할 수 있습니다. 이 함수는 a와 b의 오든 요소가 다음 조건을 만족하면 True를 반환합니다.

[a - b] <= (atol + rtol * |b|)

한편 기울기 확인을 하는 대상의 계산(함수)에 따라 atol과 rtol의 값을 미세하게 조정해야 할 수도 있습니다. 기준을 정하는데는 참고 문헌[5]등의 도움이 됩니다. 그럼 이상의 이루기 확인을 위 코드에 추가하고 테스트를 돌려봅시다. 이번에 얻은 결과는 다음과 같습니다.

---------------------------------------------------------------------- Ran 3 tests in 0.010s

이와 같이 기울기 홗인을 이용하면 미분을 자동으로 계산하는 딥러닝 프레임워크를 반자동으로 테스트할 수 있고, 덕분에 더 체계적으로 더 넓은 범위를 검증하는 테스트 케이스를 만들 수 있습니다.