03.3 다차원 배열의 계산

넘파이의 다차원 배열을 사용한 계산법을 숙달하면 신경망을 효율적으로 구현할 수 있습니다. 그래서 이번 절에서는 넘파이의 다차원 배열 계산에 대해서 설명한 뒤 신경망을 구현해보겠습니다.

3.3.1 다차원 배열

다차원 배열도 그 기본은 ‘숫자의 집합’입니다. 숫자가 한 줄로 늘어선 것이나 직사각형으로 늘어놓은 것, 3차원으로 늘어놓은 것이나 (더 일반화한) N차원으로 나열하는 것을 통틀어 다차원 배열이라고 합니다. 그럼 넘파이를 사용해서 다차원 배열을 작성해보겠습니다. 우선은 지금까지 보아온 1차원 배열입니다.

>>> import numpy as np
>>> A = np.array([1, 2, 3, 4])
>>> print(A)
[1 2 3 4]
>>> np.ndim(A)
1
>>> A.shape
(4,)
>>> A.shape[0]
4

이와 같이 배열의 차원 수는 np.ndim() 함수로 확인할 수 있습니다. 또, 배열의 형상은 인스턴스 변수인 shape로 알 수 있습니다. 이 예에서 A는 1차원 배열이고 원소 4개로 구성되어 있네요. 한 가지, A.shape가 튜플을 반환하는 것에 주의하세요. 이는 1차원 배열이라도 다차원 배열일 때와 통일된 형태로 결과를 반환하기 위함입니다. 예를 들어 2차원 배열일 때는 (4, 3), 3차원 배열의 때는 (4, 3, 2) 같은 튜플을 반환합니다. 그래서 1차원 배열일 때도 결과를 튜플로 반환하는 것이죠. 자, 이어서 2차원 배열을 작성해보죠.

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

여기에서는 ‘3×2 배열’인 B를 작성했습니다. 3×2 배열은 처음 차원에는 원소가 3개, 다음 차원에는 원소가 2개 있다는 의미입니다. 이때 처음 차원은 0번째 차원, 다음 차원은 1번째 차원에 대응합니다(파이썬의 인덱스는 0부터 시작합니다). 2차원 배열은 특히 행렬 matrix이라고 부르고 [그림 3-10]과 같이 배열의 가로 방향을 row, 세로 방향을 column이라고 합니다.

그림 3-10 2차원 배열(행렬)의 행(가로)과 열(세로)

그림 3-10

지니의 마법 설명

  • 지니: “도로시, 행렬은 대단한 게 아니라 숫자 블록들을 가로(행)와 세로(열)로 질서정연하게 나열한 격자판이야. 가로는 ‘행(Row)’, 세로는 ‘열(Column)’이라고 부르지!”
  • 도로시: “와! 격자 세상을 나침반을 들고 이동했던 것처럼, 이 숫자 격자판에서도 행과 열의 위치를 따라가면서 필요한 값을 골라낼 수 있겠구나!”
  • 토토: “크르릉 멍! (가로 블록을 가리키며) 가로줄은 행! (세로 블록을 가리키며) 세로줄은 열! 잊지 않겠다 멍!”

2차원 배열의 행과 열 지니가 격자 테이블에 숫자 블록들을 가로(행)와 세로(열) 방향으로 놓으며 2차원 배열의 구조를 설명하고 있어요.

3.3.2 행렬의 내적(행렬 곱)

이어서 행렬(2차원 배열)의 내적을 구하는 방법을 알아보겠습니다. 예를 들어 $2 \times 2$ 행렬의 내적은 [그림 3-11]처럼 계산합니다.

그림 3-11 행렬의 내적 계산 방법

그림 3-11

그림에서처럼 행렬 내적은 왼쪽 행렬의 행(가로)과 오른쪽 행렬의 열(세로 방향)을 원소별로 곱하고 그 값들을 더해서 계산합니다. 그리고 그 계산 결과가 새로운 다차원 배열의 원소가 됩니다. 예를 들어 A의 1행과 B의 1열을 곱한 값은 결과 행렬의 1행 1번째 원소가 되고, A의 2행과 B의 1열을 곱한 결과는 2행 1번째 원소가 됩니다. 참고로 이 책에서는 ‘수식에서의 행렬’을 굵은 글씨로 표기합니다. 예를 들어 행렬은 A처럼 표기하여 원소가 하나인 스칼라값(예컨대 $a$와 $b$)이나 파이썬 코드에서의 변수명과 구별합니다. 이 계산을 파이썬으로 구현하면 다음과 같습니다.

지니의 마법 설명

  • 지니: “행렬의 곱셈(내적)은 ‘곱하고 더하기’의 연속 마법이야. 왼쪽 행렬의 가로줄(행) 원소들과 오른쪽 행렬의 세로줄(열) 원소들을 순서대로 짝지어 곱한 뒤, 그 값들을 모두 합산해서 새 자리에 놓는단다!”
  • 도로시: “아! 예를 들어 A의 1행 [1, 2]와 B의 1열 [5, 7]이 만나면, 1×5와 2×7을 각각 곱해서 더하니까 5 + 14 = 19가 되는구나! 짝을 맞춰 계산하니까 재밌다!”
  • 토토: “멍멍! (꼬리를 세차게 흔들며) 짝이 딱딱 맞아야 계산 마법이 풀린다 멍!”

행렬의 내적 계산 지니가 행 블록과 열 블록을 결합해 내적을 계산하는 흐름을 보여주고, 도로시가 신나게 공식을 필기하고 있어요.

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

이 코드에서 AB는 $2 \times 2$ 행렬이며,* 이들 두 행렬의 내적은 넘파이 함수 np.dot()으로 계산합니다.* np.dot()은 넘파이 배열 2개를 인수로 받아 그 내적을 반환합니다. 여기서 한 가지 주의할 것은 np.dot(A, B)np.dot(B, A)는 다른 값이 될 수 있다는 점입니다. $+$와 $\times$ 등의 일반적인 연산과 달리 행렬의 곱에서는 피연산자의 순서가 다르면 결과도 다릅니다.

* 여기에서의 AB는 파이썬 코드의 변수명이라 굵게 표기하지 않습니다. 오직 ‘수식’일 때만 굵게 표기하여 코드 설명과 구분했습니다. * 옮긴이_ 내적을 스칼라곱(scalar product) 혹은 점곱(dot product)이라고도 합니다. ‘dot’이라는 이름이 바로 여기서 유래한 것이죠.

앞에서는 $2 \times 2$ 행렬을 곱하는 예를 보았지만, 형상이 다른 행렬의 내적도 마찬가지 방법으로 계산할 수 있습니다. 예를 들어 다음은 $2 \times 3$ 행렬과 $3 \times 2$ 행렬의 곱을 파이썬으로 구현한 모습입니다.

>>> A = np.array([[1,2,3], [4,5,6]])
>>> A.shape
(2, 3)
>>> B = np.array([[1,2], [3,4], [5,6]])
>>> B.shape
(3, 2)
>>> np.dot(A, B)
array([[22, 28],
       [49, 64]])

$2 \times 3$ 행렬 A와 $3 \times 2$ 행렬 B의 내적은 이와 같이 구현할 수 있습니다. 이때 ‘행렬의 형상shape’에 주의해야 합니다. 구체적으로 말하면 행렬 A의 1번째 차원의 원소 수(열 수)와 행렬 B의 0번째 차원의 원소 수(행 수)가 같아야 합니다. 앞의 예에서도 둘 모두 원소가 3개씩이었죠. 이 값이 다르면 행렬의 내적을 계산할 수 없습니다. 실제로 $2 \times 3$ 행렬 A와 $2 \times 2$ 행렬 C를 곱하면 파이썬은 다음과 같은 오류를 출력합니다.

>>> C = np.array([[1,2], [3,4]])
>>> C.shape
(2, 2)
>>> A.shape
(2, 3)
>>> np.dot(A, C)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: shapes (2,3) and (2,2) not aligned: 3 (dim 1) != 2 (dim 0)

이 오류는 행렬 A의 1번째 차원(dim 1)과 행렬 C의 0번째 차원(dim 0)의 원소 수가 다르다고 말합니다(차원의 인덱스는 0부터 시작합니다). 즉, 다차원 배열을 곱하려면 두 행렬의 대응하는 차원의 원소 수를 일치시켜야 합니다. 중요한 내용이니 [그림 3-12]를 보며 다시 정리해보죠.

그림 3-12 행렬의 곱에서는 대응하는 차원의 원소 수를 일치시켜라.

그림 3-12

[그림 3-12]는 $3 \times 2$ 행렬 A와 $2 \times 4$ 행렬 B를 곱해 $3 \times 4$ 행렬 C를 만드는 예입니다. 이 그림과 같이 행렬 AB의 대응하는 차원의 원소 수가 같아야 합니다. 그리고 계산 결과인 행렬 C의 형상은 행렬 A의 행 수와 행렬 B의 열 수가 됩니다(이것도 중요한 점입니다).

지니의 마법 설명

  • 지니: “행렬 곱이 성공하려면, 두 행렬이 맞물리는 ‘안쪽 차원의 크기’가 반드시 일치해야 해. 예를 들어 (3, 2) 행렬과 (2, 4) 행렬은 안쪽의 ‘2’가 자석처럼 서로 딱 맞아떨어져야만 곱셈 마법이 연결된단다!”
  • 도로시: “아하! 안쪽 모양이 서로 다른 퍼즐 조각은 아무리 끼워 맞추려고 해도 맞춰지지 않고 오류(ValueError: shapes not aligned)가 나는 거구나!”
  • 토토: “멍멍! (자석 퍼즐을 쳐다보며) 안쪽 숫자가 같아야 도킹 성공이다 멍!”

행렬 곱 형상 일치 도로시가 (3,2)와 (2,4) 모양의 퍼즐 조각을 서로 연결하며, 안쪽 차원이 동일해야 행렬 곱을 수행할 수 있다는 원리를 깨닫고 있어요.

A가 2차원 행렬이고 B가 1차원 배열일 때도 [그림 3-13]과 같이 ‘대응하는 차원의 원소 수를 일치시켜라’는 원칙이 똑같이 적용됩니다.

그림 3-13 A가 2차원 행렬, B가 1차원 배열일 때도 대응하는 차원의 원소 수를 일치시켜라.

그림 3-13

[그림 3-13]의 예를 파이썬으로 구현하면 다음과 같이 됩니다.

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

3.3.3 신경망의 내적

그럼 넘파이 행렬을 써서 신경망을 구현해보겠습니다. 이번 예에서는 [그림 3-14]의 간단한 신경망을 가정해보죠. 이 신경망은 편향과 활성화 함수를 생략하고 가중치만 갖습니다.

그림 3-14 행렬의 곱으로 신경망의 계산을 수행한다.

그림 3-14

이 구현에서도 X, W, Y의 형상을 주의해서 보세요. 특히 XW의 대응하는 차원의 원소 수가 같아야 한다는 걸 잊지 말아야 합니다.

>>> X = np.array([1, 2])
>>> X.shape
(2,)
>>> W = np.array([[1, 3, 5], [2, 4, 6]])
>>> print(W)
[[1 3 5]
 [2 4 6]]
>>> W.shape
(2, 3)
>>> Y = np.dot(X, W)
>>> print(Y)
[ 5 11 17]

다차원 배열의 내적을 구해주는 np.dot 함수를 사용하면 이처럼 단번에 결과 Y를 계산할 수 있습니다. Y의 원소가 100개든 1,000개든 한 번의 연산으로 계산할 수 있습니다! 만약 np.dot을 사용하지 않으면 Y의 원소를 하나씩 따져봐야 합니다(또는 for 문을 사용해서 계산해야 하는데, 굉장히 귀찮겠지요). 그래서 행렬의 내적으로 한꺼번에 계산해주는 기능은 신경망을 구현할 때 매우 중요하다고 말할 수 있습니다.

서브목차