[딥러닝 알아듣기] 1.6. 인공 신경망의 표현과 학습

“딥러닝 알아듣기” 시리즈는 딥러닝의 기초 지식을 저만의 방식으로 쉽게 풀어내는 시리즈입니다. 이번 챕터에서는 퍼셉트론의 개념을 인공신경망으로 확장하고, 컴퓨터 코드로 인공신경망을 표현하는 방법을 알아봅니다.


1.6.1. 활성 함수의 확장과 인공 신경망

우리는 다층 퍼셉트론이 복잡한 입출력 사이 관계를 표현할 수 있음을 보았다. 은닉층의 노드를 넓고 깊게 쌓을 수록 더욱 정교한 결정 경계를 만들 수 있다. 사람들은 다층 퍼셉트론의 가능성에 주목하기 시작했다. 퍼셉트론의 은닉층을 크게 만들면 복잡한 현실의 데이터를 활용할 수 있을 것이었기 때문이다. 그러나 아직 현실의 많은 문제들에 맞서 퍼셉트론을 활용하기에는 큰 문제가 있다. 애초에 퍼셉트론은 이중 분류 문제를 상정하고 만든 구조이기 때문이다.

다층 퍼셉트론에서 각 노드의 활성 함수는 노드의 계산 결과에 따라 0 또는 1을 출력하는 함수였다. 이를 계단 함수 라고 부른다. 아래 그래프에서 보이듯이, 출력이 마치 계단 모양을 하고 있어서 그렇다.

<그림 1> 임계치가 0.5인 계단 함수

퍼셉트론의 중간 노드들이 무슨 값을 계산하든, 그 값이 계단 함수를 거치면 노드의 출력은 결국 0 또는 1이다. 그래서 퍼셉트론은 결국 이중 분류 문제밖에 풀지 못한다. 여러 입력 값과 수많은 가중치들을 동시에 사용해서 자극을 열심히 계산했는데도, 0 또는 1의 단순한 출력만 얻을 수 있는 것이다. 큰 잠재력을 가진 퍼셉트론을 이중 분류에만 쓰기에는 조금 아쉽다. 출력으로 더욱 다양한 값들을 얻어낼 수 있으면, 표현할 수 있는 데이터들 사이의 관계도 훨씬 다양해지지 않을까?

그렇다면 퍼셉트론에서 계단 함수가 문제였으니, 각 노드의 활성 함수를 바꿔 보자. 간단하게 확인해보기 위하여 AND 게이트 퍼셉트론을 이용하겠다. AND 게이트 퍼셉트론의 노드에 계단 함수 대신 \(\text{act}(x) = x\)의 활성 함수를 적용해보자. 이 활성 함수는 노드의 계산 결과를 그대로 출력한다. 활성 함수의 출력이 연속된 실수 형태인 것이다.

<그림 2> 활성 함수 act(x) = x

파이썬으로 구현해보자. 기존 AND 게이트 퍼셉트론 코드에서 활성 함수만 변경하였다.

# 변경된 활성화 함수 act(x) = x #
def act_function(x):
    return x

# AND 게이트 #
def AND(x1, x2):
    w1, w2, b = 0.3, 0.3, 0.3  # 파라미터 설정
    y = w1*x1 + w2*x2 + b               # 노드 계산
    output = act_function(y)         # 활성화 함수 출력
    return output

if __name__ == '__main__':
    print('AND 0, 0 :', AND(0, 0))
    print('AND 0, 1 :', AND(0, 1))
    print('AND 1, 0 :', AND(1, 0))
    print('AND 1, 1 :', AND(1, 1))
AND 0, 0 : 0.3
AND 0, 1 : 0.6
AND 1, 0 : 0.6
AND 1, 1 : 0.9

퍼셉트론의 출력 결과를 가지고 구분하는 일은 퍼셉트론 바깥의 일로 생각하자. 퍼셉트론의 출력이 0.8보다 크면 1, 그렇지 않으면 0으로 판단하면 될 것이다. 계단 형태의 활성 함수를 쓰지 않아도 퍼셉트론을 구현한 본래의 목적을 달성할 수 있다. 그러나 출력의 형태가 훨씬 유연하지 않은가? 기존 퍼셉트론의 형태로 NAND 게이트를 구현하려면 활성 함수인 계단 함수의 출력을 반전시켜 주어야만 했다. 0 또는 1을 그대로 퍼셉트론의 출력으로 내보냈기 때문이다. 그러나 이제는 AND 게이트를 구현해놓고 그 출력값을 판단하는 외부의 조건문만 변경해주면 NAND 게이트를 구현할 수 있다. 위의 출력 값을 \(Y\)라고 했을 때, \(Y > 0.8\)이어야만 1이라는 조건을 주면 AND 게이트의 구현이지만, \(Y < 0.4\)이어야만 1이라는 조건을 주면 NAND 게이트로 사용할 수도 있다. 출력을 더 유연하게 바라볼 수 있게 된 것이다.

우리는 계단 형태의 활성 함수에서 벗어났다. 복잡한 함수를 표현한 퍼셉트론을 이중 분류만이 아닌 다양한 영역에 사용할 수 있게 된 것이다. 그러나 이것을 퍼셉트론이라고 부르는 것은 사실 맞지 않다. 퍼셉트론은 어디까지나 이중 분류를 수행하는 구조를 가리키는 이름이기 때문이다. 계단 형태의 활성 함수에서 벗어나 퍼셉트론이 실수 형태의 출력을 만들기 시작할 때부터, 이 구조를 본격적으로 인공 신경망 이라고 부를 수 있다. 출력의 형태를 유연하게 만들어, 이중 분류에만 쓰일 수 있는 퍼셉트론과는 달리 다양한 문제에 응용할 수 있다. 또한, 각 노드가 선형 함수를 표현하는 인공 신경망을 선형 신경망 으로 부르기도 한다.

인공 신경망이 다양한 문제에 유연하게 사용될 수 있다는 것은, 앞에서도 보았듯이 신경망의 출력을 신경망 밖에서 마음대로 활용할 수 있다는 것이다. 여러 개의 임계값을 두고 데이터를 여러 분류로 나누면 다중 분류 를 하는 신경망이 되는 식이다. 아래의 그림은 출력 값에 대해 세 개의 임계값을 두어, 네 분류로 입력 데이터를 구분하는 다중 분류 신경망의 한 예이다. 다만 간단한 예시 그림으로, 실제 다중 분류 신경망의 구조는 그림과 약간 차이가 있다. 이후 챕터에서 다중 분류 문제를 같이 다뤄볼 것이다.

<그림 3> 임계값에 따라 여러 카테고리로 입력을 분류하는 신경망

인공 신경망의 개념은 머신 러닝 분야에 큰 변화를 일으켰다. 복잡한 고차원 데이터 속에서, 데이터들 간의 관계를 컴퓨터 알고리즘으로 구현할 수 있는 획기적인 방법이 생긴 것이다.

1.6.2. 선형 신경망의 한계와 비선형 활성 함수

단층 퍼셉트론부터 선형 신경망까지 확장하면서, 은닉층을 넓고 깊게 쌓아 복잡한 입출력 사이 관계를 표현할 수 있다는 사실을 알았다. 그러나 선형 신경망에는 확실한 한계점이 존재한다. 입력 데이터와 출력 사이의 관계가 언제나 선형 이라는 점이다.
아까 선형 신경망으로 이중 분류를 했을 때, 결정 경계가 왜 다 직선으로 보였을까? 답은 간단하다. 우리가 지금까지 사용해왔던 활성 함수가 모두 선형 함수 였기 때문이다. 우리는 지금까지 모든 활성 함수를 \(\text{act}(x) = x\)로 사용했다. 각 노드의 계산 출력을 그대로 이용하여 임계값에 따라 분류했다는 이야기다. 보이다시피 \(\text{act}(x) = x\)는 입력에 따라 출력이 단조 증가(Monotonic) (*각주: 입력에 따른 출력이 모든 구간에서 증가세를 보이는 전단사 함수) 하는 선형 함수이다. 선형 신경망의 각 노드는 선형 함수의 함숫값을 계산하고, 활성 함수까지 선형 함수를 사용했다. 그래서 입력과 출력의 선형 관계만을 표현할 수 있었던 것이다.

선형 신경망이 이런 한계를 가지는 이유를 활성 함수의 관점에서도 설명할 수 있다. \(\text{act}(x) = x\)와 같은 선형 활성 함수를 아무리 거쳐봤자, 결국 하나의 선형 함수를 거친 것과 동일 한 효과를 가진다. 예를 들어, \(f(x) = 3x\)라고 정의했을 때, \(f(f(f(x))) = 3 \times 3 \times 3 \times x = 27x\)와 같을 것이다. 선형 신경망의 각 노드가 구현하는 함수 자체가 선형 함수이므로, 출력에 선형 활성 함수를 아무리 사용해봤자 결국은 하나의 선형 출력을 만들어낼 뿐이다. 조금 수학적으로 말하면, 선형 신경망의 입력부터 출력까지 모든 과정이 하나의 선형 결합으로 표현될 수 있다.
문제는 이렇게 되면 우리가 신경망을 구성하고, 은닉층을 깊게 쌓는 이유가 사라진다. 노드를 수없이 거쳐 활성 함수를 수없이 많이 지나도, 결국 신경망의 입력과 출력은 하나의 복잡한 선형 함수로 표현할 수 있다는 것이다.

그러나 우리가 머신 러닝을 활용할 현실의 데이터는 예쁘게 선형 관계로만 표현되지 않는다. 현실 세계의 데이터는 복잡한 비선형의 관계로 표현되는 경우가 많다. 아래 그림과 같이 분포된 데이터를 이중 분류하는 신경망을 만든다고 가정해 보자.

<그림 4> 비선형 데이터

데이터의 분포 자체가 지역적으로 일정하지 않기 때문에, 단순한 직선으로는 빨간 점과 파란 점을 완벽히 구분할 결정 경계를 만들어낼 수 없다. 하지만 데이터의 차원이 높아지고 개수가 많아질수록, 선형 신경망으로 입력과 출력의 관계를 설명하기가 힘들어질 것이다. 입력 데이터의 분포를 선형 함수만으로 설명할 수 없는 것이다.

<그림 5> 선형 함수로는 어떻게 해도 데이터의 결정 경계를 찾을 수 없다.

확실한 선형 신경망의 한계가 보인다. 입력 데이터의 분포가 선형 함수의 조합으로 설명되지 않을 경우, 지금까지 공부해왔던 선형 신경망을 사용할 수 없다. 데이터의 분포가 비선형인 문제를 푸는 신경망을 만드려면, 신경망의 출력 자체가 비선형(Nonlinear) 이어야 한다.

신경망의 각 노드가 계산하는 함수 자체를 비선형으로 바꾸기는 힘들다. 신경망의 노드는 애초에 입력과 가중치를 곱한 총합을 계산하도록 디자인되었기 때문이다. 비선형의 출력을 만들기 위한 가장 좋은 방법은 비선형의 출력을 가지는 활성 함수 를 사용하는 것이다. 노드가 선형 함수를 구현해도, 계산 결과가 비선형 활성 함수를 거치는 순간 노드의 출력은 비선형이 된다. 활성 함수의 변경으로 신경망이 비선형 함수 관계 를 구현할 수 있는 것이다.

<그림 6> 비선형 활성 함수를 거치면 출력이 비선형 형태가 된다.

이전 챕터에서 구현해보았던 XOR 게이트 퍼셉트론의 입력 데이터는 전형적인 비선형 데이터였다. 그러나 우리는 선형 함수를 계산하는 은닉층을 쌓아 XOR 게이트의 입력을 구분해냈다. 신경망이 데이터의 비선형적인 특징을 표현하고 있었던 것이다. 이것이 가능했던 이유는, XOR 게이트 퍼셉트론에서 각 노드의 활성 함수로 계단 함수를 사용했기 때문이다. 계단 함수는 비선형 함수다. 함수의 입력이 임계값과 동일한 지점에서 \(\text{act}(x) = 0\) 또는 \(\text{act}(x) = 1\)의 직선 두 개로 나누어지기 때문이다. 멀리 가지 않고 XOR 게이트 퍼셉트론만 보아도 비선형 활성 함수를 사용해야 하는 이유를 알 수 있었다.

시그모이드 함수

인공 신경망 분야에서 가장 많이 사용되는 비선형 활성 함수 중 하나가 바로 시그모이드(Sigmoid) 함수이다. 아래와 같이 정의된다.

직접 임의의 입력 데이터를 만들어서, Sigmoid 함수의 출력 형태를 파악해 보자.

import numpy as np
import matplotlib.pyplot as plt

def sigmoid(x):
    return 1 / (1 + np.exp(-x))

x1 = np.arange(-6, 6, 0.01)
x2 = np.array([sigmoid(n) for n in x1])

plt.figure(figsize=(8, 8))
plt.xlabel('x1', fontweight='bold', fontsize=20, color='purple')
plt.ylabel('x2', fontweight='bold', fontsize=20, color='purple', rotation='horizontal')
plt.gca().xaxis.set_label_coords(-0.12, 0.5)
plt.gca().yaxis.set_label_coords(0.5, -0.12)

plt.xlim(-6, 6)
plt.ylim(-0.01, 1.05)
plt.xticks(np.arange(-6, 6.01, 2), fontweight='bold', fontsize=15)
plt.yticks(np.arange(0, 1.1, 0.5), fontweight='bold', fontsize=15)

plt.axvline(x=0, color = 'k') # draw x=0 axes 
plt.axhline(y=0, color = 'k') # draw y=0 axes
plt.plot(x1, x2, 'b', linewidth=5)

plt.show()
<그림 8> 시그모이드 함수의 출력 그래프

주로 경제학 분야에서 사용되는 ‘시그모이드 곡선’ 과 비슷한 형태를 띄었다고 하여 시그모이드 함수리는 이름이 붙었다. 시그모이드 함수의 정의역은 실수 전체이며, 치역은 0에서 1 사이의 실수이다.

그래프에서 볼 수 있듯이, 시그모이드 함수는 입력과 출력의 관계가 비선형 인 함수이다. 따라서 아래 그림과 같이, 신경망에서 어떤 노드의 활성화 함수로 시그모이드를 사용하면 입력과 출력 사이의 비선형 관계를 만들어낼 수 있다.

<그림 9> 시그모이드 함수가 비선형 출력을 만들어 낸다.

대표적인 비선형 활성 함수인 시그모이드 함수의 주요한 특성은 아래와 같다.

  1. 출력 값이 무조건 0에서 1 사이이다. 음수 입력에 대해서는 0의 극한값, 양수 입력에 대해서는 1의 극한값을 가진다.
  2. 출력 값이 0.5에 가까워질수록 함숫값의 변화량이 증가하며, 0.5인 지점에서 최대가 된다.
  3. 함수가 전 구간에서 연속이어서 미분 가능하다.

첫 번째와 두 번째 특성으로 인해 시그모이드가 이중 분류 문제를 풀기 위한 신경망에 많이 사용된다. 시그모이드 함수의 출력이 0.5인 지점은 입력 값이 0인 지점이다. 시그모이드의 입력이 양수면 출력이 1에 가까워지고, 음수면 0에 가까워질 확률이 크다. 출력이 0 또는 1의 극한값에 가깝도록 만들 가능성이 큰 것이다. 그래서 시그모이드는 임계값이 0.5에 가까운 이중 분류 문제에 많이 사용된다. 노드의 계산 결과를 시그모이드 함수에 입력했을 때, 0 또는 1의 극한값에 가까운 출력이 나올 확률이 크기 때문이다. 이를 구분력이 좋다 고 말하기도 한다.
또한 세 번쨰 특성은 앞으로 신경망을 공부하면서 확실히 기억해두고 있어야 할 특성이다. 이후에 알아볼 신경망의 학습과 중요한 연관이 있다. 앞으로 다른 활성 함수를 만나게 될 때에도 계속 강조하게 될 특성이다. 간단히 이야기하자면, 신경망의 학습을 위해서는 활성 함수가 미분 가능해야 한다.

시그모이드 함수는 S자 모양의 곡선을 가지는 모든 활성 함수를 가리키는 말로 쓰이기도 한다. 대표적으로 쌍곡탄젠트(Hyperbolic Tangent, tanh), 아크탄젠트(Arctangent, arctan) 등이 있다.

<그림 10> tanh, arctan

최근의 인공지능 연구 추세에서 시그모이드 함수의 힘은 점점 약해지고 있기는 하다. 신경망의 성능을 훨씬 강력하게 만들어줄 수 있는 더 좋은 활성화 함수가 많이 등장했기 때문이다. 그럼에도 시그모이드 함수는 비선형 활성 함수의 대표격으로써, 이후 많은 활성 함수의 발전 아이디어를 제공해준 녀석이다. 시그모이드 함수의 특성을 확실히 기억하고 넘어가도록 하자.

다양한 활성 함수

딥 러닝의 발전과 함께 수많은 활성 함수들이 연구되었다. 앞으로 계속 이야기할거지만 활성 함수는 신경망의 성능과 학습 가능성에 매우 큰 영향을 끼친다.

시그모이드 함수는 0에서 1 사이의 값을 출력하는데, 조금 바꿔 생각하면 출력을 표현할 수 있는 범위가 0에서 1 사이로 매우 좁은 것이다. 인공 신경망으로 복잡한 데이터의 특징을 표현하려는 경우, 이런 식으로 출력의 범위를 제한하면 잠재적인 신경망의 성능을 끌어내지 못할 수도 있다. 0에서 1 사이의 값으로 표현하는 것보다, 0에서 10 사이의 값으로 표현하는 것이 훨씬 데이터의 분포를 확실하게 만들어 줄 가능성이 있다.

또한, 활성 함수가 발전하는 주요한 계기는 인공 신경망의 학습 과정을 더 원활하게 만들기 위한 노력이다. 기존에 쓰이던 활성 함수의 문제로 인해 학습이 번번이 실패하면, 활성 함수를 발전시켜 학습에 성공하도록 만드는 것이다. 그래서 인공 신경망의 성능 발전과 활성 함수의 발전은 항상 사이좋게 같이 이루어져왔다.

수 십년간의 활성 함수 연구로, 다양한 활성 함수들이 제시되었다. 이 중에는 아직도 자주 사용되는 활성 함수도 있고, 많이 쓰이지 않는 활성 함수도 있다. 다만 기억해야 할 것은 문제 상황과 신경망의 구조, 데이터의 형태에 따라 알맞은 활성 함수를 선택하도록 노력해야 한다는 것이다. 인공 신경망의 성능 발전을 위해 이렇게 다양한 활성 함수가 개발되었다는 것만 기억하자. 어떤 상황에 어떤 활성 함수를 써야 좋은지는 다음 챕터부터 천천히 알아볼 것이다.

<그림 11> 다양한 활성 함수들

1.6.3. 인공 신경망의 학습 과정

인공 신경망이 크게 주목받은 이유가 또 있다. 기존에 사용되었던 많은 종류의 고전적인 머신러닝 방법론들도 데이터 사이의 관계를 표현하는 함수를 찾아내는 데에 집중하였다. SVM은 두 분류의 데이터를 가장 잘 구분해낼 수 있는 함수를 찾는 알고리즘이다. 또한 선형 회귀는 다차원 데이터의 분포를 나타내는 함수를 데이터로부터 찾아내는 방법이다.

인공 신경망도 입력 데이터와 출력 데이터 사이의 관계를 표현하는 함수를 표현하는 구조이다. 인공 신경망이 표현하는 함수는, 은닉층 각 노드의 가중치와 편향에 의해 결정될 것이다. 이를 신경망의 파라미터(Parameter) 라고 한다. 같은 데이터가 입력되어도, 신경망의 파라미터를 어떻게 설정하는지에 따라 신경망의 출력이 달라진다. 당연히 표현하는 함수가 달라졌기 때문이다.

학자들은 신경망 안의 각 노드들이 가진 파라미터들을 학습시키고자 연구했다. 우리가 가지고 있는 입력 데이터의 관계를 잘 표현하는 함수를 구현할 수 있도록 신경망의 파라미터를 갱신하려는 것이다. 신경망의 입력과 출력에 대한 정답지를 가지고 있으면, 정답지에 맞는 출력을 만들어내기 위해 신경망의 파라미터가 어떻게 설정되어야 하는지 학습시킬 수 있을 것이다. 기존의 머신러닝 알고리즘들이 데이터로부터 학습하던 방식과 동일하다. 신경망에 직접 수많은 데이터를 넣어보면서, 각각의 입력에 대해 정답에 가까운 출력을 내놓으려면 은닉층의 파라미터들을 어떻게 설정해야 하는지 신경망이 스스로 학습하는 것이다.

신경망의 진정한 힘은 이렇게 파라미터를 학습시킬 수 있다는 점에서 나온다. 데이터의 분포가 너무 복잡하여 인간이 분석하기 힘든 경우에, 입력과 출력에 대한 정답지만 만들어 주면 신경망이 알아서 데이터의 분포 특성을 이해할 수 있게 된 것이다. 또, 은닉층 각 노드의 파라미터를 신경망이 알아서 찾아간다는 점에 주목해야 한다. 아무리 깊고 넓은 은닉층을 가지고 있어도, 신경망은 어떻게든 모든 파라미터를 설정하려고 학습을 시도할 것이다. 얼마나 복잡한 데이터를 가지고 있던지간에, 우리는 은닉층을 얼마나 깊고 넓게 쌓을까 만 고민하면 된다. 신경망의 성능이 떨어지는 것 같은 경우, 즉 데이터의 분포를 잘 이해하지 못하는 경우 은닉층을 더 확장 시켜주면 될 것이다. 은닉층을 확장하면 신경망이 자연스럽게 더욱 복잡한 함수 관계를 구현하게 되기 때문이다. 물론 은닉층을 무분별하게 확장하는 것도 오히려 독이 될 수 있다. 신경망의 크기를 키우는 정도에 대한 논의는 3챕터에서 더 자세히 다뤄보도록 하자.

신경망의 학습 과정을 간단히 살펴보자. 학습을 위한 최소의 준비물은 아래와 같다.

  • 입력 데이터와 정답 출력 데이터가 쌍으로 정리된 데이터셋(Dataset) 또는 GT(Ground Truth)
  • 학습시킬 신경망의 모델(Model)
  • 입력 데이터에 대한 신경망의 출력과 정답 출력의 차이를 계산할 오차 함수(Loss Function)
  • 파라미터를 갱신할 최적화 알고리즘(Optimizer)

이 준비물들을 이용하여, 신경망의 학습 과정을 그림과 함께 따라가보자.

(1). 데이터셋에서 무작위로 데이터를 선택하여 신경망 모델에 입력하고, 모델의 출력을 얻는다.

<그림 12> 학습 과정 (1)

(2). 오차 함수를 이용하여, 모델의 출력과 정답 출력 사이의 차이를 계산한다. 이 차이를 오차(Loss) 라고 부른다.

<그림 13> 학습 과정 (2)

(3). 최적화 알고리즘을 통해, 오차를 줄이는 방향으로 신경망의 모든 파라미터를 갱신한다. 또한 이 과정에서, 역전파(Backpropagation) 알고리즘이 활용된다.

<그림 14> 학습 과정 (3)

(4). 데이터셋에 있는 모든 입력들에 대해서, 모델의 출력과 정답 출력 사이의 오차가 0에 가까워질때까지 위의 과정을 반복한다.

시리즈의 남은 챕터에서 각각의 과정에 대해 상세하게 다뤄볼 것이다. 오차 함수를 어떻게 구현하는지, 최적화 알고리즘에는 무엇이 있는지, 역전파 알고리즘은 무엇인지, 천천히 알아가보도록 하자.

1.6.4. 행렬로 인공 신경망 표현하기

우리는 곧 신경망을 학습시켜볼 것이다. 신경망을 구현하고 학습시키려면, 컴퓨터상에서 동작할 수 있도록 학습의 모든 과정을 프로그래밍 해주어야 한다. 본격적으로 신경망의 학습 방법을 공부하기에 앞서, 하나의 원초적인 질문을 던져보자. 그럼 신경망을 어떻게 표현해야 우리가 구현하기도 편하고, 컴퓨터가 학습하기도 편할까?

간단한 신경망을 하나 생각해보자. 아래의 신경망은 입력층 노드 2개, 은닉층 노드 3개, 출력층 노드 2개인 신경망이다.

<그림 15> 간단한 신경망

생각의 편의를 위해 활성 함수는 모두 \(\text{act}(x) = x\)이며 따로 계산하지 않는 것으로 한다. 각 노드 사이에는 화살표와 함께 가중치가 표현되어 있다. 뒷 노드는 앞 노드의 출력과 그에 해당되는 가중치를 곱해 계산한다. 예를 들어 중간 은닉층 \(h_1\)은 아래와 같이 계산된다.

\[h_1 = w_{11}x_1 + w_{21}x_2\]

그림에서 보이다시피, 이 신경망 내에는 총 12개의 가중치가 존재한다. 우리가 계속 봐오던 전형적인 신경망의 계산 구조다. 파이썬으로 간단히 이 신경망을 구현해보자.

def Network(x1, x2):
    # 가중치 설정
    w11, w12, w13, w21, w22, w23 = 1, 2, 3, 1, 2, 3
    w31, w32, w41, w42, w51, w52 = 2, 3, 3, 4, 4, 5

    # 은닉층 계산
    h1 = w11*x1 + w21*x2
    h2 = w12*x1 + w22*x2
    h3 = w13*x1 + w23*x2

    # 출력층 계산
    y1 = w31*h1 + w41*h2 + w51*h3
    y2 = w32*h1 + w42*h2 + w52*h3
    
    # 신경망 결과 출력
    return y1, y2

if __name__ == '__main__':
    print(Network(3, 5))

결과는 아래와 같이, 두 출력 노드가 뱉어낸 결과를 잘 보여준다.

(160, 208)

코드를 머리 속에 담아두려고 하지 않아도 된다. 위의 코드를 보고 ‘와, 복잡하다’ 라는 느낌만 받았으면 충분하다. 은닉층이 단 하나인 간단한 신경망의 구현임에도 매우 많은 변수가 사용된다는 것을 눈으로 확인하기 위함이었다.

위의 코드에서는 각각의 가중치 파라미터를 모두 하나의 변수로 할당해 신경망을 구현하였다. 신경망을 그림으로 보았을 때는 그렇게 복잡하지 않아 보였지만, 코드로 풀어놓고 보니 이해가 어려운 느낌이 든다. 지금이야 작은 신경망이니 비교적 코드의 이해가 쉽지만, 신경망의 은닉층이 많아질수록 변수로 선언해야 할 파라미터의 개수도 기하급수적으로 늘어날 것이다. 당장 노드 세 개짜리 은닉층을 사이에 몇 개만 더 끼워 넣어도, 아래처럼 수많은 변수들을 선언해주어야 할 것이다.

w61, w62, w63, w71, w72, w73, w81, w82, w83 = 1, 2, 3, 4, 5, 6, 7, 8, 9
w91, w92, w93, w101, w102, w103, w111, w112, w113 = 1, 2, 3, 4, 5, 6, 7, 8, 9
...

상상만 해도 끔찍하다. 심지어 모든 노드의 계산 식을 일일이 코드에 적어주어야 한다. 코드를 구현하는 관점에서 매우 비효율적이다. 이렇게 각각의 파라미터에 대한 변수를 일일이 선언해주지 않고 신경망을 효율적으로 표현할 수 있는 방법이 없을까?

답은 행렬(Matrix) 에 있다. 행렬은 숫자들을 직사각형의 형태로 배열한 것이다. 행렬의 개념이 정립되고 나서부터, 행렬은 과학과 공학 분야의 수많은 이론적인 발전을 도왔다. 행렬의 개념은 자연 현상을 수학적으로 분석하고 이해하는 데에 큰 도움을 주었다. 행렬의 성질에 대해 깊이 분석하고 개념을 발전시키는 선형대수학 과 같은 학문이 발전헀다.

행렬은 인공신경망을 간단히 표현할 수 있게 만들어주는 일등 공신이다. 위의 신경망을 행렬로 같이 나타내보면, 왜 이렇게 이야기했는지 이해할 수 있다.
먼저 입력층과 은닉층 사이의 계산을 보자. 우리의 신경망은 두 개의 실수 \(x_1, x_2\)를 입력으로 받는다. 그러나 이제 신경망의 입력을 두 개의 실수로 보지 말고, 두 개의 실수가 포함된 입력 행렬 로 바라보자. 아래와 같이 신경망의 입력 행렬 \(\mathbf{X}\)를 정의할 수 있다.

\[\mathbf{X} = \begin{bmatrix} x_1 &x_2 \end{bmatrix}\]

은닉층 안의 세 개의 노드도 마찬가지이다. 세 개의 은닉층 계산 값이 아닌, 세 개의 실수가 포함된 은닉층 계산 결과 행렬 로 표현할 수 있을 것이다. 아래와 같이 정의할 수 있다.

\[\mathbf{H} = \begin{bmatrix} h_1 &h_2 &h_3 \end{bmatrix}\]

입력 행렬 \(\mathbf{X}\)와 은닉층 행렬 \(\mathbf{H}\)를 정의했다. 알다시피 은닉층 행렬의 계산 결과는 입력과 가중치들의 곱을 거쳐 계산된다. 아까는 은닉층의 계산 결과를 만들기 위해서 각각의 입력 값과 가중치에 대한 모든 식을 일일히 계산했다. 그러나 지금은 입력도 하나의 행렬, 계산 결과도 하나의 행렬이다. 행렬끼리 계산하려면 당연히 가중치 값들도 행렬로 구성되어야 한다.

다행히 행렬곱 의 성질을 이용해서 가중치 행렬을 정의할 수 있다. 먼저 결론적으로 보면, 가중치 행렬 \(\mathbf{W}\)는 아래와 같이 정의할 수 있다.

\[\mathbf{W} = \begin{bmatrix} w_{11} &w_{12} &w_{13}\\ w_{21} &w_{22} &w_{23} \end{bmatrix}\]

입력 행렬과 가중치 행렬을 곱하면 우리가 목표로 하는 은닉층의 계산 결과 행렬을 얻을 수 있다.

\[\mathbf{H} = \mathbf{W} \cdot \mathbf{x}\]

식을 계산하면, 놀랍게도 결과 행렬의 각 원소가 일일이 계산했던 결과와 동일하게 나온다. 행렬곱의 계산 과정 때문이다. 행렬곱의 계산 과정을 한 문장으로 나타내면 아래와 같다.

“앞 행렬의 \(n\) 번째 행과 뒤 행렬의 \(m\) 번째 열을 곱한 값을 결과 행렬의 \(n\)행 \(m\)열 위치에 집어 넣는다.”

위의 공식에 따라, 입력 행렬과 가중치 행렬을 곱하는 과정을 그림으로 그려보자.

<그림 16> 입력 행렬과 가중치 행렬의 곱

두 행렬의 각 행과 각 열을 곱한 값을 계산해, 결과 행렬에서 알맞은 위치에 넣어주고 있다. 여기서 각각의 곱셈 결과 값은 같은 위치 원소끼리의 곱의 총합 으로 계산된다.

<그림 17> 행렬곱의 과정

그림에서 보다시피, 각 행과 열의 같은 위치 원소끼리 모두 곱하고, 그 총합을 계산해 결과 행렬의 해당 위치에 넣어준다. 이제 어떻게 행렬로 신경망을 구현하는지 이해할 수 있다. 곱셈 결과 행렬의 원소 하나를 구하는 과정이, 우리가 직접 구현했던 은닉층 노드 계산 과정과 완전히 동일하다.

가만히 생각해보면 이건 우연이 아니다. 가중치 행렬에서 하나의 열은 하나의 은닉층 노드를 계산하기 위한 가중치들을 포함하고 있다. 그래서 입력과 가중치 행렬을 곱하는 과정이 결국 은닉층 각 노드의 계산 결과를 차례대로 구하는 과정과 동일한 것이다.

위의 과정과 동일하게, 은닉층의 계산 결과 행렬을 이용해 출력층의 계산 결과 행렬 또한 구할 수 있다. 이번에도 마찬가지로 출력을 계산하기 위한 가중치들을 행렬로 정의해서 사용한다.

<그림 18> 출력층의 계산

이미 눈치챘겠지만, 행렬곱을 위해서 꼭 지켜져야 할 하나의 규칙이 있다. 두 행렬을 곱할 때, 첫번째 행렬의 행 수와 두번째 행렬의 열 수가 동일해야 한다. 그렇지 않으면 당연히 각 행과 열을 곱할 수 없기 때문이다. 이는 행렬곱의 기본 성질 이기도 하지만, 신경망의 관점에서도 이해할 수 있다. 알다시피 신경망의 모든 노드는 연결되어 있으며, 그 사이엔 모두 가중치가 존재한다. 행렬곱으로 완전한 출력 층의 행렬을 만들려면, 모든 입력 층 행렬의 원소와 모든 은닉층 노드의 가중치들이 모두 한번씩 곱해져야 한다. 입력 행렬의 행 수와 가중치 행렬의 열 수가 다르면 신경망의 연결 중 계산하지 못하는 연결이 생기게 될 것이다. 그래서 행렬곱 계산시에 행렬의 모양을 꼭 맞춰주어야 한다.

<그림 19> 입력 행렬과 가중치 행렬의 모양은 항상 맞추어야 한다.

이제 아까의 신경망 구현 코드를 행렬을 이용해서 다시 작성해보자. 파이썬의 넘파이(numpy) 라이브러리를 사용하면 행렬 계산을 매우 간단하게 처리할 수 있다.

import numpy as np

def Network(X):
    # 가중치 설정
    W1 = np.array([[1, 2, 3], [1, 2, 3]])   # 입력층과 은닉층 사이 가중치 행렬
    W2 = np.array([[2, 3], [3, 4], [4, 5]])
    
    # 은닉층 계산
    H = np.dot(X, W1)   # 행렬곱 계산

    # 출력층 계산
    Y = np.dot(H, W2)   # 행렬곱 계산

    # 신경망 결과 출력
    return Y

if __name__ == '__main__':
    X = np.array([3, 5])
    print(Network(X))

행렬 W1, W2는 각 층 사이의 가중치 행렬이다. 처음에 변수로 일일히 선언헀던 가중치들을 행렬로 묶은 것이다. np.array로 행렬을 선언할 수 있으며, W1은 3x2, W2는 2x3 모양으로 선언되었다.
은닉층과 출력층을 계산하기 위해서 행렬곱을 수행하는 np.dot 함수를 사용한다. np.dot 함수는 입력으로 들어온 두 행렬끼리의 행렬곱이 가능한 경우 곱한 결과 행렬을 반환해준다. 두 행렬의 모양이 맞지 않아 행렬곱이 불가능할 경우 오류를 표시한다.
실행 결과는, 행렬곱을 이용한 신경망 표현이 제대로 동작하고 있음을 보여준다. 직접 모든 변수를 계산했을 때와 동일한 결과값이 출력된다.

[160 208]

복잡한 신경망을 커다란 행렬곱의 연속으로 볼 수 있기 때문에, 신경망의 크기가 훨씬 커져도 코드의 구현은 복잡해지지 않는다. 그래서 현재 거의 모든 딥 러닝 프레임워크가 기본적으로 행렬 연산을 기반으로 동작한다.

신경망의 표현에 행렬을 사용하는 이유는 비단 표현의 편리함 뿐만이 아니다. 코드를 작성하는 개발자의 입장에서, 신경망의 전체적인 구조를 보기 매우 편하다. 가중치 변수를 하나하나 선언하면 신경망이 커질수록 코드가 복잡해져 개발자의 부담이 커질 것이다.
이론적인 면에서의 장점도 크다. 수학적으로 인공 신경망을 분석할 때, 인공 신경망 내 각 층끼리의 상호작용을 행렬 연산으로 볼 수 있다. 따라서 인공 신경망의 수학적인 접근이 용이하다는 이점이 있다.

또 이후에도 계속 이야기하겠지만, 행렬 연산으로써의 인공 신경망 구현은 GPU의 활용을 가능하게 한다. GPU는 대규모 행렬 연산에 특화되어 있는 처리 장치이다. 인공 신경망의 계산은 행렬 연산의 연속이기 때문에, GPU는 인공 신경망의 빠른 계산과 학습을 가능하게 한다. GPGPU 기술의 발달과 함께 딥 러닝이 빠르게 성장한 이유가 바로 이것이다.