Posted:      Updated:

신경망 복습

수학과 파이썬 복습

벡터와 행렬

벡터는 크기와 방향을 가진 양으로, 숫자가 일렬로 늘어선 집합으로 표현할 수 있다.
파이썬에서는 1차원 배열로 취급한다.
숫자를 세로로 나열하면 열벡터, 가로로 나열하면 행벡터이다.

행렬은 숫자가 2차원 형태(사각형 형상)으로 늘어선 것으로, 2차원 배열로 표현할 수 있다.
가로줄은 행(row), 세로줄은 열(column)이라 한다.
벡터와 행렬을 확장하여 숫자 집합을 N차원으로 표현한 것을 텐서라고 한다.

import numpy as np

행렬을 취급하기 위해 넘파이를 사용한다.

x = np.array([1, 2, 3])
x.__class__
x.shape
x.ndim

행렬은 np.array() 메서드로 생성할 수 있다.
이 메서드는 넘파이의 다차원 배열 클래스인 np.ndarray 클래스를 생성한다.
nparray 에는 다양한 편의 메서드와 인스턴스 변수가 있다.
shape 는 다차원 배열 형상, ndim 은 차원 수를 담고 있다.

<class 'numpy.ndarray'>
(3,)
1

x는 1차원 배열이며 원소 수가 3개인 벡터임을 알 수 있다.

W = np.array([[1, 2, 3], [4, 5, 6]])
W.shape
W.ndim
(2, 3)
2

W는 2차원 배열이며, 2×3 행렬임을 알 수 있다.

원소별 (element-wise) 연산

W = np.array([[1, 2, 3], [4, 5, 6]])
X = np.array([[0, 1, 2], [3, 4, 5]])
X + Y
array([[1, 3, 5],
       [7, 9, 11]])
X * Y
array([[0, 2, 6],
       [12, 20, 30]])

피연산자인 다차원 배열들에서 서로 대응하는 원소끼리(각 원소가 독립적으로) 연산이 이뤄진다.

브로드캐스트 (broadcast)

넘파이의 다차원 배열에서는 형상이 다른 배열끼리도 연산할 수 있다.

A = np.array([[1, 2],
              [3, 4]])
A * 10
array([[10, 20],
       [30, 40]])

행렬 A에 10이라는 스칼라 값을 곱하게 되면, 스칼라 값 10이 2×2 행렬로 확장된 후 원소별 연산을 수행한다.
이러한 기능을 브로드캐스트라고 한다.

A = np.array([[1, 2],
              [3, 4]])
b = np.array([10, 20])
A * b
array([[10, 40],
       [30, 80]])

이 계산에서는 1차원 배열인 b가 2차원 배열 A와 형상이 같아지도록 확장된다.

broadcast

벡터의 내적

\[x{\cdot}y=x_1y_1+x_2y_2+{\cdots}+x_ny_n\]

2개의 벡터 $x = (x_1, {\cdots}, x_n)$, $y=(y_1, {\cdots},y_n)$가 있다고 가정할 때, 두 벡터에서 대응하는 원소들의 곱을 모두 더한 것이다.

a = np.array([1, 2, 3])
b = np.array([1, 2, 3])
np.dot(a, b)
32

넘파이의 np.dot() 으로 쉽게 구할 수 있다.

행렬의 곱

matrix_multiplication

행렬의 곱은 왼쪽 행렬의 행벡터(가로 방향)와 오른쪽 행렬의 열벡터(세로 방향)의 내적으로 계산한다.
계산 결과는 새로운 행렬의 대응하는 원소에 저장된다.
예를 들어 A의 1행과 B의 1열의 계산 결과는 1행 1열 위치의 원소가 된다.

A = np.array([[1, 2], [3, 4]])
B = np.array([[5, 6], [7, 8]])
np.matmul(A, B)
array([[19, 22],
       [43, 50]])

넘파이의 np.matmul() 메서드로 쉽게 구할 수 있다.

행렬의 곱에도 np.dot() 을 사용할 수 있다.
인수가 모두 1차원 배열이면 벡터의 내적을 계산하고, 2차원 배열이면 행렬의 곱을 계산한다.
그러나 가능하면 구분하여 코드의 논리와 의도를 명확히 하는 게 좋다.

행렬 형상 확인

geometry_check

행렬의 곱에서는 대응하는 차원의 원소 수를 일치시켜야 한다.
결과로 만들어지는 행렬의 형상은 A의 행 수와 B의 열 수가 되고 이 과정을 형상 확인이라고 한다.

신경망의 추론

신경망은 함수처럼 입력을 출력으로 변환한다.

2차원 데이터를 입력하여 3차원 데이터를 출력하는 함수를 예로 들면, 이를 신경망으로 구현하려면 입력층(input layer)에는 뉴런 2개, 출력층(output layer)에는 3개를 각각 준비한다.
은닉층(hidden layer)에도 적당한 수의 뉴런을 배치하는데, 뉴런 4개를 두기로 가정한다.

neural_network

화살표에는 가중치(weight)가 존재하고, 가중치와 뉴런의 값을 각각 곱한 뒤, 그 합에 활성화 함수를 적용한 값이 다음 뉴런의 입력으로 쓰인다.
각 층에서 편향(bias)이라고 하는 이전 뉴런의 값에 영향받지 않는 정수를 더한다.

위 그림의 신경망처럼 인접하는 층의 모든 뉴런과 연결되어 있다면 완전연결계층(fully connected layer)이라고 한다.

입력층의 데이터는 $(x_1,x_2)$, 가중치는 $w_{11}$과 $w_{21}$, 편향은 $b_1$로 표현할 때, 수식으로 나타내면 아래와 같다.

\[h_1 = x_1w_{11}+x_2w_{21}+b_1\]

이처럼 은닉층의 뉴런은 가중치의 합으로 계산된다.
가중치와 편향의 값을 바꿔가며 뉴런 수만큼 반복하면 은닉층에 속한 모든 뉴런 값을 구할 수 있다.

가중치와 편향에는 첨자(인덱스)가 붙는다.
첨자는 가중치 합으로 계산되며, 행렬의 곱으로 한꺼번에 계산할 수 있다.
단, 첨자를 붙이는 규칙은 중요하지 않다.

완전연결계층이 수행하는 변환은 행렬의 곱을 이용해 아래처럼 정리해서 쓸 수 있다.

\[(h_1,h_2,h_3,h_4)=(x_1,x_2)\begin{pmatrix} w_{11} & w_{12} & w_{13} & w_{14} \\ w_{21} & w_{22} & w_{23} & w_{24} \end{pmatrix}+(b_1,b_2,b_3,b_4)\]

$(h_1,h_2,h_3,h_4)$은 은닉층의 뉴런들이고, 1×4 행렬 혹은 행벡터이다.
입력 $(x_1,x_2)$는 1×2 행렬이고, 가중치는 2×4 행렬, 편향은 1×4 행렬에 대응한다.
간소화한 식은 아래와 같다.

\[h=xW+b\]

$x$는 입력, $h$는 은닉층의 뉴런, $W$는 가중치, $b$는 편향을 뜻한다.
형상을 확인해보면 대응하는 차원의 원소 수가 일치한다.

다수의 샘플 데이터(미니배치)를 처리하려면 행렬 $x$의 행 각각에 샘플 데이터를 하나씩 저장해야 한다.

import numpy as np
W1 = np.random.randn(2, 4)  # 가중치
b1 = np.random.randn(4)     # 편향
x = np.random.randn(10, 2)  # 입력
h = np.matmul(x, W1) + b1

10개의 샘플 데이터 각각을 완전연결계층으로 변환시키는 예제이다.
코드 마지막 줄의 편향 b1의 덧셈은 브로드캐스트된다.

완전연결계층에 의한 변환은 선형 변환이라고 하고, 활성화 함수를 통해 비선형 효과를 부여한다.
비선형 활성화 함수를 이용함으로써 신경망의 표현력을 높일 수 있다.

시그모이드 함수 (sigmoid function)

\[{\sigma}(x)={1\over1+\exp(-x)}\]

시그모이드 함수는 알파벳 S자 모양의 곡선 함수이다.

sigmoid_function

시그모이드 함수는 임의의 실수를 입력받아 0에서 1 사이의 실수를 출력한다.

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

시그모이드 함수 식을 그대로 표현한 코드이다.
이 코드를 통해 위의 은닉층 뉴런 h를 변환하면 아래와 같다.

a = sigmoid(h)

시그모이드 함수를 통해 비선형 변환한 출력 a를 활성화라고 한다.
그리고 계속해서 또 다른 완전연결계층에 통과시켜 반환한다.
위 예제의 은닉층의 뉴런은 4개, 출력층의 뉴런은 3개이므로 완전연결계층에 사용되는 가중치 행렬은 4×3 형상으로 설정해야 한다.
이를 통해 출력층의 뉴런을 얻을 수 있다.

예제 코드

import numpy as np

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

x = np.random.randn(10, 2)
W1 = np.random.randn(2, 4)
b1 = np.random.randn(4)
W2 = np.random.randn(4, 3)
b2 = np.random.randn(3)

h = np.matmul(x, W1) + b1
a = sigmoid(h)
s = np.matmul(a, W2) + b2

x의 형상이 (10, 2)이므로, 2차원 데이터 10개가 미니배치로 처리된다는 뜻이다.
최종 출력인 s의 형상은 (10, 3)이고, 10개의 데이터가 한꺼번에 처리되었으며 3차원 데이터라는 의미이다.

이 신경망은 3차원 데이터를 출력하므로 각 차원의 값을 이용해 3클래스 분류를 할 수 있다.
출력된 3차원 벡터의 각 차원은 각 클래스에 대응하는 점수(score)가 된다.
실제로 분류를 한다면 출력층에서 가장 큰 값을 출력하는 뉴런에 해당하는 클래스가 예측 결과가 된다.

계층으로 클래스화 및 순전파 구현

신경망 추론 과정에서 하는 처리는 신경망의 순전파(forward propagation)에 해당한다.
순전파는 입력층에서 출력층으로 향하는 전파이다.
순전파 때는 신경망을 구성하는 각 계층이 입력으로부터 출력 방향으로 처리 결과를 차례로 전파한다.
데이터를 순전파와 반대 방향으로 전파하는 경우 역전파(backward propagation)라고 한다.

시그모이드 함수를 클래스로 구현한 Sigmoid 계층은 아래와 같다.

import numpy as np

class Sigmoid:
       def __init__(self):
              self.params = []

       def forward(self, x):
              return 1 / (1 + np.exp(-x)) 

순전파를 수행하는 forward() 메서드를 가지며, 가중치와 편향 등의 매개변수들은 params 인스턴스 변수에 보관한다.
Sigmoid 계층에는 학습하는 매개변수가 없으므로 params 는 빈 리스트로 초기화한다.

완전연결계층에 의한 변환은 기하학에서의 아핀(affine) 변환에 해당하므로 Affine 계층이다.
Affine 계층의 구현은 아래와 같다.

class Affine:
       def __init__(self, W, b):
              self.params = [W, b]

       def forward(self, x):
              W, b = self.params
              out = np.matmul(x, W) + b
              return out

초기화될 때 가중치와 편향을 받는다.
가중치와 편향은 Affine 계층의 매개변수로 신경망이 학습될 때 수시로 갱신되고, 리스트인 params 인스턴스 변수에 보관한다.
forward(x) 는 순전파 처리를 구현한다.

신경망 추론 처리 구현

neural_network_inference_processing

입력 $x$가 Affine 계층, Sigmoid 계층, Affine 계층을 차례로 거쳐 점수인 $s$를 출력하게 된다.

class TwoLayerNet:
       def __init__(self, input_size, hidden_size, output_size):
              I, H, O = input_size, hidden_size, output_size

              W1 = np.random.randn(I, H)
              b1 = np.random.randn(H)
              W2 = np.random.randn(H, O)
              b2 = np.random.randn(O)

              self.layers = [
                     Affine(W1, b1),
                     Sigmoid(),
                     Affine(W2, b2)
              ]

              self.params = []
              for layer in self.layers:
                     self.params += layer.params
              
       def predict(self, x):
              for layer in self.layers:
                     x = layer.forward(x)
              return x


x = np.random.randn(10, 2)
model = TwoLayerNet(2, 4, 3)
s = model.predict(x)

신경망을 TwoLayerNet 이라는 클래스로 추상화하고, 주 추론 처리는 predict(x) 메서드로 구현했다.
초기화 메서드는 가중치를 초기화하고 3개의 계층을 생성한다.
학습해야 할 가중치 매개변수들을 params 리스트에 저장한다.

신경망의 학습

학습되지 않은 신경망은 좋은 추론을 할 수 없으므로 학습을 먼저 수행해야 한다.
추론이란 다중 클래스 분류 등의 문제의 답을 구하는 작업이다.

손실 함수

손실은 학습 데이터와 신경망이 예측한 결과를 비교하여 예측이 얼마나 나쁜가를 산출한 단일 값(스칼라)이다.
신경망의 손실은 손실 함수(loss function)를 사용해서 구한다.

다중 클래스 분류(multi-class classification) 신경망에서는 손실 함수로 교차 엔트로피 오차(Cross Entropy Error)를 많이 사용한다.
교차 엔트로피 오차는 신경망이 출력하는 각 클래스의 확률과 정답 레이블로 구할 수 있다.

아래는 손실 함수를 적용한 신경망의 계층 구성이다.

neural_network_applying_loss_function

$x$는 입력 데이터, $t$는 정답 레이블, $L$은 손실을 나타낸다.

Softmax 계층의 출력은 확률이 되고, 다음 계층인 Cross Entropy Error 계층에는 Softmax 계층에서 출력된 확률과 정답 레이블($t$)이 입력된다.

소프트맥스 함수 식은 아래와 같다.

\[y_k={\exp(S_k)\over\sum\limits_{i=1}\limits^{n}\exp(s_i)}\]

출력이 총 $n$개일 때, $k$번째의 출력 $y_k$를 구하는 계산식이다.
$y_k$는 $k$번째 클래스에 해당하는 소프트맥스 함수의 출력이다.
분자는 점수 $s_k$의 지수 함수이고, 분모는 모든 입력 신호의 지수 함수의 총합이다.

소프트맥스 함수의 출력의 각 원소는 0.0 이상 1.0 이하의 실수이고, 그 원소들을 모두 더하면 1.0이 되므로 확률로 해석할 수 있다.

교차 엔트로피 오차의 수식은 아래와 같다.

\[L=-\sum\limits_{k}t_k\log{y_k}\]

$t_k$는 $k$번째 클래스의 정답 레이블이다.
정답 레이블은 $t=[0,0,1]$과 같이 원핫 벡터로 표기한다.
원핫 벡터(one-hot vector)란 1개의 원소만 1이고, 나머지는 0인 벡터이다.
1인 원소가 정답 클래스에 해당한다.
따라서 1의 원소에 해당하는 자연로그만 계산하고, 다른 원소들은 $t_k$가 0이므로 계산 결과에 영향을 주지 않는다.

미니배치 처리를 고려한 교차 엔트로피 오차 식은 아래와 같다.

\[L = - \frac{1}{N} \sum_{n} \sum_{k} t_{nk} \log{y_{nk}}\]

데이터는 $N$개이고, $t_{nk}$는 $n$번째 데이터의 $k$차원째의 값을 의미한다.
$y_{nk}$는 신경망의 출력이고, $t_{nk}$는 정답 레이블이다.
$N$으로 나눠서 1개당의 평균 손실 함수를 구하여 미니배치의 크기의 관계 없이 항상 일관된 척도를 얻을 수 있다.

미분과 기울기

여러 개의 변수(다변수)도 미분할 수 있다.

벡터의 각 원소에 대한 미분을 정리한 것이 기울기(gradient)이다.

$L$은 스칼라, $x$는 벡터인 함수 $L=f(x)$가 있다면, $x_i$에 대한 $L$의 미분은 $\partial{L}\over{\partial{x_i}}$로 쓸 수 있다.
이를 정리하면 아래와 같다.

\[{\partial {L} \over {\partial {x}}} = \begin{pmatrix} {\partial {L} \over {\partial {x_1}}}, {\partial {L} \over \partial {x_2}}, \cdots, {\partial {L} \over \partial {x_n}} \end{pmatrix}\]

$W$가 $m\times{n}$행렬에서 $L=g(W)$ 함수의 기울기는 아래와 같이 구할 수 있다.

\[{\partial {L} \over \partial {W}} = \begin{pmatrix} {\partial {L} \over \partial {W_{11}}} && \cdots && {\partial {L} \over \partial {W_{1n}}} \\ \vdots && \ddots \\ {\partial {L} \over \partial {W_{m1}}} && && {\partial {L} \over \partial {W_{mn}}} \end{pmatrix}\]

$W$와 $\partial {L} \over {\partial {W}}$의 형상은 같다.
행렬과 그 기울기의 형상이 같다는 성질을 이용하여 매개변수 갱신과 연쇄 법칙을 쉽게 구현할 수 있다.

연쇄 법칙 (chain rule)

연쇄 법칙이란 합성함수에 대한 미분 법칙이다.
$y=f(x)$와 $z=g(y)$라는 두 함수가 있을 때, $z=g(f(x))$가 되고, 이 합성함수의 미분은 아래와 같이 구한다.

\[{\partial {z} \over {\partial {x}}} = {\partial {z} \over {\partial {y}}} {\partial {y} \over {\partial {x}}}\]

우리가 다루는 함수가 아무리 복잡하다 하더라도, 그 미분은 개별 함수의 미분을 이용해 구할 수 있기 때문에 연쇄법칙이 중요하다.
각 함수의 국소적인 미분을 계산할 수 있으면 그 값들을 곱해서 전체의 미분을 구할 수 있다.

계산 그래프

계산 그래프는 계산 과정을 시각적으로 보여준다.

덧셈 노드

아래는 $z=x+y$를 나타낸 계산 그래프이다.

simple_graph

이 계산의 앞뒤로 어떤 계산이 있다고 가정하고, 최종적으로 스칼라 값인 $L$이 출력된다고 가정한다.

add_node

$L$의 미분을 각 변수에 대해 구하고자 할 때, 역전파는 아래와 같이 그릴 수 있다.

backpropagation

연쇄 법칙에 따르면 ${\partial {L} \over {\partial {x}}} = {\partial {L} \over {\partial {z}}}{\partial {z} \over {\partial {x}}}$이고, ${\partial {L} \over {\partial {y}}} = {\partial {L} \over {\partial{z}}} {\partial{z} \over {\partial{y}}}$이다.
${\partial{z} \over {\partial{x}}} = 1$, ${\partial{z} \over {\partial{y}}} = 1$이므로 상류로부터의 기울기가 그대로 전파된다.

addition_node

곱셈 노드

$z=x\times{y}$일 때, ${\partial{z} \over \partial{x}} = y$, ${\partial {z} \over {\partial {y}}} = x$이다.
따라서 곱셈 노드의 역전파는 상류로부터 받은 기울기에 순전파 시 입력을 서로 바꾼 값을 곱한다.

multiple_node

분기 노드

quarter_node

분기 노드의 역전파는 상류에서 온 기울기들의 합이 된다.

Repeat 노드

repeat_node

길이가 $D$인 배열을 $N$개로 복제할 때, 이 Repeat 노드는 $N$개의 분기 노드로 볼 수 있으므로, 그 역전파는 $N$개의 기울기를 모두 더해 구할 수 있다.

import numpy as np

D, N = 8, 7

x = np.random.randn(1, D)
y = np.repeat(x, N, axis=0)

dy = np.andom.randn(N, D)
dx = np.sum(dy, axis=0, keepdims=True)

np.repeat() 메서드로 원소 복제를 수행하고, 배열 $x$를 $N$번 복제한다.
역전파의 총합을 구할 때 np.sum() 을 사용한다.
axis 를 지정하여 어느 축 방향으로 복제할지 조정할 수 있다.

keepdims=True 를 설정하여 2차원 배열의 차원 수를 유지한다.

Sum 노드

sum_node

Sum 노드와 Repeat 노드는 서로 반대 관계이다.
Sum 노드의 순전파는 Repeat 노드의 역전파이며, Sum 노드의 역전파는 Repeat 노드의 순전파이다.

import numpy as np
D, N = 8,7
s = np.random.randn(N, D)
y = np.sum(x, axis=0, keepdims=True)

dy = np.random.randn(1, D)
dx = np.repeat(dy, N, axis=0)

Sum 노드의 순전파는 np.sum() 메서드로, 역전파는 np.repeat() 메서드로 구현했다.

MatMul 노드

matmul_node

아래는 MatMul 계층을 구현한 코드이다.

class MatMul:
       def __init__(self, W):
              self.params = [W]
              self.grads = [np.zeros_like(W)]
              self.x = None
       
       def forward(self, W):
              W, = self.params
              out = np.matmul(x, W)
              self.x = x
              return out

       def backward(self, dout):
              W, = self.params
              dx = np.matmul(dout, W.T)
              dW = np.matmul(self.x.T, dout)
              self.grads[0][...] = dW
              return dx

학습하는 매개변수는 params 에 보관하고, 기울기는 grads 에 보관한다.
역전파에서는 dxdW 를 구해 가중치의 기울기를 grads 에 보관한다.
생략 기호를 사용한 깊은 복사를 통해 값을 계속 덮어쓸 수 있다.

생략(ellipsis) 기호

넘파이 배열이 가리키는 메모리 위치를 고정시키고 덮어쓰기를 수행한다.
grads[0] = dW 처럼 그냥 할당하면 얕은 복사가 이루어지지만, grads[0][...] = dW 처럼 덮어쓰면 깊은 복사가 이루어진다.

Sigmoid 계층

시그모이드 함수의 미분은 아래와 같다.

\[\frac{ \partial {y} }{ \partial {x} } = y(1-y)\]

Sigmoid 계층은 피 출력 쪽 계층으로부터 전해진 기울기에 시그모이드 함수의 미분을 곱하고 입력 쪽 계층으로 전파한다.

sigmoid_graph

class Sigmoid:
       def __init__(self):
              self.params, self.grads = [], []
              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

순전파 때 출력을 인스턴스 변수 out 에 저장하고, 역전파 계산 시 out 변수를 사용한다.

Affine 계층

Affine 계층의 순전파는 y = np.matmul(x, W) + b 로 구현할 수 있다.
편향을 더할 때는 넘파이의 브로드캐스트를 사용한다.

affine_graph

MatMul 노드로 행렬 곱을 계산하고, 편향은 Repeat 노드에 의해 복제된 후 더해진다.
Repeat 노드가 수행하는 복제가 넘파이의 브로드캐스트 기능에 해당한다.

class Affine:
       def __init__(self, W, b):
              self.params = [W, b]
              self.grads = [np.zeros_like(W), np.zeros_like(b)]
              self.x = None
       
       def forward(self, x):
              W, b = self.params
              out = np.matmul(x, W) + b
              self.x = x
              return out
       
       def backward(self, dout):
              W, b = self.params
              dx = np.matmul(dout, W.T)
              dW = np.matmul(self.x.T, dout)
              db = np.sum(dout, axis=0)

              self.grads[0][...] = dW
              self.grads[1][...] = db
              return dx

Affine의 역전파는 MatMul 노드와 Repeat 노드의 역전파로 구할 수 있다.

Softmax with Loss 계층

소프트맥스 함수와 교차 엔트로피 오차는 Softmax with Loss 라는 하나의 계층으로 구현할 수 있다.
3-클래스 분류를 가정하여 이전 계층으로부터 3개의 입력을 받도록 한다.

softmax_with_loss

Softmax 계층의 역전파는 자신의 출력과 정답 레이블의 차이다.
신경망의 역전파는 차이(오차)를 앞 계층에 전해주는 것이다.

가중치 갱신

오차역전파법으로 구한 기울기를 사용해 신경망의 매개변수를 갱신하는데, 이때 신경망의 학습 순서는 아래와 같다.

  1. 미니배치: 훈련 데이터 중 무작위로 다수의 데이터를 골라낸다.
  2. 기울기 계산: 오차역전파법으로 각 가중치 매개변수에 대한 손실 함수의 기울기를 구한다.
  3. 매개변수 갱신: 기울기를 사용하여 가중치 매개변수를 갱신한다.
  4. 반복: 1~3단계를 필요한 만큼 반복한다.

매개변수를 기울기와 반대 방향으로 갱신하여 손실을 줄이는 것을 경사하강법(Gradient Descent)이라고 한다.

확률적경사하강법 (Stochastic Gradient Descent)

무작위로 선택된 데이터(미니배치)에 대한 기울기를 이용한다.
현재의 가중치를 기울기 방향으로 일정한 거리만큼 갱신한다.

\[W \gets {W} - \eta {\frac{\partial {L}}{\partial {W}}}\]

갱신하는 가중치 매개변수는 $W$이고, $W$에 대한 손실 함수의 기울기는 $\frac{\partial {L}}{\partial {W}}$이다.
$\eta$는 학습률(learning rate)을 나타내고, 0.01이나 0.001 같은 값을 미리 정해서 사용한다.

class SGD:
       def __init__(self, lr=0.01):
              self.lr = lr

       def update(self, params, grads):
              for i in range(len(params)):
                     params[i] -= self.lr * grads[i]

update(params, grads) 메서드는 매개변수 갱신을 처리한다.
lr 은 학습률을 뜻한다.

신경망으로 문제를 풀다

데이터셋 준비

spiral_dataset

학습에 이용할 스파이럴 데이터셋이다.
입력은 2차원 데이터이고, 분류할 클래스는 3개가 있다.
직선만으로는 분리할 수 없는 형태이므로 비선형 분리를 학습해야 한다.

신경망 구현

은닉층이 하나인 신경망을 구현했다.

import sys
sys.path.append('..')
import numpy as np
from common.layers import Affine, Sigmoid, SoftmaxWithLoss

class TwoLayerNet:
       def __init__(self, input_size, hidden_size, output_size):
              I, H, O = input_size, hidden_size, output_size

              W1 = 0.01 * np.random.randn(I, H)
              b1 = np.zeros(H)
              W2 = 0.01 * np.random.randn(H, O)
              b2 = np.zeros(O)

              self.layers = [
                     Affine(W1, b1)
                     Sigmoid()
                     Affine(W2, b2)
              ]
              self.loss_layer = SoftmaxWithLoss()

              self.params, self.grads = [], []
              for layer in self.layers:
                     self.params += layer.params
                     self.grads += layer.grads

input_size : 입력층의 뉴런 수
hidden_size : 은닉층의 뉴런 수
output_size : 출력층의 뉴런 수

np.zeros() : 편향을 영벡터로 초기화
0.01 * np.random.randn() : 학습이 잘 진행되도록 가중치를 작은 무작위 값으로 초기화

필요한 계층을 생성해 인스턴스 변수인 layers 리스트에 모아두고, 매개변수들과 기울기를 하나로 모은다.

def predict(self, x):
       for layer in self.layers:
              x = layer.forward(x)
       return x

def forward(self, x, t):
       score = self.predict(x)
       loss = self.loss_layer.forward(score, t)
       return loss

def backward(self, dout=1):
       dout = self.loss_layer.backward(dout)
       for layer in reversed(self.layers):
              dout = layer.backward(dout)
       return dout

predict() : 추론 수행
forward() : 순전파 담당
backward() : 역전파 담당

학습용 코드

학습 데이터를 읽어 신경망(모델)과 옵티마이저(최적화기)를 생성한다.
신경망이나 SVM(서포트 벡터 머신)과 같이 문제를 풀기 위해 설계한 기법을 보통 모델이라고 부른다.

import sys
sys.path.append('..')
import numpy as np
from common.optimizer import SGD
from dataset import spiral
import matplotlib.pyplot as plt
from two_layer_net import TwoLayerNet

max_epoch = 300
batch_size = 30
hidden_size = 10
learning_rate = 1.0

x, t = spiral.load_data()
model = TwoLayerNet(input_size=2, hidden_size=hidden_size, output_size=3)
optimizer = SGD(lr=learning_rate)

# 학습에 사용하는 변수
data_size = len(x)
max_iters = data_size // batch_size
total_loss = 0
loss_count = 0
loss_list = []

for epoch in range(max_epoch):
       idx = np.random.permutation(data_size)
       x = x[idx]
       t = t[idx]

       for iters in range(max_iters):
              batch_x = x[iters*batch_size:(iters+1)*batch_size]
              batch_t = t[iters*batch_size:(iters+1)*batch_size]

              loss = model.forward(batch_x, batch_t)
              model.backward()
              optimizer.update(model.params, model.grads)

              total_loss += loss
              loss_count += 1

              if (iters+1) % 10 == 0:
                     avg_loss = total_loss / loss_count
                     print('| 에폭 %d | 반복 %d / %d | 손실 %.2f' % (epoch + 1, iters + 1, max_iters, avg_loss))
                     loss_list.append(avg_loss)
                     total_loss, loss_count = 0, 0              
  1. 하이퍼 파라미터 설정
    max_epoch : 에폭 수
    batch_size : 미니배치 크기
    hidden_size : 은닉층의 뉴런 수
    learning_rate : 학습률
    에폭(epoch)은 학습 단위로, 1 에폭은 학습 데이터를 1바퀴 돌아 모두 살펴본 시점을 뜻한다.
    미니배치 방식으로 학습하며 데이터를 무작위로 선택한다.

  2. 데이터 읽기, 모델과 옵티마이저 생성

  3. 데이터 섞기
    에폭 단위로 데이터를 뒤섞은 다음, 뒤섞은 데이터 중 앞에서부터 순서대로 뽑아내는 방식을 사용한다.
    np.random.permutation() : 인수로 $N$을 주면, 0에서 $N-1$까지의 무작위 순서를 생성해 반환한다.

  4. 기울기를 구해 매개변수를 갱신한다.

  5. 정기적으로 학습 결과를 출력한다.

아래는 손실 그래프이다.

loss_graph

가로축은 학습의 반복 수이고, 세로축은 학습 10번 반복 당 손실 평균이다.
학습을 진행할수록 손실이 줄어든다.

신경망이 영역을 어떻게 분리했는지 시각화하는 것을 결정 경계(decision_boundary)라고 한다.
아래는 학습 후 신경망의 결정 경계 결과이다.

decision_boundary

신경망이 식별하는 클래스 별 영역을 색으로 구분했다.
학습된 신경망은 나선형 패턴을 올바르게 파악했다.

이처럼 신경망에 은닉층을 추가하면 복잡한 표현이 가능해진다.
층을 깊게 쌓을 수록 표현력 또한 풍부해진다.

Trainer 클래스

학습 수행을 지원하는 클래스이다.

model = TwoLayerNet(...)
optimizer = SGD(lr=1.0)
trainer = Trainer(model, optimizer)

초기화 메서드는 신경망과 옵티마이저를 인수로 받는다.

fit() 메서드를 호출해 학습을 시작한다.
plot() 메서드로 fit() 에서 기록한 손실을 그래프로 그릴 수 있다.

fit() 메서드의 인수

  • x : 입력 데이터
  • t : 정답 레이블
  • max_epoch(=10) : 학습을 수행하는 에폭 수
  • batch_size(=32) : 미니배치 크기
  • eval_interval(=20) : 결과(평균 손실 등)를 출력할 간격
  • max_grad(=None) : 기울기 최대 노름(norm)으로, 값을 넘기면 기울기를 줄임
import sys
sys.path.append('..')
from common.optimizer import SGD
from common.trainer import Trainer
from dataset import spiral
from two_layer_net import TwoLayerNet

max_epoch = 300
batch_size = 30
hidden_size = 10
learning_rate = 1.0

x, t = spiral.load_data()
model = TwoLayerNet(input_size=2, hidden_size=hidden_size, output_size=3)
optimizer = SGD(lr=learning_rate)

trainer = Trainer(model, optimizer)
trainer.fit(x, t, max_epoch, batch_size, eval_interval=10)
trainer.plot()

해당 코드의 수행으로 위에서 구현했던 신경망 학습을 실행할 수 있다.
Trainer 클래스의 사용을 통해 코드를 깔끔하게 할 수 있다.

계산 고속화

신경망 학습과 추론의 연산량을 빠르게 처리하기 위해 비트 정밀도, GPU에 대해 알아보자.

비트 정밀도

넘파이의 부동소수점 수는 기본적으로 64비트 데이터 타입을 사용하는데, 환경이나 OS, 파이썬/넘파이 버전에 따라 바뀔 수 있다.
넘파이 인스턴스 변수 dtype을 출력해 데이터 타입을 알아볼 수 있다.

import numpy as np
a = np.random.randn(3)
a.dtype
dtype('float64')

출력 결과인 float64 는 64비트 부동소수점 수라는 뜻이다.

신경망의 추론과 학습은 32비트 부동소수점 수로도 인식률이 거의 떨어지지 않고 수행이 가능하다.
64비트의 절반이므로 메모리 관점에서는 항상 32비트가 더 좋다.
버스 대역폭(bus bandwidth) 병목 발생 시에도 데이터 타입이 작은 것이 유리하다.
CPU나 GPU 아키텍처에 따라 다르긴 하지만, 계산 속도 측면에서도 32비트 부동소수점 수가 일반적으로 더 빠르다.

b = np.random.randn(3).astype(np.float32)
b.dtype
dtype('float32')
c = np.random.randn(3).astype('f')
c.dtype
dtype('float32')

넘파이에서 32비트 부동소수점 수를 사용하려면 데이터타입을 np.float32'f' 로 지정한다.

신경망 추론으로 한정하면 16비트 부동소수점 수를 사용해도 인식률이 거의 떨어지지 않는다.
넘파이에도 16비트 부동소수점 수가 준비되어 있다.
다만 일반적으로 CPU와 GPU는 연산 자체를 32비트로 수행하므로 16비트로 변환하더라도 계산 자체는 32비트로 수행되어 처리 속도 측면에서는 이득이 없다.

학습된 가중치 저장 시에는 16비트로 저장하면 용량을 줄일 수 있어 유리하다.

최근 딥러닝이 주목받으면서 GPU들이 저장과 연산 모두에 16비트, 8비트 반정밀도 부동소수점 수를 지원한다.
구글에서 개발한 TPU 칩도 8비트 계산을 지원한다.

GPU(쿠파이)

딥러닝의 계산이 대량의 곱하기 연산으로 구성되어 있으므로 대부분은 병렬로 계산할 수 있다.
이때 CPU보다 GPU가 유리하다.

쿠파이는 엔디비아 GPU에서 동작하는 GPU 병렬 계산 수행 라이브러리이다.
CUDA라는 GPU 전용 병렬 컴퓨팅 플랫폼을 설치해야 한다.
쿠파이는 넘파이와 호환되는 API를 제공하고, 병렬 계산을 간단하게 수행할 수 있도록 돕는다.

import cupy as cp
x = cp.arange(6).rshape(2, 3).astype('f')
x
array([[ 0., 1., 2. ]
       [ 3., 4., 5. ]], dtype=float32)
x.sum(axis=1)
array([ 3., 12. ], dtype=float32)

넘파이로 작성한 코드를 GPU용으로 변경할 수 있어 간편하다.
쿠파이가 넘파이의 모든 메서드를 지원하는 것은 아니지만, 공통된 API를 많이 제공하므로 대부분은 import 문만 수정하면 된다.

Categories:

댓글남기기