페이지

2022년 8월 14일 일요일

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

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


10.2 square 함수의 역전파 테스트

 이어서 square 함수의 역전파도 테스트해보겠습니다. 방금 구현한 SquareTest클래스에 다음코드를 추가합니다.

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)

if __name__ == '__main__':
    unittest.main(argv=['first-arg-is-ignored'], exit=False)

test_backward메서드를 추가했습니다. 메서드 안에서 y.backward()로 미분값을 구하고, 그 값이 기댓값과 일치하는지 확인합니다. 참고로 여기에서 설정한 기댓값 6.0은 손으로 계산해서 구한 값을 하드코딩한 것입니다.

그럼 다시 테스트를 돌려봄시다. 결과는 다음과 같습니다.


---------------------------------------------------------------------- Ran 2 tests in 0.006s



결과를 보면 2개의 테스트를 통과했음을 알 수 있습니다. 원한다면 지금까지와 같은 요령으로 다른 테스트 케이스(입력과 기댓값)도 추가해나갈 수 있습니다. 테스트 케이스가 많아질수록 SQUARE함수의 신뢰도도 높아질 겁니다. 그리고 코드를 수정할 때마다 즉시즉시 테스트를 실행해주면 square함수의 상태를 반복해서 확인할 수 있습니다.

10.1 파이썬 단위 테스트

 파이썬으로 테스트할 때는 표준 라이브러리에 포함된 unittest를 사용하면 편합니다. 여기에서는 이전 단계에서 구현한 square함수를 테스트해봅니다. 코드는 다음과 같습니다.


import unittest

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)


이와 같이 우선 unittest를 임포트하고 unittest.TestCase를 상속한 SquareTest클래스를 구현합니다. 여기서 기억할 규칙이 있습니다. 테스트할 때는 이름이 test로 시작하는 메서드를 만들고 그안에 테스트할 내용을 적습니다. 앞의 테스트는 square 함수의 출력 기댓값(expected)과 같은지 확인합니다. 정확하게는 입력이 2.0일때 출력이 4.0이 맞는지 확인합니다.


앞의 예에서 square함수의 출력이 기댓값과 같은지 확인하기 위해 self.assertEqual이라는 메서드를 사용했습니다. 이 메서드는 주어진 두 객체가 동일한지 여부를 판정합니다. 이 메서드 외에도 self.assertGreater와 self.assertTrue등 unittest에는 다양한 메서드가 준비되어 있습니다. 다른 메서드들의 사용법은 unittest문서를 참고하세요


이제 테스트를 실행해볼까요? 

python -m unittest steps/step10.py

python 명령을 실행할 때 앞의 예처럼 -m unittest 인수를 제공하면 파이썬 파일을 테스트 모드로 실행할 수 있습니다.

unittest.main()

테스트가 뭐라고 출력했는지 확인할 차례입니다. 실제로 앞의 명령을 실행하면 다음결과가 출력됩니다.


'1개의 테스트를 실행했고, 결과는 ok다'라는 뜻입니다. 즉, 테스트를 통과한 것입니다. 만약 무언가 문제가 있다면 'FAIL': test_forward(step10.SquareTest)'같은 문장이 출력되어 테스트가 실패했음을 알려줄 것입니다.