Blog

Python + NumPy로 신경망 만들기 (3) — 숫자 784개를 받아서 “0~9 중 뭘까” 점수 10개로 바꾸기

13 minute read

이 글은 notebooks/03_model.ipynb에서 한 일을 처음부터 다시 설명합니다. 02에서 만든 (64, 784) 배치가 이제 모델로 들어갑니다.

이 노트북에서 한 일, 한 줄로

“사진 한 장(숫자 784개)을 받아 숫자 10개(각 숫자일 점수)로 바꾸는 부품 Linear, 그 사이에 끼우는 스위치 ReLU, 부품을 순서대로 잇는 Sequential을 만들었다.”

아직 학습은 안 합니다. 이 노트북에서 만든 모델은 가중치가 랜덤이라 아무 숫자에나 아무 점수를 줍니다. 지금은 “데이터가 들어가서 올바른 모양으로 나오는지”만 확인하는 단계예요. 이 방향(입력 → 출력)을 forward라고 부릅니다.


1. 모델이 하는 일

flowchart LR
    A["사진 한 장 (숫자 784개)"] --> M[모델] --> Z["점수 10개<br/>[0일 점수, 1일 점수, ..., 9일 점수]"]

점수가 가장 높은 자리가 모델의 답입니다. 배치로 보면 (64, 784)가 들어가서 (64, 10)이 나옵니다. 사진 64장, 각각 점수 10개.


2. 모양 맞추기 게임: 가중치 W는 몇 × 몇?

Linear 레이어는 입력 x에 가중치 W를 행렬 곱(@)합니다. 행렬 곱에는 모양 규칙이 있습니다.

(a, b) @ (b, c) = (a, c)
     ↑    ↑
   안쪽 둘은 같아야 함
  ↑            ↑
  바깥 둘이 결과의 모양

지금 상황에 대입하면:

  x      @   W     =  출력
(64, 784) @ (?, ?) = (64, 10)
  • 안쪽: x의 784와 W의 첫 숫자가 같아야 → 784
  • 바깥: 결과의 두 번째 숫자 10은 W의 두 번째 숫자에서 와야 → 10

그래서 W는 (784, 10) 입니다.

처음 생각한 답은 (784, 64)였다

노트북에서 (784, 64)를 넣어 봤더니 결과가 (64, 64)가 나왔습니다. 모양 규칙으로는 틀리지 않은 곱이지만, 우리가 원한 (64, 10)이 아니죠.

더 근본적인 문제도 있습니다. 64는 배치 크기입니다. 02에서 마지막 배치는 32장이었죠. W에 64가 들어 있으면 32장짜리 배치는 아예 곱할 수 없습니다. 가중치는 학습 내내 하나의 고정된 모양이어야 하니, 배치 크기가 들어갈 자리는 없습니다.

x = np.zeros((64, 784))
W = np.zeros((784, 10))
print((x @ W).shape)       # (64, 10)

사고 순서

이 게임에서 배운 게 모델 코드를 짤 때 가장 중요한 습관입니다.

  1. 입력 모양은 정해져 있다 (DataLoader가 줌)
  2. 출력 모양을 먼저 정한다 (사진마다 점수 10개)
  3. 그 사이를 잇는 가중치 모양을 규칙으로 역산한다

3. 행렬 곱이 실제로 하는 일

x @ W에서 출력의 한 칸, 예를 들어 “사진 0의 7일 점수”는 이렇게 계산됩니다.

사진 0의 픽셀 784개 × W의 7번째 열 784개를 짝지어 곱한 뒤 전부 더함
= x[0,0]·W[0,7] + x[0,1]·W[1,7] + ... + x[0,783]·W[783,7]

즉 W의 7번째 열은 “각 픽셀이 7이라는 답에 얼마나 투표하는가”를 적어 둔 784칸짜리 표입니다. 7의 가로획 자리에 있는 픽셀은 큰 값, 7과 상관없는 자리는 0 근처가 되는 게 이상적이죠. 학습이 하는 일이 바로 이 표를 채우는 겁니다.

열이 10개니까 이런 표가 10장, 그게 (784, 10)입니다.


4. 편향 b와 브로드캐스팅

Linear는 곱한 결과에 b를 더합니다.

b = np.zeros((10,))
out = x @ W + b        # (64, 10) + (10,) = (64, 10)

(64, 10)에 (10,)을 더하는 건 수학적으로는 모양이 안 맞습니다. 그런데 numpy는 b를 64행에 똑같이 복사해서 더해 줍니다. 이걸 브로드캐스팅이라고 합니다.

x @ W        b              결과
[행 0]   + [b0 ... b9]  =  행 0 + b
[행 1]   + [b0 ... b9]  =  행 1 + b      ← 모든 행에 같은 b
  ...
[행 63]  + [b0 ... b9]  =  행 63 + b

의미도 맞습니다. b는 “사진이 무엇이든 숫자 k에 기본으로 더해 주는 점수”라서 모든 사진에 같아야 하니까요. 그래서 b에도 배치 크기가 들어가면 안 됩니다.

미리 심어 두는 씨앗: forward에서 b를 64행에 복사해서 더했으니, 나중에 역전파에서 b의 기울기를 구할 때는 64행에서 온 것을 다시 하나로 합쳐야(sum) 합니다. “복사해서 퍼뜨린 것은 되돌아올 때 합친다.” 05에서 이 문장이 다시 나옵니다.


5. Linear 클래스

class Linear:
    def __init__(self, in_features, out_features):
        self.W = np.random.randn(in_features, out_features) * 0.01
        self.b = np.zeros(out_features)

    def forward(self, x):
        return x @ self.W + self.b

왜 함수가 아니라 클래스인가? 레이어는 W와 b라는 자기 상태를 갖고 있고, 그 상태를 forward 때 쓰고 학습 때 고칩니다. 함수로는 그 상태를 들고 다니기 불편해서 클래스로 만듭니다.

layer = Linear(784, 10)
layer.forward(np.zeros((64, 784))).shape    # (64, 10)

왜 W는 랜덤이고 b는 0인가?

W를 전부 0으로 시작하면 출력 10개가 전부 똑같은 값(b뿐)이 됩니다. 그러면 학습할 때 10개 뉴런이 받는 수정 신호도 똑같고, 똑같이 고쳐지니, 끝까지 10개가 전부 같은 값을 가집니다. 뉴런이 10개 있는데 사실상 1개인 셈이죠. 랜덤으로 시작해야 뉴런마다 다른 것을 배울 수 있습니다. 이걸 “대칭을 깬다”고 합니다.

b는 0이어도 됩니다. 대칭은 W가 이미 깼으니까요.

* 0.01은 randn이 만드는 값(평균 0, 크기 1 정도)이 784개 곱해져 더해지면 너무 커지기 때문에 작게 줄인 겁니다. 0.01이 최선은 아니고, 더 좋은 방법(He 초기화 등)은 확장 실험감으로 남겨 둡니다.


6. Linear 두 개를 쌓으면? → Linear 하나와 같다

레이어를 두 개 쌓아 봅니다. 중간에 128개짜리 층을 넣는 겁니다.

x = np.random.randn(64, 784)
l1 = Linear(784, 128)
l2 = Linear(128, 10)

out = l2.forward(l1.forward(x))    # (64,784) → (64,128) → (64,10)

l1의 출력 128과 l2의 입력 128이 같아야 합니다. 모양 규칙 때문이죠.

그런데 이 두 레이어는 하나로 합쳐집니다. 식으로 보면:

h   = x @ W1 + b1
out = h @ W2 + b2
    = (x @ W1 + b1) @ W2 + b2
    = x @ (W1 @ W2) + (b1 @ W2 + b2)
         └───┬────┘   └─────┬──────┘
         W_combined     b_combined

out = x @ W_combined + b_combined. Linear 하나의 식과 똑같은 꼴입니다.

W_combined = l1.W @ l2.W
b_combined = l1.b @ l2.W + l2.b
out_combined = x @ W_combined + b_combined

print(np.allclose(out, out_combined))    # True

True. 두 레이어를 거친 결과와 합친 레이어 하나의 결과가 같습니다. 파라미터를 10만 개 썼는데, 7,840개짜리 Linear 하나와 정확히 같은 함수예요. 중간 레이어가 아무 일도 못 하는 겁니다.

==가 아니라 np.allclose로 비교하는 건, 두 계산의 곱셈 순서가 달라서 소수점 끝자리가 아주 조금 다를 수 있기 때문입니다. float 결과를 비교할 때는 늘 allclose를 씁니다.


7. ReLU: 중간에 끼우는 스위치

그래서 Linear 사이에 직선이 아닌 것을 끼웁니다. 가장 흔한 게 ReLU입니다.

class ReLU:
    def forward(self, x):
        return np.maximum(0, x)

ReLU().forward(np.array([-2, -0.5, 0., 0.5, 2.]))    # [0. 0. 0. 0.5 2.]

양수는 그대로, 음수는 0. 그래프로 그리면 꺾인 직선입니다.

입력 출력 0 음수 → 0 양수 → 그대로

솔직하게: 지금 ReLU가 하는 일은 “의미 없는 점수 중 양수만 남기기”

먼저 지금 상태부터 정확히 말하겠습니다. W는 랜덤입니다. 그러니 x @ W + b로 나온 128개 점수는 아무 의미 없는 숫자이고, ReLU는 그중 양수인 것만 남깁니다. 의미 없는 숫자를 걸러 봐야 의미 없는 숫자예요. 이 노트북 시점에서 ReLU는 아무 쓸모 있는 일도 하지 않습니다. 맞습니다.

그런데도 왜 지금 만들고, 왜 “패턴”이라는 말을 쓰느냐면:

  • 학습은 W의 숫자만 바꿉니다. forward 코드는 한 글자도 안 바뀝니다. x @ W + b → max(0, ·) 이 계산 자체는 학습 전이나 후나 같아요.
  • 그래서 “이 계산이 W가 채워진 뒤에는 무엇을 뜻하게 되는가”를 미리 알아 둬야, 왜 이런 구조로 만드는지 이해할 수 있습니다.

비유하면 지금 만든 건 빈 악보대가 있는 오케스트라입니다. 자리(뉴런)도 있고 “연주할지 말지” 규칙(ReLU)도 있는데, 악보(W)가 아직 낙서예요. 학습이 하는 일이 이 구조 안에서 낙서를 악보로 바꾸는 겁니다. 아래 설명은 전부 “악보가 채워진 뒤”의 이야기입니다.

학습이 끝나면: 뉴런 하나 = W의 열 하나 = 784칸짜리 “본보기 그림”

l1 = Linear(784, 128)의 W는 (784, 128), 열이 128개입니다. 열 하나가 뉴런 하나이고, 열 하나는 숫자 784개, 즉 28×28 그림 한 장과 같은 크기입니다.

뉴런의 출력은 3절 그대로 “사진 784개 × 열 784개를 짝지어 곱한 뒤 전부 더한 값”인데, 이 계산은 사실 “사진이 이 열에 그려진 그림과 얼마나 닮았나”를 재는 겁니다. 학습이 끝난 뉴런 하나를 3×3짜리 작은 예로 보면:

어떤 뉴런의 가중치 9개를 3×3으로 접은 것 (본보기):
   -1   1  -1
   -1   1  -1        ← "가운데 세로줄은 좋아하고(+1), 양옆은 싫어한다(-1)"
   -1   1  -1

사진 A (세로줄)     사진 B (가로줄)
   0 1 0               0 0 0
   0 1 0               1 1 1
   0 1 0               0 0 0

곱해서 더하면:  A → 1+1+1 = 3 (양수, "세로줄 있다")
               B → -1+1-1 = -1 (음수, "세로줄 없다")

같은 뉴런인데 A에는 3점, B에는 −1점. 가중치가 세로줄 모양이라 세로줄이 있는 사진에만 높은 점수가 나옵니다. 이 뉴런이 담당하는 패턴은 “세로줄”이에요. 열이 128개면 본보기 그림이 128장이고, 학습이 끝나면 각각 “왼쪽 위 곡선”, “아래쪽 가로획” 같은 서로 다른 조각을 담당하게 됩니다.

그 위에 ReLU가 하는 일

l1의 중간 뉴런 128개는 각각 “내가 담당하는 패턴이 이 사진에 얼마나 있는가”를 점수로 매깁니다. 양수면 “있다”, 음수면 “없다”. 위 예에서 ReLU를 지나면 A는 3 그대로, B는 0이 됩니다. ReLU는 뉴런에게 이렇게 말하는 겁니다.

“패턴이 있으면 얼마나 있는지 말해라. 없으면 그냥 조용히 있어라(0).”

ReLU가 없으면 “없다”는 뉴런도 음수 값을 계속 흘려보내고, 다음 레이어는 그걸 더하고 빼는 것밖에 못 합니다. 그게 6절의 “결국 Linear 하나”예요. ReLU가 있으면 다음 레이어는 “켜진 뉴런들의 조합”을 보게 되고, “곡선 뉴런 켜짐 + 세로획 뉴런 켜짐 + 꼬리 뉴런 꺼짐 → 9겠다” 같은 조건부 판단이 가능해집니다.

그러면 랜덤에서 어떻게 패턴이 생기나?

학습은 “손실이 줄어드는 방향으로 W를 아주 조금씩 미는 것”을 수만 번 반복합니다(05, 06에서 만듭니다). 랜덤한 열 128개 중 어떤 열은 우연히 세로줄과 아주 조금 닮아 있을 겁니다. 그 열은 세로줄 사진에서 아주 조금 더 쓸모 있는 신호를 내고, 학습은 “쓸모 있었으니 그 방향으로 더”라며 그 열을 세로줄 쪽으로 조금 더 밉니다. 수만 번 반복하면 그 열은 선명한 세로줄 본보기가 됩니다.

열마다 우연히 기울어진 방향이 다르기 때문에 서로 다른 패턴으로 자랍니다. 5절에서 “W를 전부 0으로 시작하면 안 된다”고 한 이유가 바로 이겁니다. 전부 0이면 우연한 기울어짐이 없어서 128개가 전부 같은 방향으로 자라요.

학습이 끝난 뒤 l1.W[:, 0].reshape(28, 28)을 01처럼 #으로 그려 보면 희미하게 획 모양이 보입니다. 그 실험은 학습 단계에서 직접 해 봅니다.

왜 “꺾임”이 중요한가

직선은 몇 개를 겹쳐도 직선입니다. 꺾인 선은 여러 개를 겹치면 어떤 모양이든 만들 수 있습니다. 종이를 한 번 접으면 직선 하나지만 여러 번 접으면 어떤 모양이든 근사할 수 있는 것처럼요. 뉴런 128개가 각자 다른 위치에서 꺾이니, 그 조합으로 복잡한 경계를 그릴 수 있게 됩니다.

확인: ReLU를 끼우면 더 이상 합쳐지지 않는다

out = l2.forward(relu.forward(l1.forward(x)))
print(np.allclose(out, out_combined))    # False

6절과 같은 l1, l2, x, out_combined를 쓰고 ReLU 하나만 끼웠는데 False로 바뀌었습니다. 달라진 건 ReLU뿐이니 원인은 ReLU입니다. 꺾임이 들어가는 순간 6절의 괄호 풀기가 불가능해지고, 그때부터 중간 레이어가 의미를 가질 수 있게 됩니다.

 비교결과뜻
6절Linear → LinearTrue하나로 합쳐짐, 중간 레이어 무의미
7절Linear → ReLU → LinearFalse합칠 수 없음, 중간 레이어가 의미를 가질 수 있음

이 실험은 W가 랜덤이어도 성립합니다. “합쳐지느냐 아니냐”는 W의 값이 아니라 계산의 구조에 달린 성질이기 때문이에요. 그래서 지금 확인할 수 있는 겁니다.

정리

  • 지금: W가 랜덤 → 점수가 의미 없음 → ReLU도 의미 없는 것을 거를 뿐.
  • 학습 후: W의 열이 본보기 그림이 됨 → 점수가 “닮은 정도” → ReLU가 “닮은 뉴런만 말하게” 함.
  • ReLU가 처음부터 있어야 하는 이유: 없으면 아무리 학습해도 Linear 하나짜리 능력을 못 벗어남(6절).

구조를 먼저 만들고, 의미는 학습이 채웁니다.


8. Sequential: 부품을 순서대로 잇기

레이어가 많아지면 l2.forward(relu.forward(l1.forward(x))) 같은 괄호는 읽을 수 없습니다. 대신 리스트에 담고 반복문으로 차례로 통과시킵니다.

class Sequential:
    def __init__(self, layers):
        self.layers = layers

    def forward(self, x):
        for layer in self.layers:
            x = layer.forward(x)     # 이 레이어의 출력이 다음 레이어의 입력
        return x
model = Sequential([
    Linear(784, 128),
    ReLU(),
    Linear(128, 10),
])
model.forward(np.zeros((64, 784))).shape    # (64, 10)

이 설계가 좋은 이유: 약속

Sequential.forward는 레이어가 Linear인지 ReLU인지 전혀 모릅니다. 그냥 .forward()만 부릅니다. 이게 가능한 건 모든 레이어가 “forward(x)를 받아 결과를 돌려준다”는 약속을 지키기 때문이에요. 나중에 Sigmoid나 다른 레이어를 만들어도 Sequential은 한 줄도 안 고치고 끼울 수 있습니다.

05(역전파)에서 이 약속에 backward가 하나 추가됩니다.


9. 만들면서 겪은 실수

실수 1: return 누락 Sequential.forward에서 반복문은 잘 돌았는데 마지막에 return x가 없었습니다. Python은 return 없이 끝난 함수에서 None을 돌려주고, 그래서 'NoneType' object has no attribute 'shape' 에러가 났습니다. 이 에러를 보면 “어느 함수가 None을 돌려줬지?”부터 찾으면 됩니다.

실수 2: 노트북이 옛 코드를 씀 layers.py를 고쳤는데 노트북은 처음 import한 버전을 계속 썼습니다. .py를 고치면 커널 Restart 또는 %autoreload가 필요합니다. 순서: 수정 → 저장 → Restart → 셀 실행 → 노트북 저장.

이름 정리 Sequentials → Sequential (모델 하나니 단수), layer.py → layers.py (레이어 여러 개를 담으니 복수). 이름을 바꾸면 쓰는 곳 전부를 찾아 고쳐야 합니다. 노트북에 옛 이름이 하나 남아 있었는데, 예전 출력이 성공처럼 보여서 숨어 있었습니다.


10. 테스트에서 배운 것: 입력을 뭘로 넣나

tests/test_layers.py를 보면 어떤 테스트는 np.zeros를 넣고, 어떤 테스트는 np.random.randn을 넣습니다.

테스트가 묻는 것입력이유
모양이 맞는가np.zeros값은 무관. “여기 값은 의미 없다”가 코드에서 보임
값이 맞는가np.random.randn0을 넣으면 0 @ W = 0이라 W가 틀려도 통과해 버림

6절의 “Linear 두 개 = 하나” 테스트에서는 l1.b를 일부러 랜덤으로 바꿨습니다. b가 0이면 b_combined 식의 l1.b @ l2.W 항이 항상 0이라, 그 항이 틀려도 테스트가 통과하기 때문입니다. “테스트가 통과한다”와 “테스트가 그 부분을 검증한다”는 다릅니다.


11. 이 노트북에서 배운 것

용어뜻
forward입력 → 출력 방향의 계산
@행렬 곱. (a,b) @ (b,c) = (a,c)
W (784, 10)열마다 “각 픽셀이 숫자 j에 얼마나 투표하나” 표
b (10,)숫자마다 기본 점수
브로드캐스팅(64,10) + (10,)에서 b를 64행에 복사해 더하기
대칭 깨기W를 랜덤으로 시작해야 뉴런들이 다른 걸 배움
ReLUmax(0, x). 양수 통과, 음수 차단
비선형직선이 아닌 것. 없으면 몇 층을 쌓아도 한 층과 같음
Sequential레이어 리스트를 순서대로 통과
약속(인터페이스)모든 레이어가 forward를 가진다는 규칙
np.allclosefloat 배열이 오차 안에서 같은지

스스로 확인해 보기

  1. 입력이 (32, 784)일 때 Linear(784, 10)의 출력 모양은? W를 바꿔야 하나요?
  2. Linear(784, 128) 다음에 Linear(64, 10)을 붙이면 무슨 일이 생기나요?
  3. W를 전부 0으로 시작하면 왜 학습이 안 되나요?
  4. ReLU().forward(np.array([-3, 0, 3]))의 결과는?
  5. Linear → Linear → Linear 세 개를 ReLU 없이 쌓으면, 결과는 어떤 모델과 같나요?
  6. Sequential에 새로운 종류의 레이어를 추가하려면 Sequential 코드를 고쳐야 하나요? 그 레이어가 지켜야 할 조건은?
답
  1. (32, 10). W는 그대로 (784, 10). 배치 크기는 W와 무관.
  2. 모양 에러. 첫 레이어 출력 128과 둘째 레이어 입력 64가 안 맞음.
  3. 출력 10개가 전부 같아서, 수정 신호도 같고, 영원히 같은 값. 뉴런 10개가 1개처럼 동작.
  4. [0, 0, 3]
  5. Linear 하나. W1 @ W2 @ W3로 합쳐짐.
  6. 안 고쳐도 됨. 그 레이어가 forward(x)를 가지고 결과를 돌려주기만 하면 됨.

이전 글: 02. 사진을 인공지능이 먹을 수 있게 손질하고, 조금씩 나눠 주기

다음 글: 04. 모델이 낸 점수 10개를 확률로 바꾸고(softmax), 정답과 비교해 “얼마나 틀렸나”를 숫자 하나로 만들기(cross-entropy)

시리즈 소개: 00. 시작하며 (전체 목차)