LLM 엔지니어링15장 · 모델 경량화: 양자화, 프루닝, 지식증류18 / 22← 이전목차다음 →MetaContents

15장. 모델 경량화: 양자화, 프루닝, 지식증류

실습 5 — 지식증류: 큰 모델의 답으로 작은 모델 가르치기

실습 4와 같은 손글씨 숫자로, 큰 선생님 신경망(뉴런 256개)과 57분의 1 크기의 학생 신경망(뉴런 16개)을 견줍니다. 정답 표시가 100개뿐이고 나머지 학습 그림에는 표시가 없다고 가정하고, 학생을 정답 100개로만 가르칠 때와 표시 없는 그림에 선생님이 붙인 답으로 가르칠 때를 견줍니다.

1단계. 선생님 신경망 준비(실습 4와 같은 준비)

Python
import time
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split

X, y = load_digits(return_X_y=True)
Xtr, Xte, ytr, yte = train_test_split(X / 16.0, y, test_size=0.3, random_state=42, stratify=y)
Xtr, Xte = torch.tensor(Xtr, dtype=torch.float32), torch.tensor(Xte, dtype=torch.float32)
ytr, yte = torch.tensor(ytr), torch.tensor(yte)


def mlp(h):
    return nn.Sequential(nn.Linear(64, h), nn.ReLU(), nn.Linear(h, h), nn.ReLU(), nn.Linear(h, 10))


def fit(model, X, target, epochs=60, seed=0, soft=False, T=4.0):
    """soft=False 면 정답 번호로, soft=True 면 선생님의 점수(로짓)를 온도 T 로 부드럽게 한 확률을 흉내 내게 학습"""
    torch.manual_seed(seed)
    opt = torch.optim.Adam(model.parameters(), lr=1e-3)
    g = torch.Generator().manual_seed(seed)
    for _ in range(epochs):
        idx = torch.randperm(len(X), generator=g)
        for i in range(0, len(X), 32):
            b = idx[i:i + 32]
            out = model(X[b])
            if soft:   # 학생과 선생님의 부드러운 확률 사이의 거리(KL). T² 를 곱해 크기를 맞춘다
                loss = F.kl_div(F.log_softmax(out / T, 1), F.softmax(target[b] / T, 1), reduction="batchmean") * T * T
            else:
                loss = F.cross_entropy(out, target[b])
            opt.zero_grad()
            loss.backward()
            opt.step()
    return model


accuracy = lambda m: round((m(Xte).argmax(1) == yte).float().mean().item(), 3)
params = lambda m: sum(p.numel() for p in m.parameters())
torch.manual_seed(0)
teacher = fit(mlp(256), Xtr, ytr)
print("선생님: 파라미터", params(teacher), "· 정확도", accuracy(teacher), "· 학생 크기:", params(mlp(16)))
출력
선생님: 파라미터 85002 · 정확도 0.974 · 학생 크기: 1482

2단계. 선생님의 소프트 레이블 들여다보기

Python
with torch.no_grad():
    logits = teacher(Xtr)                                  # 학습 그림 1,257장에 선생님이 매긴 점수
i = int((logits.softmax(1).max(1).values).argmin())        # 선생님이 가장 헷갈린 그림
for T in (1, 4):
    p = (logits[i] / T).softmax(0)
    top = p.topk(3)
    print(f"T={T}: 정답 {ytr[i].item()} · 선생님의 상위 셋", [(int(k), round(float(v), 3)) for v, k in zip(top.values, top.indices)])
출력
T=1: 정답 9 · 선생님의 상위 셋 [(9, 0.974), (3, 0.016), (8, 0.01)]
T=4: 정답 9 · 선생님의 상위 셋 [(9, 0.529), (3, 0.189), (8, 0.17)]

3단계. 세 가지로 학생 가르치기

Python
n_labeled = 100                                            # 정답 표시가 있는 그림은 100장뿐이라고 가정
rows = []
torch.manual_seed(1)
s1 = fit(mlp(16), Xtr[:n_labeled], ytr[:n_labeled], epochs=200, seed=1)
rows.append(("정답 100개로만", accuracy(s1)))
torch.manual_seed(1)
s2 = fit(mlp(16), Xtr, logits.argmax(1), epochs=100, seed=1)              # 1,257장에 선생님이 고른 답(하드)
rows.append(("선생님의 답(하드)으로", accuracy(s2)))
torch.manual_seed(1)
s3 = fit(mlp(16), Xtr, logits, epochs=100, seed=1, soft=True)            # 1,257장에 선생님의 확률(소프트)
rows.append(("선생님의 확률(KD)로", accuracy(s3)))
torch.manual_seed(1)
s4 = fit(mlp(16), Xtr, ytr, epochs=100, seed=1)                           # 견줄 기준: 1,257장 모두 정답이 있다면
rows.append(("(참고) 정답 1,257개로", accuracy(s4)))
for name, acc in rows:
    print(f"{name}: {acc}")
출력
정답 100개로만: 0.863
선생님의 답(하드)으로: 0.952
선생님의 확률(KD)로: 0.952
(참고) 정답 1,257개로: 0.952

4단계. 크기와 속도 견주기

Python
def speed_ms(m):
    big_batch = Xte.repeat(50, 1)
    with torch.no_grad():
        t = time.perf_counter()
        for _ in range(5):
            m(big_batch)
    return round((time.perf_counter() - t) / 5 * 1000, 2)


for name, m in [("선생님", teacher), ("학생(KD)", s3)]:
    print(f"{name}: 파라미터 {params(m):,} · fp32 로 {params(m) * 4 / 1024:.1f}KB · 27,000개 추론 {speed_ms(m)}ms · 정확도 {accuracy(m)}")
출력
선생님: 파라미터 85,002 · fp32 로 332.0KB · 27,000개 추론 34.34ms · 정확도 0.974
학생(KD): 파라미터 1,482 · fp32 로 5.8KB · 27,000개 추론 1.31ms · 정확도 0.952

이 실습이 뜻하는 것

이 실습은 선배의 풀이 메모로 후배를 가르치는 일과 같습니다. 선배를 먼저 키우고(1단계), 선배가 가장 헷갈린 문제에 남긴 "9 같지만 3 이나 8 과도 조금 닮았다"는 메모를 들여다봅니다(2단계). 후배를 정답 100개로만, 선배가 고른 답으로, 선배의 확률 메모로 가르쳐 견주고(3단계), 두 사람의 몸집과 푸는 속도를 잽니다(4단계). 여기서 선배가 선생님 신경망, 후배가 학생 신경망, 확률 메모가 소프트 레이블, 메모를 부드럽게 펴는 손잡이가 온도 T 입니다. 비유와 다른 점은, 학생은 선생님의 짜임을 물려받지 않고 처음부터 따로 고른 작은 신경망이라는 것입니다.

단계출력뜻
1. 선생님파라미터 85002 · 정확도 0.974 · 학생 크기: 1482실습 4와 같은 선생님입니다. 학생은 숫자가 약 57분의 1 입니다
2. T=1(9, 0.974), (3, 0.016), (8, 0.01)그대로 보면 9 라는 답만 보이고 다른 보기는 거의 0 입니다
2. T=4(9, 0.529), (3, 0.189), (8, 0.17)온도로 부드럽게 펴자 "3 이나 8 과도 조금 닮았다"는 정보가 드러납니다
3. 정답 100개로만0.863정답이 붙은 그림이 적으면 학생이 덜 배웁니다
3. 선생님의 답(하드), 확률(KD)0.952, 0.952표시 없는 1,257장에 선생님이 답을 붙여 주자 0.089 올랐습니다. KD(Knowledge Distillation)는 지식증류입니다. 이번에는 하드와 소프트의 차이가 없었습니다
3. (참고) 정답 1,257개로0.952모든 그림에 진짜 정답이 있을 때와 같은 점수입니다
4. 크기와 속도332.0KB(킬로바이트) 와 5.8KB · 34.34ms 와 1.31ms학생은 선생님보다 훨씬 작고 26배쯤 빠릅니다. 이 PC 에서 잰 시간입니다
4. 정확도0.974 와 0.952그 대가로 정확도는 0.022 낮습니다

한 줄로 줄이면, 작은 학생이 큰 선생님이 붙여 준 답으로 배워, 정답이 모자란 상황에서도 정답이 다 있을 때만큼 배웠다는 실습입니다. 이번 데이터에서 증류의 힘은 소프트 레이블보다 "정답이 없는 그림에도 선생님이 답을 붙여 준다"는 데서 나왔습니다. 쉬운 손글씨 데이터에서 씨앗 하나로 한 번 돌린 결과라, 소프트 레이블이 늘 쓸모없다는 뜻은 아닙니다. 큰 LLM 의 답으로 작은 모델을 가르치는 것도 같은 생각입니다(12장).

코드 설명

  • 선생님은 실습 4와 같은 신경망(85,002개, 0.974)이고, 학생은 뉴런 16개짜리(1,482개)로 57분의 1 크기입니다. fit() 의 soft=True 는 학생의 확률과 선생님의 확률 사이의 거리(F.kl_div)를 줄이도록 학습합니다.
  • 선생님이 가장 헷갈린 그림(정답 9)에서 T=1 이면 9 의 확률이 0.974 로 다른 보기는 거의 보이지 않습니다. T=4 로 나누면 9 가 0.529, 3 이 0.189, 8 이 0.17 로 "3 이나 8 과도 조금 닮았다"는 정보가 드러납니다. 이것이 소프트 레이블이 주는 생각의 결입니다.
  • 정답 100개로만 가르친 학생은 0.863 이었고, 표시 없는 1,257장에 선생님이 답을 붙여 가르친 학생은 0.952 로, 모든 그림에 정답이 있을 때(0.952)와 같았습니다. 이번 데이터에서는 선생님의 하드 답과 소프트 확률의 차이가 없었습니다(둘 다 0.952). 증류의 힘은 여기서 소프트 레이블보다 "정답이 없는 데이터에도 선생님이 답을 붙여 준다"는 데서 나왔습니다. LLM 에서 큰 모델의 답으로 작은 모델을 가르치는 것도 같은 생각입니다(12장).
  • 학생은 fp32 로 5.8KB(선생님 332KB), 27,000개 추론에 1.31ms(선생님 34.34ms)로 26배쯤 빠르고, 정확도는 0.022 낮습니다. 실습 4의 정형 프루닝과 견주면, 증류는 학생의 짜임을 처음부터 자유롭게 고를 수 있다는 점이 다릅니다.
← 이전 절15장 · 실습 4 — 프루닝: 가지를 쳐도 맞히나다음 절 →15장 · 실습 6 — LLM 은 가지치기에 얼마나 견디나