딥러닝에서 히든 레이어 동작 원리

머신러닝을 하면서 히든 레이어를 구현하는 방식이 너무 궁금했다.
여러 레이어들간의 입력과 출력 갯수에 규칙이 있다는 것을 당연히 생각하긴 했지만
그것이 어떤 원리로 동작하는지는 알 수 없었다.

이번 예제에서는 히든 레이어를 포함한 멀티 레이어가 동작하는 방식에 대해 살펴본다.
"밑바닥부터 시작하는 데이터 과학"을 참고로 했고
내가 이해할 수 있도록 완전 기초부터 구현하는 과정에서 원본 코드를 많이 수정, 추가했다.

코드에 앞서 이해해야 할 개념으로 뉴런(neuron)이 있다.
신경계를 구성하는 기본 단위의 세포를 말하는데, 코드에서는 레이어를 구성하는 기본 단위가 된다.
히든 레이어는 여러 개의 뉴런을 가질 수 있고, 갯수에는 제한이 없다.
뉴런은 weights와 bias로 구성되어 있고,
weights는 이전 레이어의 뉴런 갯수와 동일하고, 여기에 bias가 하나 추가된다.
첫 번째 히든 레이어의 뉴런은 입력 레이어의 갯수만큼 weights를 갖게 되고
마지막 히든 레이어의 뉴런 갯수는 출력 레이어의 갯수와 같아야 한다.

코드는 basic, multi, final의 세 가지 함수로 구성했다.
원본 코드는 멀티 레이어를 구성해서 xor 연산을 수행할 수 있다는 것을 보여주는데
여기서는 basic 함수에서만 xor 연산을 보여주고,
multi와 final에서는 멀티 레이어의 원리를 보여준다.
특히, 마지막의 final에서는 히든 레이어를 자동으로 생성할 수 있다는 것을 보여준다.
그래서, 입력과 출력 레이어만 정의되면 모든 단계를 자동으로 처리할 수 있게 된다.

아래는 첫 번째 코드로, 멀티 레이어를 사용해서 xor 연산을 처리할 수 있다는 것을 보여준다.
주석을 자세히 달았으므로 설명은 생략한다.
참, show_shape 함수는 리스트 크기를 확인하기 위한 임시 함수이다.

import numpy as np
import math

def show_shape(a):
return np.array(a).shape

def sigmoid(t):
return 1 / (1 + math.exp(-t))

def neuron_output(weights, inputs):
''' np.dot 함수는 1차원에 대해서는 내적(inner), 2차원에 대해서는 행렬 곱셈 수행.
어떤 곱셈이 수행되건 결과로 숫자 1개 반환. '''
# print(show_shape(weights), show_shape(inputs))
return sigmoid(np.dot(weights, inputs))

def feed_forward(neural_network, input_vector):
''' feed backward에 반대되는 말로 앞쪽부터 순서대로 진행하는 것을 의미한다.
이곳에 작성된 모든 layer 함수에서 호출된다. 코드 전체에서 가장 중요한 함수.
neural_network에는 히든과 출력 레이어가 들어 있다. '''
outputs = []
for layer in neural_network:
# 이전 레이어의 입력에 bias 추가. bias는 항상 1.
input_with_bias = input_vector + [1]

# comprehension. 레이어에 포함된 모든 뉴런에 대해 결과 계산.
# 뉴런 하나당 결과가 한 개이므로 output 크기는 뉴런 갯수와 동일하다.
# 뉴런에는 weights와 bias가 들어 있고, 크기는 입력에 해당하는 input_with_bias와 같아야 한다.
# neuron과 input_with_bias는 모두 1차원 리스트.
output = [neuron_output(neuron, input_with_bias) for neuron in layer]

# output은 계산 결과를 의미하고, 모든 결과를 outputs에 누적.
outputs.append(output)

# 이전 레이어의 출력은 다음 레이어의 입력이 된다.
input_vector = output

# 결과를 누적시켰기 때문에 출력 레이어에 대한 결과는 마지막에 있다.
return outputs

def layer_basic():
''' xor 연산 : and 연산에는 해당하지 않지만, or 연산에는 해당하는 결과.
hidden 레이어는 첫 번째 입력이 아닌 두 번째 입력에 해당한다.
첫 번째 입력을 받아서 새로운 결과를 만든다. '''
hidden = [[20, 20, -30], # and 뉴런. x1(20), x2(20), bias(-3).
[20, 20, -10]] # or 뉴런
output = [[-60, 60, -30]] # output은 hidden 레이어의 출력을 입력으로 받는다.
xor_network = [hidden, output] # output은 hidden 요소가 2개이므로 bias까지 3개 요소

# feed_forward 호출은 각각의 데이터에 대해 검증하기 때문에 4번 호출되어야 한다.
for x in ([0, 0], [0, 1], [1, 0], [1, 1]):
# logit에는 히든 레이어에 대한 결과까지 모두 포함되어 있다.
# [-1]을 사용한 이유는 마지막에 최종 결과가 들어있기 때문이다.
logit = feed_forward(xor_network, x)[-1]
print(x, logit)

# 출력 결과를 0과 1로 제한해야 하기 때문에 0.5를 기준으로 판단해야 한다.
# hidden과 output 리스트에 있는 데이터 크기에 따라 결과가 달라진다.
#
# 출력 결과 - hidden과 output에 10을 곱한 경우. 성공(0, 1, 1, 0)
# [0, 0] [9.38314668300676e-14]
# [0, 1] [0.9999999999999059]
# [1, 0] [0.9999999999999059]
# [1, 1] [9.383146683006828e-14]

# 출력 결과 - hidden과 output에 10을 곱하지 않은 경우. 실패(0, 0, 0, 0)
# [0, 0] [0.15830332818949178]
# [0, 1] [0.4434191256899033]
# [1, 0] [0.4434191256899033]
# [1, 1] [0.1583033281894919]

layer_basic()


아래는 두 번째 코드로, 멀티 레이어의 구성 원리를 보여준다.
주석에도 달았는데, 리스트에 포함된 값은 쓰레기다.
레이어들간의 입력과 출력이 동작하는 원리를 이해하는 것이 중요하다.

def layer_multi():
'''
문제
5, 4, 3, 2로 이어지는 히든 레이어를 구성해 보세요.
히든 레이어 다음에는 결과를 보여주는 출력 레이어가 와야 합니다.
결과에 상관없이 죽지 않고 동작하면 제대로 된 구성으로 간주합니다.

hidden5, hidden4, hidden3, hidden2, output은 레이어가 되고
레이어에 포함된 요소들은 뉴런이 된다. 뉴런 안에는 weights가 들어 있다. bias는 따로 추가된다.
뉴런에 포함된 weights의 값들은 전혀 의미가 없고, 뉴런과 weights 갯수만 중요하다.
죽지 않고 동작했다는 것은 레이어들간의 입력과 출력이 정확하게 맞았다는 것을 의미한다.

[hidden5]
feed_forward에 전달된 x와 연산하기 때문에 x와 데이터의 갯수가 같아야 한다. (x1, x2, bias 순서)
요소 갯수는 4개짜리 레이어와 연결되기 때문에 bias를 제외하면 데이터는 4개.

[hidden4]
hidden5를 계산한 결과에 bias 하나가 추가된 결과와 계산하기 때문에 데이터 갯수는 5개. (x1, x2, x3, x4, bias)
요소 갯수는 3개짜리 레이어와 연결되기 때문에 bias를 재외하면 데이터는 3개.

레이어에서 뉴런(요소) 갯수는 다음 레이어의 가중치(데이터) 갯수를 결정한다.
결국 이전 레이어의 요소 갯수와 현재 레이어의 가중치 갯수가 같아야 한다는 뜻이 된다.
'''
hidden5 = [[20, 20, -30], [20, 20, -10], [20, 20, -10], [20, 20, -10]]
hidden4 = [[20, 20, 20, 20, -30], [20, 20, 20, 20, -10], [20, 20, 20, 20, -10]]
hidden3 = [[20, 20, 20, -30], [20, 20, 20, -10]]
hidden2 = [[20, 20, -30], [20, 20, -30]]
output = [[-60, 60, -30]]
xor_network = [hidden5, hidden4, hidden3, hidden2, output]

for x in ([0, 0], [0, 1], [1, 0], [1, 1]):
print(x, feed_forward(xor_network, x)[-1])

# 죽지 않고 동작하지만 출력 결과는 쓰레기이므로 코드에 표시하지 않는다.

layer_multi()


마지막으로 히든 레이어를 갯수만 지정하면 자동으로 생성할 수 있다는 것을 보여준다.
딥러닝에서 히든 레이어를 직접 만들어서 전달하는 것이 아니기 때문에 항상 궁금했던 부분이다.
역시 리스트에 포함된 값과 결과는 쓰레기다.

ef layer_final(hidden_sizes, inputs):
'''
hidden_sizes의 크기는 히든 레이어의 갯수를 뜻하고,
포함된 값은 히든 레이어에 들어가는 뉴런의 갯수를 뜻한다.
마지막 요소가 1이라는 것은 입력에 대한 결과를 1개만 만든다는 것을 의미하고,
0.5를 기준으로 0 또는 1로 변환해야 함을 의미한다.
'''
def make_hidden_layer(size_out, size_in):
''' size_out : 바깥 리스트 크기, size_in : 안쪽 리스트 크기
5와 3이 전달됐다면, 뉴런은 5개, 뉴런에 포함된 weights는 3개라는 뜻이 된다.
리스트에 대한 * 연산은 부작용이 있기 때문에 copy 함수로 깊은 복사 수행. '''
# 실제 코드에서는 weights에 들어가는 값이기 때문에 1 대신 의미 있는 난수로 초기화.
return ([[1] * size_in] * size_out).copy()

xor_network = []
size = len(inputs[0])+1

# 풀어쓴 코드. 아래 나오는 반복문으로 수정.
# (5, 3) : 뉴런 5개, 가중치 3개
# layer = make_hidden_layer(hidden_sizes[0], size)
# xor_network.append(layer)
# size = len(layer)+1 # +1은 bias.
#
# (3, 6) : 뉴런 3개, 가중치 6개
# layer = make_hidden_layer(hidden_sizes[1], size)
# xor_network.append(layer)
# size = len(layer)+1
#
# (1, 4) : 뉴런 1개, 가중치 4개
# layer = make_hidden_layer(hidden_sizes[2], size)
# xor_network.append(layer)
# size = len(layer)+1 # 반복 규칙을 위해.

# 히든 레이어 구성. 마지막 레이어는 출력 레이어로 사용.
for i in hidden_sizes:
layer = make_hidden_layer(i, size)
# print(show_shape(layer)) # (5, 3), (3, 6), (1, 4)

xor_network.append(layer)
size = len(xor_network[-1])+1

# 결과 예측. 의미 없는 weights를 사용했기 때문에 결과 또한 쓰레기.
for x in inputs:
print(x, feed_forward(xor_network, x)[-1])


inputs = ([0, 0], [0, 1], [1, 0], [1, 1])
layer_final([5, 3, 1], inputs)