글 목록으로 돌아가기

정글[week13~14] WIL + AI로 진화하기(신경망을 이용한 손글씨 숫자인식, mini GPT LLM 구현)

NumPy 기반 신경망 학습 루프와 mini GPT에서 token ID, embedding, attention, loss, 실험 해석 흐름을 정리한다.

JUHOSEOK
JUHOSEOK 2026년 6월 4일 · 8분 읽기
AI Machine Learning Deep Learning NumPy GPT jungle

이번 주에는 NumPy만으로 신경망 학습 루프를 구현하고, mini GPT 프로젝트를 통해 텍스트가 token ID와 embedding vector를 거쳐 next token prediction으로 이어지는 흐름을 정리했다.

단순히 함수별 구현을 채우는 것보다 forward -> loss -> backward -> optimizer update가 하나의 학습 구조로 연결되는 이유를 이해하는 것이 중요했다.

1. 이번 주 목표

이번 주에 가장 중요하게 이해하려고 한 것은 신경망과 GPT가 실제 코드에서 어떤 흐름으로 학습되는지였다.

NumPy 기반 MNIST 신경망에서는 ReLU, Softmax, Affine, BatchNorm, Dropout, Cross Entropy, optimizer.update(params, grads)가 각각 따로 존재하는 함수가 아니라 하나의 학습 루프 안에서 연결된다는 점을 확인했다.

mini GPT 프로젝트에서는 텍스트가 모델 입력이 되기까지의 흐름을 중심으로 봤다. 문자열이 UTF-8 byte-level BPE를 거쳐 token ID가 되고, token ID가 embedding table의 row index로 사용되어 embedding vector로 바뀐 뒤 Transformer 내부 계산에 들어간다는 점을 정리했다.

이 과정에서 vocab, token ID, byte 값, embedding vector가 서로 다른 역할을 가진다는 점이 중요했다.

또한 실험 결과를 해석할 때 단순히 train loss나 최고 accuracy 하나만 보는 것이 아니라, validation loss, train-val gap, test accuracy, downstream sentiment accuracy, 학습 시간, 파라미터 수, 수렴 속도까지 함께 봐야 한다는 기준을 세우려고 했다.

2. 시도한 접근 방식

처음에는 NumPy 신경망의 기본 학습 루프를 따라갔다. model.forward()가 예측을 만들고, loss가 정답과 예측의 차이를 계산하며, backward()가 gradient를 만들고, optimizer가 params를 갱신하는 흐름을 기준으로 각 layer의 역할을 정리했다.

forward
-> loss
-> backward
-> optimizer.update(params, grads)

이 과정에서 params와 grads를 같은 key 구조로 관리하는 이유를 확인했다. W1, b1 같은 parameter와 그에 대응하는 gradient를 딕셔너리로 관리하면 optimizer가 layer 내부 구조를 몰라도 같은 key를 기준으로 값을 업데이트할 수 있다.

이 구조 덕분에 SGD, Adam, AdaGrad 같은 optimizer를 같은 학습 루프에서 교체할 수 있다는 점을 이해했다.

그 다음에는 NumPy의 shape와 axis를 반복해서 확인했다. 특히 Softmax에서 axis=1을 사용하는 이유를 (batch_size, num_classes) 기준으로 이해하려고 했다. 한 행이 샘플 하나이고, 각 행 안의 클래스 확률 합이 1이 되어야 하므로 Softmax는 각 샘플 내부의 class 차원에서 계산되어야 한다.

mini GPT 쪽에서는 입력 흐름부터 Transformer 내부 계산까지 순서대로 봤다. byte-level BPE, token ID, embedding vector, position embedding, Q/K/V, attention score, softmax, V 가중합, causal mask, multi-head shape, logits, cross entropy loss까지 이어지는 흐름을 질문 단위로 쪼개서 정리했다.

문자열
-> UTF-8 byte-level BPE
-> token ID
-> token embedding + position embedding
-> TransformerBlock
-> LayerNorm / residual connection / FeedForward
-> LM head
-> logits
-> loss

디버깅과 실험에서는 원인을 한 덩어리로 보지 않고 구간별로 나누려고 했다. pytest 실패와 실제 accuracy 문제를 구분했고, 환경 문제와 코드 문제도 분리했다. UnicodeDecodeError처럼 학습 루프 자체가 아니라 생성 샘플 decode 단계에서 발생한 문제는 출력/logging 문제로 따로 해석했다.

3. 문제와 해결 과정

3-1. pytest 통과와 accuracy를 같은 의미로 봐도 되는지 헷갈렸다

처음에는 pytest가 통과하면 목표 정확도도 달성한 것처럼 느껴졌다. 하지만 정리해보니 pytest는 ReLU, Softmax, Dropout, optimizer 같은 부품이 의도대로 동작하는지 확인하는 단계이고, accuracy는 전체 모델이 실제 MNIST 데이터로 학습된 뒤 평가되는 결과였다.

pytest
-> 함수/부품 단위 검증

train()
-> 전체 학습 루프 실행

evaluate()
-> 학습된 모델의 실제 accuracy 확인

이 구분이 중요했던 이유는 테스트가 통과해도 epoch, learning rate, optimizer, dropout 설정에 따라 실제 성능은 달라질 수 있기 때문이다. 그래서 앞으로는 부품 테스트, 학습 동작 확인, 최종 성능 평가를 분리해서 봐야 한다고 정리했다.

3-2. ReLU는 forward 출력만 맞으면 되는 줄 알았다

처음에는 ReLU의 forward 결과만 맞으면 backward도 자연스럽게 될 것처럼 생각했다. 하지만 backward에서는 forward 때 입력이 양수였던 위치로만 gradient가 흘러야 한다. 그래서 forward 단계에서 mask를 저장해야 한다는 점을 이해했다.

ReLU forward
-> 입력이 0 이하인 위치를 mask로 저장
-> 출력 계산

ReLU backward
-> mask를 보고 0 이하였던 위치의 gradient 차단

이 경험을 통해 activation layer를 구현할 때 forward 결과만 볼 것이 아니라 backward에서 필요한 중간값을 forward 시점에 저장해야 한다는 점을 배웠다.

3-3. Softmax의 axis=1이 말로는 이해됐지만 실제 계산 축이 모호했다

처음에는 axis=1을 단순히 “행 방향”처럼 이해해서 실제로 어떤 축을 기준으로 계산되는지 헷갈렸다. MNIST 분류에서는 보통 출력 shape가 (batch_size, num_classes)이다. 여기서 각 행은 샘플 하나이고, 열은 각 클래스에 대한 점수다.

따라서 Softmax는 전체 배열을 한 번에 정규화하는 것이 아니라, 각 샘플별로 클래스 확률 합이 1이 되도록 계산해야 한다.

입력 shape: (batch_size, num_classes)

각 row = 샘플 하나
각 column = 클래스 점수

Softmax axis=1
-> 각 샘플 내부에서 class 방향으로 확률 계산

이 부분을 정리하면서 NumPy 코드는 실행 여부보다 shape 의미가 더 중요하다는 점을 배웠다. axis를 잘못 잡으면 코드가 돌아가도 의미가 틀린 결과가 나올 수 있다.

3-4. Cross Entropy를 layer처럼 봐야 하는지 헷갈렸다

처음에는 Softmax 뒤에 Cross Entropy도 layer처럼 구조도에 넣어야 하는지 헷갈렸다. 하지만 Cross Entropy는 정답 라벨과 예측 확률을 비교하는 loss 함수다. 즉 모델이 입력만 보고 수행하는 layer라기보다는, 학습 과정에서 정답이 있을 때 계산되는 목적 함수에 가깝다.

model structure
-> Affine / ReLU / BatchNorm / Dropout / Softmax 등

training objective
-> Cross Entropy loss

이 구분을 통해 모델 구조와 학습 목적 함수를 분리해서 봐야 한다는 점을 이해했다.

3-5. token ID, vocab, embedding vector가 서로 섞였다

mini GPT 쪽에서는 처음에 vocab이 벡터를 저장하는 곳인지, token ID 자체가 의미 표현인지 헷갈렸다. 정리해보니 vocab은 token과 ID의 mapping이고, token ID는 embedding table의 row index이며, embedding vector가 실제 모델 계산에 들어가는 값이었다.

text
-> tokenizer
-> token ID
-> embedding table lookup
-> embedding vector
-> Transformer 계산

이 흐름을 이해하면서 입력 계약이 중요하다는 점을 배웠다. 모델은 문자열 자체를 계산하지 않고, tokenizer와 embedding을 거쳐 만들어진 숫자 벡터를 계산한다.

3-6. Q/K/V와 attention 계산에서 최종 결과가 무엇인지 헷갈렸다

Attention을 처음 볼 때는 입력을 Q/K/V로 “나누는 것”인지, attention score나 weight가 최종 결과인지 헷갈렸다. 질문을 통해 정리한 결과, Q/K/V는 입력을 선형 변환해 만든 역할별 벡터이고, 최종 attention output은 V를 attention weight로 가중합한 문맥 벡터였다.

입력 x
-> Q, K, V 선형 변환
-> Q와 K로 attention score 계산
-> softmax로 attention weight 계산
-> attention weight와 V를 가중합
-> context vector

multi-head attention에서는 shape 변화도 중요했다. (B, T, C)(B, heads, T, head_dim)으로 바뀌고 다시 (B, T, C)로 돌아오는 흐름을 먼저 확인해야 attention 코드의 의미를 따라갈 수 있다.

3-7. train loss가 낮으면 좋은 모델이라고 단정하기 쉬웠다

처음에는 train loss가 낮을수록 더 좋은 모델이라고 생각하기 쉬웠다. 하지만 train loss가 낮아도 validation loss가 높거나 train-val gap이 크면 과적합일 수 있다. mini GPT 실험에서도 emb_dim이 커지면 train loss는 낮아질 수 있지만 downstream sentiment accuracy가 반드시 더 안정적으로 좋아지는 것은 아니었다.

train loss
-> 학습 데이터에 얼마나 잘 맞는지

validation loss
-> 학습에 쓰지 않은 데이터에서의 성능

train-val gap
-> 과적합 가능성 확인

downstream accuracy
-> 실제 목표 task에서의 성능

이후 모델 선택 기준은 train loss 하나가 아니라 validation loss, train-val gap, validation/test accuracy, downstream sentiment accuracy를 함께 보는 방향으로 바뀌었다.

3-8. 에러가 나면 전체 학습이 실패한 것처럼 느껴졌다

디버깅에서는 에러의 위치를 구분하는 것이 중요했다. 예를 들어 UnicodeDecodeError는 학습 루프 전체가 실패한 것이 아니라 byte-level BPE 생성 샘플을 UTF-8로 복원하는 출력 단계 문제였다. 환경 문제도 코드 문제와 분리해서 봐야 했다.

환경 문제
-> cd, conda, Python version, GitHub 권한

코드 문제
-> NotImplementedError, NameError, 반환값 None, axis 오류

출력/logging 문제
-> UnicodeDecodeError, decode 실패

학습 문제
-> loss 감소 실패, accuracy 저하, overfitting

이 과정을 통해 traceback 위치를 기준으로 입력, 모델, loss, optimizer, 출력 단계 중 어디에서 깨졌는지 먼저 나누는 습관이 생겼다.

4. 새롭게 배운 점

이번 주에 가장 크게 배운 점은 신경망과 GPT 모두 개별 함수보다 흐름이 중요하다는 점이다. NumPy 신경망에서는 forward -> loss -> backward -> update가 끊기지 않아야 학습이 되고, mini GPT에서는 텍스트가 token ID와 embedding vector를 거쳐 Transformer 계산과 logits로 이어져야 next token prediction이 가능하다.

또한 layer와 loss, parameter와 hyperparameter를 구분하는 기준도 더 명확해졌다. ReLU, Affine, BatchNorm, Dropout은 모델 구조 안의 계산 단위이고, Cross Entropy는 정답 라벨이 있어야 계산되는 loss 함수다. parameter는 모델이 학습하는 weight, bias, gamma, beta 같은 값이고, hyperparameter는 사람이 정하는 learning rate, epoch, drop_rate, emb_dim 같은 실험 조건이다.

실험 해석에서도 관점이 넓어졌다. MNIST에서는 accuracy, 학습 시간, 파라미터 수, 수렴 속도를 함께 봐야 했고, mini GPT에서는 train loss, validation loss, train-val gap, downstream sentiment accuracy를 함께 봐야 했다. 특히 여러 hyperparameter를 동시에 바꾸면 어떤 요인이 성능에 영향을 줬는지 분리하기 어렵기 때문에, 정확한 원인 분석을 위해서는 ablation study가 필요하다는 점을 배웠다.

아직 부족한 부분도 남아 있다. NumPy 신경망에서는 BatchNorm backward, Adam과 SGD의 수렴 차이, AdaGrad 실험, Dropout이 train loss를 높여도 validation 성능에 도움을 줄 수 있는 이유, 파라미터 수 계산, validation/test 역할 구분을 더 복습해야 한다.

mini GPT 쪽에서는 MultiHeadAttention shape, view, transpose, contiguous, mask broadcasting, BPE encode/decode 전체 구현, Cross Entropy 내부의 log_softmax와 flattening, LayerNorm, BatchNorm, RMSNorm 차이, checkpoint 선택, sampling 전략이 아직 더 공부할 부분으로 남아 있다.

5. 회고

이번 주를 돌아보면, 가장 큰 변화는 코드를 단순히 빈칸 채우기 방식으로 보지 않고 하나의 학습 흐름으로 보려고 했다는 점이다. 처음에는 ReLU, Softmax, Cross Entropy, optimizer가 각각 독립된 구현 과제처럼 느껴졌지만, 실제로는 forward -> loss -> backward -> update가 끊기지 않아야 모델이 학습된다는 것을 조금씩 이해하게 되었다.

특히 forward만 맞으면 된다고 생각했던 부분이 많이 바뀌었다. ReLU에서 mask를 저장해야 backward가 가능하다는 점을 보면서, 신경망 구현은 현재 출력만 보는 것이 아니라 나중에 gradient를 흘려보낼 때 필요한 정보까지 함께 생각해야 한다는 것을 배웠다. 이 경험은 BatchNorm이나 Dropout처럼 train/test 동작이 달라지는 layer를 볼 때도 중요한 기준이 되었다.

mini GPT를 보면서는 입력이 모델 안으로 들어가기 전에도 이미 많은 변환 과정이 있다는 점이 인상 깊었다. 문자열이 바로 모델의 입력이 되는 것이 아니라 tokenizer를 거쳐 token ID가 되고, 이 ID가 embedding table의 index로 사용되어 vector가 된 뒤에야 Transformer 계산이 시작된다. 이 흐름을 정리하면서 “모델이 무엇을 입력으로 받는가”를 정확히 이해하는 것이 구현의 출발점이라는 생각이 들었다.

실험 결과를 해석하는 관점도 전보다 넓어졌다. 이전에는 train loss가 낮거나 accuracy가 높으면 단순히 좋은 결과라고 생각하기 쉬웠다. 하지만 validation loss, train-val gap, downstream accuracy를 함께 봐야 과적합 여부나 실제 task 성능을 판단할 수 있다는 점을 알게 되었다. 특히 여러 hyperparameter를 한 번에 바꾸면 무엇이 성능에 영향을 줬는지 설명하기 어렵다는 점도 느꼈다.

디버깅에서도 무작정 에러를 고치기보다 에러가 어느 단계에서 발생했는지 나누는 것이 중요했다. UnicodeDecodeError처럼 학습 자체가 아니라 출력 단계에서 발생한 문제를 학습 실패로 오해할 수 있었고, pytest 통과와 실제 accuracy 달성도 다른 의미라는 점을 구분해야 했다. 이번 주에는 입력, 모델, loss, optimizer, 출력 단계를 나누어 보는 습관이 조금 생겼다.

아직 완전히 이해하지 못한 부분도 많다. BatchNorm backward, optimizer별 수렴 차이, MultiHeadAttention의 shape 변화, BPE encode/decode, Cross Entropy 내부 계산은 더 복습이 필요하다. 그래도 이번 주는 신경망과 GPT 구현을 단순 코드 작성이 아니라, 입력 표현부터 학습 루프, 실험 해석, 디버깅 기준까지 하나의 흐름으로 이해하기 시작한 주였다.