목차
- 들어가며
- 1장. 학습을 좌우하는 5개 하이퍼파라미터
- 2장. 잠깐 복습 — QLoRA 5개 하이퍼파라미터
- 3장. 학습은 실제로 어떻게 일어나는가 — 학습 4단계와 backpropagation
- 4장. SFTTrainer로 실제 학습 돌리기 — 그리고 wandb 곡선 읽기
- 5장. 오버피팅 관찰기 — train loss가 좋아 보일 때가 더 위험하다
- 6장. LLM은 "숫자를 빼지 않는다" — cross-entropy와 128k 확률분포
- 7장. 최종 평가와 전체 성적표 — 작은 모델이 프론티어를 이긴다
- 마무리 — 파인튜닝은 만능인가
들어가며
지난 글(글 15)에서는 실제 모델 학습을 거의 하지 않았다.
대신 모델 학습을 시작하기 전에 알아야 할 것들을 파악해보는 시간을 가졌다.
- 왜 3B(30억) 파라미터를 그냥 학습할 수 없는지
- LoRA와 양자화(Quantization)가 각각 무엇을 해결하는지
- 그리고 파인튜닝 전 base 모델(라마 3.2)이 가격 예측을 얼마나 못하는지
이번 글은 드디어 그 준비물을 들고 진짜 학습에 들어간다.
Day 3~4에서 Llama 3.2 3B를 내 Google Colab에서 QLoRA로 미세조정하고, Day 5에서 그 결과를 평가한다.
지난 글 15 도입부에서 스포일러를 하나 풀어놨었다.
"Week 6에서 잘 동작하지 않던 파인튜닝이(GPT 4.1 nano를 OpenAI API를 써서 파인튜닝 했었음)
이번엔 프론티어 모델을 전부 제치고 1등을 한다"고.
이번 글이 그 회수다.
3B(30억 파라미터)짜리 오픈소스 모델이, 그것도 4비트 양자화(Quantization)으로 짓눌린 채로,
GPT-5.1까지 제치고 가격 예측 task에서 성능 1위를 찍는다.
그런데 결과 뿐 아니라, 나는 그 과정에서 지금껏 미지의 세계에 가까웠던 모델 학습에 관해 궁금한 게 훨씬 많았다.
예를 들면,
- 학습을 "돌린다"는 게 코드로는 사실상 train() 한 줄인데, 그 안에서 대체 무슨 일이 일어나는가?
- 학습률(Learning rate)·배치(batch size)·에폭(epoch) 같은 학습을 위한 하이퍼 파라미터들 어떤 기준으로 정하는가? 아무 값이나 넣으면 안 되나?
- LLM은 "89달러가 정답인데 99달러라고 했으니 10만큼 틀렸다"는 식으로 학습하는 걸까?
=> 이번 글은 이 질문들을 하나씩 풀면서, 마지막에 모델간의 성능 비교로 마무리한다.
코드는 짧지만 그 밑에 깔린 모델학습에 대한 개념이 이번 주의 진짜 핵심이다.
1. 학습을 좌우하는 5개 하이퍼파라미터
학습 코드를 열면 맨 위에 상수들이 잔뜩 선언돼 있다.
그중 "학습이 어떻게 진행될지"를 정하는 5개의 핵심 하이퍼파라미터가 있다.
강사는 이걸 산을 내려가는 사람에 빗대 설명하는데, 이 비유가 다섯 개를 한 줄에 꿰어줘서 나도 그대로 정리해본다.
(loss라는 골짜기 바닥을 향해 산을 내려가는 게 학습이다.)
실제 학습 노트북의 상수는 이렇게 생겼다.
EPOCHS = 1 if LITE_MODE else 3
BATCH_SIZE = 32 if LITE_MODE else 256
GRADIENT_ACCUMULATION_STEPS = 1
LEARNING_RATE = 1e-4
LR_SCHEDULER_TYPE = 'cosine'
WARMUP_RATIO = 0.01
OPTIMIZER = "paged_adamw_32bit"
LITE_MODE는 내가 무료 T4 GPU에서 가볍게 돌릴지(lite, 데이터 2만 개), 유료 A100에서 제대로 돌릴지(full, 80만 개)를 선택할 수 있게 해준다. 나는 lite로 돌렸다. 위 값들을 하나씩 풀어보자.
1. epochs — 데이터를 몇 바퀴 돌릴지
- 전체 데이터를 몇 번 반복 학습할지다. lite는 1바퀴, full은 3바퀴.
- 처음엔 이게 이상했다. 같은 데이터를 왜 학습을 위해 또 넣지?
- 답을 외운 학생한테 같은 문제집을 또 풀리는 것 아닌가?
- 강사의 설명은 이랬다. 매 epoch마다 데이터를 섞어(shuffle) 배치를 새로 구성하기 때문에, 완전히 같은 입력이 아니다
- (강사의 비유) 같은 학생 1000명을 다시 한 번 전부 만나긴 하는데, 줄 세우는 순서와 조 편성이 바뀐다.
- 셔플의 예시를 좀 더 설명하자면 아래와 같다
- epoch 1에서는 데이터 순서가 A, B, C, D
- epoch 2에서는 C, A, D, B이런 식으로 순서가 바뀔 수 있음. 그리고 배치로 나누기 떄문에 첫 epoch의 첫 배치와 다음 epoch의 첫 배치 구성이 달라질 수 있음
=> 그리고 애초에 한 바퀴로는 파라미터가 조금씩만 움직여서 충분히 안 배운다. 다만 너무 많이 돌리면 문제집을 통째로 외워버리는 과적합이 온다(이건 5장에서 실제로 관찰한다). 산 비유로는 "산을 몇 번 반복해서 답사할지"다.
2. batch size — 한 번에 몇 개를 보고 판단할지
- 한 스텝에 데이터를 몇 개 묶어서 볼지다. lite는 32, full은 256.
- 예를 들어 데이터 1,000개가 있고 batch size가 10이면:
- 한 번에 10개씩 넣어서 학습
- 총 100번에 나눠서 1 epoch를 끝냄
- 데이터 전체를 한 번에 넣으면 좋을 것 같지만, 보통은 GPU 메모리가 부족해서 그렇게 못한다.
- 그래서 현실적으로는
- 몇 개씩 묶어서 넣고 -> loss 계산하고 -> gradient 계산하고 -> 업데이트하고 -> 다시 다음 묶음을 넣는다
- Batch Size를 크게 잡으면 여러 샘플의 평균으로 방향을 정하니 안정적이지만, 한 번 한 번의 업데이트가 너무 “평균적”이어서 거칠게 탐색을 덜 할 수 있음.
- 작게 잡으면 메모리를 덜 먹고 작은 GPU에서도 학습이 가능하며 때로는 일반화에 유리하다.
=> (강사의 비유) 작은 batch는 여러 사람의 불완전한 의견을 들으며 움직이는 것, 큰 batch는 아주 많은 사람의 평균 의견으로 움직이는 것. 평균 의견은 안정적이지만 가끔 너무 보수적일 수 있다.
=> 배치 크기를 보통 2의 거듭제곱(32, 256…)으로 잡는 건, GPU가 딱 떨어지는 묶음 단위를 좋아하기 때문이다.
요즘은 엄격한 규칙은 아니고 관습에 가깝다. 산 비유로는 "한 번에 주변을 얼마나 넓게 보고 판단할지".
3. learning rate — 한 걸음의 보폭
- 가중치를 한번 업데이트할 때, 얼마나 크게 움직일지 정하는 값임
- 모델은 loss를 줄이는 방향으로 움직이는데,learning rate는 그 이동 폭을 정함.
- learning rate가 크다 → 큰 걸음
- learning rate가 작다 → 작은 걸음
- 산 내려가기 비유로 한다면, 한 스텝에서 파라미터를 얼마나 크게 움직일지.
- 산을 내려가는 걸 생각해보자.
- 정상적인 경우: 조금씩 내려가면 바닥에 가까워짐
- 그런데 발걸음이 너무 크면:
- 바닥을 지나쳐 반대편으로 튐
- 또 다시 튐
- 계속 오락가락
- 심하면 발산
- 즉, 좋은 지점 근처에서도 너무 크게 움직여서 안정적으로 못 멈출 수 있음
- 반대로 발걸음이 너무 작다면
- 학습이 너무 느림
- 좋아지긴 하는데 너무 오래 걸림
- 평평하거나 애매한 구간에서 답답하게 머물 수 있음
- 정리를 하면
- 높은 learning rate는 때때로 좁은 지역 해(local minimum)에서 벗어나게 도와줄 수 있음
- 하지만 너무 크면 아예 좋은 곳에 정착을 못 함
- 산을 내려가는 걸 생각해보자.
- 보통 1e-4(0.0001)에서 시작해 10배 단위로 탐색한다.
=> 이렇게 발걸음(learning rate) 크기에 따라 trade off 가 있다 작으면 안정적이지만 답답하고, 크면 빠르지만 위험하다.
=> 그래서 학습률(learning rate)을 처음부터 끝까지 고정하지 않고 시간에 따라 바꾸는 scheduler를 쓴다. 코드의 'cosine'이 그거다. 초반엔 크게 시작해서 빠르게 배우고, 뒤로 갈수록 작게 줄여서 섬세하게 미세 조정한다.
4. gradient accumulation — 메모리 우회용
이건 이름 때문에 오해하기 쉽다. 나도 처음엔 "속도 높이는 기법인가?" 했는데, 속도가 아니라 메모리 우회가 목적이다.
오히려 더 느려질 수도 있다.
보통 학습은 아래 4단계를 거친다.
- forward pass: 입력 넣고 예측
- loss calculation: 얼마나 틀렸는지 계산
- backward pass: 어느 방향으로 고쳐야 할지 gradient 계산
- take a step: optimizer가 실제로 가중치 업데이트
gradient accumulation은 GPU 메모리가 부족해서, 큰 batch size를 한 번에 못 넣을 때 쓰는 우회 방법이라 할 수 있다.
예를 들어 원래는 batch 32로 학습하고 싶은데
내 GPU 메모리가 batch 8밖에 못 버틴다고 해보자.
그러면:
- batch 8 넣고 gradient 계산
- 또 batch 8 넣고 gradient 계산을 누적
- 또 batch 8
- 또 batch 8
- 그다음에 한 번만 step 수행
이렇게 하면 실질적으로 batch 32처럼 동작하게 만들 수 있다.
쉽게 말하면, 작은 배치를 여러 번 처리하면서 gradient를 모아뒀다가, 한 번에 업데이트한다.
그러면 GPU 메모리는 작은 배치만큼만 쓰면서, 효과는 큰 배치처럼 낼수 있다.
- micro batch size = GPU에 실제로 한 번 넣는 크기
- gradient accumulation steps = 몇 번 모을지
- effective batch size = 둘을 곱한 값
effective batch size = micro batch size × gradient accumulation steps
# 예: 8 × 4 = 32
이번 코드에선 GRADIENT_ACCUMULATION_STEPS = 1이라 실제로는 쓰지 않았지만(배치 32/256이 메모리에 들어갔으니까),
"메모리가 부족할 때 큰 배치를 흉내 내는 카드"로 알아두면 된다.
산 비유로는 "좁은 시야로 여러 번 본 뒤 종합해서 한 걸음 떼기".
5. optimizer — 걸음걸이
gradient가 방향을, learning rate가 보폭을 정한다면, optimizer는 어떤 걸음걸이로 움직일지를 정한다.
코드의 paged_adamw_32bit(AdamW 계열)
gradient를 받아서
실제로 가중치를 어떻게 바꿀지 결정하는 알고리즘라고 할 수 있다.
즉,
- backward pass가 “어느 방향으로 가야 할지” 알려주고
- optimizer가 “그 방향으로 실제로 어떻게 움직일지” 결정한다
optimizer의 종류인 SGD와 ADAM 간 차이를 비교하면 아래와 같다.
SGD는 가장 단순한 방식으로
- loss 줄이는 방향으로
- learning rate만큼
- 한 걸음 이동
그래서 이해는 쉽지만, 실제로는 좀 투박할 수 있다.
Adam은 SGD보다 똑똑하게 움직이려는 optimizer다
직관적으로는
- 최근 gradient의 평균도 보고
- 변화량의 크기도 고려해서
- 파라미터마다 업데이트를 다르게 조절한다
그래서 보통:
- 더 빨리
- 더 안정적으로
- 튜닝이 쉬운 편이야
딥러닝, 특히 LLM fine-tuning에서는
SGD보다 Adam/AdamW를 훨씬 자주 볼수 있다고 한다
=> (강사의 비유) SGD는 지금 발밑 기울기만 보고 걷는 사람. "지금 오른쪽 아래네? 그쪽으로 가자."
Adam은 최근 몇 걸음의 경향도 보고, 이 길이 얼마나 울퉁불퉁했는지도 보면서 걷는 사람. "최근 계속 이쪽이 맞았고, 여기선 흔들림이 컸으니 조금 조심해서 가자."
=> Adam/AdamW은 파라미터마다 보폭을 적응적으로 조절해서 LLM 파인튜닝의 사실상 표준이다. (노트북엔 "Adam은 이전 gradient들의 이동평균을 저장해서 잘 수렴하지만, 그만큼 모델 파라미터 수만큼의 메모리를 더 쓴다"는 강사 메모도 붙어 있다.)
기타. loss 와 gradient의 차이
loss 와 gradient에 대해 조금 헷갈렸어서 추가적으로 공부한 내용은 아래와 같다.
1. loss 계산
loss는
- 모델이 얼마나 틀렸는지 숫자로 표현한 값
예를 들어:
- 정답: “고양이”
- 모델 예측: “개”
그러면 틀렸으니까 loss가 큼.
반대로:
- 정답에 아주 가깝게 맞추면
- loss가 작아짐.
즉 loss는
현재 모델 상태가 얼마나 나쁜지 평가하는 점수
2. gradient 계산
gradient는
loss를 줄이려면 각 파라미터를 어느 방향으로 얼마나 바꿔야 하는지 알려주는 값
쉽게 말하면:
- loss: “지금 틀린 정도”
- gradient: “그래서 어디로 얼마나 고치면 되는지”
예를 들어 비유하면:
- loss = 시험 점수표에서 “지금 60점”
- gradient = “어느 문제 유형을 어떻게 고치면 점수가 오를지”
3. loss와 gradient의 차이
정리하면:
- loss: 결과 평가
- gradient: 수정 방향 정보
순서도 보통 이래:
- 입력 넣음
- 예측 나옴
- loss 계산
- 그 loss를 기준으로 gradient 계산
- optimizer가 gradient를 이용해 파라미터 수정
즉 gradient는 그냥 갑자기 생기는 게 아니라,
loss를 미분해서 얻는 값
지금까지 설명한 5개의 학습 관련 하이퍼파라미터를 한 표로 정리하면 이렇다.
| 하이퍼파라미터 | 쉽게 말하면 | 이번 코드 값(lite / full) |
| epochs | 몇 바퀴 학습할지 | 1 / 3 |
| batch size | 한 번에 먹는 양 | 32 / 256 |
| learning rate | 발걸음 크기 | 0.0001 (+ cosine scheduler) |
| gradient accumulation | 메모리 부족 우회 | 1 (미사용) |
| optimizer | 업데이트 방식 | paged_adamw_32bit |
느낀 점
- 이전 회사에서 모델 부서와 협업할떄, 노코드 llmops 툴로 모델 학습 쪽 화면을 볼때, 혹은 모델 부서의 자료들을 볼 떄 저런 다양한 모델학습의 하이퍼 파라미터들이 완전히 이해가진 않았었다 (그때그때 용어들을 AI에게 물어보긴 했어도)
- 이번에 이렇게 하나하나 더 깊게 뜯어보면서 좀 더 관련 이해를 높일 수 있던게 의미가 있었고, 왜 이런 하이퍼파라미터들을 모델 학습과정에서 조정하는지를 알게된게 흥미로웠다
2. 잠깐 복습 — QLoRA 5개 하이퍼파라미터
앞의 5개(epochs·batch size·learning rate·gradient accumulation·optimizer)가 "어떤 모델을 학습하든 공통으로 쓰는" 하이퍼파라미터라면, 이번 장의 5개는 QLoRA라는 학습 방식 자체를 정의하는 하이퍼파라미터다.
이 5개는 글 15에서 이미 원리를 다뤘다(LoRA·양자화 편).
그래서 여기선 개념을 처음부터 다시 세우기보다, 실제 학습 코드의 LoraConfig가 이 값들을 어떻게 받는지 연결하면서 짧게 복습하고,
대신 지난 글에서 얕게 넘어갔던 dropout만 좀 더 깊게 파본다.
실제 코드는 이렇게 생겼다.
LORA_R = 32 if LITE_MODE else 256
LORA_ALPHA = LORA_R * 2 # 항상 r의 2배
ATTENTION_LAYERS = ["q_proj", "v_proj", "k_proj", "o_proj"]
MLP_LAYERS = ["gate_proj", "up_proj", "down_proj"]
TARGET_MODULES = ATTENTION_LAYERS if LITE_MODE else ATTENTION_LAYERS + MLP_LAYERS
LORA_DROPOUT = 0.1
QUANT_4_BIT = True
lora_parameters = LoraConfig(
lora_alpha=LORA_ALPHA,
lora_dropout=LORA_DROPOUT,
r=LORA_R,
bias="none",
task_type="CAUSAL_LM",
target_modules=TARGET_MODULES,
)
이 LoraConfig 한 덩어리에 5개 중 4개(target modules, r, alpha, dropout)가 다 들어 있다.
나머지 하나(quantization)는 글 15에서 본 BitsAndBytesConfig 쪽에서 따로 처리한다.
하나씩 짚어보자.
1. target modules — 어느 레이어에 어댑터를 붙일지
신경망의 어느 층을 LoRA로 건드릴지다.
강사 말로는 어텐션 레이어부터 타겟팅하는 경우가 많다.
코드를 보면 lite는 어텐션 4개(q_proj, v_proj, k_proj, o_proj)만, full은 거기에 MLP 3개(gate_proj, up_proj, down_proj)까지 붙인다. 즉 full이 훨씬 넓게, 많은 층에 어댑터를 붙여 더 공격적으로 학습한다는 뜻이다.
=> 이게 나중에 lite와 full의 성능 차이로 이어진다.
2. r (rank) — 어댑터 행렬의 차원
LoRA 행렬의 rank, 즉 차원 수다. lite는 32, full은 256.
r이 클수록 어댑터의 표현력이 커지지만(더 많이 배울 수 있지만), 그만큼 파라미터가 늘고 과적합 위험도 커진다.
lite와 full이 8배(32→256) 차이 나는 게, full이 기본 base 모델에 "더 큰 보정 장치"(로라 어댑터) 를 다는 것과 같다.
3. alpha — 어댑터를 얼마나 세게 반영할지
LoRA가 만든 변화(BA)를 원본 모델에 더할 때 얼마나 스케일업할지다.
코드에선 LORA_ALPHA = LORA_R * 2, 즉 항상 r의 2배로 잡는다(lite=64, full=512).
강사도 "대부분 2배가 일반적"이라고 한다.
4. quantization — 정밀도를 낮춰 메모리 확보
베이스 모델의 가중치 정밀도를 낮춰(여기선 4비트) 메모리를 줄이는 것. 코드의 QUANT_4_BIT = True가 이걸 켠다.
이게 QLoRA의 "Q"이고, 글 15에서 6.4GB (16비트)→ 2.2GB(4비트 양자화)로 줄어드는 걸 실측했던 그 부분이다.
(핵심은 베이스 모델만 양자화하고, LoRA 어댑터 행렬은 그대로 둔다는 것.)
5. dropout — LoRA가 데이터에 너무 딱 붙지 않게 막는 브레이크
여기가 이번 장에서 새로 판 부분이다.
강사 메모엔 그냥 "10% 주로 사용, 일반화 가능성↑, 훈련시간↑"으로만 적혀 있었는데, 이게 정확히 무슨 뜻인지 헷갈려서 더 파봤다.
1) dropout이 어디에 걸리나?
LoRA는 원본 출력 Wx에 보정 BAx를 더한다(Wx + BAx).
dropout은 여기서 LoRA가 보는 입력 일부를 학습 중 랜덤하게 꺼버린다(Wx + BA(dropout(x)) 느낌).
중요한 건 원본 모델 본체를 끄는 게 아니라 LoRA 쪽 보정 경로에만 건다는 점이다.
=> 그래서 기본 모델의 원래 능력은 그대로 유지되고, LoRA가 특정 feature에만 과하게 맞춰지는 것만 막는다.
2) 왜 일반화가 좋아지나?
dropout이 없으면 LoRA가 학습 데이터를 아주 빠르게 "외워버린다".
특히 데이터가 적거나 비슷한 패턴만 많으면 "이 상황엔 무조건 이 표현"처럼 좁게 적응한다.
dropout을 넣으면 매번 일부 정보 없이도 버텨야 하니, 특정 신호 하나에 의존하지 못하고 더 분산된 방식으로 배운다.
(강사의 비유) dropout 없음 = 시험 문제를 통째로 외워버리기 쉬움. dropout 있음 = 개념을 더 넓게 익히도록 강제.
3) "훈련시간이 높아진다"는 말의 진짜 의미.
나는 처음에 "연산량이 늘어서 느려지나?" 했는데 그게 아니었다.
dropout 때문에 학습 신호가 조금 더 noisy해져서, 같은 성능에 도달하려면 더 많은 step이 필요하고 loss가 더 천천히 안정된다.
=> 한 스텝이 무거워지는 게 아니라, 학습이 덜 직진적이라 수렴이 느려지는 것에 가깝다.
4) 왜 하필 0.1인가, 그리고 안 쓰기도 하나?
0.1은 무난한 기본값이다(0.0은 빠르지만 과적합 위험, 0.2 이상은 데이터가 아주 작을 때나).
흥미로운 건 LoRA/QLoRA에서 dropout을 아예 0으로 두는 경우도 꽤 많다는 것.
항상 필수가 아니라 "정규화가 필요할 때 켜는 조절 장치"에 가깝다.
데이터가 많고 다양하면 0, 데이터가 적거나 형식이 반복적이면 0.05~0.1을 고려한다.
=> 그래서 강사는 r·alpha·dropout을 "기본 모델 위에 덧붙이는 작은 보정 장치"로 묶어 이렇게 정리한다.
- r: 보정 장치의 크기
- alpha: 보정 장치의 증폭 정도
- dropout: 보정 장치가 너무 설치지 않게 일부러 랜덤 브레이크 거는 것
한 줄로 기억하면
- QLoRA의 dropout은 "LoRA가 학습 데이터에 너무 딱 붙지 않도록, 학습 중 일부 신호를 랜덤하게 끄는 정규화 장치"다.
지금까지의 QLoRA 5개를 표로 묶으면 이렇다.
| QLoRA 하이퍼파라미터 | 무슨 뜻 | 이번 코드 값(lite / full) |
| target modules | 어느 층에 어댑터를 붙일지 | 어텐션만 / 어텐션+MLP |
| r (rank) | 어댑터 행렬의 차원 | 32 / 256 |
| alpha | 어댑터를 얼마나 세게 반영할지 | 64 / 512 (= r×2) |
| quantization | 정밀도를 낮춰 메모리 확보 | 4비트 (동일) |
| dropout | 과적합 막는 랜덤 브레이크 | 0.1 (동일) |
느낀 점
- lite와 full의 차이가 결국 이 표에 다 들어 있다는 게 인상적이었다. 더 넓은 층(target)에, 더 큰 어댑터(r)를, 더 세게(alpha) — full은 이 세 개를 전부 키운 설정이다. "모델 크기가 아니라 어댑터 설정으로 학습 규모를 조절한다"는 QLoRA의 감각이 여기서 잡혔다.
- dropout이 "데이터가 적을 때 켜는 안전장치"라는 것, 그리고 실무에선 0으로도 자주 둔다는 게 의외였다. 데이터 양에 따라 조절을 해나가는게 인상적이었다
- dropout의 개념은 꼭 모델학습이 아니라 에이전트 품질 개선 작업에서도 쓸 수 있는 기법이라고 생각한다. 개선해야하는 테스트 케이스들이 있을 때 과적합 막기 위해 일부는 Dropout 해버리고 적용하는 방식도 써먹을 수 있지 않을까 라는 생각을 하게 됐다.
3. 학습은 실제로 어떻게 일어나는가 — 학습 4단계와 backpropagation
앞의 두 장은 "학습과정에서 세부적으로 어떤 세팅값을 넣을지"(하이퍼파라미터)를 봤다.
이번 장은 그 값들을 넣고 train()을 눌렀을 때 모델 안에서 실제로 무슨 일이 일어나는지에 대해 소개하려 한다.
강사는 학습을 이렇게 한 문장으로 정의한다.
tweaking the parameters of a model based on training data in a way that should generalize to unseen data
(훈련 데이터로 모델의 파라미터를 조정하되, 단순 암기가 아니라 처음 보는 데이터에도 잘 동작하도록 만드는 것)
여기서 핵심은 두 개다.
①파라미터를 조정한다
②처음 보는 데이터에도 잘하게 만든다.
즉 학습은 "훈련 데이터 정답 맞히기 게임"이 아니라 일반화를 만드는 것
그래서 앞 장에서 소개한 학습파라미터인 dropout·epochs 같은 게 다 이 "일반화"를 지키려는 장치였던 거다.
먼저 짚고 갈 것 — 파라미터 vs 하이퍼파라미터
4단계로 들어가기 전에 이 구분을 깔끔히 하면 전체가 쉬워진다.
- 파라미터(parameter): 모델이 학습 중에 직접 바꾸는 값. 신경망 가중치, bias, 그리고 QLoRA에선 특히 LoRA A/B 행렬.
=> 학습의 대상. - 하이퍼파라미터(hyperparameter): 학습 전에 사람이 미리 정해두는 설정값. learning rate, batch size, epochs, r, alpha, dropout… => 파라미터를 어떻게 바꿀지 정하는 규칙.
한 줄로:
파라미터 = 모델이 배우는 값, 하이퍼파라미터 = 사람이 학습 방식을 정하는 값. (앞의 1·2장에서 다룬 10개가 전부 하이퍼파라미터였다.)
학습의 4단계 — "답안지 고치기"로 보면
강사는 학습 한 스텝을 4단계로 쪼개고, 시험 답안지 고치는 과정에 빗대준다.
1) forward pass — 답안지 제출
현재 파라미터 상태로 입력을 넣고 예측을 만든다.
입력에서 첫 레이어 → 다음 레이어 → … → 출력으로 정보가 앞 방향으로 흐른다고 해서 "forward".
=> 시험 문제를 보고 지금 실력으로 답안지를 제출하는 단계. 아직 아무것도 고치지 않는다.
- QLoRA에선 출력이 Wx + ΔWx로 만들어진다(ΔW = (α/r)BA). 즉 베이스 모델의 원래 계산에, LoRA가 만든 보정이 alpha 스케일을 거쳐 더해진다.
2) loss calculation — 채점
예측이 정답과 얼마나 다른지 숫자로 계산한다. 거의 맞으면 loss 가 작고, 많이 틀리면 loss 가크다.
=> 답안 제출이 끝났으니 채점 결과를 받는 단계.
- 중요한 건 loss가 단순 정오판정(맞음/틀림)이 아니라는 것. 얼마나 자신 있게 틀렸는지까지 반영하는 연속적인 점수다.
"애매하게 틀림"과 "확신하며 틀림"은 loss가 다르다. (이 감각이 6장 cross-entropy로 이어진다.)
3) backward pass — 빨간펜으로 원인 분석
loss를 줄이려면 각 파라미터를 어느 방향으로 얼마나 바꿔야 하는지(gradient)를 계산한다.
forward가 입력→출력이었다면, backward는 반대로 출력의 오차(loss)에서 시작해 거꾸로 앞 레이어로 영향을 전파한다.
=> 채점이 끝난 답안지에 "여길 이렇게 바꾸면 점수가 오른다"를 빨간펜으로 분석하는 단계. 아직 고친 건 아니고 계산만 한 상태.
- QLoRA에선 베이스 모델은 frozen이고 LoRA 행렬 A·B에 대한 gradient만 계산·저장한다.
4) optimization — 연필로 실제 수정
backward에서 구한 gradient로 파라미터를 실제로 조금 수정한다. 드디어 모델이 바뀐다.
=> 분석대로 연필로 답안지를 조금 고치는 단계. 여기서 1장의 optimizer(걸음걸이)와 learning rate(보폭)가 등장한다.
SGD로 쓰면 이 한 줄이다.
θ = θ − η·∇L
# θ: 현재 파라미터, ∇L: gradient(방향), η: learning rate(보폭)
이 네 단계를 실제 예로 이어보면:
입력 "한국의 수도는" / 정답 "서울"
1) forward : 모델이 "부산"이라고 예측
2) loss : 정답 "서울"과 차이 → loss 큼
3) backward : 왜 틀렸는지 거꾸로 추적 → (LoRA 행렬의) gradient 계산
4) optimize : optimizer가 LoRA 행렬을 아주 조금 수정
→ 다음번엔 "서울" 확률이 조금 더 높아지도록
이걸 수천·수만 번 반복하는 게 학습이다.
QLoRA에서도 이 흐름은 똑같고, 단지 업데이트 대상이 베이스 모델 전체가 아니라 LoRA 행렬이라는 점만 다르다.
내가 자주 헷갈렸던 두 지점만 못 박아두면:
- backward pass ≠ 업데이트. backward는 gradient 계산, optimization이 실제 적용. (계산과 적용은 다르다.)
- loss ≠ gradient. loss는 "지금 틀린 정도", gradient는 "그래서 어디로 얼마나 고칠지".
backward pass를 한 겹 더 — gradient는 "책임 점수"
3단계(backward)가 가장 헷갈려서 여기만 더 팠다. gradient가 대체 뭔지, 왜 파라미터마다 크기가 다른지.
gradient = "이 파라미터를 조금 바꾸면 loss가 얼마나, 어느 방향으로 변하나" 를 나타내는 미분값이다.
- 부호가 양수면: 그 값을 키우면 loss가 커지니 → 줄이는 게 낫다
- 부호가 음수면: 그 값을 키우면 loss가 줄어드니 → 늘리는 게 낫다
- 절댓값이 크면 loss에 영향이 큼, 작으면 영향이 작음
아주 단순한 예(ŷ = wx, L = (wx−y)²)로 손으로 계산해보면 감이 온다.
x=2, y=10, w=3이면 예측 ŷ=6, loss (6−10)²=16. 이걸 w로 미분하면:
∂L/∂w = 2(wx − y)·x = 2(6 − 10)·2 = −16
gradient가 −16이라는 건 => "w를 키우면 loss가 줄어든다(음수), 그것도 꽤 크게(절댓값 16)"라는 뜻.
실제로 예측 6을 정답 10에 가깝게 만들려면 w를 키워야 하니 맞다.
그런데 실제 모델은 층이 여러 개라 loss가 파라미터에 직접 안 붙어 있다.
그래서 연쇄법칙(chain rule) 으로 쪼개 계산한다.
loss가 중간값 a를 거쳐 w에 연결돼 있다면:
dL/dw = (dL/da) × (da/dw)
# "loss가 중간값에 얼마나 민감한지" × "중간값이 파라미터에 얼마나 민감한지"
딥러닝 프레임워크가 이걸 출력층부터 입력층까지 층층이 자동으로 해준다. 이게 backpropagation이다.
강사는 이걸 공장 비유로 정리한다.
(강사의 비유) 공장에서 최종 제품이 불량이 났다고 하자.
forward pass = 원재료 투입 → 공정1 → 공정2 → 공정3 → 최종 제품.
backpropagation = 최종 제품이 왜 불량인지 보고, 공정3의 책임은 얼마인지, 공정2는, 공정1은… 이렇게 거꾸로 책임을 배분하는 것.
각 공정 담당자가 다음번엔 얼마나 수정해야 할지를 정하는 셈.
=> 그래서 gradient를 아주 직관적으로 말하면 "너를 조금 바꾸면 loss가 얼마나 달라질까?"에 대한 책임 점수다.
책임이 크면 gradient가 크고, 작으면 작다.
파라미터마다 gradient 크기가 다른 이유도 결국 "책임"으로 설명된다(강사가 든 이유들 중 핵심만):
- 출력에 더 직접적으로 큰 영향을 준 파라미터일수록 gradient가 크다
- 이번 입력 샘플에 많이 관여한 파라미터일수록 크다 (gradient는 현재 배치 데이터에 대한 책임도를 반영)
- 이미 잘 맞고 있는 부분은 굳이 고칠 필요가 없으니 gradient가 작다
- 위 단순식에서 봤듯 gradient는 오차뿐 아니라 입력 활성값 x에도 비례한다 (2(wx−y)·x) — 같은 오차여도 입력이 크면 gradient가 크다
QLoRA에서도 원리는 똑같다.
어떤 토큰 관계를 더 잘 잡아야 하는 샘플에선 그쪽 LoRA 파라미터의 gradient가 크게 나오고, 아닌 경우 작다.
다만 업데이트 대상이 LoRA 쪽으로 제한돼 있을 뿐.
한 줄 압축: forward는 답 제출, loss는 채점, backpropagation은 오답 원인 추적, optimization은 실제 수정.
느낀 점
- "gradient = 책임 점수"라는 프레임이 나한테 제일 크게 남았다. 그동안 backprop을 많이 들어보기만 하고 원리는 잘 몰랐는데, 미분 chain rule을 이용해서 거꾸로 전파하는 프레임을 보며 이해가 갔던 거 같다. "이번 오답에 네가 얼마나 책임 있냐"로 바꿔 생각하니 이해가 조금 더 됐던 거 같다.
- 학생 시절 배웠던 미분의 개념이 실제로 이런식으로 쓰이게 되는 구나를 배운 느낌이었달까
- 학습 단계에 대해서도 좀 더 깊게 배워볼 수 있어 의미있었다
4. SFTTrainer로 실제 학습 돌리기 — 그리고 wandb 곡선 읽기
개념은 여기까지. 이제 이 모든 걸 코드로 돌린다.
좋은 소식은, 앞에서 그렇게 길게 뜯어본 forward → loss → backward → optimize의 반복이 실제 코드에선 SFTTrainer가 다 대신해준다는 것이다.
내가 할 일은 "무엇을·어떻게 학습할지"만 설정 객체 두 개로 넘겨주는 거다.
전체 흐름은 이렇다.
데이터 로드 → 4비트 양자화로 베이스 모델 로드 → LoRA 어댑터 부착
→ SFTTrainer로 학습 → 매 SAVE_STEPS마다 HuggingFace Hub에 업로드
→ wandb로 학습 곡선 추적
4비트로 양자화한 모델을 메모리에 올린다
글 15에서 본 그 양자화 설정 그대로, 베이스 모델을 4비트로 로드한다.
base_model = AutoModelForCausalLM.from_pretrained(
BASE_MODEL, quantization_config=quant_config, device_map="auto",
)
print(f"Memory footprint: {base_model.get_memory_footprint() / 1e6:.1f} MB")
Memory footprint: 2197.6 MB # 약 2.2GB — 30억 파라미터 모델이 이 크기로
30억 파라미터 모델이 2.2GB로 줄어 colab에서 제공하는 T4 무료 GPU에도 올라간다.
(참고로 내 실행은 T4라 use_bf16=False로 잡혀서 계산은 fp16으로 돌아갔다. A100이면 더 안정적인 bf16을 쓴다.)
설정 객체 ①: LoraConfig — "어떤 어댑터를"
2장에서 본 그 LoraConfig다. 여기에 눈여겨볼 인자 두 개만 더 짚으면:
lora_parameters = LoraConfig(
lora_alpha=LORA_ALPHA, lora_dropout=LORA_DROPOUT, r=LORA_R,
bias="none", # bias 파라미터는 학습 안 함
task_type="CAUSAL_LM", # 인과적 언어모델 = 다음 토큰 예측
target_modules=TARGET_MODULES,
)
task_type="CAUSAL_LM"이 이 학습이 "다음 토큰 예측"임을 알려주고, bias="none"으로 bias는 건드리지 않는다.
설정 객체 ②: SFTConfig — "어떻게"
여기가 실제 학습 옵션이 다 모인 곳이다. 인자가 많은데, 그냥 나열하면 안 와닿아서 "왜 넣는지" 위주로 핵심만 추렸다.
train_parameters = SFTConfig(
num_train_epochs=EPOCHS,
per_device_train_batch_size=BATCH_SIZE,
per_device_eval_batch_size=1, # 평가 배치는 1 (메모리 절약)
learning_rate=LEARNING_RATE,
lr_scheduler_type=LR_SCHEDULER_TYPE, # cosine
warmup_ratio=WARMUP_RATIO, # 초반 1% 동안 lr을 0→1e-4로 서서히
optim=OPTIMIZER, # paged_adamw_32bit
max_grad_norm=0.3, # 그래디언트 클리핑 — gradient 폭주 방지
group_by_length=True, # 비슷한 길이끼리 배치 → 패딩 낭비 ↓
max_length=MAX_SEQUENCE_LENGTH, # 128토큰으로 자름
fp16=not use_bf16, bf16=use_bf16, # GPU에 따라 정밀도 자동 선택
eval_strategy="steps", eval_steps=SAVE_STEPS, # N스텝마다 평가
save_steps=SAVE_STEPS, save_total_limit=10, # 체크포인트 주기/최대 개수
push_to_hub=True, hub_strategy="every_save", # 체크포인트마다 Hub 업로드
hub_model_id=HUB_MODEL_NAME, hub_private_repo=True,
)
이 중에서 내가 "아, 이래서 넣는구나" 했던 것들:
- max_grad_norm=0.3 (그래디언트 클리핑): 3장에서 gradient가 "움직이는 방향"이라 했는데, 그럼 한 스텝에 파라미터가 확 튀어(=산에서 반대편으로 튀는 것) 학습이 망가진다. 그래서 gradient 크기에 상한을 씌운다.
- group_by_length=True: 길이가 비슷한 샘플끼리 배치로 묶으면 패딩(빈칸 채우기) 토큰이 줄어 학습이 빨라진다. 짧은 문장과 긴 문장을 한 배치에 넣으면 짧은 쪽을 긴 쪽 길이에 맞춰 낭비하게 되니까.
- push_to_hub=True + hub_strategy="every_save": 이게 은근히 중요했다. Colab 무료 세션은 언제든 끊긴다. 매 저장 시점마다 어댑터를 HuggingFace Hub에 올려두면, 끊겨도 마지막 체크포인트에서 재개할 수 있다. (강사도 "재개하려면 모델을 is_trainable=True로 불러오는 게 트릭"이라고 안내한다.)
- optim="paged_adamw_32bit": 1장에서 본 AdamW인데, optimizer 상태(모멘텀 등)를 GPU↔CPU로 페이징해 메모리 초과(OOM)를 피한다. T4 같은 작은 GPU에선 사실상 필수.
설정 객체 ③: SFTTrainer — 두 개를 합쳐 학습 시작
그리고 이 두 설정과 데이터를 SFTTrainer에 넘긴다.
fine_tuning = SFTTrainer(
model=base_model, # 4비트 양자화된 베이스 모델
train_dataset=train, eval_dataset=val,
peft_config=lora_parameters, # ★ 이걸 주면 자동으로 LoRA 어댑터 부착
args=train_parameters,
)
fine_tuning.train()
여기서 마법은 peft_config=lora_parameters 한 줄이다. 이걸 넘기면 SFTTrainer가 알아서:
- 베이스 모델을 얼리고(freeze),
- 지정한 모듈(q_proj 등)에 LoRA 어댑터를 끼워넣고,
- 어댑터만 학습시킨다.
3장에서 손으로 뜯어본 4단계(forward/loss/backward/optimize)를, .train() 한 줄이 매 배치마다 알아서 돌리는 것이다.
=> 개념을 알고 나니 이 한 줄이 "블랙박스"가 아니라 "그 반복을 돌리는 버튼"으로 보인다.
한 가지 헷갈릴 만한 지점 — "4비트로 얼린 모델을 어떻게 학습하지?"
답은 베이스 가중치는 학습 안 한다는 것.
베이스 W는 4비트로 얼려두고 forward 때만 잠깐 fp16/bf16으로 풀어 계산하며, 학습 가능한 건 LoRA 어댑터 A·B뿐(이건 풀 정밀도로 따로 보관).
그래서 gradient도 어댑터에만 흐른다. 강사 말대로 이게 "LLM을 작은 메모리의 GPU에서도 학습시킬 수 있는 QLoRA의 묘미"다.
wandb 학습 곡선 읽기
학습이 도는 동안 wandb가 곡선을 그려준다(모델 자체는 안 올리고 loss·lr·평가 지표만: WANDB_LOG_MODEL="false", WANDB_WATCH="false").
내 lite 실행 기준 — 데이터 2만 개, 배치 32 → 한 에폭에 625 스텝(20000 ÷ 32 ≈ 625)이었고, 약 1시간 13분 걸렸다.
학습이 진행되며 찍힌 값은 이랬다.
Step Training Loss Validation Loss Mean Token Accuracy
100 1.303 1.2918 0.757
200 1.265 1.2722 0.760
...
600 1.265 1.2483 (—)

곡선에서 읽은 것:
- train loss가 1.303 → 1.236 근처로 내려간다(노이즈는 있지만 추세는 하락). 모델이 실제로 배우고 있다는 뜻.
- validation loss도 1.2918 → 1.2483로 꾸준히 내려간다.
=> 훈련 데이터만 외운 게 아니라 처음 보는 데이터(val)에도 잘하고 있다는 신호. 아직 과적합 조짐이 없다. - mean token accuracy가 0.757 → 0.763으로 오른다.
- learning rate는 cosine 스케줄이라 초반 워밍업 뒤 0.0001 부근을 유지하며 서서히 내려간다.
학습이 끝나면 어댑터가 Hub에 올라간다.
adapter_model.safetensors: 100% 73.4MB / 73.4MB
Saved to the hub: price-2026-05-01_...-lite

여기서 73.4MB가 인상적이다.
30억 파라미터 모델을 파인튜닝했는데, 결과물로 저장되는 건 어댑터 73MB짜리 파일 하나다.
베이스 모델(라마 3.2)은 건드리지 않았으니 저장할 필요도 없다.
=> "학습 결과 = 작은 어댑터 파일 하나"라는 게 LoRA에서 일어나는 일이다
느낀 점
- 그렇게 개념을 길게 팠는데 정작 학습 코드는 SFTTrainer(...) + .train()이 전부였다. 라이브러리가 4단계 반복을 대신 돌려주니, 내가 신경 쓸 건 "무엇을·어떻게(설정 두 개)"뿐이라는 것. => 전직장에서 있던 서비스인 노코드 LLMOps 툴의 학습 세팅 화면이 왜 결국 "데이터 + 하이퍼파라미터 몇 개" 입력폼으로 수렴하는지 이제 이해가 됐다.
- val loss가 같이 내려가는 걸 눈으로 확인하니, 앞 장에서 배운 "일반화"가 추상적 개념이 아니라 실제로 지켜보며 판단하는 지표라는 게 와닿았다. 그리고 이 val loss가 다음 장의 주인공이다 => lite 모드로 1epoch를 돌릴때가 아니라, full 모드로 3epoch을 돌리면 이게 어느 순간 다시 올라가기 시작하니까.
5. 오버피팅 관찰기 — train loss가 좋아 보일 때가 더 위험하다
앞 장 끝에서 예고한 그 이야기다. 내가 돌린 lite는 1에폭이라 val loss가 내려가기만 하고 끝났다 -> 과적합을 볼 기회 자체가 없었다.
그래서 이 장은 강사가 full 모드로 3에폭 돌린 곡선을 기준으로 본다.
(같은 코드에서 LITE_MODE=False로 두면 데이터 80만·배치 256·3에폭이 된다.)
(나도 colab에서 A100 이용권을 사서 Full 모드로 돌리려 했으나, 메모리가 초과되어서 실패했다..ㅎ)
먼저: train loss가 초반에 급락한다
full 곡선을 보면 train loss가 시작하자마자 뚝 떨어진다. 이건 좋은 신호라기보다 태스크가 쉬워서다.
이 학습의 정답은 "219.00" 같은 짧은 숫자 하나.
모델이 "숫자를 예측한 다음 끝(끝 토큰)을 내면 된다"는 형식을 금방 익히기 때문에, 형식만 맞춰도 loss가 확 준다.


참고로 full(80만 개) 곡선이 lite보다 더 매끄러워 보이는 건 배치가 더 크기 때문이다(256 vs 32). 1장에서 본 그 얘기
큰 배치는 여러 샘플의 평균으로 움직이니 한 스텝 한 스텝이 덜 튄다. => 곡선의 매끈함 정도에도 batch size가 그대로 드러난다.
진짜 봐야 할 건 train loss가 아니라 eval loss
문제는 3에폭째다. train loss가 2에폭 → 3에폭 구간에서 너무 크게 떨어진다. 왜 하필 여기서 급락할까?
핵심은 에폭이 바뀌어도 데이터는 같은 데이터라는 점이다(1장에서 본 것 — 순서·조 편성만 셔플될 뿐).
그러니 두 번, 세 번 본 데이터에 대해 모델이 예측을 잘하게 되는 건 당연하다.
=> 즉 3에폭째 train loss 급락은 "더 똑똑해졌다"가 아니라 본 문제집을 외우기 시작했다는 신호에 가깝다.
이게 문제인 이유: 훈련 데이터를 외우면 정작 처음 보는 데이터(unseen)에선 잘 못할 수 있다.
그래서 train loss만 봐선 안 되고, 학습 중 따로 떼둔 검증셋으로 재는 eval loss를 봐야 한다(이 코드에선 일정 스텝마다 평가한다).
그리고 eval loss 곡선에서 결정적인 장면이 나온다.
eval loss가 저점 1.123까지 내려갔다가, 어느 순간 1.283까지 확 올라가는 구간이 있다.
바로 이 구간이 오버핏이 일어나는 지점이고, 세 번째 에폭에서 (unseen 데이터에 대한) 성능이 오히려 줄어든다.
train loss는 계속 내려가는데 eval loss는 다시 올라간다
=> 이 "엇갈림"이 과적합의 교과서적 신호다. 훈련 데이터엔 점점 잘 맞는데 새 데이터엔 오히려 나빠지는 것.
그래서 결론: "현재 상황에선 2에폭이 생산적"
강사의 결론은 담백하다. 생산적인 건 2에폭까지고, 3에폭째는 오히려 성능을 깎아먹는다.
완화하려면 앞서 2장에서 본 dropout을 올려보는 것도 방법이다(LoRA가 데이터에 너무 딱 붙는 걸 막으니까).
그러면 3에폭째 과적합이 좀 줄어들 수도 있다.
=> 여기서 1·2장이 다시 연결된다. epochs를 몇으로 둘지, dropout을 얼마로 둘지가 추상적인 "설정값"이 아니라, 이 eval loss 곡선을 보고 조정하는 실제 조정장치였던 거다.
느낀 점
- "train loss가 좋아지는데 안심하면 안 된다"는 게 이 장의 교훈이었다. 오히려 train loss만 예쁘게 떨어질 때가 가장 위험한 순간(외우는 중)일 수 있다는 것. => "학습 데이터에서의 성적"과 "처음 보는 데이터에서의 성적"은 다르고, 후자를 봐야 한다.
- 그리고 이 판단을 눈으로 곡선 보며 한다는 게 인상적이었다. "몇 에폭이 정답"이라는 공식이 있는 게 아니라, eval loss가 반등하는 지점을 관찰해서 "여기까지"를 정하는 것. 결국 하이퍼파라미터 튜닝과, 모델 학습은 계산이 아니라 관찰과 판단, 실험의 영역이라는 감각이 잡혔다.
6. LLM은 "숫자를 빼지 않는다" — cross-entropy와 128k 확률분포
이 글에서 개인적으로 제일 인상 깊었던 장이다. 글 초반에 던진 세 번째 질문 — "LLM은 89달러가 정답인데 99달러라고 했으니 10만큼 틀렸다는 식으로 학습하는 걸까?" — 의 답이 여기 있다. 결론부터 말하면 아니다. LLM은 숫자를 빼지 않는다.
다시, 가격 예측을 forward pass부터
3장에서 본 학습의 4단계를 이번엔 가격 예측 태스크에 그대로 얹어보자.
입력 프롬프트: "...(상품 설명)... Price is $"
→ 토큰 id로 변환 → Llama 모델에 입력
→ 마지막에 나오는 게 predicted next token → "99" (모델이 99달러라고 예측)
실제 코드도 딱 이 흐름이다(글 15에서 본 그 추론 함수, 파인튜닝 모델로 바뀐 것만 다르다).
def model_predict(item):
inputs = tokenizer(item["prompt"], return_tensors="pt").to("cuda")
with torch.no_grad():
output_ids = fine_tuned_model.generate(**inputs, max_new_tokens=8)
prompt_len = inputs["input_ids"].shape[1]
generated_ids = output_ids[0, prompt_len:] # 새로 생성된 토큰만
return tokenizer.decode(generated_ids)
자, 모델이 "99"라고 했는데 실제 정답이 89달러였다고 하자.
그럼 loss는 어떻게 계산될까? "99 − 89 = 10, 10만큼 틀렸다"일까?
=> 여기서부터가 핵심이다.
forward pass의 진짜 출력은 "토큰 하나"가 아니다
깊이 들어가면, 모델은 다음 토큰을 출력하는 과정에서 ₩다음 토큰 하나를 딱 집어내는 게 아니다.
모델은 해당 모델의 vocab 사전에 있는 가능한 128,000개 토큰 각각에 대한 확률 분포를 출력한다.
3장에서 잠깐 봤던 신경망의 마지막 층 lm_head를 기억하면 이 층의 출력이 128,000차원짜리 벡터이고,
각 원소가 "각 토큰이 다음에 올 확률"이다. 그리고 이 확률을 다 합치면 1이 된다.
(쉽게 설명하면 모델이 내장하고 있는 128000개 토큰 사전 각각에 대해 다음 토큰에 해당 토큰이 나올 확률을 계산한다고 보면 된다)
우리가 보는 "99"라는 예측은, 이 128,000개 확률 중 가장 높은 걸 고른 결과일 뿐이다(temperature 0이면 최고 확률 토큰 선택).
=> 즉 모델은 매 순간 "다음은 무조건 99"라고 단정하는 게 아니라, "89일 확률 몇 %, 99일 확률 몇 %, cat일 확률 몇 %…" 하는 분포를 통째로 내놓고 있는 거다.
그럼 loss는? — 정답 토큰에 준 확률을 본다
정답이 89달러라면, loss 계산에서 묻는 건 이거다.
"모델이 실제 정답인 89 토큰에 어떤 확률을 부여했나?"
- 89에 100% 확률을 줬다면 → 완벽한 예측 → loss는 0이어야 한다.
- 89에 아주 작은 확률만 줬다면 → 많이 틀린 것 → loss는 커야 한다.
이 "확률이 1이면 loss 0, 확률이 작으면 loss 큼"을 만족하는 공식이 바로 음의 로그(negative log) 다.
loss = -log(정답 토큰에 부여한 확률)
-log(1) = 0 # 확률 100% → loss 0 (완벽)
-log(작은 수) = 큰 값 # 확률 작음 → loss 큼
확률이 작을수록 -log는 커진다. 이게 바로 cross-entropy loss(교차 엔트로피 손실) 다.
그리고 학습(backward → optimize)은 이 값을 줄이는 방향으로, 즉 정답 토큰에 부여하는 확률을 조금씩 높이는 방향으로 LoRA 어댑터를 조정한다.
=> 여기서 3장의 "loss는 얼마나 자신 있게 틀렸는지까지 반영한다"가 정확히 맞아떨어진다. 89가 정답인데 89에 5%밖에 안 줬으면 loss가 크고, 40%쯤 줬으면 loss가 작다 — 틀린 정도가 아니라 "정답에 얼마나 확신을 줬나"로 재는 것이다.
대체 자신있게 틀렸는지가 뭔말인가 했는데 이런 의미였다
그래서 "숫자를 빼지 않는다"
핵심 통찰이 여기 있다. 모델은 99 − 89 = 10 같은 산수를 하는 게 아니다.
정답 토큰 "89"에 부여한 확률을 보고, 그 확률의 음의 로그로 loss를 매길 뿐이다.
강사 표현 그대로 옮기면 "숫자 차이를 하는 게 아니라 이런 식으로 확률에 대해" 학습하며,
"LLM은 (애초에 산수를 하도록) 이런 식으로 설계되지 않았기 때문"*이다.
그리고 이게 왜 중요하냐면 cross-entropy는 가격 예측에만 쓰는 게 아니라 어디에나 적용 가능하다는 것.
강사가 든 예가 인상적이었다.
추천을 제시하고 → 그 추천에 모델이 어떤 확률을 부여했는지 알아내고 → 그거에 대해 cross-entropy loss를 매긴다.
즉 "정답이 있고, 모델이 그 정답에 부여한 확률을 본다"는 틀만 있으면, 가격이든 추천이든 분류든 전부 같은 방식으로 학습시킬 수 있다는 얘기다.
느낀 점
- "LLM은 숫자를 빼지 않는다"는 한 문장이 그동안의 오해를 정리해줬다. 나는 은연중에 가격 예측 모델이 "얼마 차이 났나"를 최소화하는 회귀 문제처럼 학습된다고 생각했는데(loss 계산도), 실제로는 "89라는 토큰에 확률을 얼마나 몰아줬나" 를 최적화하는 방식으로 작동됐다. 같은 "가격 예측"이라도 그 밑에서 도는 손실 함수는 전혀 다른 것.
- 특히 "추천에도 cross-entropy를 그대로 쓸 수 있다"는 대목이 광고 도메인 실무자로서 확 와닿았다. 추천 클릭 예측이 결국 "정답(클릭한 아이템)에 모델이 부여한 확률"을 높이는 문제라는 걸 생각하면, LLM 학습과 추천 학습이 손실 함수 레벨에선 형제였던 셈이다. 도메인이 달라 보여도 "정답 확률의 음의 로그를 줄인다"는 뼈대는 똑같을 수 있다는게 이번 장에서 배워간 것이었다
- 앞의 주차에서 배웠던 트랜스포머 모델 구조 (다음 토큰의 확률을 예측하는) 가 이런 식으로 모델 학습에서도 이어지는 것을 볼 수 있어서 공부가 이어지는 느낌을 낼 수 있었다.
7. 최종 평가와 전체 성적표 — 작은 모델이 프론티어를 이긴다
이제 진짜 결승선이다.
Day 5는 파인튜닝한 모델을 test set으로 평가하고,
지금까지 이 프로젝트(The Price Is Right)에 등장한 모든 모델을 한 판에 세워 성적을 비교한다.
어떻게 평가하나
평가 코드는 단순하다. 4비트 베이스 모델을 로드한 뒤, 학습해서 Hub에 올려둔 어댑터를 붙이면 끝이다.
fine_tuned_model = PeftModel.from_pretrained(base_model, HUB_MODEL_NAME, revision=REVISION)
print(f"Memory footprint: {fine_tuned_model.get_memory_footprint() / 1e6:.1f} MB")
Memory footprint: 3753.7 MB # full 모델(r=256, 어텐션+MLP 전부에 어댑터) 기준
full 모델은 어댑터를 더 넓게(어텐션+MLP 전부, r=256) 붙여서 약 3.7GB다(lite는 훨씬 작다).
예측 함수는 6장에서 본 model_predict 그대로고, 평가는 이 한 줄이다.
set_seed(42) # 매번 같은 test 샘플을 뽑도록 고정 (공정 비교)
evaluate(model_predict, test)
evaluate는 Week 6부터 쭉 쓴 util.py의 그 함수다.
test 샘플마다 예측가와 실제가의 오차(Error) = |예측 − 정답| 를 재서 평균 낸다.
예측이 실제가와 가까우면 초록, 어중간하면 주황, 많이 빗나가면 빨강으로 산점도에 찍힌다(오차 $40 미만 또는 20% 이내면 초록, $80/40% 선이 주황).
=> 그래서 산점도에서 초록 점이 대각선(y=x)에 몰릴수록 좋은 모델이다.
강사가 이번에 넘어야 한다고 걸어둔 기준선은 두 개였다.
- Human(Ed 본인) 수준 $87.62를 이기기
- 가능하면 GPT-4.1-nano(파인튜닝 없는 기본)의 $62.51 근처까지
전체 성적표
그리고 이 프로젝트의 모든 모델을 오차 순으로 세운 최종 성적표다.
낮을수록(오차가 작을수록) 좋다.

| 모델 | 오차($) | 분류 |
| Base Llama 3.2 4bit | 185.52 | ← 파인튜닝 전, 꼴찌 (내 Colab 실측) |
| Constant (평균값 찍기) | 106.18 | 전통 베이스라인 |
| Linear Regression | 101.56 | 전통 ML |
| Human (Ed) | 87.62 | 사람 |
| NLP + LR | 76.81 | 전통 ML |
| GPT-4.1-nano (Fine-tuned) | 75.91 | ← Week 6 결과 |
| Random Forest | 72.28 | 전통 ML |
| XGBoost | 68.23 | 전통 ML |
| Fine-tuned Lite | 65.40 | ← 우리 lite, GPT-4.1-nano급 |
| Neural Network | 63.97 | 신경망 |
| GPT-4.1-nano | 62.51 | 프론티어 제로샷 |
| Grok 4.1 Fast | 57.62 | 프론티어 |
| Gemini 3 Pro | 50.54 | 프론티어 |
| Claude 4.5 Sonnet | 47.10 | 프론티어 |
| Deep Neural Network | 46.49 | 신경망 |
| GPT-5.1 | 44.74 | 프론티어 최강 |
| Fine-tuned Full | 39.85 | ← 우리 full, 전체 1위 |
(Base Llama 는 내가 직접 Colab에서 돌린 실측치다. 나머지 수치는 강사가 돌렸을 때의 results 기준.)
이 표를 스토리로 읽으면 이렇다.
- 출발점 — Base Llama, 꼴찌. 파인튜닝 전 베이스 Llama 3.2(4bit)는 오차 $185.52로 성적표 맨 밑이다. Constant(그냥 평균값만 찍는 베이스라인 $106)보다도 못하다. => 글 15에서 "베이스 모델은 형편없다"고 했던 그 지점.
- 한 걸음 — Lite, GPT-4.1-nano급. 내가 T4에서 1에폭만 가볍게 돌린 lite가 $65.40. 프론티어인 GPT-4.1-nano 제로샷($62.51)과 거의 어깨를 나란히 한다. 꼴찌였던 모델이 어댑터 73MB 붙이고 프론티어급으로 올라온 것.
- 결승선 — Full, 전체 1위. full로 제대로 학습한 모델은 $39.85로 성적표 전체 1등이다. GPT-5.1($44.74), Claude 4.5 Sonnet($47.10)은 물론, 사람(강사, $87.62)도 한참 앞선다.
3B(30억 파라미터)짜리 오픈소스 모델이, 4비트 양자화로 짓눌린 채, 수조 파라미터급 프론티어 모델들을 전부 제친 것이다.
글 초반에 풀어둔 스포일러가 여기서 회수된다.
한 가지 강사가 못 박은 주의점도 함께 옮긴다.
상품 가격은 변동이 크다. 세일가처럼 모델이 알 수 없는 정보로 정해지는 가격까지 맞힐 수는 없다.
즉 이 1위가 "모델이 시세를 완벽히 안다"는 뜻은 아니다.
주어진 상품 설명 안에서 가격을 추정하는 이 태스크에 한해 가장 잘한다는 것이다.
느낀 점
- Week 6에서 파인튜닝(GPT-4.1-nano)이 오히려 성능을 깎아먹는 걸 보고 "파인튜닝으로 성능개선하는게 쉽지 않구나" 했는데, 이번엔 정반대 결과가 나왔다. 같은 "파인튜닝"인데 왜 결과가 뒤집혔을까 -> 이건 마무리에서 정리하려 한다.
- 실무 감각으로 제일 크게 남은 건 "좁은 태스크 하나엔, 작은 전문가 모델이 거대 제너럴리스트를 이길 수 있다"는 실증이었다. 프론티어 API를 그냥 부르는 것보다, 내 도메인 데이터로 작은 오픈소스 모델을 파인튜닝하는 게 성능·비용·소유권 모든 면에서 나을 수 있다는 것. 실제 에이전트를 만드는 태스크에서도 의도분류라든가, 일부 좁게 정의된 태스크에선 이렇게 로컬 llm 을 파인튜닝해서 쓰는 방식도 가능하겠다라는 걸 이번 결과를 통해서 본거 같다.
- 오히려 학습 자체보다도 학습을 위한 데이터를 만드는 일, 그리고 여러 실험을 통해서 최적의 하이퍼 파라미터들을 지정하고, 최적의 체크포인트를 찾아가는 그 과정이 중요하다는 걸 실제로 파인튜닝을 돌려보고 느꼈던 거 같다.
- 파인튜닝.. 실무를 하면서는 모델을 직접 만드는 부서에서나 가능한 먼 이야기 처럼만 들리고 관련된 용어들도 너무 낯설었는데 이번에 직접 돌려보며 조금 더 모델 단의 지식까지 알아볼 수 있었던 게 의미가 있던 거 같다.
마무리 — 파인튜닝은 만능인가
두 편에 걸친 Week 7이 끝났다.
글 15에서 QLoRA로 30억 파라미터를 작은 GPU에 욱여넣는 준비를 했고, 이 글에서 실제로 학습시켜 프론티어 모델을 이겼다.
마지막으로 처음의 질문 하나가 남는다.
Week 6에선 그렇게 헛돌던 파인튜닝이, 왜 이번엔 1등을 했을까? 같은 "파인튜닝"인데.
내가 두 주를 겪고 내린 정리는 이렇다.
- Week 6은 이미 방대한 지식을 가진 프론티어 모델(GPT-4.1-nano) 을, 적은 데이터(200개 정도) 로 파인튜닝했다. 원래 잘하는 모델에 좁은 데이터를 덧입히니 오히려 기존 능력이 흔들려 성능이 떨어졌다.
=> 그래서 그때 결론이 "파인튜닝은 성능 향상이 아니라 스타일·형식 맞추기용"이었다. - Week 7은 그 태스크를 아직 못 하는 작은 base 모델을, 그 태스크와 관련된 수많은 데이터로(Full 기준 80만건, Epoch 3회) 파인튜닝했다. 백지에 가까운 모델에 "이 일 하나만 잘하도록" 집중적으로 가르치니 성능이 확 올랐다.
=> 즉 파인튜닝이 성능을 올리느냐 마느냐는 "어떤 모델을, 어떤 데이터로, 무엇을 위해" 에 달렸다.
이미 잘하는 큰 모델을 미세하게 다듬는 것과, 못 하는 작은 모델을 특정 태스크의 전문가로 키우는 것은 같은 "파인튜닝"이라도 전혀 다른 작업이었다.
그래서 파인튜닝은 만능이 아니라, "작은 전문가를 만드는 도구" 에 가깝다는 게 두 주의 결론이다.
데이터가 있고 태스크가 명확하다면, 거대 모델을 부르는 대신 작은 모델을 내 손으로 키우는 선택지
그게 이번 주에 QLoRA로 직접 확인한 것이다.
여기까지가 Ed Donner LLM Engineering 강의의 Week 7 정리였다. 개념(글 15)부터 학습·평가(글 16)까지, 오픈소스 모델을 내 손으로 파인튜닝하는 한 사이클을 처음 끝까지 돌려본 셈이다.
다음 Week8은 강의의 마지막 주차로, 지금까지 공부한 것의 총 집합체이다.
지금껏 파인튜닝한 가격 예측 모델을 기반으로, 전 주차들에서 배운 RAG등도 활용하여 하나의 에이전트 시스템을 만들어보는 주차다.
해당 내용으로 다시 찾아오겠습니다.
(꾸준히 공부하고 적을테니 많은 관심 부탁드립니다.)
Profile:
Linkedin
'AI 공부' 카테고리의 다른 글
| 17. 상품 핫딜 헌팅 에이전트 (1) - 파인튜닝 모델 서비스화 및 가격 예측 에이전트 개발 (0) | 2026.08.02 |
|---|---|
| 15. LoRA·QLoRA 원리 파악 — Llama 3.2 파인튜닝 준비하기 (0) | 2026.06.28 |
| 14. 내 데이터로 GPT를 파인튜닝하면 더 똑똑해질까 - 가격 예측 모델 실험기 (0) | 2026.06.06 |
| 13. RAG를 넘어 파인튜닝으로 - 그 첫단계, 데이터 큐레이션 및 전처리 (0) | 2026.05.31 |
| 12. RAG의 한계를 어떻게 깰까 — Advanced RAG (Semantic Chunking·Reranking·Query Rewriting 등) (1) | 2026.05.09 |