hmy751.dev

DistilBERT 파인튜닝, accuracy만으로 실험을 읽지 않은 이유

2026년 2월 3일에 쓴 1년 회고에서 AI를 직접 배워보겠다고 적었습니다. 그때의 관심은 단순히 AI 도구를 더 잘 쓰는 것에 머물지 않았습니다. 모델이 어떤 조건에서 달라지고, 그 변화를 어떤 기준으로 읽을 수 있는지 직접 다뤄보고 싶었습니다.

PI Lab 첫 스프린트에서 그 관심은 바로 실험으로 바뀌었습니다. Yelp 리뷰를 1점부터 5점까지 분류하는 과제였고, 사전학습된 DistilBERT를 파인튜닝해서 정확도를 올려야 했습니다.

처음에는 정확도를 올리는 과제처럼 보였습니다. 데이터를 더 넣을지, epoch을 늘릴지, learning rate를 바꿀지 고르면 될 것 같았습니다. 그런데 첫 스프린트에서 데이터를 나누고, 텍스트를 벡터로 만들고, 선형 모델과 손실함수, 경사하강법을 지나 DistilBERT 파인튜닝까지 이어서 보니 질문이 조금 달라졌습니다.

정확도를 얼마나 올렸는가보다 먼저, 그 변화가 무엇 때문에 생겼는지 읽을 수 있어야 했습니다.

손실함수를 실험 언어로 다시 잡았습니다

첫 페어에서는 개념 설명과 실습을 번갈아 진행했습니다. 이 과정에서 멘토가 손실함수를 직접 설명해보라고 했습니다. 그때 얻은 자각은 "몰랐다"보다 "실험 전에 이 개념을 더 정확한 단위로 잡고 들어가야 한다"에 가까웠습니다.

개발에서도 비슷한 일이 있습니다. 어떤 API나 라이브러리를 써본 것과, 그것이 어떤 문제를 풀기 위해 있고 어떤 조건에서 다르게 동작하는지 설명할 수 있는 것은 다릅니다. 모델 실험에서도 마찬가지였습니다. 교차 엔트로피를 "분류에 쓰는 손실함수" 정도로 알고 있으면 코드는 돌릴 수 있지만, 뒤에서 accuracy와 loss가 서로 다른 신호를 줄 때 해석이 흔들립니다.

그래서 손실함수를 다시 정리했습니다. accuracy는 맞힌 개수의 비율이라 사람이 결과를 보기에는 편합니다. 하지만 학습 중 가중치를 조정하려면 연속적으로 변하는 신호가 필요합니다. 정답 확률이 0.22에서 0.40으로 올라가도 최종 예측이 여전히 틀렸다면 accuracy는 변하지 않습니다. 반면 교차 엔트로피는 그 변화를 손실값 감소로 남깁니다.

교차 엔트로피는 정답 클래스에 부여한 확률에 -log를 취합니다. 정답 확률이 0.9이면 손실은 약 0.1로 작고, 0.1이면 약 2.3으로 커집니다. 처음에는 로그가 붙으면 잘 맞춘 쪽의 값이 커질 것처럼 느껴졌는데, 실제 관계는 반대였습니다. 잘 맞출수록 손실은 작아지고, 못 맞출수록 손실은 크게 커집니다. 이 구조 덕분에 모델은 크게 틀린 예측을 더 강하게 교정합니다.

여기서 train_loss, eval_loss, gap도 같은 흐름으로 정리됐습니다. train_loss는 학습 데이터에 대한 손실이고, eval_loss는 학습에 쓰지 않은 데이터에 대한 손실입니다. gapeval_loss - train_loss로 계산했고, train/eval 손실이 어느 방향으로 벌어지는지 보기 위한 보조 신호로 썼습니다. 데이터 수, epoch, learning rate, batch size가 우리가 바꾸는 조건이라면, accuracy와 loss 계열 지표는 그 조건 변화가 모델에 어떤 영향을 줬는지 읽는 신호였습니다. 이 둘을 섞으면 결과가 좋아져도 무엇 때문에 좋아졌는지 설명하기 어려웠습니다.

실험 조건이 먼저 보여야 했습니다

이 기준을 잡고 나서 성능 개선 실험을 설계했습니다. 여기서 한 번 멈췄습니다. 노트북에 바로 코드를 쓰기 시작하면 빠르긴 하지만, 어떤 실험에서 무엇이 바뀌었는지가 흐려질 수 있었습니다.

평소 프론트엔드 작업이었다면 반복되는 코드를 함수로 묶고, 설정 객체로 빼고, 호출부를 깔끔하게 만드는 쪽을 먼저 생각했을 것입니다. 코드의 재사용성 자체를 낮게 본 것은 아닙니다. 오히려 실험 원칙에 공감했기 때문에, 짧은 스프린트 안에서 어떤 것을 더 먼저 드러내야 할지 정해야 했습니다.

아직 모델 학습 실험이 익숙하지 않은 상태에서, 매번 사전학습된 모델에서 새로 시작하기, 한 번에 하나의 변수만 바꾸기, 같은 환경에서 실행하기, accuracy뿐 아니라 loss까지 같이 보기 같은 원칙을 동시에 지켜야 했습니다. 여기에 추상화를 먼저 얹으면, 내가 지금 무엇을 고정했고 무엇을 바꿨는지 확인하는 비용이 커질 수 있었습니다. 그래서 처음에는 코드를 조금 중복되게 두더라도 실험 조건이 노트북 위에서 바로 보이게 하는 쪽을 택했습니다.

그래서 원칙을 먼저 세웠습니다.

  • 매 실험은 사전학습된 모델에서 새로 시작합니다.
  • 한 번에 하나의 변수만 바꿉니다.
  • 같은 환경에서 실행합니다.
  • 정확도만 보지 않고 eval_loss, train_loss, 두 값의 차이도 같이 봅니다.
  • 코드는 조금 중복되더라도, 실험표에서 바뀐 조건이 바로 보이게 둡니다.

이 결정은 코드 품질을 포기한 것이 아니라, 디버깅 대상을 명확히 하는 결정에 가까웠습니다. 실험을 읽을 수 있게 만들지 않으면, 이후에 나온 숫자는 개선 결과가 아니라 해석하기 어려운 로그가 됩니다.

accuracy만 보면 실험 #4를 잘못 읽습니다

이 원칙대로 한 번에 하나의 조건만 바꾸며 실험했습니다. gapeval_loss - train_loss로 계산했습니다.

# 변경 설정 accuracy eval_loss train_loss gap
0 baseline 10k/ep1/lr5e-5/b16 60.15% 0.9165 1.0433 -0.127
1 데이터 증가 20k/ep1/lr5e-5/b16 61.15% 0.8658 0.9978 -0.132
2 epoch 증가 20k/ep2/lr5e-5/b16 64.15% 0.8558 0.8673 -0.012
3 lr 낮춤 20k/ep2/lr2e-5/b16 61.55% 0.8766 0.9076 -0.031
4 lr 높임 20k/ep2/lr1e-4/b16 62.75% 0.8742 0.9148 -0.041
5 batch 줄임 20k/ep2/lr5e-5/b8 61.95% 0.8823 0.8709 +0.011

가장 좋았던 건 #2였습니다. 데이터를 20k로 늘리고 epoch을 2로 올리자 정확도가 64.15%까지 올라갔고, gap도 거의 0에 가까워졌습니다. 여기까지는 해석이 비교적 쉬웠습니다. 데이터만 늘렸을 때보다, 늘어난 데이터를 한 번 더 보는 쪽이 더 효과적이었습니다.

문제는 #4였습니다. 학습률을 5e-5에서 1e-4로 올렸는데 정확도는 #2보다 떨어졌고, gap은 다시 음수 방향으로 갔습니다. 보폭을 키웠는데 더 빨리 좋아진 것도 아니고, gap만 보면 오히려 덜 학습된 것처럼 보였습니다.

여기서 accuracy만 봤다면 "#2보다 낮으니 실패" 정도로 끝났을 것입니다. 하지만 loss를 같이 보니 질문이 달라졌습니다. 왜 더 큰 보폭이 과적합이 아니라 과소적합처럼 보였을까요.

두 가설을 세웠지만, 답은 둘 다 아니었습니다

처음에는 두 가지로 나눠 생각했습니다.

하나는 사전학습 가중치 훼손이었습니다. DistilBERT는 이미 대량의 텍스트로 언어 표현을 학습한 모델이고, 파인튜닝은 그 가중치를 과제에 맞게 조정하는 과정입니다. lr=1e-4가 너무 크면 미세 조정이 아니라 기존 가중치를 크게 흔들어버릴 수 있습니다. 그러면 모델이 20k 데이터만 보고 별점 분류를 새로 배우는 상태가 되고, 과소적합처럼 보일 수 있습니다.

다른 하나는 진동이었습니다. 보폭이 커서 최적점 근처에 안착하지 못하고 지나쳤다가 돌아오는 상태라면, epoch을 더 늘렸을 때 결국 수렴할 수도 있습니다.

두 가설은 같은 추가 실험으로 갈라볼 수 있었습니다. lr=1e-4를 유지하고 epoch을 4까지 늘렸습니다. 결과는 예상한 두 방향 중 어느 쪽도 아니었습니다.

실험 설정 accuracy eval_loss train_loss gap
B 20k/ep4/lr1e-4/b16 61.95% 1.4053 0.6586 +0.747

train_loss는 0.6586까지 내려갔습니다. 적어도 모델이 훈련 데이터를 전혀 학습하지 못하는 상태는 아니었습니다.

반대로 eval_loss는 계속 올라가 1.4053까지 갔습니다. 최적점 주변을 오르락내리락한 것이 아니라, 평가 데이터에서는 점점 나빠지고 있었습니다. 진동도 아니었습니다.

이 실험에서는 과적합으로 보는 쪽이 더 맞았습니다. 큰 학습률이 훈련 데이터에는 더 강하게 맞춰졌지만, 평가 데이터에 일반화되는 방향은 아니었습니다. 중요한 점은 정확도만 보면 이 흐름이 늦게 보인다는 것입니다. 모델은 일부 샘플을 더 잘 맞혔지만, 틀린 샘플에 대해서는 더 자신 있게 틀렸습니다. 그래서 accuracy 한 줄과 loss 추이가 서로 다른 신호를 줬습니다.

여기서 실험표를 먼저 만든 일이 의미가 생겼습니다. 바꾼 변수는 하나였고, 확인할 지표도 정해져 있었습니다. 그래서 "lr을 올렸더니 성능이 떨어졌다"가 아니라, "lr을 올렸을 때 accuracy와 loss가 서로 다른 방향의 신호를 준다"로 문제를 다시 쪼갤 수 있었습니다.

작은 learning rate도 안전장치는 아니었습니다

#2가 가장 균형이 좋아 보였기 때문에, 그 체크포인트에서 더 작게 이어서 학습하는 실험도 진행했습니다. 직관적으로는 안전해 보였습니다. 좋은 지점에서 보폭만 줄이면 조금 더 정교하게 움직일 수 있을 것 같았습니다.

결과는 반대였습니다.

실험 변경 accuracy eval_loss train_loss gap
A #2에서 lr1e-5로 추가학습 62.45% 1.1501 0.4265 +0.724
A-2 #2에서 lr2e-5로 추가학습 62.20% 1.0386 0.4911 +0.548
A-3 #2에서 라벨 스무딩 추가 62.10% 1.1862 0.7478 +0.438

모두 #2를 넘지 못했습니다. 특히 lr=1e-5는 보폭이 가장 작았는데 gap은 가장 크게 벌어졌습니다. 작은 learning rate는 과적합을 막아주는 안전장치가 아니었습니다. 이미 균형점에 가까운 체크포인트라면, 추가 학습 자체가 모델을 훈련 데이터 쪽으로 더 밀어붙일 수 있었습니다.

이 실험에서 fine-tuning이라는 말을 조금 다르게 보게 됐습니다. 단어만 보면 늘 미세하게 좋아지는 작업처럼 느껴지지만, 실제로는 사전학습된 표현과 새 데이터 사이의 균형을 다시 잡는 일입니다. 균형점 근처에서는 더 학습하는 것보다 멈추는 기준이 더 중요할 수 있습니다.

다음 실험에 남긴 규칙

첫 스프린트에서 가장 크게 남은 건 특정 하이퍼파라미터 값이 아니었습니다. lr=5e-5가 항상 좋다거나, epoch 2가 정답이라는 이야기도 아닙니다. 그 값들은 이번 데이터와 조건에서만 의미가 있습니다.

대신 다음 실험에 가져갈 규칙이 생겼습니다.

  • 실험 코드는 깔끔함보다 해석 가능성을 먼저 둡니다.
  • accuracy와 loss를 같이 봅니다.
  • 가설은 두 칸으로 닫지 않고, 둘 다 아닐 가능성을 남겨둡니다.
  • 좋은 체크포인트에서 이어서 학습할 때는 "더 좋아질 것"이 아니라 "언제 멈출 것인가"를 먼저 정합니다.

AI/ML을 배우기 시작하면 새 모델과 기법을 따라잡는 일도 필요합니다. 하지만 첫 스프린트에서 더 오래 남은 것은 이름보다 실험을 설계하고 읽는 기준이었습니다. 변수 하나를 바꾸고, 그 결과를 여러 지표로 읽고, 처음 세운 가설이 좁았다는 걸 확인하는 것. 이 기준은 이후 스프린트에서 RAG나 멀티모달 실험을 볼 때도 계속 남는 출발점이 됐습니다.