이번 주 학습 내용

지난주 KPT에서 남겼던 숙제가 "PyTorch 학습 루프를 예제 없이 직접 짜보고, 역전파를 손으로 익혀보기"였다. 이번 주 초반은 그 숙제를 그대로 따라갔다. 손실 함수, 그래디언트, 역전파, 옵티마이저를 numpy로 하나씩 직접 구현해보고 나서야 optimizer.zero_grad() → loss.backward() → optimizer.step() 세 줄이 실제로 무슨 일을 하는지 감이 잡혔다. 중반부터는 이걸 발판으로 커스텀 모델 클래스, Optuna 하이퍼파라미터 튜닝, 과적합 방지 기법을 배웠고, 후반에는 회귀·분류 프로젝트를 각각 하나씩 끝까지 돌린 뒤 마지막으로 CNN에 입문해 이미지 분류 종합 프로젝트로 한 주를 마무리했다.

01. 손실 함수 직접 구현해보기

지금까지는 nn.BCELoss(), nn.CrossEntropyLoss()처럼 이름만 불러서 썼는데, 이번엔 그 안에서 실제로 무슨 계산이 일어나는지 numpy로 직접 짜봤다.

  • MSE(평균제곱오차)와 MAE(평균절대오차)는 회귀에서 예측이 얼마나 틀렸는지를 재는 가장 기본적인 방식이고, Huber Loss는 오차가 작을 땐 MSE처럼, 오차가 크면 MAE처럼 움직이도록 delta 기준으로 계산 방식을 바꾸는 손실이었다. 이상치에 너무 민감하게 반응하지 않으면서도 작은 오차는 세밀하게 잡아낸다는 절충안이라는 걸 코드로 보니 확실히 이해가 됐다
  • Cross Entropy Error는 정답 클래스에 해당하는 확률에만 -log를 씌워 더하는 방식이었는데, log(0)이 무한대로 발산하는 걸 막으려고 아주 작은 값(1e-7)을 더해준다는 디테일도 챙겼다
def huber_loss(y_true, y_pred, delta=1.0): error = y_true - y_pred is_small_error = np.abs(error) <= delta squared_loss = 0.5 * error**2 linear_loss = delta * (np.abs(error) - 0.5 * delta) return np.mean(np.where(is_small_error, squared_loss, linear_loss))

02. 그래디언트와 역전파, 손으로 계산해보기

지난주 가장 큰 숙제였던 역전파를 이번엔 numpy로 직접 구현하며 파봤다. 먼저 미분을 근사로 계산하는 수치 미분(numerical_diff)부터 시작해서, 이 값으로 기울기가 낮아지는 방향으로 조금씩 움직이는 경사하강법을 처음부터 짜봤다.

  • 단일 뉴런에 시그모이드를 붙여 순전파로 예측값을 낸 뒤, 오차를 거꾸로 흘려보내며 가중치의 기울기를 구하는 과정을 직접 계산했다. delta = (a - y_true) * sigmoid_derivative(z)로 출력층의 오차를 구하고, 이걸 입력값과 곱해 가중치의 기울기를 얻는 연쇄법칙(chain rule)의 흐름을 한 줄씩 손으로 따라가니 "역전파 = 연쇄법칙을 뒤에서부터 반복 적용하는 것"이라는 말이 실감났다
  • y = 2x² 같은 간단한 합성함수에서 수치 미분과 해석적(analytical) 미분 결과를 나란히 비교해보니 오차가 1e-11 수준으로 거의 일치했다. PyTorch의 backward()가 내부적으로 이 해석적 미분을 자동으로 해주고 있다는 걸 눈으로 확인한 셈이었다
# 역전파 (연쇄법칙) delta = (a - y_true) * sigmoid_derivative(z) grad_w = delta * x w -= learning_rate * grad_w

03. Autograd — PyTorch가 미분을 대신 해주는 이유

02번에서 손으로 짠 역전파를 PyTorch는 requires_grad_(True) 하나로 대신해준다. 텐서에 이 옵션을 걸면 그 텐서가 관여한 모든 연산이 계산 그래프에 기록되고, 마지막에 .backward()만 호출하면 그래프를 거슬러 올라가며 모든 기울기를 한 번에 계산해준다.

  • y = sum((x+2)(x-2))처럼 여러 단계를 거친 계산도 y.backward() 한 줄이면 x.grad2x가 정확히 채워졌다. 손으로 미분해서 검산해보니 정확히 일치했다
  • 계산 그래프가 붙은 텐서는 .numpy()로 바로 못 바꾸고 .detach()로 그래프를 떼어내야 한다는 것도 확인했다. 나중에 손실 값을 기록할 때 텐서를 그대로 쌓으면 그래프까지 계속 참조된다는 지난주 트러블슈팅과도 이어지는 내용이었다

04. 옵티마이저를 손으로 구현하며 비교하기

경사하강법(SGD)부터 Adam까지, 지금까지 이름만 바꿔 쓰던 옵티마이저들을 하나씩 numpy로 구현하면서 발전 순서를 따라가봤다.

  • SGD는 기울기 방향으로 그냥 이동하는 게 전부였고, Momentum은 이전 이동 방향(속도)을 일부 유지해서 관성을 주는 방식이었다. AdaGrad는 지금까지 누적된 기울기 제곱합으로 학습률을 자동으로 줄여나가는데, 그러다 보니 학습이 길어지면 학습률이 너무 작아지는 문제가 있었고, RMSProp은 누적 대신 지수이동평균을 써서 이 문제를 완화했다
  • Adam은 Momentum(1차 모멘트)과 RMSProp(2차 모멘트)을 합친 것이었다. 편향 보정(m_hat, v_hat)까지 직접 코드로 넣어보니, 그동안 optim.Adam(...) 한 줄로 불러 쓰던 게 사실은 이렇게 여러 아이디어가 쌓인 결과라는 걸 알게 됐다
def adam_update(w, m, v, grad, t, lr=0.01, beta1=0.9, beta2=0.999, epsilon=1e-8): m = beta1 * m + (1 - beta1) * grad v = beta2 * v + (1 - beta2) * (grad ** 2) m_hat = m / (1 - beta1 ** t) v_hat = v / (1 - beta2 ** t) w -= (lr / (np.sqrt(v_hat) + epsilon)) * m_hat return w, m, v

05. 커스텀 모델 클래스와 Optuna 하이퍼파라미터 튜닝

지금까지는 nn.Sequential로 층을 나열하기만 했는데, nn.Module을 상속받아 __init__forward를 직접 정의하는 방식도 써봤다. 층을 조건에 따라 동적으로 쌓거나 중간에 다른 연산을 끼워 넣을 때는 이 방식이 훨씬 유연했다.

  • 유방암 데이터로 train/valid/test를 6:2:2로 나누고, DataLoader로 미니배치를 구성한 뒤 Optuna로 은닉층 크기·드롭아웃 비율·학습률·배치 크기 같은 하이퍼파라미터를 20번 자동으로 탐색해봤다. 사람이 하나씩 바꿔가며 실험하는 대신, "이 범위 안에서 최적값을 찾아줘"라고 맡기는 방식이라 훨씬 체계적이었다
  • 탐색이 끝난 뒤 최적 파라미터로 1000 에폭까지 다시 학습시켜 테스트 정확도 95.6%, ROC-AUC 0.989를 얻었다. ROC 곡선과 confusion matrix, 클래스별 예측 확률 분포까지 함께 그려보며 숫자 하나만 보고 끝내지 않는 습관을 들였다

06. 과적합 방지 — BatchNorm과 Dropout

둘 다 과적합을 막는 데 쓰이지만 원리가 달랐다. BatchNorm은 각 층의 출력을 배치 단위로 정규화해서 학습을 안정시키고, Dropout은 학습할 때마다 일부 뉴런을 무작위로 꺼서 모델이 특정 뉴런에 과하게 의존하지 못하게 막는다.

  • 둘을 같이 쓸 땐 Linear → BatchNorm → ReLU → Dropout 순서로 배치하는 게 정석이라는 걸 배웠다. 순서를 바꿔 붙이면 오히려 학습을 방해할 위험이 있다고 한다
  • skorch로 PyTorch 모델을 사이킷런 인터페이스처럼 감싸서 RandomizedSearchCV에 바로 넣어볼 수 있다는 것도 새로 알았다. 지난주까지 사이킷런 모델에만 쓰던 튜닝 도구를 PyTorch 모델에도 그대로 재사용할 수 있다는 게 신기했다

07. 회귀 프로젝트 — 의료비 예측

보험 가입자 정보로 의료비(charges)를 예측하는 회귀 프로젝트를 처음부터 끝까지 돌려봤다. 타겟 분포가 한쪽으로 심하게 쏠려 있어서 np.log1p로 로그 변환하고, 수치형은 StandardScaler, 범주형은 OneHotEncoder로 각각 처리하는 ColumnTransformer 파이프라인을 구성했다.

  • 모델은 Linear → BatchNorm → SiLU → Dropout 블록을 세 번 쌓은 구조로 만들고, Huber Loss와 AdamW, ReduceLROnPlateau 스케줄러, 그래디언트 클리핑, Early Stopping까지 지금까지 배운 걸 전부 붙여봤다
  • 예측값을 다시 로그 역변환(np.expm1)해서 실제 단위로 복원한 뒤 평가하니 MAE 2,184원, R² 0.87이 나왔다. 흡연 여부가 의료비에 미치는 영향이 산점도로 눈에 띄게 갈라져 있었는데, 모델도 그 패턴을 잘 잡아낸 셈이었다

08. 분류 프로젝트 — 당뇨병 예측과 임계값 튜닝

당뇨병 여부를 예측하는 이진분류 프로젝트에서는 지난주 신용 위험 예측과 달리 SMOTE 대신 클래스 가중치(pos_weight)로 불균형을 처리하는 방식을 써봤다. 소수 클래스를 늘리는 대신, 손실 함수 자체가 소수 클래스를 틀렸을 때 더 크게 벌점을 주도록 만드는 방식이었다.

  • 기본 임계값 0.5로 양성을 판정하는 대신, validation 데이터로 0.1~0.9 사이 임계값을 촘촘히 바꿔가며 precision·recall·F1을 전부 계산해봤다. F1이 가장 높은 지점은 0.60이었고, 이 임계값을 테스트에 그대로 적용하니 정확도 87%, 재현율 81.5%가 나왔다
  • "모델 목적에 따라 임계값을 조정해서 쓴다"는 걸 처음 실습해봤는데, 양성(위험군)을 놓치지 않는 게 중요하다면 임계값을 더 낮춰 재현율을 우선할 수도 있다는 걸 그래프로 확인했다
num_positive = y_train_tensor.sum() pos_weight = (len(y_train_tensor) - num_positive) / num_positive criterion = nn.BCEWithLogitsLoss(pos_weight=pos_weight) # best F1 threshold: 0.60 → test acc 0.870, recall 0.815

09. CNN 첫걸음 — MNIST 손글씨 분류

지금까지는 표 형태 데이터만 다뤘는데, 처음으로 이미지 데이터에 CNN(합성곱 신경망)을 써봤다. Linear 층 대신 Conv2d로 이미지의 지역적인 패턴(선, 모서리 등)을 훑고, MaxPool2d로 크기를 줄여가는 구조였다.

  • MNIST 손글씨 6만 장을 학습 5만/검증 1만으로 나누고, 학습 데이터에만 RandomRotation으로 약간의 회전을 줘서 증강했다. Conv2d → BatchNorm2d → SiLU → MaxPool2d → Dropout 블록을 두 번 쌓은 뒤 Flatten해서 분류층에 넣는 구조로, GPU(Colab)에서 10 에폭만 돌렸는데도 테스트 정확도 98.88%가 나왔다
  • 틀린 112장을 따로 뽑아 예측 확신도(confidence)와 함께 시각화해보니, 사람이 봐도 헷갈리는 손글씨이거나 모델이 애매하게 확신한 경우가 대부분이었다. 정확도 숫자 뒤에 숨은 "어떤 데이터를 못 맞췄는지"를 직접 눈으로 보는 게 왜 중요한지 체감했다

10. 종합 프로젝트 — 혈액 세포 이미지 분류와 전이학습

이번 주 마지막은 현미경으로 찍은 혈액 도말 이미지에서 백혈구 4종(호산구·림프구·단핵구·호중구)을 분류하는 종합 프로젝트였다. 총 12,444장을 학습 7,965 / 검증 1,992 / 테스트 2,487장으로 나누고, 직접 설계한 SimpleCNN과 ImageNet으로 사전학습된 ResNet18, EfficientNet-B0(전이학습) 세 가지 모델을 동일한 조건에서 비교했다.

  • 전이학습은 이미 이미지의 기본 패턴을 학습한 모델의 마지막 출력층만 우리 데이터의 클래스 수에 맞게 바꿔 재학습하는 방식이다. 처음부터 학습하는 SimpleCNN보다 훨씬 빠르게 손실이 떨어질 거라 기대했는데, 실제 테스트 성능은 오히려 파라미터 39만 개짜리 SimpleCNN(F1 0.899)이 파라미터 1,100만 개인 ResNet18(F1 0.868)과 400만 개인 EfficientNet-B0(F1 0.880)를 앞섰다
  • 클래스별로 보면 호중구와 호산구, 단핵구와 호중구 사이의 혼동이 가장 많았는데, 형태가 비슷한 세포는 저해상도(128px)에서 색상·과립 차이가 미세해 구분이 어려웠던 것으로 보인다
  • ROC-AUC(macro, ovr) 기준으로는 세 모델 모두 0.96 이상으로 높게 나왔지만, 이는 클래스가 명확히 갈리는 림프구(AUC 1.0)가 평균을 끌어올린 영향이 컸다. 평균 지표 하나만 보지 않고 클래스별 지표를 같이 봐야 한다는 걸 다시 확인한 프로젝트였다
accuracy f1 roc_auc params cnn 0.8991 0.8991 0.9899 390,404 resnet 0.8649 0.8680 0.9639 11,178,564 efficientnet 0.8770 0.8802 0.9706 4,012,672

KPT 회고

Keep

지난주 가장 큰 숙제였던 "역전파와 학습 루프를 손에 익히기"를 손실 함수 → 그래디언트 → 역전파 → Autograd → 옵티마이저 순서로 하나씩 직접 구현하면서 제대로 풀었다. 예제 코드를 그대로 따라 치던 것과, 왜 그 코드가 그렇게 생겼는지 아는 것 사이의 차이를 확실히 느낀 한 주였다.

회귀·분류·이미지 분류 프로젝트를 각각 전처리부터 평가, 오류 분석까지 끝까지 돌려본 것도 좋았다. 특히 혈액 세포 프로젝트에서는 결과가 예상과 다르게 나왔을 때 "왜 그런지" 가설을 세우고 검증까지 시도해본 게 이번 주에서 가장 남는 경험이었다.

Problem

혈액 세포 프로젝트에서 전이학습 모델의 valid-test 격차 원인을 학습률 조정으로 검증해봤지만 가설이 틀렸다는 것만 확인했을 뿐, 진짜 원인은 아직 못 찾았다. 데이터 누수 가능성을 의심만 하고 직접 확인해보지는 못했다.

CNN 구조 자체를 설계하는 감각은 아직 부족하다. 채널 수, 커널 크기, 층 깊이를 왜 그렇게 정하는지는 예제를 따라 쓰는 수준이라, 내가 새로운 데이터를 받았을 때 처음부터 구조를 설계할 자신은 없다.

Try

혈액 세포 데이터에서 train/valid 사이에 유사 이미지가 섞여 있는지 이미지 해시나 유사도 비교로 직접 확인해본다. 그리고 Grad-CAM 같은 시각화 도구로 CNN이 실제로 세포의 어떤 부분을 보고 판단하는지 확인해보면서, "정확도가 높다 = 잘 배웠다"가 항상 맞는지도 검증해보고 싶다.

새롭게 발견한 것

Autograd는 계산 그래프를 기록해뒀다가 거꾸로 훑으며 연쇄법칙을 자동 적용하는 것이다. back_propa 실습에서 손으로 짠 delta = (a - y_true) * sigmoid_derivative(z) 같은 계산을, PyTorch는 requires_grad_(True).backward() 두 줄로 대신 해준다는 걸 눈으로 확인했다. 편하게 쓰던 기능이 실은 손으로 짠 것과 똑같은 계산을 하고 있었다는 게 명확해졌다.

크고 사전학습된 모델이 항상 이기는 건 아니다. 혈액 세포 분류에서 파라미터가 가장 적고 사전학습도 없는 SimpleCNN이 ResNet18·EfficientNet-B0보다 테스트 성능이 좋았다. 사전학습 모델은 validation에서 거의 만점(99~100%)에 가까웠는데도 테스트에서는 오히려 뒤처졌는데, 데이터 규모나 특성에 따라 "무거운 모델 = 좋은 모델"이 아닐 수 있다는 걸 숫자로 확인했다.

Optuna는 하이퍼파라미터 탐색을 코드로 자동화해준다. 은닉층 크기, 드롭아웃, 학습률, 배치 크기를 사람이 하나씩 바꿔가며 실험하는 대신 탐색 범위만 정해주면 20번의 시도 중 최적값을 찾아준다. 지금까지 grid search나 random search로 하던 걸 더 똑똑하게 대신해주는 도구라는 걸 알게 됐다.

트러블슈팅

문제 : Validation 정확도가 거의 만점인 모델이 Test에서는 크게 떨어졌다

혈액 세포 분류에서 ResNet18과 EfficientNet-B0는 validation 정확도가 각각 99.40%, 100%까지 나왔는데, 정작 테스트 정확도는 86.49%, 87.70%로 10%p 넘게 떨어졌다. 반면 사전학습 없이 처음부터 학습한 SimpleCNN은 validation 91.16% → 테스트 89.91%로 격차가 1.26%p에 불과했다.

best_valid_acc test_acc 격차 cnn 0.9116 0.8991 1.26%p resnet 0.9940 0.8649 12.91%p efficientnet 1.0000 0.8770 12.30%p

원본 데이터가 소수의 원본 이미지를 증강해서 만들어졌을 가능성이 있어, train/valid 사이에 비슷한 이미지가 섞였을 수 있다고 의심했다. 사전학습된 모델일수록 그 패턴을 더 빠르고 강하게 외워버려 격차가 커졌을 거라는 가설을 세웠다.

문제 : 학습률을 낮추면 격차가 줄어들 줄 알았는데 아니었다

위 가설을 검증하려고, "사전학습된 backbone에 비해 학습률(1e-3)이 너무 커서 가중치가 급하게 그 패턴에 맞춰졌다"는 두 번째 가설을 세우고 학습률을 1e-4로 낮춰 재학습했다.

-- lr=1e-3 (기존) -- lr=1e-4 (재학습) resnet acc 0.8649 resnet acc 0.7805 (더 나빠짐) gap 12.91%p gap 21.80%p efficientnet acc 0.8770 efficientnet acc 0.8705 (거의 그대로) gap 12.30%p gap 12.95%p

결과는 기대와 반대였다. ResNet18은 오히려 테스트 정확도가 더 떨어지고 격차도 커졌고, EfficientNet-B0는 별 차이가 없었다. 학습률 가설은 틀렸다는 것만 확인했고, 진짜 원인은 다음 주 Try에서 데이터 누수 여부를 직접 들여다보며 다시 찾아보기로 했다. 예상이 틀렸다는 것도 결과라는 걸 이번에 배웠다.

다음 주 목표

  • 혈액 세포 프로젝트의 train/valid 데이터 누수 가능성을 이미지 유사도 비교로 직접 확인해보기
  • Grad-CAM 등으로 CNN이 실제로 어느 부분을 보고 판단하는지 시각화해보기
  • CNN 구조(채널 수, 커널 크기, 층 깊이)를 왜 그렇게 정하는지 감을 잡기 위해 직접 바꿔가며 실험해보기
지난주 "손에 안 붙는다"고 적었던 역전파를, 이번 주엔 numpy로 직접 짜보고 나서야 온전히 내 것이 됐다.
정확도 100%짜리 결과를 보고 좋아하기보다 의심부터 해보는 습관이, 이번 주 가장 큰 수확이었다.