Python + NumPy로 신경망 만들기 (3) — 숫자 784개를 받아서 “0~9 중 뭘까” 점수 10개로 바꾸기
이 글은
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)
사고 순서
이 게임에서 배운 게 모델 코드를 짤 때 가장 중요한 습관입니다.
- 입력 모양은 정해져 있다 (DataLoader가 줌)
- 출력 모양을 먼저 정한다 (사진마다 점수 10개)
- 그 사이를 잇는 가중치 모양을 규칙으로 역산한다
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. 그래프로 그리면 꺾인 직선입니다.
솔직하게: 지금 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 → Linear | True | 하나로 합쳐짐, 중간 레이어 무의미 |
| 7절 | Linear → ReLU → Linear | False | 합칠 수 없음, 중간 레이어가 의미를 가질 수 있음 |
이 실험은 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.randn | 0을 넣으면 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를 랜덤으로 시작해야 뉴런들이 다른 걸 배움 |
| ReLU | max(0, x). 양수 통과, 음수 차단 |
| 비선형 | 직선이 아닌 것. 없으면 몇 층을 쌓아도 한 층과 같음 |
Sequential | 레이어 리스트를 순서대로 통과 |
| 약속(인터페이스) | 모든 레이어가 forward를 가진다는 규칙 |
np.allclose | float 배열이 오차 안에서 같은지 |
스스로 확인해 보기
- 입력이
(32, 784)일 때Linear(784, 10)의 출력 모양은?W를 바꿔야 하나요? Linear(784, 128)다음에Linear(64, 10)을 붙이면 무슨 일이 생기나요?W를 전부 0으로 시작하면 왜 학습이 안 되나요?ReLU().forward(np.array([-3, 0, 3]))의 결과는?- Linear → Linear → Linear 세 개를 ReLU 없이 쌓으면, 결과는 어떤 모델과 같나요?
Sequential에 새로운 종류의 레이어를 추가하려면Sequential코드를 고쳐야 하나요? 그 레이어가 지켜야 할 조건은?
답
(32, 10).W는 그대로(784, 10). 배치 크기는W와 무관.- 모양 에러. 첫 레이어 출력 128과 둘째 레이어 입력 64가 안 맞음.
- 출력 10개가 전부 같아서, 수정 신호도 같고, 영원히 같은 값. 뉴런 10개가 1개처럼 동작.
[0, 0, 3]- Linear 하나.
W1 @ W2 @ W3로 합쳐짐. - 안 고쳐도 됨. 그 레이어가
forward(x)를 가지고 결과를 돌려주기만 하면 됨.
이전 글: 02. 사진을 인공지능이 먹을 수 있게 손질하고, 조금씩 나눠 주기
다음 글: 04. 모델이 낸 점수 10개를 확률로 바꾸고(softmax), 정답과 비교해 “얼마나 틀렸나”를 숫자 하나로 만들기(cross-entropy)
시리즈 소개: 00. 시작하며 (전체 목차)