Project 1 Ablation · README: 한 번에 하나씩 빼봐야 Tiny GPT에서 진짜 중요한 부품이 보인다
지금까지 만든 Tiny GPT에서 RoPE, pre/post-norm, weight tying, SwiGLU 같은 부품을 한 번에 하나씩 바꿔 가며 성능 변화를 비교합니다. 이런 실험을 ablation(제거 실험)이라고 부릅니다. 결과를 표로 정리해 "가장 중요한 요소"를 근거와 함께 지목하고, README에 재현 가능하게 남기는 것까지 오늘 끝냅니다.
By 송민성
오늘의 목표
Tiny GPT에는 부품이 여럿 들어 있습니다. 이 중 어떤 부품이 성능을 가장 많이 좌우하는지 "느낌"이 아니라 "숫자"로 찾아냅니다. 부품을 하나씩 바꿔서 학습시키고, 그 결과를 표로 만들어 README에 남깁니다.
개념과 직관
Ablation(제거 실험, 모델의 구성요소를 하나씩 빼거나 바꿔서 성능이 얼마나 변하는지 보는 실험)
요리에 비유해 봅시다. 맛있는 찌개가 있는데 재료가 다섯 가지입니다. 어떤 재료가 맛을 좌우하는지 알고 싶다면 한 번에 하나씩만 빼고 끓여 봐야 합니다. 마늘과 파를 동시에 빼면 맛이 변한 이유가 마늘인지 파인지 알 수 없습니다.
원칙 1: 한 번에 하나만 바꾼다. 기준(baseline) 모델을 정하고, 실험마다 딱 하나만 다르게 합니다. 나머지(데이터, 학습 스텝 수, 학습률, 시드, 배치 크기)는 모두 같아야 합니다.
원칙 2: 같은 조건에서 비교한다. 파라미터 수가 달라지는 실험(예: weight tying)은 그 사실을 표에 함께 적습니다. 파라미터가 줄어서 나빠진 건지, 구조 때문에 나빠진 건지 구분해야 하기 때문입니다.
원칙 3: 시드 노이즈를 의심한다. 같은 설정이라도 랜덤 시드(seed, 난수 초기값)가 다르면 결과가 조금 달라집니다. 차이가 시드 간 흔들림보다 작다면 "차이가 있다"고 말하면 안 됩니다. 가능하면 시드를 2~3개 돌려 평균과 범위를 기록합니다.
오늘 비교할 네 가지 부품입니다.
- RoPE 제거: RoPE(회전 위치 인코딩, 토큰의 순서 정보를 벡터를 회전시켜 넣는 방식)를 빼면 모델이 단어 순서를 알 길이 없어집니다. 빼면 크게 나빠질 거라는 가설을 세울 수 있습니다.
- pre-norm vs post-norm: 정규화(Normalization, 값의 크기를 일정하게 맞추는 연산)를 각 블록 앞에 두느냐(pre) 뒤에 두느냐(post)의 차이입니다. 보통 pre-norm이 학습이 더 안정적이라고 알려져 있는데, 우리 크기에서도 그런지 직접 확인합니다.
- weight tying(가중치 공유, 입력 임베딩 행렬과 출력 층 행렬을 같은 파라미터로 쓰는 것): 파라미터 수가 줄어듭니다. 작은 모델에서는 오히려 도움이 되는지 보는 실험입니다.
- SwiGLU vs GELU: 블록 안의 feed-forward(각 토큰 벡터를 독립적으로 변환하는 층)에서 쓰는 활성화 방식 차이입니다. GELU는 단순한 비선형 함수이고, SwiGLU는 두 갈래 중 한쪽이 다른 쪽의 "문 열림 정도"를 조절하는 게이트 구조입니다.
텍스트로 쓴 최소 수식입니다.
GELU 방식 FFN: y = W2 * gelu(W1 * x)
SwiGLU 방식 FFN: y = W2 * ( silu(Wa * x) 곱하기 (Wb * x) )
silu(z) = z * sigmoid(z)SwiGLU는 행렬이 3개(Wa, Wb, W2)라 같은 hidden 크기로 만들면 파라미터가 늘어납니다. 공정하게 비교하려면 중간 차원을 줄여 파라미터 수를 비슷하게 맞춥니다.
결과 표 양식
| 실험 | 바꾼 것 | 파라미터 수 | val loss (평균) | 기준 대비 차이 | 비고 |
|---|---|---|---|---|---|
| baseline | - | 0 | |||
| no-RoPE | RoPE 제거 | ||||
| post-norm | norm 위치 | ||||
| no-tying | 가중치 공유 해제 | ||||
| GELU | SwiGLU→GELU |
평가 지표는 검증 데이터의 loss(예측이 얼마나 틀렸는지 나타내는 값, 낮을수록 좋음)를 씁니다. 학습 loss가 아니라 반드시 검증(val) loss로 비교합니다.
코드로 직접 해보기
핵심은 모델 코드를 복붙해서 여러 개 만들지 않고, 설정(config) 하나로 부품을 켜고 끄는 것입니다. 아래는 Day 1~3에서 만든 구조에 맞춰 붙이는 형태의 예시입니다. 변수명은 본인 코드에 맞게 바꾸세요.
1단계: config에 스위치 추가
from dataclasses import dataclass
@dataclass
class Config:
vocab_size: int = 65
block_size: int = 128
n_layer: int = 4
n_head: int = 4
d_model: int = 128
use_rope: bool = True
norm_style: str = "pre" # "pre" 또는 "post"
tie_weights: bool = True
ffn_type: str = "swiglu" # "swiglu" 또는 "gelu"
seed: int = 02단계: FFN 두 가지를 하나의 클래스로
import torch
import torch.nn as nn
import torch.nn.functional as F
class FFN(nn.Module):
def __init__(self, cfg):
super().__init__()
d = cfg.d_model
self.kind = cfg.ffn_type
if self.kind == "swiglu":
h = int(8 * d / 3) # 행렬 3개라서 파라미터를 GELU(4d)와 비슷하게 맞춤
self.wa = nn.Linear(d, h, bias=False)
self.wb = nn.Linear(d, h, bias=False)
self.w2 = nn.Linear(h, d, bias=False)
else:
h = 4 * d
self.w1 = nn.Linear(d, h, bias=False)
self.w2 = nn.Linear(h, d, bias=False)
def forward(self, x):
if self.kind == "swiglu":
return self.w2(F.silu(self.wa(x)) * self.wb(x))
return self.w2(F.gelu(self.w1(x)))중간 차원 8d/3은 두 방식의 파라미터를 거의 같게 맞추기 위한 계산입니다. GELU는 2 x d x 4d = 8d², SwiGLU는 3 x d x (8d/3) = 8d²입니다.
3단계: pre/post-norm을 블록에서 분기
class Block(nn.Module):
def __init__(self, cfg, attn_cls):
super().__init__()
self.norm_style = cfg.norm_style
self.ln1 = nn.LayerNorm(cfg.d_model)
self.ln2 = nn.LayerNorm(cfg.d_model)
self.attn = attn_cls(cfg) # 앞선 Day에서 만든 attention (use_rope 인자를 받도록)
self.ffn = FFN(cfg)
def forward(self, x):
if self.norm_style == "pre":
x = x + self.attn(self.ln1(x))
x = x + self.ffn(self.ln2(x))
else: # post
x = self.ln1(x + self.attn(x))
x = self.ln2(x + self.ffn(x))
return xpre는 "정규화 후 계산, 그 결과를 원래 값에 더함", post는 "계산 결과를 더한 뒤 정규화"입니다. 줄 위치 하나 차이입니다.
4단계: weight tying 스위치
# 모델 __init__ 끝부분
if cfg.tie_weights:
self.lm_head.weight = self.tok_emb.weight # 같은 텐서를 공유5단계: RoPE 스위치
attention 안에서 q, k에 회전을 적용하는 줄을 if cfg.use_rope:로 감쌉니다. 주의할 점이 있습니다. 기존에 절대 위치 임베딩 없이 RoPE만 쓰고 있었다면, RoPE를 끄는 순간 위치 정보가 아예 사라집니다. 이것이 "위치 정보 없음" 실험임을 표 비고에 적으세요.
6단계: 모든 실험을 루프로 돌리고 기록
import json, dataclasses
experiments = {
"baseline": {},
"no_rope": {"use_rope": False},
"post_norm": {"norm_style": "post"},
"no_tying": {"tie_weights": False},
"gelu": {"ffn_type": "gelu"},
}
results = []
for name, change in experiments.items():
for seed in [0, 1, 2]:
cfg = Config(**{**dataclasses.asdict(Config()), **change, "seed": seed})
val_loss, n_params = train_and_eval(cfg) # 기존 학습 함수 (스텝 수 고정)
results.append({"name": name, "seed": seed,
"val_loss": val_loss, "params": n_params})
with open("ablation_results.json", "w") as f:
json.dump(results, f, indent=2)train_and_eval은 Day 1~3의 학습 루프를 함수로 감싼 것입니다. 모든 실험에서 스텝 수, 학습률, 배치 크기가 같도록 이 함수 안에서 고정하세요.
7단계: 평균 표 출력
from collections import defaultdict
import statistics
g = defaultdict(list)
for r in results:
g[r["name"]].append(r)
base = statistics.mean(x["val_loss"] for x in g["baseline"])
print("| 실험 | 파라미터 | val loss 평균 | 범위 | 기준 대비 |")
print("|---|---|---|---|---|")
for name, rs in g.items():
ls = [x["val_loss"] for x in rs]
m = statistics.mean(ls)
print(f"| {name} | {rs[0]['params']} | {m:.4f} | {min(ls):.4f}~{max(ls):.4f} | {m-base:+.4f} |")오늘의 실험
바꾸는 것: 위 네 가지 스위치를 각각 하나씩만 바꿉니다. 총 5개 설정(baseline 포함) x 시드 3개 = 15번 학습입니다. Tiny GPT라면 한 번이 몇 분 안에 끝나도록 스텝 수를 정해 두세요. 시간이 부족하면 시드를 2개로 줄여도 됩니다.
관찰하는 것:
- 각 설정의 val loss 평균과 시드 간 범위
- 기준 대비 loss 차이
- 학습 곡선이 초반에 불안정해지는지 (특히 post-norm)
기록 방법:
- 실험 전에 가설을 먼저 한 줄씩 적습니다. 예: "no-RoPE가 가장 크게 나빠질 것이다." 결과가 가설과 달라도 괜찮고, 그 자체가 배움입니다.
- 위 표 양식을 채웁니다.
- "가장 중요한 요소" 지목: 기준 대비 loss 증가가 가장 큰 항목을 고르되, 그 차이가 시드 범위보다 큰지 확인하고 근거 문장을 씁니다. 예: "no-RoPE는 +0.xx로 가장 크고 시드 범위(0.0x)보다 훨씬 크다. 따라서 이 설정에서 가장 중요한 요소는 위치 정보다."
- 차이가 시드 범위 안이면 "이 규모에서는 구분되지 않는다"라고 솔직하게 씁니다.
README에 들어갈 구성:
- 프로젝트 한 줄 소개와 모델 크기, 데이터
- 실행 방법(명령어 몇 줄로 재현 가능하게)
- Ablation 결과 표와 학습 곡선 그림 1장
- 결론: 가장 중요한 요소와 근거
- 한계: 작은 모델, 짧은 학습, 적은 시드이므로 큰 모델에 그대로 일반화할 수 없다는 점
왜 이걸 배우나 (LLM / Post-training 연결)
LLM 연구는 "이 변경이 정말 효과가 있었나"를 통제된 비교로 증명하는 일이 대부분이며, ablation은 그 기본 습관입니다. 이후 파인튜닝이나 post-training에서도 데이터 구성, 학습률, 손실 함수를 한 번에 하나씩 바꿔 비교하는 같은 사고방식이 그대로 쓰이고, 잘 쓴 README는 면접에서 실험 설계 능력을 보여 주는 증거가 됩니다.
초보자가 막히는 지점
- 여러 개를 동시에 바꿈: SwiGLU로 바꾸면서 학습률도 살짝 만지면 원인을 알 수 없습니다. config diff가 항상 한 줄이어야 합니다.
- 시드 하나로 결론 내림: 0.01 차이는 노이즈일 수 있습니다. 시드 범위를 꼭 같이 적으세요.
- 파라미터 수 무시: no-tying은 파라미터가 늘고, SwiGLU는 중간 차원을 맞추지 않으면 늘어납니다.
sum(p.numel() for p in model.parameters())로 항상 출력해서 표에 넣으세요. weight tying은 공유된 텐서를 한 번만 세므로parameters()가 알아서 중복을 제거해 줍니다. - train loss로 비교: 과적합(overfitting, 학습 데이터만 외워서 새 데이터에서 성능이 떨어지는 현상) 때문에 train loss는 비교 기준으로 부적절합니다. val loss를 쓰세요.
- post-norm이 발산함: loss가 NaN이 되거나 안 내려가면 버그가 아니라 post-norm의 특성일 수 있습니다. 학습률을 낮춰 다시 돌릴 수는 있지만, 그러면 baseline과 조건이 달라진다는 점을 비고에 적으세요.
- no-RoPE에서 shape 에러:
if cfg.use_rope:분기에서 q, k 변수명이 바뀌지 않았는지 확인하세요. 끈 경우에도 원래 q, k가 그대로 attention 계산에 들어가야 합니다. - README가 "내 컴퓨터에서만" 돌아감: 새 폴더에서 README 명령어만 따라 쳐서 실행되는지 직접 확인하세요.
알고리즘 (병행 1시간)
LeetCode Medium 2문제를 구현 유형(문자열·배열·시뮬레이션처럼 규칙을 코드로 정확히 옮기는 문제)에서 골라 푸세요. 문제당 30분을 목표로 하고, 막히면 30분이 지난 뒤에 풀이를 봅니다.
풀이 후 각 문제마다 아래 세 가지를 노트에 기록합니다.
- 접근: 어떤 아이디어로 풀었는지 2~3문장 (예: 어떤 자료구조를 왜 썼는지)
- 시간복잡도: 입력 크기 n에 대해 어떻게 늘어나는지, 공간복잡도와 함께
- 틀린 이유: 처음 제출이나 시도에서 막힌 원인 (경계값, 인덱스 실수, 조건 누락 등). 틀리지 않았다면 헷갈렸던 지점을 적습니다.