1. 신경망 개요
■ 퍼셉트론의 장점은 XOR같은 다소 복잡한 함수도 퍼셉트론으로 표현할 수 있지만, 가중치 설정을 사람이 수동으로 설정해야 한다는 단점이 있다.
■ 이 단점은 신경망이 해결할 수 있다. 신경망의 중요한 성질로서 가중치 매개변수의 적절한 값을 자동으로 학습하는 능력이 있기 때문이다.
1.1 신경망의 구조
■ 신경망은 입력층, 은닉층, 출력층으로 구성되어 있으며, 입·출력층과 달리 은닉층의 뉴런은 사람 눈에는 보이지 않는다. 이를 그림으로 나타내면 다음과 같다.
- 위의 그림을 보면 각 층의 뉴런이 연결되는 방식은 퍼셉트론과 달라진 점이 없다. 그리고 신경망에서 신호를 전달하는 방법도 기본적인 개념은 유사하지만, 주된 차이점이 있다. 그 차이점은 활성화 함수이다.
■ 앞서 입력 신호 (\( x_1, x2 \))와 출력 \( y \)에 편향을 반영한 퍼셉트론은 수식으로 나타내면 \(
y = \begin{cases}
0 & \text{if, } w_1x_1 + w_2x_2 + b \leq 0 \\
1 & \text{if, } w_1x_1 + w_2x_2 + b > 0
\end{cases}
\) 이며, 이를 그림으로 나타내면 다음과 같다. (*편향의 입력 신호는 항상 1)
- 이 퍼셉트론의 동작은 \( x_1, x2, 1 \)이라는 3개의 입력 신호가 뉴런에 입력되고, 각 신호에 가중치를 곱하여 다음 뉴런에 전달한다.
- 다음 뉴런에서는 이 신호들의 값을 더하여 합이 0을 넘으면 1, 그렇지 않으면 0을 출력한다.
■ 여기서 분기의 조건인 '0을 넘으면 1, 그렇지 않으면 0을 출력'을 하나의 함수 \( h(x) \)로 나타내면,
\(
y = h(w_1x_1 + w_2x_2 + b), \quad h(x) = \begin{cases}
0 & \text{if, } x \leq 0 \\
1 & \text{if, } x > 0
\end{cases}
\)으로 표현할 수 있다.
■ 이 식들의 의미는 다음과 같다.
1) \( y = h(w_1x_1 + w_2x_2 + b) \) 각 입력 신호에 가중치를 곱한 뒤 이들을 모두 더한 값\( ( w_1x_1 + w_2x_2 + b ) \)이 \( h(x) \) 함수의 식에 입력으로 들어가며, 2) \(
h(x) = \begin{cases}
0 & \text{if, } x \leq 0 \\
1 & \text{if, } x > 0
\end{cases}
\) 이때 입력이 '0을 넘으면 1, 그렇지 않으면 0으로' 변환된 값을 반환하고, 이 변환된 값이 \( y \)의 출력이 됨을 의미한다. 이렇게 입력의 신호의 총합을 출력의 신호로 변환해 주는 함수, \( h(x) \)를 활성화 함수(activation function)라고 한다.
2. 활성화 함수(activation function)
■ \( h(x) \), 활성화 함수는 입력 신호의 총합( \( \sum_{i=1}^{n} w_i x_i + b \) )이 활성화를 발생시키는지를 정의하는 역할이라 볼 수 있다.
■ \( y = h(w_1x_1 + w_2x_2 + b) \)를 가중치가 곱해진 입력 신호의 총합. 이 총합을 활성화 함수에 입력하여 결과를 계산하는 2단계로 나타내면, (1단계) \(
a = w_1x_1 + w_2x_2 + b
\), (2단계) \(
y = h(a)
\)이다.
이 과정을 그림으로 나타내면 다음과 같다. (뉴런(노드)을 원으로 표시)
가중치가 곱해진 각 입력 신호들의 총합의 결과가 \( a \)라는 뉴런(노드)이 되고, 이 \( a \)는 활성화 함수 \( h( ) \)를 거쳐, \( y \)라는 뉴런(노드)으로 변환된다.
■ 위의 \(
h(x) = \begin{cases}
0 & \text{if, } x \leq 0 \\
1 & \text{if, } x > 0
\end{cases}
\) 식과 같이 활성화 함수는 임곗값을 경계로 출력이 바뀐다. 이런 함수를 계단 함수(step function)라고 한다. 따라서 퍼셉트론은 활성화 함수로 계단 함수를 이용한다고 볼 수 있다.
■ 신경망에서 사용하는 대표적인 활성화 함수는 다음과 같다.
2.1 시그모이드 함수(sigmoid function)
■ 시그모이드 함수는 \( h(x) = \dfrac{1}{1 + e^{-x}} \)이다.
■ 앞서 신호를 전달할 때, 신경망과 퍼셉트론의 주된 차이점은 활성화 함수이며, 그 외에 뉴런이 다른 층으로 연결되는 구조와 신호를 전달하는 방법은 퍼셉트론과 동일하다.
2.2 계단 함수, 시그모이드 함수 비교
2.2.1 계단 함수 구현
■ 계단 함수는 입력이 0을 넘으면 1, 그 외에는 0을 출력하므로, 다음과 같이 간단하게 구현할 수 있다.
def step_function(x):
y = x > 0
return y.astype(int)
step_function(np.array([-1.3, 0, 1.2]))
```#결과#```
array([0, 0, 1])
`````````````
def step_function2(x):
y = x > 0
return y, y.astype(int)
step_function2(np.array([-1.3, 0, 1.2]))
```#결과#```
(array([False, False, True]), array([0, 0, 1]))
````````````
- y = x > 0은 x > 0이면 y는 True, x ≤ 0이면 y는 False를 반환한다. 계단 함수의 출력 결과는 0 또는 1이므로 y의 타입을 int로 변경해 주었다.
- y는 True, False인 bool type이므로 int로 바꾸면 True는 1, False는 0으로 변환된다.
- 이를 시각화하면 다음과 같다.
import matplotlib.pylab as plt
x = np.arange(-1, 1, 0.1)
y = step_function(x)
plt.plot(x,y)
plt.title('step function')
plt.show()
2.2.2 시그모이드(Sigmoid) 함수 구현
■ 시그모이드 함수는 \( h(x) = \dfrac{1}{1 + e^{-x}} \)이므로 다음과 같이 구현할 수 있다.
def sigmoid_function(x):
return 1 / (1 + np.exp(-x))
x = np.array([-1, 0, 1])
sigmoid_function(x)
```#결과#```
array([0.26894142, 0.5 , 0.73105858])
````````````
2.2.2 계단 함수와 시그모이드 함수 비교
■ 직관적으로 두 함수의 그래프를 보았을 때, 차이점은 시그모이드는 그래프가 연속적으로 나타나지만, 계단 함수는 0을 경계로 계단 모양이 되는 것을 볼 수 있다. 즉, 퍼셉트론은 뉴런 사이에 0또는 1이 흘렀다면 신경망에서는 연속적인 실수가 흐른다.
- 신경망에서 연속적인 실수가 흐른다는 점은 신경망의 중요한 특징이다. 활성화 함수를 사용하여 뉴런의 출력이 연속적인 실수 값으로 변환되면, 이를 통해 비선형성을 도입할 수 있고, 퍼셉트론이 해결하지 못하는 복잡한 문제를 해결할 수 있기 때문이다.
- 또한 학습 과정에서 가중치를 조정할 때 출력이 연속적인 실수 값이면 미세하게 조정할 수 있다. 하지만, 퍼셉트론처럼 0또는 1같은 이산적인 값만 사용하면 가중치 조정이 제한적일 수밖에 없다.
■ 공통점은 두 함수 모두 입력이 작으면 출력은 0에 가깝거나 0이고, 입력이 커지면 출력이 1에 가까워지거나 1이 되는 구조라는 점이다. 즉, 두 함수는 입력이 중요하면 큰 값을 출력하고, 입력이 중요하지 않으면 작은 값을 출력한다.
2.3 하이퍼볼릭 탄젠트(hyperbolic tangent)
■ tanh 함수는 \( tanh(x) = \dfrac{e^x - e^{-x}}{e^x + e^{-x}} \)로 시그모이드 함수의 변종이다.
- \( tanh(x) = \dfrac{e^x - e^{-x}}{e^x + e^{-x}} = \dfrac{1 - e^{-2x}}{1 + e^{-2x}} = \dfrac{2 - (1+e^{-2x})}{1 + e^{-2x}} = \dfrac{2}{1 + e^{-2x}} - 1 = 2sigmoid(2x) - 1 \)
■ tanh 함수도 시그모이드처럼 입력값을 압축하는 압축 함수이다. 다만 시그모이드는 \( (-\infty, \infty) \) 범위의 실숫값(입력값)을 0~1 사이로 압축하지만, tanh 함수는 -1~1 사이로 압축한다는 점이 다르다.
2.4 비선형 함수
■ 위에서 언급한 것을 제외하고 계단 함수와 시그모이드 함수의 공통점은 비선형 함수라는 것이다.
- 시그모이드는 곡선, 계단 함수는 계단 모양, 즉 선형 형태가 아니므로 두 함수는 비선형 함수로 분류된다.
(*선형 함수가 직선 형태가 되는 이유는 입력에 따른 출력이 입력의 상수배만큼 변하는 함수이기 때문인데, 선형 함수의 수식이 \( f(x) = ax + b \)이며, \( a \)와 \( b \)는 상수이기 때문이다.
■ 신경망의 활성화 함수는 선형 함수가 아닌 비선형 함수를 사용한다. 활성화 함수를 선형 함수로 사용하면 신경망의 층을 깊게 하는 의미가 없기 때문이다.
■ 왜냐하면 선형 함수를 사용해 아무리 층을 깊게 만들어도 은닉층이 없는 형태와 동일한 기능을 수행하기 때문이다.
■ 예를 들어 5층이면 \( y(x) = h(h(h(h(h(x))))) \)가 되고 \( y(x) = c \cdot c \cdot c \cdot c \cdot c \cdot x \)가 되지만, \( c \)가 상수이기 때문에 \( a = c^5 \)라 한다면 선형 함수인 \( y(x) = ax \)와 똑같은 식이기 때문이다.
따라서 이렇게 여러 층을 쌓아도 사용되는 활성화 함수가 선형 함수라면 층을 깊게 하는 이점이 없다.
- 만약, \( h(x) = \dfrac{1}{1 + e^{-x}} \)라면 \( y(x) = h(h(h(h(h(x))))) \)는 \( y(x) = ax \) (\( a = c^5 \))에 비해 복잡한 식이 된다. 따라서 신경망에서 층을 깊게 한 이점을 얻으려면 활성화 함수는 비선형 함수를 사용해야 한다.
2.4.1 ReLU(Rectified Linear Unit) 함수
■ 비선형 함수인 ReLU 함수는 \( h(x) = \begin{cases}
x & \text{if } x > 0 \\
0 & \text{if } x \leq 0
\end{cases} \)이라서 0을 넘으면 입력(x)을, 0 이하이면 0을 출력하는 함수이므로 0 이하는 0, 0 초과는 선형을 그리는 비선형 형태의 함수이다.
■ 즉, ReLU 함수는 입력이 들어왔을 때, 0 혹은 \( x \) 중에서 가장 큰 값을 반환하는 것으로 볼 수 있다.
- 예를 들어 입력으로 -1이 들어오면 0과 -1 중 큰 값인 0을, 입력이 5로 들어오면 0과 5 중에서 큰 값인 5를 반환하는 것이다.
이 점을 이용하면 다음과 같이 ReLU 함수를 구현할 수 있다.
def ReLU_function(x):
return np.maximum(0, x) # 0과 x 중 가장 큰 값을 반환
x = 5
ReLU_function(x)
```#결과#```
5
````````````
x2 = -1
ReLU_function(x2)
```#결과#```
0
````````````
x = np.arange(-10, 10, 0.1)
y = ReLU_function(x)
plt.plot(x, y)
plt.title('ReLU function')
plt.show()
■ 시그모이드 함수는 손실 함수 값을 줄이는 방향으로 이동하기 위해 역전파를 수행하는 과정에서 그래디언트 값이 0으로 수렴하는 기울기 소실 문제가 발생할 수 있다.
https://hyeon-jae.tistory.com/54
매개변수 갱신 방법(2)
1. 가중치 초깃값■ 너무 큰 가중치 (매개변수)값은 학습 과정에서 과적합을 발생시킬 수 있어서 초깃값을 최대한 작은 값에서 시작하거나 가중치 감소 기법을 통해 과적합을 억제해야 한다.■
hyeon-jae.tistory.com
■ 더 정확히는, 시그모이드 함수는 실수 범위를 0부터 1 범위에 매핑하도록 계산하다 보니 거의 모든 입력 값을 0과 1 사이로 매핑되며, 입력 값이 어느 정도 범위를 벗어나면 0이나 1에 수렴한다. 이런 현상을 포화(saturate)라고 하며, 이런 포화 상태에서는 미분 값이 거의 0에 수렴한다.
■ 반면, ReLU 함수는 h(x) = max(0, x)이기 때문에 역전파 과정에서 ReLU 함수를 미분하면 순전파 과정에서 입력 값이 0 이상인 부분은 그래디언트(기울기) 값이 1, 입력 값이 0 이하인 부분은 0이 된다.
■ 즉, 역전파 과정 중 곱해지는 활성화 함수의 미분 값이 0 또는 1이 되기 때문에 기울기를 0으로 아예 없애거나, 기울기를 완전히 1로 살릴 수 있다.
■ 따라서 ReLU 함수는 Sigmoid 함수의 문제점을 어느 정도 개선한 활성화 함수로 볼 수 있다. 신경망 모델의 은닉층이 깊어져도 기울기 소실이 발생하는 것을 완화시킬 수 있기 때문에, 활성화 함수로 Sigmoid를 사용했을 때보다는 신경망의 층을 더 깊게 쌓아 복잡한 모델을 만들 수 있다.
2.4.2 Leaky ReLU 함수
■ ReLU 함수는 포화되지 않기 때문에 기울기 소실 문제가 방지되는 장점이 있지만, 입력 데이터가 이미지 픽셀 데이터같은 양수 데이터가 아닌 음수 데이터가 있다면 그래디언트가 바로 0이 되기 때문에 뉴런은 죽은 상태가 되어 버린다.
■ 이런 문제를 방지하기 위해 Leaky ReLU나 ELU 같은 변형된 ReLU 함수를 사용하기도 한다.
■ 먼저, Leaky ReLU 함수는 \( h(x) = \text{max}(ax, x) \)로 정의된다.
def LeakyReLU_function(x, a):
return np.maximum(a*x, a)
x = np.arange(-10, 10, 0.1)
y = LeakyReLU_function(x, a = 0.01)
plt.plot(x, y)
plt.title('Leaky ReLU function')
plt.show()
- \( a \)를 사용함으로써 뉴런이 죽는 현상을 방지하고 ReLU의 범위를 음수까지 확장하게 해준다.
- 여기서 일반적으로 \( a = 0.01 \)를 고정해 사용하는 것이 기본 Leaky ReLU이며, \( a \)를 하이퍼파라미터로 사용하는 PReLU( Parametric Leaky ReLU)는 역전파를 통해 \( a \) 값을 찾아간다.
2.4.3 ELU(Exponential ReLU)함수
■ ELU 함수는 지수 함수 \( e^x \)를 사용해 음수 데이터의 그래디언트가 0이 되는 것을 방지하는 또 하나의 변형된 ReLU 함수이다. ELU 함수는 \(
h(x) =
\begin{cases}
x & \text{if } x > 0 \\
a(e^x - 1) & \text{otherwise}
\end{cases}
\) \( h(x) = (a(\exp(x) - 1), x) \)로 정의된다.
def ELU_function(x, a = 1):
return (x > 0)*x + (x <= 0)*(a*(np.exp(x) - 1))
x = np.arange(-10, 10, 0.1)
y = ELU_function(x, a = 1)
plt.plot(x, y)
plt.title('ELU function')
plt.show()
- x > 0이면 x > 0보다 큰 값만 True로 1, 나머지 값은 0으로 만든다. 반대로 x <= 0이면 0 이하인 값만 True로 1, 나머지 값은 0으로 만든다.
- 0 이하의 값들은 지수 함수를 사용하기 때문에 매끄러운 함수가 나오는 것을 확인할 수 있다. 이는 입력이 0 이하인 경우 뉴런이 죽지 않게 해준다.
- 단, 지수 함수를 사용하므로 계산 비용이 높아진다는 단점이 있다.
3. 신경망 구현
3.1 신경망의 순방향 전파(feed-forward propagation)
■ 순방향 전파는 Input에서 Output까지 계산하는데, Input에서 가중치와 은닉층을 거쳐 Output을 내보낸다. 이 과정을 'Feed Forward'라 한다.
■ ex) 입력층(0층)은 2개, 첫 번째 은닉층(1층) 3개, 두 번째 은닉층(2층) 2개, 출력층(3층)은 2개의 노드를 가진다고 하자.
- 만약 \( w_{12}^{(1)} \)이면 앞 층의 두 번째 뉴런(\(x_2 \))에서 다음 층의 첫 번째 뉴런($ a_1^{(1)} $)으로 향할 때의 가중치이다. $ w_{ij}^{(k)} $에서 i와 j는, i는 다음 층의 첫 번째 뉴런, j는 앞 층의 두 번째 뉴런을 의미하며, k는 몇 층에 있는지를 의미한다.
■ 1층의 첫 번째 뉴런으로 가는 신호를 수식으로 나타내면 다음과 같다.
- 1층의 첫 번째 뉴런으로 가는 신호는 \( a_{1}^{(1)} = w_{11}^{(1)}x_1 + w_{12}^{(1)}x_2 + b_{1}^{(1)} \)
- 1층의 두 번째 뉴런으로 가는 신호는 \( a_{2}^{(1)} = w_{21}^{(1)}x_1 + w_{22}^{(1)}x_2 + b_{2}^{(1)} \)
- 1층의 세 번째 뉴런으로 가는 신호는 \( a_{3}^{(1)} = w_{31}^{(1)}x_1 + w_{32}^{(1)}x_2 + b_{3}^{(1)} \)
■ 위와 같은 신경망의 각 층의 계산은 '행렬 곱'을 이용하여 처리할 수 있다.
- 위의 1층의 가중치 부분을 행렬 곱을 이용하면,
\(
A^{(1)} = \begin{pmatrix}
a_1^{(1)} & a_2^{(1)} & a_3^{(1)}
\end{pmatrix}
\), \(
X = \begin{pmatrix}
x_1 & x_2
\end{pmatrix}
\), \(
W^{(1)} = \begin{pmatrix}
w_{11}^{(1)} & w_{21}^{(1)} & w_{31}^{(1)} \\
w_{12}^{(1)} & w_{22}^{(1)} & w_{32}^{(1)}
\end{pmatrix}
\), \( B^{(1)} = \begin{pmatrix} b_1^{(1)} & b_2^{(1)} & b_3^{(1)} \end{pmatrix} \)
을 \( A^{(1)} = X W^{(1)} + B^{(1)} \)으로 나타낼 수 있다.
■ 이를 파이썬에서 구현하면 (입력 신호, 가중치, 편향 값은 적당한 값으로 설정)
X = np.array( [1.0, 1.5] ) # 1차원 배열, 원소 2개
W1 = np.array( [ [0.1, 0.2, 0.3],
[0.4, 0.5 ,0.6] ]) # 2×3
B1 = np.array( [0.1, 0.2, 0.3] ) # 1차원 배열 원소 3개
A1 = np.dot(X, W1) + B1
print(X.shape, W1.shape, B1.shape, A1.shape) # X와 W1에 대응하는 차원의 원소 수가 동일
```#결과#```
(2,) (2, 3) (3,) (3,)
````````````
■ 다음으로, 1층에서의 활성화 함수 처리 과정은 다음과 같다.
가중 신호와 편향의 총합\( (WX + B) \)을 \( a \)라 하고, 이 \( a \)가 활성화 함수 \( h( ) \)를 거쳐 변환된 신호를 \( z \)라 하자.
■ 이를 파이썬에서 구현하면 (*활성화 함수로 시그모이드 함수 사용)
Z1 = sigmoid_function(A1)
print(A1)
print(Z1)
```#결과#```
[0.8 1.15 1.5 ]
[0.68997448 0.75951092 0.81757448]
`````````````
■ 다음으로, 1층에서 2층으로 가는 과정은
1층의 첫 번째 뉴런의 출력인 \( z_1^{(1)} \)이 2층의 입력이 된다.
■ 이를 파이썬에서 구현하면 입력이 \( z_1^{(1)} \)이므로 (*활성화 함수로 시그모이드 함수 사용)
print(Z1.shape) # 입력 크기 확인
```#결과#```
(3,)
````````````
W2 = np.array( [ [0.6, 0.5],
[0.4, 0.3],
[0.2, 0.1] ])
B2 = np.array( [0.1, 0.2] )
A2 = np.dot(Z1, W2) + B2
Z2 = sigmoid_function(A2)
print(W2.shape, B2.shape, A2.shape, Z2.shape)
```#결과#```
(3, 2) (2,) (2,) (2,)
````````````
■ 마지막으로 2층에서출력층으로 신호 전달은 출력층의 활성화 함수가 다른 점을 제외하면 동일하게 진행된다.
- 출력층의 활성화 함수는 풀고자 하는 문제에 따라 다르게 정의해야 하기 때문이다.
- 주로 회귀에서는 항등 함수, 이항 분류 문제에서는 시그모이드 함수, 다중 분류 문제에서는 소프트맥스 함수를 사용한다.
■ 만약, 출력층의 함수가 항등 함수라면
def identity_function(x): # 항등 함수
return x
W3 = np.array( [ [0.2, 0.3], [0.4,0.5] ] )
B3 = np.array([0.1, 0.2])
A3 = np.dot(Z2, W3) + B3
Y = identity_function(A3) # 출력층 활성화 함수 적용
print(W3.shape, B3.shape, A3.shape)
```#결과#```
(2, 2) (2,) (2,)
````````````
print(Y)
```#결과#```
[0.52608569 0.76897545]
````````````
- 각 층의 첫 번째 뉴런에 대해 순방향 전파를 구현하였는데, 이를 일반화하면 다음과 같다.
## 은닉층에 사용할 활성화 함수
def sigmoid_function(x):
return 1 / (1 + np.exp(-x))
## 출력층에 사용할 활성화 함수
def identity_function(x):
return x
def getWeightsAndBiases(w1_1, w1_2, b1, w2_1, w2_2, w2_3, b2, w3_1, w3_2, b3): # 가중치와 편향 값을 정의하는 함수
layer_params = {} # 딕셔너리로 Key는 각 층의 가중치와 편향, Value는 그 가중치와 편향의 값
layer_params['W1'] = np.array([w1_1, w1_2])
layer_params['b1'] = b1
layer_params['W2'] = np.array([w2_1, w2_2, w2_3])
layer_params['b2'] = b2
layer_params['W3'] = np.array([w3_1, w3_2])
layer_params['b3'] = b3
return layer_params
def FeedForward(parmas, x): # 순전향 전파, x는 입력값
for key, value in parmas.items():
globals()[key] = value # ex) W1 = np.array([...], [...])
a1 = np.dot(x, W1) + b1
z1 = sigmoid_function(a1)
a2 = np.dot(z1, W2) + b2
z2 = sigmoid_function(a2)
a3 = np.dot(z2, W3) + b3
y = identity_function(a3) # 출력
return y
x = np.array([1.0, 1.5])
w1_1 = np.array([0.1,0.2,0.3])
w1_2 = np.array([0.4,0.5,0.6])
b1 = np.array([0.1, 0.2, 0.3])
w2_1 = np.array([0.6,0.5])
w2_2 = np.array([0.4,0.3])
w2_3 = np.array([0.2,0.1])
b2 = np.array([0.1, 0.2])
w3_1 = np.array([0.2,0.3])
w3_2 = np.array([0.4, 0.5])
b3 = np.array([0.1, 0.2])
layer_params = getWeightsAndBiases(w1_1, w1_2, b1, w2_1, w2_2, w2_3, b2, w3_1, w3_2, b3)
y = FeedForward(layer_params, x)
print(y)
```#결과#```
[0.52608569 0.76897545]
````````````
3.2 출력층 설계
■ 신경망은 분류와 회귀 문제 모두에 이용할 수 있다. 단, 어떤 문제인지(분류 or 회귀 or ...)에 따라 사용하는 활성화 함수가 달라진다.
3.2.1 항등 함수(identity function)
■ 항등 함수는 입력과 출력이 항상 같은, 즉 입력이 들어오면 그 입력을 그대로 출력으로 반환하는 함수이다.
따라서 출력층에서 활성화 함수로 항등 함수를 사용하면 출력층 이전 층의 입력이 그대로 출력이 된다.
# 항등 함수
def identity_function(x): # x는 입력
return x # 입력이 곧 출력
3.2.2 소프트맥스 함수(softmax function)
■ 소프트맥스 함수의 식은 \( y_k = \dfrac {e^{a_k}}{\displaystyle \sum_{i=1}^{n} e^{a_i}} \), 여기서 \( n \)은 출력층의 뉴런 수, \( y_k \)는 그 중 \( k \)번째 출력을 의미한다.
- 소프트맥스 함수는 모든 출력의 합으로 각 출력을 나눠 k개 클래스에 대한 이산 확률 분포를 계산한다.
즉 \(k \)는 \( 1 \leq k \leq n \) 범위를 가진다. 그리고 분자는 \( k \)번째 입력 신호 \( a_k \)의 지수 함수이며, 분모는 모든 입력 신호의 지수 함수의 합이다. 따라서 \( a \)는 \( n\)차원 벡터임을 알 수 있다.
■ 소프트맥스 함수의 분모가 '모든 입력 신호의 지수 함수의 합'이다. 즉, 출력층의 각 뉴런은 모든 입력 신호로부터 영향을 받기 때문에, 다음 그림처럼 소프트맥스 함수의 출력은 모든 입력 신호로부터 화살표를 받는 형태를 갖는다.
■ 소프트맥스 함수를 파이썬에서 구현하면 다음과 같다.
def softmax_function(x):
exp_a = np.exp(x) # 분자
return exp_a / np.sum(exp_a) # 분모 np.sum(exp_a)
a = np.array([0.1, 1.5])
softmax_function(a)
```#결과#```
array([0.19781611, 0.80218389])
````````````
■ 위에서 함수로 구현한 softmax_function은 소프트맥스 함수의 식을 똑같이 나타냈다. 하지만 이와 같이 구현할 경우, 오버플로우(overflow) 문제로 인해 잘못 계산될 수 있다.
cf) 오버플로우: 컴퓨터는 수를 길이가 유한한 데이터( ex) 4바이트, 8바이트)만 다룰 수 있으므로, 컴퓨터가 표현할 수 있는 수의 범위를 넘어가면 불안정한(잘못된) 값을 반환하는 문제가 발생하는데, 이를 오버플로우라고 한다.
■ 소프트맥스 함수의 식은 지수 함수 \( e^x \)로 이루어졌기 때문에 \( e^100, e^1000, ... \)이 되면 컴퓨터가 다룰 수 있는 수의 범위가 초과되어, 이렇게 큰 값끼리 나눗셈 연산을 했을 때 결과값이 불안정한(잘못된) 값을 얻게 된다.
■ 이런 문제를 해결하기 위해 소프트맥스 함수를 다음과 같은 방법으로 계산할 수 있다.
임의의 정수 \( C \)에 대하여,\(
y_k = \dfrac{e^{a_k}}{\displaystyle \sum_{i=1}^{n} e^{a_i}} = \dfrac{C \cdot e^{a_k}}{\displaystyle C \cdot \sum_{i=1}^{n} e^{a_i}} = \dfrac{e^{a_k + \log C}}{\displaystyle \sum_{i=1}^{n} e^{a_i + \log C}} = \dfrac{e^{a_k + C'}}{\displaystyle \sum_{i=1}^{n} e^{a_i + C'}} \quad (C' = \log C)
\)가 성립한다.
- 이 식의 의미는 소프트맥스의 지수 함수를 계산할 때, 어떤 정수를 더하거나 빼도 결과는 바뀌지 않는다는 것이다.
- 여기서 \( C' \)에 어떤 값을 사용해도 되지만, 보통 오버플로우를 아예 방지할 목적으로는 입력 신호 중 최댓값을 사용한다.
- 예를 들어 입력 신호를 \( z \)라 했을 때, 입력 신호 \( z \)중 최댓값 \(
z_i' = z_i - \max(z)
\), 즉 이 식을 계산하면 \( z \) 중 가장 큰 값이 \( z_i' \)라면 이 식의 결과는 0이 되고, 가장 큰 값을 제외한 나머지도 0 이하의 값이 되기 때문에 오버플로우 문제를 효과적으로 방지할 수 있기 때문이다.
■ 예를 들어 오버플로우를 고려하지 않고 계산할 경우, 다음과 같이 결과가 nan을 반환된다.
a = np.array([1, 10, 100, 1000])
np.exp(a) / np.sum(np.exp(a))
```#결과#```
RuntimeWarning: overflow encountered in exp np.exp(a) / np.sum(np.exp(a))
RuntimeWarning: invalid value encountered in divide np.exp(a) / np.sum(np.exp(a))
array([ 0., 0., 0., nan])
````````````
■ 만약, 오버플로우를 고려하여 최댓값을 빼주면 다음과 같이 계산이 제대로 되는 것을 볼 수 있다.
c = np.max(a)
np.exp(a-c) / np.sum(np.exp(a-c))
```#결과#```
array([0., 0., 0., 1.])
````````````
■ 따라서 개선한 소프트맥스 함수는 다음과 같이 만들 수 있다.
def softmax_function(x):
exp_a = np.exp(x - np.max(x)) # 분자
sum_exp_a = np.sum(exp_a) # 분모
y = exp_a / sum_exp_a
return y
a = np.array([0.1, 1, 5])
y = softmax_function(a)
print(y)
```#결과#```
[0.00725956 0.01785564 0.9748848 ]
````````````
b = np.array([10, 100, 1000])
y2 = softmax_function(b)
print(y2)
```#결과#```
[0. 0. 1.]
````````````
print(np.sum(y), np.sum(y2))
```#결과#```
1.0 1.0
````````````
■ 위의 예시에서 소프트맥스 함수의 출력은 0 에서 1.0 사이의 실수이며, 소프트맥스 함수 출력의 총합이 1임을 확인할 수 있다.
이와 같은 소프트맥스 함수의 성질 덕분에 결과를 확률로 해석할 수 있다.
■ 확률로 해석할 수 있는 이유는 어떤 표본공간 S의 사상 A가 일어날 확률은 \( 0 \leq P(A) \leq 1 \)이며, \( P(S) = 1 \)이기 때문이다.
- 따라서 위 예시에서 y[0]의 확률은 0.007..., y[1]의 확률은 0.01785..., y[2]의 확률은 0.97488....이므로 출력 결과는 약 0.7%의 확률로 첫 번재 클래스, 약 1.8%의 확률로 두 번째 클래스, 약 97.5%의 확률로 세 번째 클래스인 것을 나타낸다고 해석할 수 있다.
- 그리고 세 번째 원소의 확률이 가장 높으므로 '정답은 세 번째 클래스이다.'라고 할 수 있다.
- 단 주의점은 소프트맥스 함수의 식이 \( y = e^x \)로, 정의역 \( a, b \)가 \( a \leq b \)일 때 \( f(a) \leq f(b) \)인 단조 증가 함수이므로 소프트맥스 함수를 적용해도 각 원소의 대소 관계가 변하지 않는다는 점이다.
- 즉 예시에서 a의 원소 0, 1, 5 사이, b의 원소 10, 100, 1000 사이의 대소 관계가 소프트맥스 함수를 적용한 y의 원소, y2의 원소 사이의 대소 관계로 그대로 유지가 된다는 점이다. 다음과 같이 a에서 가장 큰 원소인 5가 y의 원소들 중 가장 큰 것을 볼 수 있다. b도 마찬가지이다.
print(y)
[0.00725956 0.01785564 0.9748848 ] # a의 원소를 소프트맥스 함수에 적용한 결과
print(y2)
[0. 0. 1.] # b의 원소를 소프트맥스 함수에 적용한 결과
■ 위의 예시처럼 소프트맥스 함수를 적용해도 출력이 가장 큰 뉴런의 위치는 변하지 않을 뿐더러 신경망을 이용한 분류에서 보통 가장 큰 출력을 내는 뉴런에 해당하는 클래스만 인식한다. 따라서 분류 문제에서는 출력층의 소프트맥스 함수를 생략하기도 한다.
3.2.3 출력층의 뉴런 수 설정
■ 출력층 뉴런의 수는 분류 문제에서는 분류하려는 클래스의 개수로 설정하는 것이 일반적이며, 단일 회귀 문제같은 경우는 1개로 설정한다.