05.5 활성화 함수 계층 구현하기

드디어 계산 그래프를 신경망에 적용할 때가 왔습니다. 여기에서는 신경망을 구성하는 층(계층) 각각을 클래스 하나로 구현합니다. 우선은 활성화 함수인 ReLU와 Sigmoid 계층을 구현하겠습니다.

5.5.1 ReLU 계층

활성화 함수로 사용되는 ReLU는 다음 식으로 나타납니다.

\[y = \begin{cases} x & (x > 0) \\ 0 & (x \le 0) \end{cases} \tag{식 5.7}\]

[식 5.7]에서 $x$에 대한 $y$의 미분은 [식 5.8]처럼 구합니다.

\[\frac{\partial y}{\partial x} = \begin{cases} 1 & (x > 0) \\ 0 & (x \le 0) \end{cases} \tag{식 5.8}\]

[식 5.8]에서와 같이 순전파 때의 입력인 $x$가 0보다 크면 역전파는 상류의 값을 그대로 하류로 흘립니다. 반면, 순전파 때 $x$가 0 이하면 역전파 때는 하류로 신호를 보내지 않습니다(0을 보냅니다). 계산 그래프로는 [그림 5-18]처럼 그릴 수 있습니다.

그림 5-18 ReLU 계층의 계산 그래프

그림 5-18

이제 이 ReLU 계층을 구현해볼까요? 신경망 계층의 forward()backward() 함수는 넘파이 배열을 인수로 받는다고 가정합니다.

class Relu:
    def __init__(self):
        self.mask = None
        
    def forward(self, x):
        self.mask = (x <= 0)
        out = x.copy()
        out[self.mask] = 0
        
        return out
        
    def backward(self, dout):
        dout[self.mask] = 0
        dx = dout
        
        return dx

![ReLU 계층 코드의 self.mask 역할](./img/fig-5-5-relu-code.png)
*도로시가 `Relu` 클래스 코드에서 `self.mask` 변수가 인덱스 필터링 작업을 지휘하는 흐름을 보고 손뼉을 치며 감탄하고 있어요.*

> **도로시의 넘파이 마스크 감탄**
> * **도로시**: "와! `self.mask = (x <= 0)`이라는 한 줄의 코드로 0 이하인 위치를 기억해 뒀다가, `backward` 때 그 True인 위치의 기울기만 쏙 골라내어 0으로 덮어써 버리는구나! 넘파이(NumPy)의 마스크 배열 연산 기능이 이렇게 강력하고 깔끔한 줄 몰랐어!"
> * **지니**: "그렇지! 복잡한 반복문(`for`) 루프 없이 고성능으로 마스킹 배열 연산을 수행해 주니까 신경망 학습 속도가 비약적으로 빨라진단다."

Relu 클래스는 mask라는 인스턴스 변수를 가집니다. mask는 True/False로 구성된 넘파이 배열로, 순전파의 입력인 x의 원소 값이 0 이하인 인덱스는 True, 그 외(0보다 큰 원소)는 False로 유지합니다. 예컨대 mask 변수는 다음 예와 같이 True/False로 구성된 넘파이 배열을 유지합니다.

>>> x = np.array( [[1.0, -0.5], [-2.0, 3.0]] )
>>> print(x)
[[ 1.  -0.5]
 [-2.   3. ]]
>>> mask = (x <= 0)
>>> print(mask)
[[False  True]
 [ True False]]

[그림 5-18]과 같이 순전파 때의 입력 값이 0 이하이면 역전파 때의 값은 0이 돼야 합니다. 그래서 역전파 때는 순전파 때 만들어둔 mask를 써서 mask의 원소가 True인 곳에는 상류에서 전파된 dout을 0으로 설정합니다.

NOTE_ ReLU 계층은 전기 회로의 ‘스위치’에 비유할 수 있습니다. 순전파 때 전류가 흐르고 있으면 스위치를 ON으로 하고, 흐르지 않으면 OFF로 합니다. 역전파 때는 스위치가 ON이라면 전류가 그대로 흐르고, OFF면 더 이상 흐르지 않습니다.

지니의 스위치와 필터 마법 (Relu & Sigmoid)

  • 지니: “활성화 함수 계층은 역방향으로 흐르는 기울기 신호를 켜고 끄는 ‘스위치’나 값을 압축하는 ‘필터’ 역할을 해. ReLU는 순방향 때 0보다 컸던 곳은 스위치 ON(기울기를 그대로 통과)하고, 0 이하인 곳은 스위치 OFF(기울기를 0으로 차단)하지! Sigmoid는 복잡한 수식을 거치지만 결국 순방향 출력값 $y$만 기억해 두면, 역전파 때 $y(1-y)$라는 아주 간결한 비율로 압축해서 통과시킨단다.”
  • 도로시: “와! 복잡한 미분 공식을 매번 계산할 필요 없이, 순방향 때 얻어둔 출력값 $y$만 딕셔너리에 가지고 있다가 곱해주기만 하면 되는구나! 정말 영리한 모듈식 설계야!”
  • 토토: “왈왈! (스위치를 켜고 끄며) ReLU 스위치 온오프 작동 완료다 멍!”

ReLU 스위치와 Sigmoid 필터 계층 시각화 도로시가 책상 위에 놓인 ‘ReLU 계층’ 스위치와 ‘Sigmoid 계층’ 다이얼 필터를 직접 코딩하며 테스트하고 있고, 지니와 토토가 그 곁에서 기울기 신호가 통과하거나 압축되는 흐름을 모니터링하고 있어요.

5.5.2 Sigmoid 계층

다음은 시그모이드 함수 차례입니다. 시그모이드 함수는 다음 식을 의미하는 함수입니다.

\[y = \frac{1}{1 + \exp(-x)} \tag{식 5.9}\]

[식 5.9]를 계산 그래프로 그리면 [그림 5-19]처럼 됩니다.

그림 5-19 Sigmoid 계층의 계산 그래프(순전파)

그림 5-19

[그림 5-19]에는 ‘$\times$’와 ‘$+$’ 노드 말고도 ‘$\exp$’와 ‘$/$’ 노드가 새롭게 등장했습니다. ‘$\exp$’ 노드는 $y = \exp(x)$ 계산을 수행하고 ‘$/$’ 노드는 $y = \frac{1}{x}$ 계산을 수행합니다.*

[그림 5-19]와 같이 [식 5.9]의 계산은 국소적 계산의 전파로 이뤄집니다. 이제 [그림 5-19]의 역전파를 알아볼 차례입니다. 여기에서는 역전파의 흐름을 오른쪽에서 왼쪽으로 한 단계씩 짚어보겠습니다.

1 단계

’$/$’ 노드, 즉 $y = \frac{1}{x}$을 미분하면 다음 식이 됩니다.

\[\begin{aligned} \frac{\partial y}{\partial x} &= -\frac{1}{x^2} \\ &= -y^2 \end{aligned} \tag{식 5.10}\]

[식 5.10]에 따르면 역전파 때는 상류의 예측값에 $-y^2$(순전파의 출력을 제곱한 후 마이너스를 붙인 값)을 곱해서 하류로 전달합니다. 계산 그래프에서는 다음과 같습니다.

그림 5-19 1단계

2 단계

’$+$’ 노드는 상류의 값을 여과 없이 하류로 내보내는 게 다입니다. 계산 그래프에서는 다음과 같습니다.

그림 5-19 2단계

3 단계

’$\exp$’ 노드는 $y = \exp(x)$ 연산을 수행하며, 그 미분은 다음과 같습니다.

\[\frac{\partial y}{\partial x} = \exp(x) \tag{식 5.11}\]

계산 그래프에서는 상류의 값에 순전파 때의 출력(이 예에서는 $\exp(-x)$)을 곱해 하류로 전파합니다.

그림 5-19 3단계

4 단계

’$\times$’ 노드는 순전파 때의 값을 ‘서로 바꿔’ 곱합니다. 이 예에서는 $-1$을 곱하면 되겠습니다.

그림 5-20 Sigmoid 계층의 계산 그래프

그림 5-20

이상으로 [그림 5-20]과 같이 Sigmoid 계층의 역전파를 계산 그래프로 완성했습니다. [그림 5-20]에서 보듯이 역전파의 최종 출력인 $\frac{\partial L}{\partial y} y^2 \exp(-x)$ 값이 하류 노드로 전파됩니다. 여기에서 $\frac{\partial L}{\partial y} y^2 \exp(-x)$를 순전파의 입력 $x$와 출력 $y$만으로 계산할 수 있다는 것을 눈치채셨나요? 그래서 [그림 5-20]의 계산 그래프의 중간 과정을 그룹화하여 [그림 5-21]처럼 단순한 ‘sigmoid’ 노드 하나로 대체할 수 있습니다.

그림 5-21 Sigmoid 계층의 계산 그래프(간소화 버전)

그림 5-21

[그림 5-20]의 계산 그래프와 [그림 5-21]의 간소화 버전의 결과는 똑같습니다. 그러나 간소화 버전은 역전파 과정의 중간 계산들을 생략할 수 있어 더 효율적인 계산이라 말할 수 있지요. 또, 노드를 그룹화하여 Sigmoid 계층의 세세한 내용을 노출하지 않고 입력과 출력에만 집중할 수 있다는 것도 중요한 포인트입니다.

또한, $\frac{\partial L}{\partial y} y^2 \exp(-x)$ 는 다음과 같이 정리해서 쓸 수 있습니다.

\[\begin{aligned} \frac{\partial L}{\partial y} y^2 \exp(-x) &= \frac{\partial L}{\partial y} \left( \frac{1}{1 + \exp(-x)} \right)^2 \exp(-x) \\ &= \frac{\partial L}{\partial y} \frac{1}{1 + \exp(-x)} \frac{\exp(-x)}{1 + \exp(-x)} \\ &= \frac{\partial L}{\partial y} y (1 - y) \end{aligned} \tag{식 5.12}\]

이처럼 Sigmoid 계층의 역전파는 순전파의 출력($y$)만으로 계산할 수 있습니다.

그림 5-22 Sigmoid 계층의 계산 그래프 : 순전파의 출력 $y$만으로 역전파를 계산할 수 있다.

그림 5-22

그럼 Sigmoid 계층을 파이썬으로는 어떻게 구현할까요? [그림 5-22]를 참고해 다음과 같이 구현할 수 있습니다.

class Sigmoid:
    def __init__(self):
        self.out = None
        
    def forward(self, x):
        out = 1 / (1 + np.exp(-x))
        self.out = out
        
        return out
        
    def backward(self, dout):
        dx = dout * (1.0 - self.out) * self.out
        
        return dx

![Sigmoid 계층 코드의 self.out 역할](./img/fig-5-5-sigmoid-code.png)
*지니가 시그모이드 계층 `Sigmoid` 클래스 코드에서 순방향 출력 `self.out` 변수 하나만으로 역전파 계산을 끝마치는 최적화 연산 기법을 손가락으로 짚어 보여주고 있어요.*

> **지니의 시그모이드 최적화 코드 해설**
> * **지니**: "도로시, `Sigmoid` 계층 코드를 보렴. 순방향 연산 결과인 `out`을 인스턴스 변수 `self.out`에 소중하게 담아놓으니까, `backward`에서는 그저 `dout * (1.0 - self.out) * self.out` 한 줄만으로 복잡한 지수함수 미분 연산이 해결됐어!"
> * **도로시**: "와! 역방향 계산을 위해 다시 무거운 지수함수 연산(`np.exp`)을 계산 그래프 중간에서 반복 호출할 필요가 없어서 연산 부하가 엄청나게 단축되겠네! 정말 명쾌하고 효율적인 코딩 노하우야!"
> * **토토**: "왈왈! (노트북 화면을 보며) 시그모이드 다이얼 최적화 성공이다 멍!"

이 구현에서는 순전파의 출력을 인스턴스 변수 out에 보관했다가, 역전파 때 그 변수를 사용해 계산을 수행합니다.


* 옮긴이_ [그림 5-19]와 비교하며 읽으면 헷갈릴 수도 있는데, 본 설명글(not 그림)에서의 $x$는 함수의 입력을 추상화해 표기할 때 가장 흔히 쓰는 기호인 $x$입니다. 예를 들어 [그림 5-19]의 ‘$/$’ 노드의 입력 $1+\exp(-x)$ 전체를 $x$ 하나로 치환하면 설명글과 잘 들어맞는 걸 알 수 있습니다.

서브목차