목차
들어가며
1. 이번엔 오프소스 모델을 직접 파인튜닝한다
2. 3B(30억) 파라미터 모델을 그냥 학습 못하는 이유
3. LoRA - 원본은 얼리고(freeze), 작은 어댑터만 학습
4. QLoRA의 Q - 양자화로 4비트까지 줄이기
5. LoRA 어댑터는 실제로 얼마나 작을까
6. 학습데이터 만들기 - 프롬프트 설계
7. 파인튜닝 전, 베이스 모델은 얼마나 못할까
마무리
들어가며
지난 글 2편
- https://doobeom-coding.tistory.com/90
- https://doobeom-coding.tistory.com/91
에서 나는 GPT-4.1-nano를 데이터로 파인튜닝을 해보는 작업을 진행하고, 이를 통해 상품 설명을 기반으로 가격을 예측해보는 작업을 진행했다.
결과는 좀 허무했다. 아무 튜닝 없이 그냥 호출을 통해 물어본 프론티어 LLM의 정확도가 제일 높았고,
정성껏 파인튜닝한 모델은 오히려 원본 모델보다 성능이 더 나빠졌으니까.
그런데 Week 7에서 강사는 같은 가격 예측 문제(The Price Is Right)를 다시 들고 와서 이렇게 말한다.
"이번엔 오픈소스 모델을, 그것도 노트북에 달릴 법한 GPU 한 장에서 직접 파인튜닝해보자."
여기서 의문이 두 개 생겼다.
- 첫째, Week 6에선 OpenAI 서버가 알아서 파인튜닝해줬는데, 모델을 내 손으로 직접 학습하는 건 어떤식으로 이뤄질까
- 둘째, 우리가 쓸 모델은 Llama 3.2의 30억(3B) 파라미터짜리다. Week 6에서 만든 신경망(딥러닝 모델)의 파라미터가 2억 개였는데, 그보다 15배 큰 모델을 단일 GPU에서 학습할 수 있을까?
이 두 번째 질문에 대한 답이 이번 주의 핵심 키워드, QLoRA다.
그리고 스포일러를 하나 풀자면 Week 6에서 그렇게 헛돌던 파인튜닝이, 이번엔 제로샷 프론티어 모델을 전부 제치고 1등을 한다.
같은 "파인튜닝"인데 왜 결과가 정반대일까? 그 이야기는 다음 글(글 16)에서 제대로 풀고,
이번 글에서는 우선 QLoRA가 도대체 무슨 마법이길래 30억 파라미터를 작은 GPU에 욱여넣는지부터 차근차근 뜯어보려 한다.
이번 글은 모델을 거의 "학습"시키지 않는다.
대신 학습을 시작하기도 전에 알아야 할 것들
1) 왜 큰 모델은 그냥 학습할 수 없는지
2) LoRA와 양자화가 각각 무엇을 해결하는지,
3) 그리고 파인튜닝 전 베이스 모델이 얼마나 못하는지(= 비교 기준선) 을 다룬다.
Week 6 때도 느꼈지만, 평가의 신뢰도는 데이터와 베이스라인 단계에서 이미 결정된다.
그래서 이번에도 모델을 돌리기 전 준비 과정에 글의 절반을 쓴다.
1. 이번엔 오픈소스 모델을 직접 파인튜닝한다
우리가 푸는 문제는 Week 6과 똑같다. 상품 설명을 보고 가격을 맞히는 것(The Price Is Right)
Week 6과 무엇이 다른지 표로 정리하면 이렇다.
| Week 6 (글 13·14) | Week 7 (이번) | |
| 모델 | GPT-4.1-nano (OpenAI 클라우드) | Llama 3.2 3B (오픈소스) |
| 학습 위치 | OpenAI 서버 (블랙박스) | 내 GPU 한 장 (Colab) |
| 가중치 접근 | 불가 (API로만) | 전부 열려 있음 |
| 방법 | 파일 업로드 → 서버가 알아서 | QLoRA로 직접 학습 |
한마디로 Week 6이 남의 주방에 재료를 맡기는 것이었다면, Week 7은 내 주방에서 직접 칼을 잡는 것이다.
가중치를 직접 만질 수 있으니 "어디를, 어떻게, 얼마나" 학습할지 내가 정한다. 대신 그만큼 알아야 할 것도 많아진다.
그리고 한 가지 더 짚고 갈 게 있다. 우리가 고른 모델은 겨우 30억 파라미터짜리다.
Llama 3.2 3B (30억, 게다가 4비트로 양자화) vs GPT-5 (수조 단위 파라미터)
체급이 말이 안 되게 차이 난다. 강사가 굳이 이 작은 모델을 고른 건, "좁은 태스크 하나에선 작은 전문가가 거대한 제너럴리스트를 이길 수 있다" 는 가설을 검증하려는 거다. 우리가 시키는 일은 "온 세상 질문에 답하기"가 아니라 "상품 설명 보고 가격 맞히기" 딱 하나니까.
이번 주 진행 순서는 이렇게 깔린다.
- Day 1 — QLoRA가 뭔지 (이 글의 2~5장)
- Day 2 — 데이터 만들기 + 베이스 모델 측정 (이 글의 6~7장)
- Day 3~4 — 실제로 학습 돌리기 (다음 글)
- Day 5 — 최종 평가 (다음 글)
2. 3B(30억) 파라미터 모델을 그냥 학습 못 하는 이유
QLoRA를 이해하려면, 먼저 "왜 그냥은 안 되는가" 부터 봐야 한다.
우리가 쓸 Llama 3.2 3B는 이름 그대로 약 30억 개의 파라미터를 가진 모델이다.
Week 6에서 직접 만든 딥러닝 신경망이 2억 개 정도였으니, 그보다 15배쯤 크다.
그런데 모델을 "학습한다"는 건 단순히 가중치를 GPU에 올려 추론(inference)에 쓰는 것과는 차원이 다른 일이다.
학습 한 바퀴는 대략 이런 단계로 돌아간다.
- 모델 파라미터(weights)를 GPU 메모리에 올린다
- 입력을 넣고 forward pass(순전파)를 수행한다
- 정답과 비교해 loss(얼마나 틀렸는지)를 계산한다
- backward pass(역전파)로 각 파라미터의 gradient(기울기)를 구한다
- optimizer가 그 gradient를 받아 파라미터를 업데이트한다
문제는 이 과정에서 GPU 메모리에 올라가 있어야 하는 게 모델 원본 가중치 하나가 아니라는 점이다.
모델 파라미터 30억 개를 전부 학습하려면 최소한 이만큼이 동시에 필요하다.
- 원본 가중치
- gradient (각 파라미터마다 하나씩)
- optimizer state (Adam류는 파라미터당 추가로 상태값을 들고 있다)
- 중간 activation (forward pass에서 나온 중간 결과들)
그래서 강의 노트에 이렇게 적어뒀다.
"모델을 올리는 것"보다 "학습하는 것"이 훨씬 더 비싸다.
감을 잡아보자면, Llama 3.2 3B는 가중치만 올려도 대략 13GB 정도를 차지한다. (3bilion * 32 bits(4byte) = 13gb)
그런데 이건 어디까지나 학습을 시작하기도 전의 숫자다. 위의 gradient·optimizer state·activation까지 얹히면 실제 학습에 드는 메모리는 그보다 훨씬 불어난다.
→ 노트북에 흔히 달려 있는 GPU 한 장(16GB 안팎)으로는 30억 파라미터를 통째로 학습하는 건 사실상 불가능하다는 얘기다.
(일반적인 인프라로는 모델 파라미터 전체를 건드리는 풀 파인튜닝은 쉽지 않다는 의미)

그렇다면 방법은 두 갈래다.
① 학습해야 할 파라미터 수 자체를 줄이거나
② 파라미터 하나하나가 차지하는 메모리를 줄이거나.
QLoRA는 이 둘을 동시에 쓴다.
앞쪽이 LoRA(파라미터 수 줄이기),
뒤쪽이 Q = Quantization(파라미터당 메모리 줄이기)다.
다음 장에서 이 둘을 차례로 뜯어본다.
3. LoRA — 원본은 얼리고(freeze), 작은 어댑터만 학습
앞 장에서 "학습할 파라미터 수를 줄이는 쪽"이 LoRA라고 했다.
LoRA(Low-Rank Adaptation)의 발상은 한 문장으로 요약된다.
원본 Llama는 그대로 두고, 그 위에 작은 보정치만 학습해서 원하는 동작을 만들자.
이걸 풀면 핵심이 세 개다.
① 원본은 전부 얼리고(freeze),
② 그중 중요한 곳 몇 군데만 고르고,
③ 거기에 작은 행렬 두 개를 붙여 그것만 학습한다.
먼저 ①.
full fine-tuning이 원본 모델의 모든 가중치(파라미터)를 업데이트하는 것이라면, LoRA는 원본 30억 개 파라미터를 전부 고정한다. gradient 기반 최적화의 대상에서 빼버리는 것이다. 원본 가중치는 한 톨도 바뀌지 않는다.
→ 학습 대상이 줄어드니 파라미터 외에 gradient·optimizer state·activation을 들고 있어야 할 양도 같이 줄어든다.
비유하면 이렇다.
- full fine-tuning: 자동차 엔진을 통째로 뜯어 전부 개조
- LoRA: 엔진은 그대로 두고, 성능에 영향을 주는 작은 조절 장치만 추가
3-1. 어디를 건드릴까 — target modules
그럼 어디에 그 "작은 조절 장치"를 붙일까?
모델의 모든 부분을 다 건드리는 게 아니라, 영향력이 큰 특정 선형층(linear layer) 만 골라서 거기에만 LoRA를 붙인다.
이 고른 층들을 target modules라고 부른다.
예를 들어 아래와 같은 영역들이 있다.
- Attention안의 projection Layer 들:
- q_proj
- k_proj
- v_proj
- o_proj
- 혹은 MLP 안의:
- gate_proj
- up_proj
- down_proj
이런 선형 층들이 모델이 정보를 "표현하는 방식"을 크게 좌우하기 때문에, LoRA는 여기를 노린다. 발상은 이렇다.
모델 전체를 다시 배우게 하지 말고, 행동을 바꾸는 데 효과가 큰 지점 몇 군데만 살짝 조정하자.
참고로 이번 실험에선 "가벼운 설정(lite)"은 attention의 4개 층만, "본격 설정(full)"은 MLP까지 합쳐 7개 층을 target으로 잡는다. 어디까지 건드릴지가 곧 학습량을 정하는 셈인데, 이 둘이 실제로 파라미터 수에서 얼마나 차이 나는지는 5장에서 직접 세본다.
3-2. 왜 LoRA에서 행렬(rank)이 둘이고, alpha는 무엇인가
원래 선형층에서의 계산은 이렇다.
y = Wx
- W : 원본 모델의 큰 가중치 행렬
- x : 입력, y : 출력
full fine-tuning이면 이 W 자체를 바꾼다. 하지만 LoRA는 W를 건드리지 않고, 대신 아래처럼 생각한다.
y = (W + ΔW) x
여기서 ΔW만 새로 학습하는 것이다.
즉 원래 가중치 W는 그대로 두고,작은 보정 행렬 ΔW 를 더해서 동작을 바꾸는 것이다.
그런데 문제가 하나 있다. W와 더하려면 ΔW도 W와 같은 크기여야 한다. (행렬 연산 상)
근데 큰 크기 그대로 ΔW를 만들어 학습하면, 결국 파라미터가 원본만큼 많아져서 LoRA를 쓰는 의미가 사라진다. (학습해야 할 파라미터 수 자체를 줄여 메모리를 덜 차지)
그래서 LoRA는 ΔW를 직접 만들지 않고, 두 개의 작은 행렬 곱으로 표현한다. 이게 LoRA에서의 행렬(rank)이 둘인 이유다.
ΔW = B A
- A ∈ R^(r × d_in) — 입력 차원을 작은 r로 줄인다
- B ∈ R^(r × d_out) 방향으로 — 다시 출력 차원으로 늘린다
- r 은 아주 작은 값 (보통 8, 16, 32, 64)
두 행렬을 곱하면 B A 의 크기는 다시 (d_out × d_in) 이 되어 원본 W와 딱 맞는다.
차원 문제는 풀리는데 파라미터 수는 훨씬 적은 것이다.
직관적으로는, 큰 수정 행렬 하나를 통째로 배우는 대신 아주 낮은 차원(r)의 병목(bottleneck)을 거쳐서 근사하는 것이다.
그래서 이름이 Low-Rank Adaptation(낮은 랭크의 적응)이다. 모델이 변할 수 있는 자유도를 일부러 작게 묶어두는 대신, 훨씬 적은 파라미터로도 꽤 효과적인 미세조정을 해낸다.
실제로 노트북에서 LoRA를 붙인 모델 구조를 찍어보면, 원래 한 줄이던 q_proj 가 이렇게 바뀌어 있다.
(q_proj): lora.Linear4bit(
(base_layer): Linear4bit(in=3072, out=3072) ← 원본 W (고정, 4bit)
(lora_A): Linear(in=3072, out=32) ← A: 3072 → 32 (r=32)
(lora_B): Linear(in=32, out=3072) ← B: 32 → 3072
(lora_dropout): Dropout(p=0.1)
)
원본 base_layer는 그대로 얼어 있고, 그 옆에 lora_A(3072→32)와 lora_B(32→3072) 두 행렬만 새로 붙은 게 눈에 보인다.
학습 중 gradient가 실제로 반영되는 건 이 A·B 두 행렬뿐이다.

마지막으로 alpha 가 왜 필요한지를 설명하겠다.
행렬 A와 B를 곱해 ΔW = BA를 만들었는데, 이로 인한 모델의 보정값이 너무 약하거나 너무 셀 수 있다.
그래서 alpha를 곱해 LoRA의 개입 세기를 조절할 수 있는 것이다.
y = W x + (α / r) · B A x
- alpha가 크면 → LoRA가 원본에 더 강하게 개입
- alpha가 작으면 → 더 약하게 개입
즉 alpha는 "이 LoRA를 통한 보정을 원본에 얼마나 크게 반영할까" 를 정하는 볼륨 손잡이다.
경험적으로는 보통 alpha = 2 × r 정도로 잡는다(이번 실험도 그렇게 설정한다).
정리하면 LoRA는
1) 원본 모델 가중치는 얼리고(freeze),
2) 중요한 선형층만 골라(target modules), 거
3) 기에 작은 두 행렬 A·B를 붙여(low-rank) 그 작은 행렬만 학습하고,
4) alpha로 세기를 조절해 보정을 얼마나 원본에 크게 반영할지를 정하는 작업이다.
이걸로 "학습할 파라미터 수"는 확 줄었다.
그런데 얼려둔 원본 파라미터 30억 개가 메모리에 떡하니 올라가 있는 문제는 아직 그대로다.
→ 그걸 해결하는 게 다음 장의 Q, 양자화다.
4. QLoRA의 Q — 양자화로 4비트까지 줄이기
LoRA로 "① 학습해야 할 파라미터 수 자체"는 줄였다.
그런데 3장 끝에서 말했듯, 얼려둔 원본 30억 개 파라미터는 여전히 메모리에 통째로 올라가 있다.
학습은 안 하더라도 forward pass에서 계속 쓰이니까 어딘가엔 있어야 한다.
이 덩치를 줄이는 게 QLoRA의 Q, 양자화(Quantization) 다. (② 파라미터 하나하나가 차지하는 메모리를 줄이기)
왜 이게 문제냐면 — 파라미터 하나하나가 기본적으로 32비트 부동소수점으로 저장되기 때문이다.
계산해보면:
30억 개 × 32비트(= 4바이트) ≈ 13GB (강사의 말)
3B "작은" 모델인데도 32비트로 저장하면 가중치만 13GB다. 그럼 이렇게 생각해볼 수 있다.
"정확도를 유지한 채로 저장하지 말고, 각 가중치의 정밀도(precision)를 좀 낮추면 어떨까?"
물론 공짜는 아니다. 정밀도를 거칠게 깎는 거라, 사전학습에서 배운 것을 일부 버리는 셈이긴 하다.
하지만 강사 말로는 그 영향이 생각보다 크지 않고, 차라리 레이어 수를 줄이는 것보다 훨씬 낫다. 대신 얻는 메모리 이득은 확실하다.
실제로 노트북에서 같은 모델을 정밀도만 바꿔가며 올려보고 get_memory_footprint()로 재면 이렇게 줄어든다.
| 정밀도 | 메모리 footprint |
| 기본 로드 (bf16, 16비트) | 6.4 GB |
| 8비트 양자화 | 3.6 GB |
| 4비트 양자화 (NF4) | 2.20 GB |
잠깐, 앞에서 "32비트면 13GB"라고 했는데 표의 첫 줄은 왜 6.4GB일까? → Llama 3.2가 기본으로 bf16(16비트)으로 로드되기 때문이다.
30억 × 2Byte(16bit) ≈ 6.4GB. 즉 13GB는 "각 가중치가 32비트(4바이트)였다면"의 이론값이고, 노트북이 아무 양자화 없이 라마 3.2 에서 기본 지원하는 bf16으로 올린 실측치가 6.4GB다.
둘 다 같은 모델을 가리키는데, 저장하는 비트 수에 따라 메모리가 이렇게 달라진다는 게 바로 양자화의 핵심이다.
여기서 한 발 더 나아가 8비트(3.6GB) → 4비트(2.2GB)로 더 짓누르는 것이다.



quant_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_use_double_quant=True,
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_quant_type="nf4",
)
옵션 하나씩 풀어보면:
- load_in_4bit=True — 모델 가중치를 4비트로 저장한다.
- bnb_4bit_quant_type="nf4" — 핵심. 4비트는 가질 수 있는 값이 16개뿐인데, 그냥 균등하게 16칸으로 나누는 게 아니라 정규분포에 맞춰 16개의 "거친 위치"를 배치한다. 모델 가중치 값들이 대체로 0 근처에 정규분포처럼 몰려 있으니, 그 분포를 반영한 NF4(Normal Float 4)가 일반 int4보다 정보를 덜 잃는다.
- bnb_4bit_use_double_quant=True — 양자화하면서 생기는 보조 상수들까지 한 번 더 압축해 메모리를 조금 더 아낀다.
- bnb_4bit_compute_dtype=torch.bfloat16 — 저장은 4비트로 하되, 실제 계산은 bfloat16으로 한다. 정확도와 속도의 균형을 맞추는 부분이다.
여기서 헷갈리기 쉬운 포인트가 하나 있다. 양자화하는 건 어디까지나 "원본 base 모델"뿐이고, 우리가 새로 붙여 학습할 LoRA 행렬(A·B)은 32비트 그대로 둔다. 무거운 원본은 4비트로 짓눌러 메모리를 아끼고, 정작 학습이 일어나는 작은 어댑터는 정밀도를 온전히 유지하는 것이다. → 이래서 이름이 Q + LoRA = QLoRA다.
3장의 그림 위에 이 장을 얹으면 이렇게 정리된다.
- LoRA → 학습할 파라미터 "개수"를 줄인다 (원본은 얼리고 작은 A·B만)
- Q (양자화) → 얼려둔 원본 파라미터 "하나당 메모리"를 줄인다 (16/32비트 → 4비트)
두 트릭을 합치니, 13GB짜리 모델을 학습용으로도 단일 GPU에 욱여넣을 수 있게 된다.
이게 "30억 파라미터를 노트북 GPU에서 학습"이 가능한 진짜 이유다.
5. LoRA 어댑터는 실제로 얼마나 작을까
LoRA가 "작은 행렬만 학습한다"고 계속 말했는데, 그래서 얼마나 작은 걸까? 말로만 "작다"가 아니라 직접 측정해본다.
어차피 LoRA 어댑터 A·B는 차원이 다 정해져 있으니, 행렬 원소 개수를 더하면 된다.
먼저 우리 모델(Llama 3.2 3B)의 선형층(linear layer) 차원부터 정리하면 이렇다. (3장에서 본 q/k/v/o, MLP 층들의 실제 크기)
| 층 | 입력 → 출력 |
| q_proj | 3072 → 3072 |
| k_proj | 3072 → 1024 |
| v_proj | 3072 → 1024 |
| o_proj | 3072 → 3072 |
| gate_proj | 3072 → 8192 |
| up_proj | 3072 → 8192 |
| down_proj | 8192 → 3072 |
각 층에 붙는 LoRA는 A(입력→r)와 B(r→출력) 두 행렬이다. 그러니 한 층의 LoRA 파라미터 수는 (입력 × r) + (r × 출력) 으로 떨어진다.
① 가벼운 설정 (lite): r = 32, attention 4개 층만 LoRA 학습
r = 32
# attention 층만 (각 층 = 입력×r + r×출력)
lora_q_proj = 3072*r + 3072*r
lora_k_proj = 3072*r + 1024*r
lora_v_proj = 3072*r + 1024*r
lora_o_proj = 3072*r + 3072*r
lora_layer = lora_q_proj + lora_k_proj + lora_v_proj + lora_o_proj
params = lora_layer * 28 # decoder layer가 28개
size = (params * 4) / 1_000_000 # 32비트(4바이트)로 저장
print(f"Total params: {params:,}, size {size:,.1f}MB")
출력:
Total params: 18,350,080, size 73.4MB
→ 30억 개짜리 파라미터를 가진 모델에 붙는 학습 대상이 고작 1,835만 개, 용량으로 치면 약 73MB다.
원본의 0.6%쯤밖에 안 된다. 학습이 끝나면 이 73MB짜리 어댑터만 따로 저장하면 되니,
모델 하나를 통째로 복제해 들고 다닐 필요가 없다.
② 본격 설정 (full): r = 256, attention + MLP 7개 층 전부 LoRA 학습
같은 계산을 r을 256으로 키우고, MLP 3개 층(gate/up/down)까지 target에 넣어서 돌리면:
r = 256
# attention 4개 + MLP 3개 (gate/up/down: 3072*r + 8192*r ...)
# ... 같은 방식으로 7개 층을 28번
출력:
Total params: 389,021,696, size 1,556.1MB
→ 이쪽은 약 3.9억 개, 1.5GB짜리 어댑터다.
lite보다 20배 넘게 크다. 강사도 이건 "꽤 극단적인(aggressive) LoRA 설정" 이라고 부른다.
학습 데이터가 워낙 많아서(full 데이터셋) "그만큼 많이 학습시켜보겠다"는 의도로 일부러 r과 target을 키운 것이다.
두 설정을 나란히 놓으면 3장에서 깔아둔 "linear layer에서 어디까지 건드릴지가 학습량을 정한다"는 말이 숫자로 와닿는다.
| 설정 | r | target 층 | 학습 파라미터 | 어댑터 용량 |
| lite | 32 | attention 4개 | 약 1,835만 | 73 MB |
| full | 256 | attention + MLP 7개 | 약 3.9억 | 1,556 MB |


어느 쪽이든 핵심은 같다. 30억 개짜리 원본은 손도 안 대고, 그 옆에 붙인 작은 어댑터(73MB ~ 1.5GB)만 학습한다.
이렇게 "학습 대상"을 잘게 줄여놓은 덕분에, 4장의 양자화와 합쳐 단일 GPU 학습이 현실이 되는 것이다.
다음 장부터는 이론을 떠나, 이 모델에게 먹일 학습 데이터를 직접 만드는 이야기로 넘어간다.
6. 학습 데이터 만들기 — 프롬프트 설계
여기까지가 Day 1, "어떻게 작은 GPU에 욱여넣나(QLoRA)"였다.
Day 2부터는 모델에게 먹일 데이터를 만진다.
학습 코드를 돌리기 전에, 모델이 보게 될 입력과 정답을 어떤 모양으로 빚을지부터 정해야 한다.
데이터셋은 강사가 미리 만들어 HuggingFace에 올려둔 걸 쓴다. 이번 글은 가벼운 쪽(lite)으로 진행한다.
- ed-donner/items_prompts_lite — train 20,000 / val 1,000 / test 1,000
- (full 버전은 train 80만 / val·test 1만 — 다음 글의 본격 학습용)
한 샘플은 이렇게 생겼다. 1장에서 봤던 그 형태다.
{
'prompt': 'What does this cost to the nearest dollar?\n\n'
'Title: Schlage F59 ... Interior Knob ...\n\n'
'Price is $',
'completion': '64.00'
}
모델 입장에서 할 일은 단순하다.
Price is $ 로 끝나는 prompt를 받아, 그 뒤에 올 completion(64.00)을 이어서 생성하는 것.
이 prompt/completion 한 쌍을 만드는 게 make_prompts 메서드다.
def make_prompts(self, tokenizer, max_tokens, do_round):
tokens = tokenizer.encode(self.summary, add_special_tokens=False)
if len(tokens) > max_tokens:
summary = tokenizer.decode(tokens[:max_tokens]).rstrip()
else:
summary = self.summary
self.prompt = f"{QUESTION}\n\n{summary}\n\n{PREFIX}"
self.completion = f"{round(self.price)}.00" if do_round else str(self.price)
여기서 설계 결정이 두 개 숨어 있다. (1) summary를 110 토큰에서 자른다,
그리고 (2) 정답을 반올림할지 말지를 train/test에서 다르게 둔다. 하나씩 보자.
① 프롬프트 구조 — 왜 Price is $ 까지 미리 넣을까
프롬프트는 세 조각을 이어 붙인다.
What does this cost to the nearest dollar? ← QUESTION
<상품 summary>
Price is $ ← PREFIX
여기서 포인트는 마지막 PREFIX = "Price is $" 에 $ 기호까지 미리 박아둔다는 점이다. 모델은 base 모델(다음 토큰을 이어 쓰는 모델)이라, 프롬프트를 $ 로 끝내두면 그 뒤에 자연스럽게 숫자만 이어 쓰게 유도된다. "가격이 얼마인가요?"라고 열어두는 것보다 출력이 훨씬 깔끔해진다.
② CUTOFF = 110 — summary를 잘라내는 이유
make_prompts의 앞부분은 summary를 토큰화해서 110개를 넘으면 앞 110개만 남기고 자른다(tokens[:max_tokens]). 왜 굳이 길이 상한을 둘까? 노트에 네 가지로 정리해뒀다.
- 학습 비용/속도 — 트랜스포머의 attention은 시퀀스 길이에 대해 O(n²) 다. summary가 길수록 메모리·시간이 급격히 는다.
110으로 자르면 batch를 키울 수 있고 한 epoch이 훨씬 빨라진다. - 패딩 낭비 줄이기 — 배치 학습은 시퀀스를 같은 길이로 맞춰 패딩한다. 일부 샘플만 200~500 토큰이면 나머지도 거기 맞춰 패딩돼, 계산량 대부분이 의미 없는 [PAD] 토큰에 쓰인다. 상한을 두면 이 낭비가 준다.
- 정답 토큰 보호 — 이게 제일 중요하다. 프롬프트는 ... summary ... Price is $123.00 순서인데, summary가 너무 길면 모델 max length에 걸려 맨 뒤의 123.00(= loss를 계산하는 정답)이 잘려나간다. summary를 미리 잘라두면 정답 토큰은 항상 살아남는다.
- 노이즈 차단 — 긴 상품 설명엔 스펙 나열·마케팅 문구 같은 군더더기가 많다. 핵심 정보가 앞쪽 110 토큰에 몰려 있다고 보고 잘라내, 신호 대 잡음비를 높인다.
그럼 왜 하필 110일까? 셀 위쪽에서 전체 데이터의 토큰 수 히스토그램을 그려보고, 잘려나가는 샘플 비율이 아주 작게(몇 %) 떨어지는 지점으로 잡은 값이다. 대부분 데이터는 손실 없이 두고, 소수의 outlier만 truncate되도록 한 것이다.

곁다리 하나: Llama 토크나이저는 0~999를 토큰 하나로 처리한다.
가격 대부분이 이 범위에 들어가니, 정답 가격이 토큰 1개로 깔끔하게 떨어진다 → 이 가격 예측 태스크에 Llama가 잘 맞는 이유 중 하나다.
③ do_round — train은 반올림, test는 원본 가격
마지막 줄에서 정답(completion)을 만들 때 do_round 로 분기한다.
- do_round=True → f"{round(price)}.00" (예: 123.00)
- do_round=False → str(price) (예: 123.45)
그리고 데이터를 두 루프로 나눠, train/val에는 True, test에는 False 를 준다.
for item in train + val:
item.make_prompts(tokenizer, CUTOFF, True) # 정답을 반올림 → "123.00"
for item in test:
item.make_prompts(tokenizer, CUTOFF, False) # 정답을 원본 그대로 → "123.45"
왜 다르게 줄까?
- train/val을 반올림하는 이유: 정답을 정수로 만들면(.00 고정) 모델이 배울 패턴이 단순해진다. 사실상 "정수 분류"에 가까워져 출력 공간이 줄고, 토큰도 일관되며, 수렴이 빨라진다. 1센트 단위까지 맞히려 들면 학습이 어렵고 노이즈만 는다.
- test를 원본으로 두는 이유: 평가는 진짜 정답으로 해야 공정하다. 학습은 반올림 값으로 했어도, 최종 채점은 실제 가격($123.45)과 모델 예측을 비교해야 한다.
이 루프를 돌고 나면 모든 샘플에 prompt(... Price is $로 끝남)와 completion(정답 숫자)이 채워지고,
{"prompt": ..., "completion": ...} 형태로 HuggingFace Hub에 올라가 Day 3의 학습에 그대로 쓰인다.
데이터 준비가 끝났으니, 이제 마지막으로 — 학습을 시작하기 전에 베이스 모델(라마 3.2)이 이 문제를 얼마나 못 푸는지(= 우리의 비교 기준선)부터 재본다.
7. 파인튜닝 전, 베이스 모델은 얼마나 못할까
이제 글의 마지막 단계다. 학습을 시작하기 전에, 베이스 모델 Llama 3.2 3B가 가격 예측을 얼마나 못 하는지부터 재둔다.
이 숫자가 곧 다음 글에서 "파인튜닝이 얼마나 끌어올렸나"를 가늠할 비교 기준선(baseline) 이 된다.
(이전 글(Week 6)에서도 강조했지만, 기준선 없는 성능 숫자는 의미가 없다.)
왜 base 모델인가 — instruct(챗) 모델이 아니라
먼저 짚고 갈 게 하나 있다. 우리가 쓰는 건 Llama-3.2-3B, 즉 base 모델이다.
보통 우리가 챗으로 쓰는 모델은 그 위에 다시 학습을 얹은 instruct(챗) variant다. 둘의 차이는 이렇다.
- base 모델 — 인풋 토큰이 들어오면 그다음에 올 가장 가능성 높은 토큰을 예측하도록만 학습된 모델. (순수한 "이어 쓰기" 기계)
- instruct 모델 — 거기에 "지시를 따르고 대화하도록" 추가 학습을 얹은 모델.
그래서 6장에서 system/user 프롬프트 같은 챗 형식을 안 쓰고 그냥 ... Price is $ 로 끝나는 평문을 던진 것이다. 우리가 하려는 건 대화가 아니라 "가격 예측"이라는 새 기술 하나를 가르치는 것이고, 이렇게 단일 기술을 가르칠 땐 잡다한 지침이 안 붙은 base 모델이 오히려 더 깔끔한 출발점이다.
모델 로드 — pad_token 트릭
베이스 모델은 4장의 그 4비트 설정(NF4)으로 올린다. 토크나이저 쪽에 작은 트릭이 하나 있다.
tokenizer.pad_token = tokenizer.eos_token # Llama엔 pad 토큰이 따로 없어서
tokenizer.padding_side = "right"
Llama 계열은 패딩 토큰이 따로 없어서, 문장 끝 토큰(eos)을 패딩용으로 빌려 쓰는 게 관례다.
예측 함수 — model_predict
베이스 모델에게 한 상품을 물어보는 함수는 이렇게 생겼다.
def model_predict(item):
inputs = tokenizer(item["prompt"], return_tensors="pt").to("cuda")
with torch.no_grad():
output_ids = base_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)
포인트 두 개만 짚으면:
- max_new_tokens=8 — 새로 만들 토큰을 8개로 제한한다. 우리가 원하는 건 249.99 같은 짧은 숫자뿐이라, 8개면 충분하고 길게 풀면 모델이 횡설수설하며 시간만 쓴다.
- output_ids[0, prompt_len:] — generate()는 입력 프롬프트 + 새 토큰을 합쳐서 돌려준다. 그래서 입력 길이(prompt_len)만큼 잘라내고 새로 생성된 부분만 디코딩한다. 안 그러면 프롬프트가 통째로 따라 나와 후처리가 번거롭다.
실제로 돌려보면 — 처참하다
test 첫 샘플(정답 $219)을 던지면, 베이스 모델은 이렇게 답한다.
정답: $219
모델 출력: '179.00. The pedal is currently'
가격을 $179로 빗나간 것도 문제지만, 더 눈에 띄는 건 숫자만 뱉고 멈추질 못하고 "The pedal is currently..." 하고 문장을 이어 쓰려 한다는 점이다. 그야 그럴 게, 이 모델은 아직 "가격만 답하는 법"을 배운 적이 없다. 그냥 "그럴듯한 다음 토큰"을 이어 쓰는 base 모델일 뿐이니까.
이런 식으로 test 1,000건 전체를 evaluate(model_predict, test)로 돌려 평균 오차를 내면 결과는 이렇다.
Base Llama 3.2 (4bit) — 평균 오차 $185.52

이 $185.52가 어느 정도냐면, 전체 성적표에서 가장 못하는 축이다. 참고로 같은 표의 다른 기준점 몇 개만 미리 꺼내보면:
- 사람(강사 Ed)이 직접 맞힌 오차: $87.62
- GPT-4.1-nano 제로샷: $62.51
지금의 베이스 모델은 사람은커녕 작은 프론티어 모델에도 한참 못 미친다. → 바로 이 $185.52가 다음 글의 출발선이다.
마무리
이번 글에서는 모델을 한 번도 "학습"시키지 않았다. 대신 학습을 시작하기 위한 판을 깔았다.
- 왜 30억 파라미터를 그냥 학습할 수 없는지 (메모리 벽)
- LoRA — 원본은 얼리고 작은 어댑터 A·B만 학습 (학습 파라미터 수 ↓)
- Q(양자화) — 얼려둔 원본을 4비트로 (파라미터당 메모리 ↓)
- 어댑터가 실제로 얼마나 작은지 (73MB ~ 1.5GB)
- 데이터/프롬프트 설계 (CUTOFF=110, do_round)
- 그리고 베이스라인 측정 — $185.52, 현재 꼴찌
느낀점
- 말로만 LoRA, QLoRA 이야기를 많이 들었었는데 실제로 모델의 행렬계산 단에서 어떻게 작동하는 것인지
어떻게 적은 GPU로 파인튜닝이 가능했었던 것인지 실제 LoRA의 원리를 뜯어보며 이해할 수 있던 게 의미 있었다.
- 그리고 실제로 코드로 찍어보며 모델의 크기가 양자화로 줄어드는 과정, LoRA 어댑터는 어느정도 사이즈인지 등을 재볼 수 있던 것 자체가 의미가 있었다
- 추가로 base 모델과 instruct 모델의 차이를 보는 것도 의미가 있었는데, 단순 다음 토큰을 예측하도록만 작동이 되는 base 모델이 오히려 이런 가격 예측 task와 같은 한정적인 상황에서는 파인튜닝할 때 더 적합한 모델이라는 점이 인상적이었다. (잡다한 대화 지침등이 안붙어있는)
- Llama 토크나이저는 0~999를 토큰 하나로 처리하는 것을 파악해, "가격 대부분이 이 범위에 들어가니, 정답 가격이 토큰 1개로 깔끔하게 떨어진다 → 이 가격 예측 태스크에 Llama가 잘 맞을 거다" 를 생각해내는 강사가 대단하다고 느껴졌다. 결국은 해결하려는 비즈니스에 맞는 파인튜닝 모델 선정도 중요하다고 느꼈다.
- train/val 부분과 test 부분에서 가격 예측을 측정할떄 한쪽은 반올림을 진행하고, 한쪽은 없이 진행하는 것도 인상적이었다. (모델이 배울 패턴을 단순화 시켜서 학습 효율을 높이고, 대신 실제 측정은 공정성을 위해 반올림 없이 진행하는 등)
다음 글(글 16)에서는 드디어 이 판 위에서 실제로 학습을 돌린다.
1) 학습을 좌우하는 하이퍼파라미터들(epochs·batch·learning rate 등),
2) 학습이 forward → loss → backward → 업데이트로 도는 4단계에 대한 정리
3) 그리고 글 초반에 흘린 스포일러에 대한 회수와 (라마 3.2 모델이 어떻게 GPT 5.1까지 이기게 되는지) 같은 "파인튜닝"인데 Week 6과 왜 결과가 정반대인지도 그때 정리하겠다.
다음 글에서 오픈소스 모델을 파인튜닝한 결과로 찾아오겠습니다.
(꾸준히 공부하고 적을테니 많은 관심 부탁드립니다.)
Profile:
Linkedin
'AI 공부' 카테고리의 다른 글
| 17. 상품 핫딜 헌팅 에이전트 (1) - 파인튜닝 모델 서비스화 및 가격 예측 에이전트 개발 (0) | 2026.08.02 |
|---|---|
| 16. 3B 오픈소스 모델(Llama 3.2)로 GPT-5.1 이겨보기 — QLoRA 파인튜닝 실전 (0) | 2026.07.11 |
| 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 |