본문 바로가기
TYLER SONGBlog
← Blog index
LLM Research8 min read

Project 1 (3일차) 구조·하이퍼파라미터 실험: 무엇이 perplexity를 낮추는지 seed 노이즈와 비교해 판정하기

어제까지 만든 Tiny GPT의 층 수, hidden dimension, learning rate, context length를 하나씩 바꿔 가며 val perplexity가 얼마나 달라지는지 표로 정리한다. 같은 설정도 seed에 따라 결과가 흔들리므로 seed 2개로 노이즈 크기를 재고, 개선폭이 그보다 큰지 판정하는 법을 배운다. "좋아진 것 같다"가

By 송민성

오늘의 목표

오늘은 Tiny GPT의 설정을 한 번에 하나씩 바꿔 가며 어떤 변경이 validation perplexity(검증 데이터에서 모델이 얼마나 헷갈리는지를 나타내는 값, 낮을수록 좋음)를 실제로 낮추는지 숫자로 확인한다. 그리고 그 차이가 seed(난수 초기값)만 바꿔도 생기는 흔들림보다 큰지 판정한다.

개념과 직관

오늘 바꿀 네 가지 손잡이

  • 층 수(depth, n_layer): 트랜스포머 블록을 몇 개 쌓았는지. 건물의 층수와 비슷하다. 높을수록 더 복잡한 처리를 순서대로 할 수 있지만 계산이 늘고 학습이 어려워질 수 있다.
  • hidden dimension(너비, n_embd): 토큰 하나를 표현하는 벡터의 길이. 메모장 한 장에 적을 수 있는 칸 수라고 보면 된다. 칸이 많으면 표현력이 늘지만 파라미터가 대략 제곱으로 늘어난다.
  • learning rate(학습률, lr): 한 번의 업데이트에서 파라미터를 얼마나 크게 움직일지. 너무 크면 골짜기를 건너뛰며 튀고, 너무 작으면 정해진 step 안에 도착하지 못한다.
  • context length(block_size): 모델이 한 번에 볼 수 있는 앞 글자(토큰)의 개수. 책을 읽을 때 직전 몇 줄까지 기억하고 다음 말을 맞히는지에 해당한다. 길수록 단서가 늘지만 비용도 늘어난다. 어텐션 계산량은 길이의 제곱에 비례한다고 알려져 있다. (Attention은 아직 깊게 배우지 않았다면, "토큰들끼리 서로 참고하는 계산" 정도로만 기억해 두자.)

Perplexity

perplexity는 평균 손실(loss, 정답을 얼마나 못 맞혔는지의 평균 점수)에서 바로 계산한다.

perplexity = exp(val_loss)

예를 들어 val_loss가 1.5면 perplexity는 약 4.48이다. "다음 글자를 대략 4~5개 후보 중에서 고르는 정도로 헷갈린다"는 뜻이다. 작을수록 좋다.

Seed 분산

같은 설정이라도 seed가 다르면 가중치 초기값과 배치 순서가 달라져 결과가 조금 달라진다. 이 흔들림이 seed 노이즈다. 예를 들어 같은 설정으로 seed 0과 1을 돌렸더니 perplexity가 4.50과 4.58이면 노이즈는 약 0.08이다. 이때 어떤 변경으로 perplexity가 4.55에서 4.52가 됐다고 "개선"이라 부르면 안 된다. 0.03은 노이즈 안에 묻힌다.

공정한 비교의 원칙

  1. 한 번에 하나만 바꾼다. 층 수와 lr을 동시에 바꾸면 무엇 덕인지 모른다.
  2. 같은 데이터, 같은 step 수, 같은 평가 방법을 쓴다.
  3. 비용도 같이 기록한다: 파라미터 수와 학습 시간. 성능이 0.1 좋아졌어도 시간이 3배라면 판단이 달라진다.

코드로 직접 해보기

어제까지 만든 학습 코드가 있다고 가정한다. 이름은 달라도 괜찮다. 핵심은 설정을 딕셔너리로 모아 함수 하나로 학습·평가하는 것이다. 아래는 그 뼈대다. build_model, get_batch, estimate_val_loss는 앞선 Day에서 만든 것을 그대로 쓴다.

1단계: 설정과 실행 함수

python
import time, math, random import numpy as np import torch base = dict(n_layer=4, n_embd=128, lr=3e-4, block_size=128, steps=2000, batch_size=32, eval_every=100) def run(cfg, seed): random.seed(seed); np.random.seed(seed); torch.manual_seed(seed) model = build_model(cfg) # cfg의 n_layer, n_embd, block_size 사용 n_params = sum(p.numel() for p in model.parameters()) opt = torch.optim.AdamW(model.parameters(), lr=cfg["lr"]) curve = [] # (step, val_loss) t0 = time.time() for step in range(1, cfg["steps"] + 1): x, y = get_batch("train", cfg) _, loss = model(x, y) opt.zero_grad(); loss.backward(); opt.step() if step % cfg["eval_every"] == 0: curve.append((step, estimate_val_loss(model, cfg))) return dict(curve=curve, n_params=n_params, sec=time.time() - t0, val_ppl=math.exp(curve[-1][1]))

opt.zero_grad(); loss.backward(); opt.step()은 앞서 배운 학습 루프 그대로다.

2단계: 런 목록 만들기

base에서 하나씩만 바꾼 설정을 만든다. 여기서 base도 하나의 런이다.

python
runs = { "base": base, "deep": {**base, "n_layer": 8}, "wide": {**base, "n_embd": 256}, "high_lr": {**base, "lr": 1e-3}, "long_ctx": {**base, "block_size": 256}, }

5개가 부담스러우면 long_ctx를 빼서 4개로 해도 된다. 목표는 3~4개 변경 런이다.

3단계: seed 2개로 돌리고 저장

python
import json results = {} for name, cfg in runs.items(): results[name] = [run(cfg, seed) for seed in (0, 1)] print(name, [round(r["val_ppl"], 3) for r in results[name]]) json.dump(results, open("exp_day3.json", "w"))

결과를 파일로 저장해 두면 그래프만 다시 그릴 때 재학습하지 않아도 된다.

4단계: 표 만들기

python
print(f"{'run':10} {'params':>9} {'sec':>6} {'ppl_s0':>7} {'ppl_s1':>7} {'mean':>7} {'gap':>6}") for name, rs in results.items(): p = [r["val_ppl"] for r in rs] print(f"{name:10} {rs[0]['n_params']:>9,} {np.mean([r['sec'] for r in rs]):>6.0f} " f"{p[0]:>7.3f} {p[1]:>7.3f} {np.mean(p):>7.3f} {abs(p[0]-p[1]):>6.3f}")

gap은 같은 설정에서 두 seed의 차이, 즉 노이즈의 대략적인 크기다.

5단계: 학습곡선 오버레이

python
import matplotlib.pyplot as plt for name, rs in results.items(): steps = [s for s, _ in rs[0]["curve"]] mean_loss = np.mean([[l for _, l in r["curve"]] for r in rs], axis=0) plt.plot(steps, mean_loss, label=name) plt.xlabel("step"); plt.ylabel("val loss"); plt.legend(); plt.grid(alpha=.3) plt.savefig("curves_day3.png", dpi=150)

한 그림에 모든 런을 겹쳐 그리면 "어디서 갈라지는지", "아직 내려가는 중인지"가 보인다.

오늘의 실험

바꾸는 것: base 대비 깊이(4→8층), 너비(128→256), lr(3e-4→1e-3), context length(128→256) 중 3~4개. 한 런에서는 한 가지만 바꾼다.

고정하는 것: 데이터, step 수, 배치 크기, 평가 방법. 각 설정을 seed 0, 1로 두 번씩 돈다.

관찰하는 것

  1. 최종 val perplexity (seed별, 평균)
  2. 파라미터 수와 학습 시간 (비용)
  3. 학습곡선의 모양: 계속 내려가는가, 중간에 튀는가, train은 내려가는데 val은 올라가는가(과적합, 학습 데이터만 외우는 현상)

판정 규칙 (간단한 기준이며 통계 검정은 아니다)

  • noise = base의 두 seed perplexity 차이(gap)
  • improvement = base 평균 − 변경 런 평균
  • improvement가 noise보다 분명히 크고(예: 2배 이상), 두 seed 모두 base보다 낮으면 "개선"
  • 그렇지 않으면 "차이 불확실"

seed가 2개뿐이라 노이즈 추정 자체가 거칠다. 그래서 결론을 "확정"이 아니라 "이 규모에서는 그렇게 보인다"로 적는 습관을 들이자.

기록 양식 (노트에 그대로 옮긴다)

run변경params시간(s)ppl s0ppl s1평균base 대비판정
base---
deepn_layer 8

아래에 한 줄 결론을 쓴다. 예: "lr 1e-3은 노이즈보다 큰 개선, 8층은 시간 2배에 노이즈 수준." (이 문장은 양식 예시이며 실제 결과는 직접 돌려서 채운다.)

왜 이걸 배우나 (LLM / Post-training 연결)

LLM 연구는 "이 변경이 정말 효과가 있는가"를 작은 실험으로 확인하는 일의 연속이고, 그때 seed 노이즈와 비용을 함께 보는 습관이 결론의 신뢰도를 결정한다. Post-training(사전학습 후 미세조정·정렬 단계)에서도 lr, 데이터 구성, 학습 step을 바꾼 비교를 하게 되는데, 평가 점수가 몇 점 오른 것이 개선인지 우연인지 가르는 사고방식은 그대로 쓰인다.

초보자가 막히는 지점

  • seed를 고정했는데 값이 조금씩 다르다: GPU 연산 일부는 결정적이지 않아 완전히 같은 값이 안 나올 수 있다. 같은 seed를 두 번 돌려 그 차이도 확인해 두면 노이즈의 하한을 알 수 있다.
  • 런마다 여러 개를 동시에 바꾼다: 원인을 분리할 수 없다. 반드시 base에서 하나만 바꾼다.
  • context length를 바꿨는데 비교가 불공정하다: block_size가 다르면 평가하는 위치와 토큰 수가 달라질 수 있다. 평가 방식이 같은지 확인하고, 다르면 표에 메모한다.
  • 큰 모델이 더 나쁘게 나온다: 작은 데이터와 짧은 step에서는 흔하다. 과적합이거나 아직 덜 학습된 것이다. 학습곡선에서 train/val 격차와 기울기를 먼저 본다.
  • lr을 올렸더니 loss가 nan이 된다: lr이 너무 큰 신호다. lr을 낮추거나 이 런을 "발산"으로 기록하고 넘어간다. 실패도 결과다.
  • val loss와 perplexity를 섞어 쓴다: perplexity = exp(loss)다. 표에는 하나로 통일한다.
  • GPU 메모리 부족(OOM): batch_size를 줄이되, 모든 런에서 똑같이 줄여야 공정하다.

알고리즘 (병행 1시간)

LeetCode Medium 그래프 문제 2문제를 푼다. 문제 선택이 어렵다면 아래처럼 유형을 하나씩 골라도 좋다.

  • 그래프 탐색(BFS/DFS) 유형: 예) 섬의 개수 계열
  • 위상 정렬 또는 사이클 판별 유형: 예) 수업 일정 계열

각 문제를 풀고 다음을 노트에 기록한다.

  • 접근: 그래프를 어떻게 표현했는지(인접 리스트 등), 탐색 방식(BFS/DFS)과 선택 이유
  • 시간복잡도 / 공간복잡도: 정점 수 V, 간선 수 E로 표현
  • 틀린 이유: 방문 체크 누락, 시작점 처리, 경계 조건 등 실제로 막힌 지점 한 줄

제한 시간은 문제당 약 30분이다. 막히면 20분 이상 붙잡지 말고 풀이를 본 뒤, 보지 않고 다시 구현해 본다.