페이지

2022년 8월 14일 일요일

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)'같은 문장이 출력되어 테스트가 실패했음을 알려줄 것입니다.

STEP10 테스트

 소프트웨어 개발에서는 테스트를 빼놓을 수 없습니다. 테스트를 해야 실수(버그)를 예방할 수 있으며 테스트를 자동화해야 소프트웨어의 품질을 유지할 수 있습니다. DeZero도 마찬가지입니다. 그래서 이번 단계에서는 테스트 방법, 특히 딥러닝 프레임워크의 테스트 방법에 대해 설명하겠습니다.


소프트웨어 테스트는 규모가 커지면 독특한 규칙이나 세세한 규칙이 많아지기 쉽습니다. 그렇다고 테스트를 처음부터 어렵게 생각할 필요는 없습니다. 우선 '테스트 한다'는 그 자체가 중요합니다. 이번 단계에서는 본격적인 테스트가 아니라 가능한 간단한 테스트를 해보겠습니다.

9.3 ndarray만 취급하기

 DeZero의 Variable은 데이터로 ndarray 인스턴스만 취급하게끔 의도했습니다. 하지만 사용하는 사람이 모르고 float나 int같은 의도치 않은 데이터 타입을 사용하는 일도 충분히 일어날 수 있습니다. 예컨대 Variable(1.0)혹은 Variable(3)처럼 사용할 수도 있겠죠, 이런 사태를 막기 위해 Variable의 ndarray 인스턴스만을 담는 '상자'가 되도록 고민을 해봤습니다. 그래서 Variable에 ndarray인스턴스 외의 데이터를 넣을 경우 즉시 오류를 일으키리로 했습니다(미분값은 None으로 유지합니다). 이렇게 하면 문제를 조기에 발견할 수 있겠지요. 자, 우선 Variable클래스의 초기화 부분에 다음 코드를 추가합니다.

class Variable:
  def __init__(selfdata):
    if data is not None:
      if not isinstance(data, np.ndarray):
        raise TypeError('{}은(는) 지원하지 않습니다.' .format(type(data)))
    
    self.data = data
    self.grad = None
    self.creator = None

이와 같이 인수로 주어진 data가 None이 아니고 ndarray 인스턴스도 아니라면 TypeError라는 예외를 발생시킵니다. 이때 오류 메시지로 출력할 문장도 준비합니다. 이제 새로워진 Variable을 사용해봅시다.

x = Variable(np.array(1.0)) # OK
x = Variable(None)          # OK

x = Variable(1.0)           # NG: 오류 발생!

TypeError                                 Traceback (most recent call last)
<ipython-input-11-b6a0c1e73929> in <module>()
      2 x = Variable(None)          # OK
      3 
----> 4 x = Variable(1.0)           # NG: 오류 발생!

<ipython-input-10-4b32df2c7112> in __init__(self, data)
      3     if data is not None:
      4       if not isinstance(data, np.ndarray):
----> 5         raise TypeError('{}은(는) 지원하지 않습니다.' .format(type(data)))
      6 
      7     self.data = data

TypeError: <class 'float'>은(는) 지원하지 않습니다.

보는 바와 같이 ndarray나 None이면 아무 문제가 없지만, 다른 데이터 타입을 입력하면 (앞의 예에서는 float)예외가 발생합니다. 덕분에 잘못된 데이터 타입을 사용했음을 즉시 알수 있습니다.

그런데 이렇게 바꾸면 주의할 게 하나 생깁니다. 넘파이의 독특한 관례 때문인데요. 다음 코드를 보면서 설명하겠습니다.


x = np.array([1.0])
y = x ** 2
print(type(x), x.ndim)
print(type(y))


<class 'numpy.ndarray'> 1 <class 'numpy.ndarray'>

여기서 x는 1차원 ndarray입니다. 여기에 제곱 (x ** 2)을 하면 y의 데이터 타입도 ndarray가 됩니다. 예상대로의 결과죠. 문제가 되는 것은 다음 경우입니다.

x = np.array(1.0)   # 0차원 ndarray
y = x ** 2
print(type(x), x.ndim)
print(type(y))

<class 'numpy.ndarray'> 0 <class 'numpy.float64'>

여기서 x는 0차원의 ndarray인데, 제곱(x ** 2)을 하면 np.float64가 되어버립니다. 이상해보일지 모르지만 넘파이가 의도한 동작입니다. 즉, 0차원 ndarray인스턴스를 사용하여 계산하면 결과의 데이터 타입이 numpy.float64나 numpy.float32 등으로 달라집니다. 다시말해 DeZero 함수의 계산 결과(출력)도 numpy.float64나 numpy.float32가 되는 경우가 나옵니다. 그러나 우리 Variable은 데이터가 항상 ndarray 인스턴스라고 자정하고 있으니 대처를 해줘야 합니다. 이를 위해 우선 다음과 같은 편의 함수를 준비합니다.

def as_array(x):
  if np.isscalar(x):
    return np.array(x)
  return x


여기에 쓰인 np.isscalar는 입력 데이터가 numpy.float64 같은 스칼라 타입인지 확인해주는 함수입니다(파이썬의 int와 float 타입도 스칼라로 판단합니다). 다음은 np.isscalar함수를 사용하는 예입니다.

import numpy as np
np.isscalar(np.float64(1.0))
True
np.isscalar(2.0)
True
np.isscalar(np.array(1.0))
False
np.isscalar(np.array([123]))
False

이처럼 x가 스칼라 타입인지 쉽게 확인할 수 있으며, as_array함수는 이를 이용하여 입력이 스칼라인 경우 ndarray인스턴스로 변환해줍니다. 이제 as_array라는 편의 함수가 준비되었으니 Function 클래스에 다음의 음영 부분 코드를 추가합니다.

class Function:
  def __call__(selfinput):
    x = input.data
    y = self.forward(x)
    output = Variable(as_array(y))
    output.set_creator(self)
    self.input = input
    self.output = output
    return output
    
  def forward(selfx):
    raise NotImplementedError()

이와 같이 순전파의 결과인 y를 Variable 로 감쌀때 as_array()를 이용합니다. 이렇게 하여 출력 결과인 output은 항상 ndarray 인스턴스가 되도록 보장하는 것이죠. 이제 0차원 ndarray인스턴스를 사용한 계산에서도 모든 데이터는 ndarray인스턴스이니 안심해도 좋습니다.

이상으로 이번 단계에서 할 일을 마쳤습니다. 다음 단계의 이야기 주제는 DeZero의 '테스트'입니다.


9.2 backward 메서드 간소화

 두 번재 개선은 역전파 시 사용자의 번거로움을 줄이기 위한 것입니다. 구체적으로는 방금 작성한 코드에서 y.grad = np.array(1.0) 부분을 생략하려 합니다. 지금까지는 역전파할 때마다 y.grad = np.array(1.0)이라는 코드를 작성해야 했습니다. 이 코드를 생략할 수 있도록 Variable의 backward메서드에 다음 두 줄을 추가합니다.

class Variable:
  def __init__(selfdata):
    self.data = data
    self.grad = None
    self.creator = None

  def set_creator(selffunc):
    self.creator = func
  
  def backward(self):
    if self.grad is None:
      self.grad = np.ones_like(self.data)

    funcs = [self.creator]
    while funcs:
      f = funcs.pop()
      x, y = f.input, f.output
      x.grad = f.backward(y.grad)

      if x.creator is not None:
        funcs.append(x.creator)

이와 같이 만약 변수의 grad가  None이면 자동으로 미분값을 생성합니다. np.ones_like(self, data)코드는 self.data와 형상과 데이터 타입이 같은 ndarray 인스턴스를 생성하는데, 모든 요소를 1로 채워서 돌려줍니다. self.data가 스칼라이면 self.grad도 스칼라가 됩니다.


이전까지는 출력의 미분값을 nparray(1.0)으로 사용했지만, 방금 코드에서는 np.ones_like()를 썼습니다. 그 이유는 Variable의 data와 grad의 데이터 타입을 같게 만들기 위해서입니다. 예를 들어 data의 타입이 32비트 부동소스점 숫자이면 grad의 타입도 32비트 부동소수점 숫자가 됩니다. 참고로 nparray(1.0)은 64비트 부동소수점 숫자 타입으로 만들어 줍니다.


이제 어떤 계산을 하고 난 뒤의 최종 출력 변수에서 backward 메서드를 호출하는 것만으로 미분값이 구해집니다. 실제로 돌려보죠.


x = Variable(np.array(0.5))
y = square(exp(square(x)))
y.backward()
print(x.grad)

3.297442541400256