본문 바로가기
  • 비둘기다
  • 비둘기다
  • 비둘기다
AI/Deep learning from Scratch

[머신러닝, 딥러닝] 신경망 (2) - 다차원 배열

by parzival56 2023. 1. 21.

계단 함수, 시그모이드와 ReLU에 이어 넘어가기 전 행렬에 대한 개념을 짚고 넘어가 보고자 합니다.

행렬은 행과 열로 이루어진 수를 의미합니다.

가로가 행, 세로가 열인데 기존의 배열의 형태와 유사함을 알 수 있습니다. 배열은 단순히 숫자를 나열한 형태이지만 가장 간단한 한 줄에 숫자를 나열한 배열도 하나의 행과 하나의 열을 가지는 행렬이라고 부를 수 있습니다.

만약 1, 2, 3, 4라는 배열이 있다면 우리는 이것을 다차원 배열로 쉽게 변환할 수 있습니다. 

# 1차원
A = [1,2,3,4]
# 2차원
B = ([[1,2], [3,4]])

개념적인 부분은 기본적인 수학이니 넘어가고 이제 계산에 대해 알아보겠습니다. 

예를 들어 2 x 2 행렬 곱 연산을 한다고 할 때 같은 크기의 행렬을 연산하면 결과도 같은 크기로 나오기 때문에 먼저 결과의 1행 1열의 값에는 앞 행렬의 1행과 뒤 행렬의 1열을 곱 연산, 1행 2열의 값에는 앞 행렬의 2행과 뒤 행렬의 1열을 곱 연산, 

2행 1열의 값에는 앞 행렬의 1행과 뒤 행렬의 2열을 곱 연산, 2행 2열의 값에는 앞 행렬의 2행과 뒤 행렬의 2열을 곱 연산 해줍니다.

그럼 말로는 복잡한 과정을 넘파이 배열을 통해 구현하면 표현이 편리한 것을 알 수 있습니다.

A = np.array([[1,2], [3,4]])
A.shape
B = np.array([[5,6], [7,8]])
B.shape

np.dot(A,B)

위에 나오는 shape()는 배열의 형태를 '튜플'의 형태로 반환해 줍니다. 그리고 dot()이 행렬을 곱 연산해 주는 메서드입니다.

행렬의 수학적인 특성상 1차원끼리 곱하면 벡터를 반환하고 높은 차원을 곱하면 행렬 곱의 형태로 결과가 도출됩니다.

 

행렬 계산을 코딩하다 보면 주의할 점은 '행렬의 형상'입니다. 이를 좀 더 구체적으로 말하자면 행렬 A의 첫 번째 차원의 원소 수(열 수)와 행렬 B의 0번째 차원의 원소 수(행 수)가 같아야 합니다.

A = np.array([[1,2,3], [4,5,6]])
C = np.array([[1,2], [3,4]])

np.dot(A, C)

위와 같이 두 행렬의 크기가 맞지 않을 시 오류가 발생합니다.

식으로 쉽게 표현하자면 A는 3 x 2이고 B는 2 x 4라고 할 때 A의 열 수 2와 B의 행 수 2가 같기 때문에 이는 계산이 성립합니다. 0차원 배열도 마찬가지입니다. (위에서는 1차원이라고 썼는데 인덱스로 취급하면 차원도 0부터 시작하기 때문에 코드로 이해하기 쉽게 바꿈) 

A는 위 그대로이고 B가 [1, 2] 즉 2 x 1 or 2일 때에도 성립합니다.

 

3층 신경망에서의 계산을 구현해 보도록 하겠습니다.

기존의 신경망은 0층에 입력, 1층에 은닉, 2층에 출력으로 총 2층이었으나 이제는 은닉층을 하나 추가하여 3층으로 구성하였습니다.

위의 그림에서 1층의 은닉층 중 가장 위에 있는 층을 임의로 A1이라고 하겠습니다. 그리고 A1의 값에 영향을 주는 다른 값들 뒤에 1을 붙여 표현하고 여기에 편향을 추가한다면 아래와 같이 표현할 수 있습니다.

X = np.array([1.0, 0.5])
W1 = np.array([[0.1, 0.3, 0.5], [0.2, 0.4, 0.6]])
B1 = np.array([0.1, 0.2, 0.3])

A1 = np.dot(X, W1) + B1
print(A1)

A1의 계산 식은 우리가 아는 가중치에 편향을 도입한 식과 동일한 계산이지만 전과는 달리 dot()을 이용하였습니다.

그다음 A1을 활성화 함수로 변환한 값을 Z1이라고 하겠습니다. 

Z1 = sigmoid(A1)
print(Z1)

이 과정을 거치면 A1과 동일한 크기를 가지지만 시그모이드 함수를 지나기 때문에 다른 값이 Z1으로 나오게 됩니다.

그럼 이제 2층의 은닉층을 살펴보겠습니다. 2층에서도 위에 있는 층을 A2라고 가정해 보겠습니다.

W2 = np.array([[0.1, 0.4], [0.2, 0.5], [0.3, 0.6]])
B2 = np.array([0.1, 0.2])

A2 = np.dot(Z1, W2) + B2
Z2 = sigmoid(A2)
print(Z2)

마찬가지로 편향이 적용되었고, 위와 달리 2층은 층보다 신호를 전달할 곳이 한 군데 적기 때문에 편향과 가중치 모두 줄었지만 가중치의 경우에는 3개의 값이 1층으로부터 나온 상태에서 2층으로 가는 것이기 때문에 3 x 2 임을 알 수 있습니다.

 

이제 2층에서 출력으로 가는 3층입니다. 

def identify_function(x):
    return x

W3 = np.array([[0.1, 0.3], [0.2, 0.4]])
B3 = np.array([0.1, 0.2])

A3 = np.dot(Z2, W3) + B3
Y = identify_function(A3)

print(Y)

여기서 굳이 identify_function이라는 함수를 쓴 이유는 출력 계층도 A에서 활성화 함수로 인한 변환으로 Z가 되는 은닉층처럼 은닉층으로부터 받은 값에서 값은 같지만 출력의 형태 y로 바뀌기 때문에 가시성을 고려하여 추가하였습니다.

 

위의 3층 신경망을 계산하는 모든 과정을 하나의 네트워크처럼 통합하면 아래와 같습니다.

def init_network():
    network = {}
    network['W1'] = np.array([[0.1, 0.3, 0.5], [0.2, 0.4, 0.6]])
    network['B1'] = np.array([0.1, 0.2, 0.3])
    network['W2'] = np.array([[0.1, 0.4], [0.2, 0.5], [0.3, 0.6]])
    network['B2'] = np.array([0.1, 0.2])
    network['W3'] = np.array([[0.1, 0.3], [0.2, 0.4]])
    network['B3'] = np.array([0.1, 0.2])
    
    return network

def forward(network, x):
    W1, W2, W3 = network['W1'], network['W2'], network['W3']
    b1, b2, b3 = network['B1'], network['B2'], network['B3']
    
    a1 = np.dot(x, W1) + b1
    z1 = sigmoid(a1)
    a2 = np.dot(z1, W2) + b2
    z2 = sigmoid(a2)
    a3 = np.dot(z2, W3) + b3
    y = identify_function(a3)
    
    return y

network = init_network()
x = np.array([1.0, 0.5])
y = forward(network, x)
print(y)

init_network에서 가중치와 편향을 초기화하였고, network는 딕셔너리로 정의하여 후에 forward에서 변수와 매칭시켜 주었습니다. 이름을 forward라고 한 이유는 이 신경망이 순전파의 구성이 되게 설계를 하였기 때문입니다. 역전파라면 backward가 되겠죠. 이처럼 넘파이의 다차원 배열을 활용하면 신경망을 효율적으로 구현할 수 있습니다. 

 

출력층을 설계하는 것에서 신경망을 나누곤 합니다. 크게 회귀와 분류로 나누는데 일반적으로 회귀에는 항등 함수를, 분류에는 소프트맥스 함수를 사용합니다.

분류는 데이터가 말 그대로 어느 분류에 속하는지에 대한 문제입니다. 예를 들어 사진 속의 인물의 성별을 분류하는 문제가 있습니다. 회귀는 입력 데이터의 연속적인 수치를 예측하는 문제입니다. 

 

 

이상으로 신경망에 사용되는 계산에서 행렬과 이를 넘파이의 다차원 배열로 구현하는 과정을 살펴보고 이를 활용하여 신경망을 3층까지 구현해 보았습니다.

댓글