이 책은 프레임워크를 쓰지 않는다. 텐서플로도 파이토치도 없이, 손글씨를 알아보는 신경망을 넘파이 배열 연산만으로 밑바닥부터 쌓는다. 그래서 1장은 도구 소개처럼 보이지만 사실은 선언이다 — 앞으로 등장할 모든 계산은 넘파이 배열, 그리고 배열들 사이의 곱이라는 단 하나의 언어로 쓰인다. 퍼셉트론의 가중합도, 신경망의 순전파도, 역전파로 흐르는 기울기도, 전부 배열에 대한 산술과 행렬 곱으로 환원된다.
그러니 이 장에서 배열이 어떻게 동작하는지 — 특히 shape가 다른 배열들이 어떻게 서로 맞춰지는지 — 를 몸에 익혀 두면, 뒤에 나올 신경망 코드가 “마법”이 아니라 “배열 산술의 조합”으로 읽힌다. 반대로 여기서 shape 감각을 건너뛰면, 3장에서 np.dot(X, W)가 왜 그 차원이어야 하는지에서 매번 멈춰 서게 된다. 이 장의 목표는 신경망을 만드는 게 아니라, 신경망을 만들 손을 만드는 것이다.
왜 하필 넘파이 배열인가 — 벡터화라는 사고방식
신경망의 한 층을 계산한다는 건, 수백 개의 입력에 수백 개의 가중치를 곱해 더하는 일을 수만 번 반복하는 것이다. “곱해서 전부 더한다”는 이 연산은 수식에서 시그마()로 쓴다 — 뉴런 하나의 값은 , 즉 입력과 가중치를 짝지어 곱한 뒤 모두 합한 것이다(시그마 기호가 낯설면 먼저 수학부록1을 보고 오면 좋다). 이것을 파이썬 for 루프로 하나씩 돌면 두 가지가 무너진다. 느리고(파이썬 반복문은 원소마다 인터프리터를 거친다), 코드가 수식에서 멀어진다(수식은 “행렬을 곱한다”인데 코드는 삼중 루프가 된다).
넘파이 배열은 이 둘을 한 번에 해결한다. A * B나 np.dot(A, B) 한 줄이 내부적으로 C로 구현된 반복을 돌려 원소 전체를 한꺼번에 처리한다. 이렇게 반복을 명시적 루프가 아니라 배열 연산 한 방으로 표현하는 것을 벡터화라 부른다. 벡터화된 코드는 빠를 뿐 아니라 수식과 모양이 같다 — 가 코드에서도 거의 그대로 y = np.dot(W, x) + b가 된다. 이 책이 앞으로 배열을 고집하는 이유가 여기 있다. 속도는 부수 효과고, 진짜 이득은 수식과 코드 사이의 거리가 0에 가까워진다는 것이다.
판단 기준: 원소별 반복을 파이썬 for로 짜고 싶어질 때마다, 그것을 배열 연산 한 줄로 바꿀 수 있는지 먼저 물어라. 대개 바꿀 수 있고, 바꾸면 빨라지고 짧아진다. 함정: 벡터화가 익숙지 않아 습관적으로 루프를 돌리면, 코드는 돌지만 큰 데이터에서 수십~수백 배 느려진다 — “돌아가니까 됐다”가 여기서 가장 비싼 착각이다.
넘파이 배열 만들기 — shape, dtype, ndim
넘파이의 모든 것은 np.array로 시작한다. 파이썬 리스트를 넘기면 ndarray(N차원 배열)가 만들어진다.
import numpy as np
x = np.array([1.0, 2.0, 3.0])
print(x) # [1. 2. 3.]
print(type(x)) # <class 'numpy.ndarray'>배열을 다룰 때 늘 세 가지를 먼저 본다. **shape(각 차원의 크기), dtype(원소 타입), ndim(차원 수)**이다. 이 셋이 신경망 디버깅의 대부분을 차지한다. 여기서 처음 “행렬”과 “shape”가 나온다 — 숫자를 격자로 깐 것이 행렬이고, 그 격자가 몇 행 몇 열인지가 shape다. 이 두 단어가 낯설면 수학부록3을 먼저 보고 오면 이 장 전체가 훨씬 편해진다.
A = np.array([[1, 2], [3, 4], [5, 6]])
print(A.shape) # (3, 2) — 3행 2열
print(A.ndim) # 2 — 2차원(행렬)
print(A.dtype) # int64 — 원소 타입shape는 튜플이다. (3, 2)는 “바깥 축이 3, 안쪽 축이 2” — 즉 3개의 행, 각 행에 2개의 원소다. 1차원 배열의 shape는 (3,)처럼 원소 하나짜리 튜플로 나온다는 점을 기억해 두자. 뒤에서 (3,)(1차원 벡터)과 (1, 3)이나 (3, 1)(2차원)을 혼동해 생기는 버그가 자주 나온다.
판단 기준: 배열이 얽힌 코드에서 뭔가 이상하면, 값보다 먼저 shape를 찍어라. 신경망 버그의 태반은 값이 틀린 게 아니라 shape가 어긋난 것이다. 함정: (3,)과 (3, 1)을 같다고 여기는 것 — 전자는 1차원, 후자는 2차원이고, 브로드캐스트와 행렬 곱에서 전혀 다르게 행동한다.
원소별 산술 — 같은 shape끼리는 자리마다 계산한다
같은 shape의 배열끼리 사칙연산을 하면, 대응하는 자리끼리 계산된다. 스칼라 하나에 대한 연산이 아니라 배열 전체에 한꺼번에 적용되는 것 — 이것이 벡터화의 가장 기본 형태다.
x = np.array([1.0, 2.0, 3.0])
y = np.array([2.0, 4.0, 6.0])
print(x + y) # [3. 6. 9.] 자리마다 더함
print(x * y) # [ 2. 8. 18.] 자리마다 곱함(행렬 곱이 아니다!)
print(x / y) # [0.5 0.5 0.5]여기서 x * y는 원소별 곱이지 행렬 곱이 아니다. 이 구분이 뒤에서 몹시 중요해진다. 활성화 함수를 원소마다 적용할 때는 원소별 연산(*)을, 층과 층을 연결하는 가중합을 계산할 때는 행렬 곱(np.dot)을 쓴다. 둘을 헷갈리면 shape가 우연히 맞아떨어지는 경우 조용히 틀린 값을 내놓는다.
판단 기준: “각 위치를 독립적으로 계산”하면 원소별 연산(*, +), “행과 열을 짝지어 합산”하면 행렬 곱(np.dot)이다. 함정: *를 행렬 곱으로 착각하는 것 — 정사각 행렬처럼 shape가 맞으면 에러 없이 다른 값을 내므로, 오답이 침묵 속에 흘러간다.
브로드캐스트 — shape가 다른 배열을 자동으로 맞춘다
넘파이의 진짜 힘은 shape가 다른 배열끼리도 연산이 된다는 데 있다. 넘파이는 작은 쪽을 자동으로 늘려(broadcast) 큰 쪽에 맞춘 뒤 원소별로 계산한다. 가장 단순한 예는 배열과 스칼라다.
A = np.array([[1, 2], [3, 4]]) # shape (2, 2)
print(A * 10)
# [[10 20]
# [30 40]]
# 스칼라 10이 (2,2) 전체로 퍼져 원소마다 곱해졌다.스칼라 10이 마치 [[10, 10], [10, 10]]으로 확장된 것처럼 동작한다. 실제로 그 큰 배열을 메모리에 만들지는 않지만(그래서 빠르고 메모리도 아낀다), 결과는 확장한 것과 같다. 한 단계 더 흥미로운 것은 배열과 배열의 브로드캐스트다.
A = np.array([[1, 2], [3, 4]]) # shape (2, 2)
b = np.array([10, 20]) # shape (2,)
print(A * b)
# [[10 40]
# [30 80]]
# b가 (2,)에서 (2,2)로 각 행에 복제되어 곱해졌다.(2,)짜리 b가 A의 각 행마다 한 번씩 적용됐다. 이것이 신경망에서 편향(bias)을 더할 때 쓰는 바로 그 패턴이다 — (N, M) 배치에 (M,) 편향 하나를 더하면 모든 샘플에 같은 편향이 얹힌다.
브로드캐스트가 성립하는 규칙은 명확하다. 두 배열의 shape를 오른쪽 끝(안쪽 축)부터 맞춰 비교하면서, 각 축에서 (1) 크기가 같거나 (2) 한쪽이 1이거나 (3) 한쪽에 그 축이 없으면 통과다. 통과하는 축은 크기가 1이거나 없는 쪽을 상대에 맞춰 늘린다. 어느 축에서든 이 조건이 깨지면 에러가 난다.
# 규칙을 shape로 따라가 보기 (오른쪽 끝부터 정렬)
# (2, 3) * (3,) → (3,)을 (1,3)으로 보고 → (2,3)로 확장. OK
# (2, 3) * (2, 1) → 안쪽 3 vs 1 → 1을 3으로, 바깥 2 vs 2 → 그대로. OK → (2,3)
# (2, 3) * (2,) → 안쪽 3 vs 2 → 같지도 1도 아님 → 에러!세 번째가 특히 중요하다. (2, 3)에 (2,)를 더하고 싶어도 넘파이는 안쪽 축(3과 2)을 먼저 맞추려 하므로 실패한다. “행마다 하나씩 더하고 싶다”면 (2,)가 아니라 (2, 1)로 만들어야 한다(b.reshape(2, 1) 또는 b[:, np.newaxis]). 브로드캐스트를 손에 익히려면, 실패하는 경우를 직접 만들어 에러 메시지를 읽어 보는 게 가장 빠르다.
판단 기준: 두 배열을 연산하기 전에 shape를 오른쪽 끝부터 짝지어, 각 축이 “같거나 1이거나 없음”을 만족하는지 눈으로 확인하라. 편향처럼 “각 행에 하나”를 더하고 싶으면 (N,)이 아니라 (N, 1)로 축을 세운다. 함정: 브로드캐스트는 shape가 우연히 맞으면 의도와 다른 방향으로도 조용히 성립한다. 예컨대 (3,) + (3, 1)은 에러 없이 (3, 3)을 만든다 — 벡터 둘을 더한 줄 알았는데 외적 비슷한 행렬이 튀어나오는 것이다. 값이 아니라 결과 shape를 찍어 의도와 대조하는 습관만이 이 침묵의 버그를 잡는다.
브로드캐스트가 숨긴 버그를 직접 만들어 보기
말로만 “조용한 버그”라 하면 와닿지 않으니, 스텝으로 한 배열을 조금씩 다루며 브로드캐스트가 언제 돕고 언제 배신하는지 따라가 본다. 각 스텝의 주석에 shape가 어떻게 흐르는지 명시했다.
import numpy as np# 행 = 샘플, 열 = 특성. 신경망 입력의 표준 배치 형태다.X = np.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]) # X: (3, 2) 샘플 3개, 특성 2개print(X.shape) # (3, 2)# 이 (N, 특성) 배치 모양이 앞으로 모든 신경망 입력의 기본 골격이다.
마지막 스텝이 이 장에서 가장 기억할 장면이다. u + v는 에러를 내지 않는다. 오히려 넘파이가 “친절하게” 두 벡터를 모두 확장해 (3, 3) 행렬을 만들어 준다. 우리가 원한 건 길이 3짜리 벡터였는데 말이다. 이 오차가 신경망 손실 계산 중간에 섞여 들면, 뒤늦게 “왜 손실이 이상하지”를 며칠 헤맬 수 있다. 방어법은 단순하다 — 연산 결과의 shape가 내 예상과 같은지 매번 확인하는 것. 이 습관 하나가 앞으로의 모든 장을 편하게 만든다.
판단 기준: 벡터 둘을 “대응 원소끼리” 더하고 싶다면 두 배열이 정확히 같은 shape인지 먼저 확인하라. 하나라도 축이 다르면 브로드캐스트가 개입해 차원이 늘어날 수 있다. 함정: 넘파이가 에러를 안 냈다는 것이 “의도대로 됐다”는 뜻은 아니다. 브로드캐스트는 관대해서, 틀린 조합도 그럴싸한 shape로 성립시켜 버린다.
행렬 곱 — 층과 층을 잇는 연산
원소별 곱이 “자리마다 곱하기”라면, 행렬 곱은 “행과 열을 짝지어 곱한 뒤 더하기”다. 신경망에서 한 층의 출력을 다음 층으로 넘기는 가중합이 바로 이 연산이다. 넘파이에서는 np.dot(또는 @ 연산자)을 쓴다. 행렬 곱 자체가 처음이라면 수학부록3에서 작은 격자로 손계산해 보고 오면 아래가 그대로 눈에 들어온다.
A = np.array([[1, 2], [3, 4]]) # (2, 2)
B = np.array([[5, 6], [7, 8]]) # (2, 2)
print(np.dot(A, B))
# [[19 22]
# [43 50]]
# (1행)·(1열) = 1*5 + 2*7 = 19, 이런 식으로 행·열을 짝지어 합산한다.행렬 곱의 핵심 규칙은 딱 하나다 — 앞 배열의 안쪽 축과 뒤 배열의 바깥 축(가운데 두 차원)의 크기가 같아야 한다. (a, b) · (b, c) → (a, c). 가운데 b끼리 맞물려 사라지고, 바깥의 a와 c만 결과에 남는다. 이 규칙 하나로 신경망 순전파의 shape 설계가 전부 결정된다.
한 칸이 정확히 어떻게 채워지는지 시그마로 적으면 이렇다. 결과 의 행 열 원소는
즉 의 행 전체와 의 열 전체를 짝지어 곱한 뒤 모두 더한 것이다. 이 시그마가 바로 앞에서 본 “곱해서 전부 더한다”의 그 시그마다 — 뉴런 하나의 가중합 가, 뉴런 여러 개·샘플 여러 개로 확장되면 이 행렬 곱 하나로 접힌다.
# 신경망 한 층의 순전파를 shape로 미리 설계해 보기
# X: (N, 784) 입력 — N개 샘플, 각 784차원(28×28 이미지를 편 것)
# W: (784, 50) 가중치 — 784 입력을 50개 은닉 뉴런으로
# X · W → (N, 50) 가운데 784가 맞물려 사라지고 (N, 50)만 남는다
# b: (50,) 편향 — 브로드캐스트로 각 샘플에 더해짐 → (N, 50)
# 결과 A1: (N, 50) 다음 층의 입력이 된다3장에서 만들 순전파가 정확히 이 모양이다. 지금 (N, 784)·(784, 50) → (N, 50)이 왜 성립하는지가 눈에 들어온다면, 3장의 신경망 코드는 그저 이 곱을 여러 번 쌓은 것에 지나지 않는다.
판단 기준: 행렬 곱을 쓰기 전에 (a, b)·(b, c)의 가운데 b가 양쪽에서 같은지 확인하고, 결과가 (a, c)가 될 것을 미리 그려라. 함정: np.dot(A, B)는 순서가 중요하다 — 곱셈과 달리 np.dot(A, B) ≠ np.dot(B, A)이며, 가운데 축이 안 맞으면 즉시 에러가 난다(이건 그나마 다행인 에러다, 조용히 틀리는 브로드캐스트와 달리 바로 잡아 준다).
숫자로 따라가기 — (2,3)·(3,2) 행렬 곱을 손으로 굴리기
규칙을 봤으니 이제 실제 숫자를 정해 처음부터 끝까지 손으로 계산해 본다. 종이에 그대로 옮겨 적을 수 있게, 작은 두 배열을 고른다.
0단계 — shape부터 맞춰 본다. 에서 가운데 두 숫자가 3과 3으로 같다. 통과다. 결과는 바깥 두 숫자만 남아 가 된다. 즉 답은 2행 2열짜리 격자다. 계산을 시작하기도 전에 답의 모양을 안다는 것 — 이게 shape 감각이다.
1단계 — 결과의 (1,1)칸. 의 1행 [1, 2, 3]과 의 1열 [7, 9, 11](세로로 내려 읽은 것)을 짝지어 곱해 더한다.
2단계 — 결과의 (1,2)칸. 의 1행 [1, 2, 3]과 의 2열 [8, 10, 12].
3단계 — 결과의 (2,1)칸. 의 2행 [4, 5, 6]과 의 1열 [7, 9, 11].
4단계 — 결과의 (2,2)칸. 의 2행 [4, 5, 6]과 의 2열 [8, 10, 12].
네 칸을 다 채우면 이렇게 된다. 미리 예측한 대로 격자다.
넘파이로 같은 숫자가 나오는지 확인한다.
import numpy as np
A = np.array([[1, 2, 3],
[4, 5, 6]]) # (2, 3)
B = np.array([[7, 8],
[9, 10],
[11, 12]]) # (3, 2)
C = np.dot(A, B)
print(C.shape) # (2, 2) — 0단계에서 예측한 그대로
print(C)
# [[ 58 64]
# [139 154]]손계산한 58, 64, 139, 154와 정확히 맞는다. 여기에 브로드캐스트로 편향까지 얹어, 신경망 한 층에 가장 가까운 형태를 만들어 보자. 편향 b = [100, 200]은 shape (2,)이고, 결과 (2,2)에 오른쪽 끝부터 맞추면 각 행에 [100, 200]이 복제되어 더해진다.
- (1행) ,
- (2행) ,
b = np.array([100, 200]) # (2,)
# (2,2)와 (2,) → (2,)를 각 행에 복제 → (2,2)
print(C + b)
# [[158 264]
# [239 354]]방금 계산한 np.dot(A, B) + b는 축소해 보면 신경망 한 층의 순전파 바로 그것이다. 를 입력 배치, 를 가중치, b를 편향으로 읽으면 — 샘플 2개가 각각 뉴런 2개짜리 출력으로 변환된 것이다. 3장에서 만들 순전파는 여기서 숫자만 커질 뿐, 계산의 뼈대는 이 열 몇 줄이 전부다.
판단 기준: 새 행렬 곱을 만나면 값보다 shape를 먼저 손으로 짝지어 답의 모양을 예측하고, 그다음에 한 칸(대개 (1,1)칸)만 실제로 계산해 넘파이 결과와 대조하라. 그 한 칸이 맞으면 규칙을 옳게 적용한 것이다. 함정: 오른쪽 행렬의 “열”을 가로로 읽는 실수 — 의 1열은 [7, 9, 11](세로)이지 [7, 8](가로 1행)이 아니다. 왼쪽은 가로(→), 오른쪽은 세로(↓)로 손가락으로 짚으며 계산하면 틀리지 않는다.
인덱싱과 슬라이싱 — 배치에서 원하는 조각 꺼내기
배열에서 특정 원소나 부분을 꺼내는 방법도 신경망에서 늘 쓴다. 미니배치를 뽑거나, 예측값 중 최댓값 위치를 찾거나, 조건에 맞는 원소만 거를 때다.
X = np.array([[51, 55], [14, 19], [0, 4]]) # (3, 2)
print(X[0]) # [51 55] 0번째 행
print(X[0][1]) # 55 0행 1열
print(X[:, 0]) # [51 14 0] 모든 행의 0번째 열
# 조건(불리언) 인덱싱 — 활성화 함수나 필터링에서 자주 쓴다
print(X > 15) # [[ True True] [False True] [False False]]
print(X[X > 15]) # [51 55 19] 조건이 True인 원소만 1차원으로 뽑힌다불리언 인덱싱은 특히 ReLU 같은 함수를 넘파이답게 구현할 때 등장한다(3장). X[X > 15]처럼 “조건에 맞는 원소”를 루프 없이 한 번에 골라내는 것도 벡터화의 한 형태다. 슬라이싱 X[:, 0]이 “모든 행의 0열”을 (3,) 벡터로 돌려주는 것, 그리고 이때 차원이 하나 줄어든다는 점을 기억해 두자 — 여기서도 (3,)이냐 (3, 1)이냐가 뒤에 브로드캐스트와 얽힌다.
판단 기준: 조건에 맞는 원소만 다루고 싶으면 for+if가 아니라 불리언 인덱싱(X[X > 0])이나 마스크 연산을 먼저 떠올려라. 함정: 슬라이싱으로 한 축을 뽑으면 차원이 줄어든다(X[:, 0]은 2차원이 아니라 1차원). 2차원 형태를 유지하고 싶으면 X[:, 0:1]처럼 범위로 뽑아야 한다.
matplotlib — 배열을 눈으로 확인하기
넘파이가 계산이라면 matplotlib은 그 결과를 그림으로 보는 눈이다. 활성화 함수의 곡선을 그리거나, 학습 중 손실이 내려가는지 확인하거나, 손글씨 이미지를 화면에 띄울 때 쓴다. 배열을 눈으로 보는 것은 디버깅에서 shape를 찍는 것만큼 강력하다.
import numpy as np
import matplotlib.pyplot as plt
# 함수 곡선 그리기 — 3장의 활성화 함수를 볼 때 그대로 쓴다
x = np.arange(-5.0, 5.0, 0.1) # -5부터 5까지 0.1 간격 → (100,)
y = 1 / (1 + np.exp(-x)) # 시그모이드. exp도 배열 전체에 원소별 적용
plt.plot(x, y)
plt.show()np.exp(-x)가 (100,) 배열 전체에 한 번에 적용된다는 점을 다시 눈여겨보자 — 벡터화 덕에 곡선을 그리는 코드가 수식과 거의 같다. 이미지는 imshow로 띄운다.
from matplotlib.image import imread
img = imread('cat.png') # img: (높이, 너비, 3) RGB 3채널
plt.imshow(img)
plt.show()
# MNIST 손글씨는 (28, 28) 흑백 배열 → plt.imshow(img, cmap='gray')로 본다3장에서 MNIST 손글씨를 인식할 때, 데이터가 정말로 숫자 모양인지 imshow로 확인하는 것이 첫걸음이 된다. 이미지가 곧 (28, 28) 또는 그것을 편 (784,) 배열이라는 감각 — 이것이 이 장이 남기는 마지막 다리다.
판단 기준: 배열이 기대한 값인지 의심스러우면 숫자를 노려보기 전에 그려 봐라. 곡선이나 이미지로 보면 “값 하나가 튄다”거나 “이미지가 뒤집혔다” 같은 문제가 한눈에 드러난다. 함정: imshow에 흑백 (28, 28) 배열을 넘길 때 cmap='gray'를 빼면 엉뚱한 색으로 칠해져 형태 판단을 그르친다 — 채널 수(2차원인지 3차원인지)를 먼저 확인하라.
정리 — 이 장이 남긴 손
이 장은 신경망을 한 줄도 만들지 않았지만, 신경망을 만들 도구는 전부 손에 쥐여 줬다. 뒤의 모든 장은 결국 이 배열 연산들의 조합이다.
- 이 책의 유일한 무기는 넘파이 배열이다. 신경망의 모든 계산은 배열의 원소별 산술과 행렬 곱으로 환원된다 — 벡터화는 속도를 넘어 수식과 코드의 거리를 0으로 만드는 사고방식이다.
- 원소별 곱(
*)과 행렬 곱(np.dot)은 다르다. 전자는 자리마다, 후자는 행과 열을 짝지어 합산한다. 이 구분이 순전파와 활성화 함수를 가른다. - 브로드캐스트는 shape가 다른 배열을 오른쪽 끝부터 맞춰 “같거나 1이거나 없음” 규칙으로 확장한다. 편향 더하기가 이 규칙 위에 서 있다.
- 브로드캐스트의 관대함은 양날의 검이다 — 틀린 조합도 에러 없이 그럴싸한 shape로 성립시킨다. 연산 결과의 shape를 매번 확인하는 습관이 침묵의 버그를 막는 유일한 방어다.
- 행렬 곱은
(a, b)·(b, c) → (a, c), 가운데 축이 맞물려 사라진다.(2,3)·(3,2)=(2,2)를 손으로 굴려 본 감각 — 답의 모양을 먼저 예측하고 한 칸을 대조하는 습관 — 이 규칙 하나가 신경망 순전파의 shape 설계 전부를 결정한다. - matplotlib은 배열을 눈으로 보는 도구다. shape를 찍는 것과 함께, 그려 보는 것이 디버깅의 두 축이다.
다음 장은 이 배열들을 처음으로 “판단하는 장치”로 조립한다 — 입력에 가중치를 곱해 더하고 임계값을 넘는지 보는 퍼셉트론이다. 그 가중합이 바로 이 장의 원소별 곱과 합, 그리고 행렬 곱이다.
다음장으로 2장