글 목록으로 돌아가기

Jungle / WIL(Weekly I Learned)

[WIL] 13주차

13주차에는 PyTorch나 TensorFlow 없이 NumPy만으로 MNIST 손글씨 숫자 분류 신경망을 직접 구현하며 Forward, Loss, Backward, Optimizer Update가 어떻게 연결되는지 코드 수준에서 학습했다.

임재환
임재환 2026년 5월 28일 · 6분 읽기 · 수정 2026년 5월 28일
[WIL] 13주차

13주차 주간 회고: 프레임워크 없이 신경망 학습 과정을 직접 만난 한 주

13주차에는 MNIST 손글씨 숫자 분류기를 NumPy만으로 구현했다. 딥러닝 프레임워크를 사용하면 model.fit()이나 loss.backward() 뒤에 숨어 지나가는 과정들을 직접 코드로 풀어보는 것이 목표였다. 그래서 이번 프로젝트의 초점은 단순히 MNIST 정확도를 높이는 것이 아니라, 신경망 학습이 실제로 어떤 계산 흐름으로 이루어지는지 확인하는 데 있었다.

이번 주의 흐름은 데이터 전처리 -> layer 구현 -> forward -> loss -> backward -> optimizer update -> 학습 결과 검증으로 이어졌다. 최종적으로는 다층 퍼셉트론 기반 MNIST 분류기를 만들었고, 테스트 정확도 98.52%를 기록했다. 최소 목표였던 95%와 권장 목표였던 97%를 모두 넘겼다.

MNIST MLP 구조와 NumPy 기반 학습 흐름
이번 주에는 784차원 입력에서 10개 클래스 출력까지 이어지는 MLP 학습 과정을 NumPy 코드로 직접 구현했다

1. 프로젝트 목표와 최종 결과

이번 프로젝트의 목표는 MNIST 손글씨 숫자 이미지를 0부터 9까지 분류하는 신경망을 직접 만드는 것이었다. 데이터셋 자체는 익숙한 예제지만, 중요한 조건이 있었다. PyTorch나 TensorFlow 같은 프레임워크 없이 NumPy만 사용해야 했다.

항목 내용
데이터셋 MNIST 손글씨 숫자 데이터
문제 유형 0~9 숫자 이미지 분류
구현 방식 NumPy 기반 직접 구현
최소 목표 테스트 정확도 95% 이상
권장 목표 테스트 정확도 97% 이상
최종 결과 테스트 정확도 98.52%

최종 결과만 보면 단순히 “정확도가 잘 나왔다”로 끝낼 수도 있다. 하지만 이번 프로젝트에서 더 의미 있었던 부분은 정확도보다 그 정확도가 만들어지는 내부 과정을 직접 따라갔다는 점이었다. 입력이 layer를 지나 확률이 되고, 확률과 정답의 차이가 gradient가 되고, 그 gradient가 다시 각 파라미터를 움직이는 흐름을 코드로 확인했다.

2. 모델 구조

MNIST 이미지는 28x28 픽셀이다. 입력 단계에서는 이를 784차원 벡터로 펼쳤고, 두 개의 은닉층을 거쳐 10개 클래스에 대한 확률을 출력하도록 구성했다.

Input(784)
-> Affine(784 -> 512)
-> BatchNorm
-> ReLU
-> Dropout
-> Affine(512 -> 256)
-> BatchNorm
-> ReLU
-> Dropout
-> Affine(256 -> 10)
-> Softmax

구조를 표로 정리하면 다음과 같다.

구분 구성
입력층 784차원 벡터
은닉층 1 Affine(784 -> 512), BatchNorm, ReLU, Dropout
은닉층 2 Affine(512 -> 256), BatchNorm, ReLU, Dropout
출력층 Affine(256 -> 10), Softmax
손실 함수 Cross Entropy Loss
출력 값 숫자 0~9에 대한 클래스별 확률

처음에는 단순한 Affine-ReLU 구조만으로도 충분하지 않을까 생각했지만, 최종 모델에는 BatchNorm과 Dropout까지 넣었다. BatchNorm은 학습을 안정화하고, Dropout은 특정 뉴런이나 경로에 과하게 의존하지 않도록 정규화 역할을 한다.

3. 직접 구현한 모듈들

이번 프로젝트에서는 신경망을 구성하는 주요 부품을 각각 나누어 구현했다.

파일 역할
src/data.py MNIST 데이터 로드, 784차원 flatten, 0~1 정규화
src/activations.py ReLU, Softmax 구현
src/layers.py Affine, BatchNorm, Dropout 구현
src/losses.py Cross Entropy Loss 구현
src/optimizers.py SGD, Adam Optimizer 구현
src/network.py 전체 신경망 구조 조립, forward/backward/loss/predict
src/training.py 학습 루프, 평가 함수, loss curve 시각화
src/util.py 데이터와 라벨을 같은 순서로 섞는 유틸리티

프레임워크를 사용할 때는 activation, layer, loss, optimizer가 이미 준비된 부품처럼 느껴진다. 하지만 직접 구현해보니 각 모듈은 서로 강하게 연결되어 있었다. 특히 layer는 forward에서 중간 값을 저장해두어야 backward에서 gradient를 계산할 수 있었다. 이 점이 가장 먼저 체감된 차이였다.

4. Forward, Loss, Backward, Update

학습 루프의 핵심 흐름은 네 단계로 정리할 수 있었다.

Forward
-> Loss
-> Backward
-> Optimizer Update

NeuralNetwork.forward()OrderedDict에 저장된 layer를 순서대로 실행한다. BatchNorm과 Dropout은 학습 모드와 평가 모드에서 동작이 다르기 때문에 train 인자를 함께 넘겨야 했다. 학습 중에는 mini-batch 통계와 dropout mask를 사용하지만, 평가 중에는 running mean, running variance와 보정된 출력을 사용한다.

Loss는 Cross Entropy Loss를 사용했다. 예측 확률과 실제 정답 레이블을 비교해 손실을 계산하고, np.clip으로 log(0)이 발생하지 않도록 처리했다.

역전파에서는 Softmax와 Cross Entropy를 결합한 gradient를 직접 만들었다.

dout = y_pred.copy()
dout[np.arange(x_batch.shape[0]), y_batch] -= 1
dout = dout / x_batch.shape[0]

이 코드는 짧지만 의미는 크다. 모델이 낸 확률 분포에서 정답 클래스 위치만 1을 빼면, Softmax와 Cross Entropy를 함께 사용했을 때의 gradient가 된다. 수식으로만 볼 때는 추상적이었는데, 코드로 쓰고 나니 출력층에서 역방향으로 어떤 신호가 흘러가는지 훨씬 선명해졌다.

마지막으로 optimizer가 model.params를 직접 갱신한다.

optimizer.update(model.params, grads)

최종 학습에서는 Adam Optimizer를 사용했다. SGD도 구현했지만, Adam이 손실을 더 빠르게 낮추는 것을 확인할 수 있었다.

5. 학습 설정과 파라미터 수

최종 학습 설정은 다음과 같았다.

항목
Optimizer Adam
Learning rate 0.001
Epochs 20
Batch size 128
Dropout ratio 0.5
BatchNorm momentum 0.9
Weight initialization He initialization
Bias initialization 0
입력 정규화 0~1 범위
Seed 42

총 학습 가능 파라미터 수는 537,354개였다.

파라미터 Shape 개수
W1 (784, 512) 401,408
b1 (512,) 512
W2 (512, 256) 131,072
b2 (256,) 256
W3 (256, 10) 2,560
b3 (10,) 10
gamma1 (512,) 512
beta1 (512,) 512
gamma2 (256,) 256
beta2 (256,) 256

직접 숫자를 세어보니 “작은 MNIST 모델”이라고 해도 생각보다 많은 파라미터가 움직이고 있었다. 특히 첫 번째 Affine layer의 W1만 해도 40만 개가 넘는다. 입력 차원 784와 은닉층 512가 곱해지는 순간 파라미터 수가 빠르게 커진다는 점을 체감했다.

6. 테스트와 최종 학습 결과

단위 테스트는 전체 21 passed를 달성했다.

항목 결과
실행 명령 pytest tests/ -q
결과 21 passed
실행 시간 약 0.85초

최종 학습 결과는 다음과 같았다.

항목
학습 데이터 MNIST train set 60,000개
테스트 데이터 MNIST test set 10,000개
Epochs 20
Batch size 128
Optimizer Adam
Learning rate 0.001
초기 train loss 0.4139
최종 train loss 0.0430
Test accuracy 98.52%
총 파라미터 수 537,354
학습 시간 약 392.89초, 약 6분 32.89초
평가 시간 약 0.22초

1 epoch 연습 실행에서도 테스트 정확도 95.63%를 기록했다. 이 결과는 전체 학습 파이프라인이 정상적으로 연결되어 있다는 신호였다. 최종 20 epoch 학습에서는 train loss가 0.4139에서 0.0430까지 감소했고, 테스트 정확도는 98.52%까지 올라갔다.

7. 이번 주에 배운 것

이번 주의 가장 큰 배움은 신경망 학습이 “마법 같은 자동 계산”이 아니라는 점이었다. Forward에서 저장한 중간 값이 Backward의 재료가 되고, Backward에서 만든 gradient가 Optimizer의 입력이 되고, Optimizer가 파라미터를 바꾸면 다음 Forward의 출력이 달라진다. 이 연결이 끊기면 학습은 진행되지 않는다.

두 번째 배움은 BatchNorm과 Dropout처럼 익숙한 이름의 layer도 학습 모드와 평가 모드에서 전혀 다르게 동작한다는 점이다. BatchNorm은 학습 중에는 mini-batch 통계를 사용하지만, 평가 중에는 running mean과 running variance를 사용한다. Dropout은 학습 중에는 일부 뉴런을 비활성화하고, 평가 중에는 출력 크기를 보정한다. 같은 layer라도 현재 모델이 train 상태인지 eval 상태인지에 따라 계산이 달라진다.

세 번째 배움은 optimizer의 역할이다. Optimizer는 단순히 learning rate를 곱해 빼는 함수가 아니라, gradient를 어떤 방식으로 해석하고 누적할지 결정하는 전략이다. SGD와 Adam을 둘 다 구현해보면서 같은 gradient라도 업데이트 방식에 따라 수렴 속도와 안정성이 달라질 수 있다는 점을 확인했다.

8. 아쉬운 점과 개선 방향

아쉬운 점은 기본 설정만으로 목표 정확도를 달성했기 때문에 하이퍼파라미터 탐색을 깊게 하지는 못했다는 점이다. Learning rate, dropout ratio, epoch 수를 바꾸면서 정확도와 과적합 여부를 비교했다면 결과 해석이 더 풍부해졌을 것이다.

또 하나의 개선 방향은 CNN 구조를 직접 구현하는 것이다. 이번 모델은 이미지를 784차원 벡터로 펼쳐서 사용했다. 이 방식은 간단하지만, 이미지의 공간적 구조를 충분히 활용하지는 못한다. Convolution layer를 직접 구현하면 MNIST 이미지의 지역 패턴을 더 자연스럽게 잡아낼 수 있을 것이다.

SGD와 Adam의 수렴 속도도 같은 조건에서 더 체계적으로 비교해보고 싶다. 이번에는 최종 결과 중심으로 Adam을 사용했지만, optimizer별 loss curve를 나란히 비교하면 각 방식의 특징을 더 명확히 설명할 수 있을 것 같다.

현재 아쉬웠던 점과 다음 액션

현재 아쉬운 점은 역전파 수식을 완전히 내 언어로 설명하는 데 아직 시간이 더 필요하다는 것이다. 코드로는 구현했지만, Affine, BatchNorm, Softmax-Cross Entropy의 gradient가 왜 그렇게 나오는지 누군가에게 처음부터 설명하려면 더 정리해야 한다.

다음 액션은 세 가지다.

첫째, 각 layer의 backward 계산을 수식과 코드가 나란히 보이도록 다시 정리한다. 둘째, SGD와 Adam을 같은 조건에서 비교하는 실험을 추가한다. 셋째, 가능하다면 CNN의 Convolution, Pooling layer를 NumPy로 직접 구현해본다.

13주차는 프레임워크가 대신 해주던 일을 직접 손으로 따라가본 주였다. 덕분에 신경망을 “모델을 선언하고 학습시키는 도구”가 아니라, 데이터와 파라미터와 gradient가 계속 주고받는 계산 그래프로 다시 보게 되었다.