이 부록은 본문에서 제일 자주 튀어나오는 두 단어, 벡터행렬을 다룬다. 3장 순전파에서 np.dot(X, W)가 나오고, 5장 Affine 계층에서 전치()가 나오고, 7장 CNN의 im2col에서도 결국 행렬 곱을 쓴다. 무섭게 들리지만 걱정 말자. 벡터는 “숫자를 한 줄로 세운 것”, 행렬은 “숫자를 격자로 깐 것”일 뿐이다. 여기서는 아주 작은 숫자 격자를 손으로 직접 계산해 보면서, 딱 필요한 만큼만 쉽게 떼어 가자.

벡터 — 숫자를 한 줄로 세운 것

벡터라는 말은 어려워 보이지만, 그냥 숫자 여러 개를 한 줄로 나란히 세운 것이다. 그게 전부다.

(브이라고 읽는다)는 숫자 세 개짜리 벡터다. 장바구니를 떠올리면 쉽다. 마트에서 사과 2개, 우유 5개, 빵 3개를 담았다고 하자. 이걸 그냥 [2, 5, 3]이라고 한 줄로 적으면, 이게 바로 벡터다. 순서가 정해져 있어서 첫 번째 칸은 언제나 사과, 두 번째 칸은 언제나 우유다.

벡터 안에 든 숫자 하나하나를 원소라고 부른다. 위 벡터는 원소가 3개다. 원소가 몇 개인지를 벡터의 길이라고 한다. [2, 5, 3]의 길이는 3이다.

벡터끼리 더하는 것도 쉽다. 같은 자리끼리 더하면 된다.

첫 칸은 , 둘째 칸은 , 셋째 칸은 . 장바구니 두 개를 합친 것과 같다. 사과끼리, 우유끼리, 빵끼리 합쳐진다.

헷갈리기 쉬워요: 벡터를 가로로 눕혀 쓸 수도 있고(), 세로로 세워 쓸 수도 있다. 세로로 세운 건 이렇게 생겼다. 담긴 숫자는 똑같다. 나중에 행렬 곱을 할 때 가로냐 세로냐가 중요해지니, “같은 벡터도 눕히거나 세울 수 있다”는 것만 기억해 두자.

이것만 기억하자: 벡터는 숫자를 한 줄로 세운 장바구니다. 같은 자리끼리 더하고 뺀다.

행렬 — 숫자를 격자로 깐 것

벡터가 숫자 한 줄이라면, 행렬숫자를 격자(표)처럼 여러 줄로 깐 것이다. 엑셀 표를 떠올리면 딱 맞다.

는 가로줄이 3개, 세로줄이 2개인 격자다. 가로줄을 , 세로줄을 이라고 부른다. 헷갈리면 이렇게 외우자. 행은 가로(→), 열은 세로(↓). ‘행’의 받침 ㅇ이 옆으로 눕고, ‘열’은 위아래로 선다고 상상해도 좋다.

  • 1행: [1, 2]
  • 2행: [3, 4]
  • 3행: [5, 6]
  • 1열: [1, 3, 5] (세로로 내려 읽기)
  • 2열: [2, 4, 6]

행렬 안의 숫자 하나를 가리킬 때는 “몇 행 몇 열”로 말한다. 예를 들어 위 행렬에서 2행 1열의 숫자는 3이다. 2행 [3, 4]에서 1열이니 첫 번째 숫자 3이다. 3행 2열은? 3행 [5, 6]에서 2열, 즉 6이다. 직접 손가락으로 짚어 보자.

shape 읽는 법 — (행, 열)

넘파이(1장에서 만난 그 배열 도구)는 행렬의 크기를 shape라고 부른다. 셰이프라고 읽고, “모양”이라는 뜻이다. shape는 (행의 개수, 열의 개수) 순서로 적는다.

는 3행 2열이니 shape가 (3, 2)다. 코드로 찍어 보면 이렇다.

import numpy as np
 
A = np.array([[1, 2],
              [3, 4],
              [5, 6]])
print(A.shape)   # (3, 2)  → 3행 2열

읽는 순서를 꼭 지키자. 앞이 행, 뒤가 열. (3, 2)는 “3행 2열”이지 “2행 3열”이 아니다. 이 순서를 헷갈리면 뒤에서 행렬 곱을 맞출 때 계속 꼬인다.

헷갈리기 쉬워요: 벡터 [2, 5, 3]의 shape는 (3,)이라고 나온다. 쉼표 뒤가 비어 있다. 이건 “숫자 3개짜리 한 줄, 행/열 구분이 아직 없는 1차원”이라는 뜻이다. 반면 (1, 3)이나 (3, 1)은 격자(2차원)다. 1장에서 봤듯이 (3,)(3, 1)은 넘파이가 다르게 취급하니, 지금은 “쉼표 뒤가 비면 그냥 한 줄짜리 벡터”라고만 알아 두자.

이것만 기억하자: 행렬은 숫자 격자다. shape는 (행, 열) 순서 — 앞이 가로줄 수, 뒤가 세로줄 수.

행렬 곱 — 한 칸은 “왼쪽 행 × 오른쪽 열, 곱해서 더한 것”

이제 이 부록의 심장인 행렬 곱이다. 어려워 보이지만, 규칙 딱 하나만 손에 익히면 된다. 결과 격자의 한 칸은, 왼쪽 행렬의 한 ‘행’과 오른쪽 행렬의 한 ‘열’을, 서로 짝지어 곱한 다음 전부 더한 값이다.

말로만 들으면 어지러우니 2×2짜리 작은 예로 직접 손계산해 보자.

두 격자를 곱한 의 결과도 2행 2열짜리 격자가 된다. 결과의 네 칸을 하나씩, 손으로 채워 보자.

결과 1행 1열 = (A의 1행) 와 (B의 1열) 을 짝지어 곱해 더한다.

  • A의 1행: [1, 2]
  • B의 1열: [5, 7] (세로로 내려 읽은 것!)
  • 첫 칸끼리 , 둘째 칸끼리 , 더하면 .

결과 1행 2열 = (A의 1행) 와 (B의 2열).

  • A의 1행: [1, 2]
  • B의 2열: [6, 8]

결과 2행 1열 = (A의 2행) 와 (B의 1열).

  • A의 2행: [3, 4]
  • B의 1열: [5, 7]

결과 2행 2열 = (A의 2행) 와 (B의 2열).

  • A의 2행: [3, 4]
  • B의 2열: [6, 8]

네 칸을 다 채우면 이렇게 된다.

넘파이로도 똑같이 나오는지 확인해 보자.

import numpy as np
 
A = np.array([[1, 2], [3, 4]])
B = np.array([[5, 6], [7, 8]])
print(np.dot(A, B))     # np.dot이 행렬 곱이다 (@ 기호로도 된다)
# [[19 22]
#  [43 50]]

손으로 계산한 19, 22, 43, 50과 딱 맞는다. 규칙을 다시 한 번 소리 내어 보자. 결과의 (몇 행, 몇 열) 칸은 → 왼쪽의 그 ‘행’ × 오른쪽의 그 ‘열’, 짝지어 곱해서 더한 것. 이 한 문장이 행렬 곱의 전부다.

헷갈리기 쉬워요: 오른쪽 행렬 의 열은 세로로 내려 읽어야 한다. [5, 7]의 1열인데, 격자에서 눈으로 보면 5는 위 칸, 7은 아래 칸에 있다. 가로로 [5, 6]을 읽으면 그건 행이지 열이 아니다. 처음엔 손가락으로 왼쪽은 가로(→), 오른쪽은 세로(↓)로 짚으며 계산하면 안 틀린다.

이것만 기억하자: 결과 한 칸 = 왼쪽 ‘행’과 오른쪽 ‘열’을 짝지어 곱해서 더한 값. 왼쪽은 가로로, 오른쪽은 세로로 읽는다.

shape 짝 맞추기 — 안쪽 숫자가 같아야 곱해진다

행렬 곱은 아무 두 격자나 곱할 수 있는 게 아니다. 왼쪽의 열 개수와 오른쪽의 행 개수가 같아야만 곱할 수 있다. 왜 그럴까? 방금 손계산을 떠올려 보자. “왼쪽 행”과 “오른쪽 열”을 짝지어 곱했다. 짝을 지으려면 왼쪽 행의 원소 개수(= 왼쪽의 열 수)와 오른쪽 열의 원소 개수(= 오른쪽의 행 수)가 딱 맞아떨어져야 한다.

이걸 shape로 적으면 아주 예쁜 규칙이 나온다.

가운데 있는 두 b같아야 하고, 곱하고 나면 그 b는 사라진다. 바깥의 ac만 결과에 남는다. 그림으로 보면 이렇다.

왼쪽 (a, b) · 오른쪽 (b, c)  →  결과 (a, c)
        └───┘
      이 둘이 같아야 곱셈 가능,
      맞물려서 사라진다

숫자로 확인해 보자. 아까 (2, 2), (2, 2)였다.

가운데 2와 2가 같으니 곱할 수 있고, 결과는 (2, 2). 실제로 결과가 2행 2열이었다. 딱 맞는다.

크기가 다른 예도 보자. (3, 2) 격자와 (2, 4) 격자를 곱하면?

가운데 2와 2가 같으니 통과. 결과는 3행 4열이다. 반대로 안쪽 숫자가 다르면 어떻게 될까?

가운데가 2와 3, 서로 다르다. 짝을 지을 수 없으니 이건 곱할 수 없다. 넘파이에게 시키면 곧바로 에러를 낸다.

import numpy as np
 
A = np.array([[1, 2], [3, 4], [5, 6]])   # (3, 2)
B = np.array([[1, 1, 1, 1], [2, 2, 2, 2]]) # (2, 4)
print(np.dot(A, B).shape)   # (3, 4)  ← 가운데 2가 맞아서 OK
 
C = np.array([[1, 2], [3, 4], [5, 6]])   # (3, 2)
# np.dot(A, C)  ← (3,2)·(3,2), 가운데 2 vs 3 안 맞음 → 에러!
# ValueError: shapes (3,2) and (3,2) not aligned

이 에러는 사실 고마운 에러다. 1장에서 배운 브로드캐스트처럼 조용히 틀린 답을 내는 게 아니라, “안쪽이 안 맞아요”라고 바로 알려 준다. 그러니 행렬 곱을 쓰기 전에 항상 shape를 나란히 적고 가운데 두 숫자가 같은지 먼저 눈으로 확인하는 습관을 들이자.

이것만 기억하자: . 안쪽 두 숫자가 같아야 곱해지고, 그 숫자는 맞물려 사라진다.

전치 — 행과 열을 뒤집기

전치(transpose)는 이름만 어렵지, 하는 일은 단순하다. 격자를 대각선으로 뒤집어서 행과 열을 서로 바꾸는 것이다. 원래 1행이던 게 1열이 되고, 원래 1열이던 게 1행이 된다. 기호로는 의 전치를 (에이 트랜스포즈, 또는 에이 T)라고 쓴다.

숫자로 보자.

원래 의 1행 [1, 2]에서는 1열 [1, 2](세로)로 섰다. 원래 1열 [1, 3, 5](세로)는 의 1행 [1, 3, 5](가로)로 누웠다. 그러니까 shape도 뒤집힌다. (3, 2)였으면 (2, 3)이다.

import numpy as np
 
A = np.array([[1, 2],
              [3, 4],
              [5, 6]])   # (3, 2)
print(A.T)               # .T 하나로 전치 끝
# [[1 3 5]
#  [2 4 6]]
print(A.T.shape)         # (2, 3)  → 행과 열이 뒤집혔다

넘파이에서는 .T 한 글자면 전치가 된다. 아주 간단하다.

그럼 이 전치를 대체 왜 배울까? 5장 역전파(오차역전파법)에서 등장하기 때문이다. 아직 역전파를 몰라도 괜찮다. 딱 이 감각만 미리 심어 두자. 순전파(앞으로 계산)에서 입력에 가중치 를 곱했다면, 역전파(거꾸로 계산)에서는 그 가중치를 뒤집은 를 곱하게 된다. 앞으로 갈 때 쓴 격자를, 거꾸로 올 때는 행과 열을 뒤집어서 다시 쓰는 것이다. 왜 하필 뒤집는지는 5장에서 shape를 맞춰 보면 자연스럽게 드러난다 — 지금은 “역전파에서 로 뒤집혀 나온다”는 예고편만 봐 두면 충분하다.

이것만 기억하자: 전치는 행과 열을 뒤집는 것(, 넘파이는 .T). shape도 뒤집힌다. 5장 역전파에서 가중치가 뒤집혀 다시 나온다.

신경망은 왜 온통 행렬 곱인가

마지막 질문. 왜 이 책은 신경망을 온통 행렬 곱으로 쓸까? 답은 여러 입력에 여러 가중치를 곱해 더하는 일을, 한 방에 처리하려고다.

아주 작은 신경망을 상상하자. 입력이 2개() 있고, 이걸로 은닉 뉴런 3개를 만들려고 한다. 뉴런 하나는 “입력들에 각자의 가중치를 곱해서 다 더한 값”이다. 뉴런이 3개니 가중치 묶음도 3벌 필요하다. 이걸 하나씩 손으로 쓰면 이렇게 지저분하다.

세 줄이나 된다. 뉴런이 50개, 100개면 50줄, 100줄이 된다. 그런데 잘 보면 이건 전부 “입력 한 줄 × 가중치 한 열, 곱해서 더하기” — 바로 행렬 곱이다! 그래서 이렇게 한 줄로 접을 수 있다.

입력 벡터 (1, 2)에 가중치 행렬 (2, 3)을 곱하니, 가운데 2가 맞물려 사라지고 뉴런 3개 (1, 3)이 한 번에 나왔다. shape 규칙 이 그대로 들어맞는다. 지저분하던 세 줄이 행렬 곱 한 방으로 접혔다.

여기에 더 좋은 게 있다. 입력이 한 명이 아니라 여러 명(배치)이어도 똑같다. 사람이 4명이면 입력을 (4, 2) 격자로 쌓기만 하면 된다.

4명 각자에 대해 뉴런 3개씩, (4, 3) 격자가 한 번의 곱셈으로 쏟아진다. for 루프로 사람마다 돌 필요가 전혀 없다. “입력 여러 개 × 가중치 여러 개”를 격자 곱 한 방으로 — 이것이 신경망이 행렬 곱을 고집하는 이유다. 1장에서 배운 벡터화(수식과 코드의 거리를 0으로)가 바로 이 모양으로 실현된다.

import numpy as np
 
X = np.array([[1.0, 2.0],
              [3.0, 4.0],
              [5.0, 6.0],
              [7.0, 8.0]])   # (4, 2)  사람 4명, 입력 2개씩
W = np.array([[1.0, 0.0, 1.0],
              [0.0, 1.0, 1.0]])  # (2, 3)  입력 2개 → 뉴런 3개
 
print(np.dot(X, W).shape)   # (4, 3)  4명 × 뉴런 3개, 한 방에!

이것만 기억하자: 신경망 한 층 = 입력 격자 × 가중치 격자. 여러 입력과 여러 가중치의 곱셈-덧셈을 행렬 곱 한 번으로 끝내려는 것이다.

정리 — 이 부록이 남긴 것

  • 벡터는 숫자를 한 줄로 세운 장바구니. 같은 자리끼리 더한다.
  • 행렬은 숫자 격자. shape는 (행, 열) 순서 — 앞이 가로줄 수, 뒤가 세로줄 수.
  • 행렬 곱의 결과 한 칸은 “왼쪽 행 × 오른쪽 열, 짝지어 곱해서 더한 것”. 왼쪽은 가로로, 오른쪽은 세로로 읽는다.
  • shape 짝 맞추기: . 안쪽 두 숫자가 같아야 곱해지고 맞물려 사라진다.
  • 전치(, .T)는 행과 열 뒤집기. 5장 역전파에서 가중치가 뒤집혀 나온다.
  • 신경망이 온통 행렬 곱인 이유: 여러 입력 × 여러 가중치를 한 방에 처리하려고.

이걸 본문 어디서 쓰나:

  • 3장 순전파: np.dot(X, W) + b가 이 부록의 그대로다. shape 짝 맞추기만 알면 순전파 코드가 곱셈의 나열로 읽힌다.
  • 5장 Affine 계층: 순전파에서 쓴 가, 역전파에서 전치 로 뒤집혀 다시 곱해진다. 전치의 예고편이 여기서 본편이 된다.
  • 7장 CNN의 im2col: 이미지 조각들을 행렬로 쫙 펼쳐 놓고, 결국 한 번의 큰 행렬 곱으로 합성곱을 계산한다. “복잡해 보이는 연산도 결국 행렬 곱으로 접는다”는 이 부록의 정신 그대로다.

다음 부록으로 수학부록4