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

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

실습 4 — 프루닝: 가지를 쳐도 맞히나

2장의 손글씨 숫자(load_digits, scikit-learn 에 들어 있는 실제 공개 자료)로 작은 신경망을 학습한 뒤, 크기가 작은 가중치를 0 으로 만드는 비정형 프루닝을 0% 에서 95% 까지 해 보고, 지운 뒤 다시 학습하면 회복되는지, 뉴런을 통째로 지우는 정형 프루닝은 어떤지 봅니다. CPU 에서 씨앗을 고정해 돌립니다.

1단계. 신경망 학습하기

Python
import copy, time
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.nn.utils.prune as prune
import matplotlib.pyplot as plt
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
plt.rcParams["font.family"] = "Malgun Gothic"
plt.rcParams["axes.unicode_minus"] = False

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, y, epochs=60, seed=0):
    """작은 묶음(32개)으로 Adam 학습. 씨앗을 고정해 다시 돌려도 같다"""
    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]
            loss = F.cross_entropy(model(X[b]), y[b])
            opt.zero_grad()
            loss.backward()
            opt.step()
    return model


accuracy = lambda m: round((m(Xte).argmax(1) == yte).float().mean().item(), 3)
torch.manual_seed(0)
dense = fit(mlp(256), Xtr, ytr)
print("파라미터:", sum(p.numel() for p in dense.parameters()), "· 시험 정확도:", accuracy(dense))
출력
파라미터: 85002 · 시험 정확도: 0.974

2단계. 비정형 프루닝으로 지워 보기

Python
def pruned_copy(model, amount):
    """모든 층의 가중치를 한데 모아, 절댓값이 작은 쪽부터 amount 만큼 0 으로 만든 복사본"""
    m = copy.deepcopy(model)
    params = [(m[0], "weight"), (m[2], "weight"), (m[4], "weight")]
    if amount > 0:
        prune.global_unstructured(params, pruning_method=prune.L1Unstructured, amount=amount)
    return m


amounts = [0, 0.3, 0.5, 0.7, 0.8, 0.9, 0.95]
curve = [accuracy(pruned_copy(dense, a)) for a in amounts]
print(dict(zip(amounts, curve)))
plt.plot([a * 100 for a in amounts], curve, "o-")
plt.xlabel("지운 가중치의 몫(%)")
plt.ylabel("시험 정확도")
plt.title("비정형 프루닝: 지울수록 정확도는?")
plt.grid(alpha=0.3)
plt.show()
출력
{0: 0.974, 0.3: 0.974, 0.5: 0.976, 0.7: 0.969, 0.8: 0.952, 0.9: 0.856, 0.95: 0.613}
지운 가중치의 몫을 0% 에서 95% 까지 늘릴 때 손글씨 숫자 분류의 시험 정확도를 그린 그래프

3단계. 90% 지운 뒤 다시 학습하기

Python
m90 = pruned_copy(dense, 0.9)
before = accuracy(m90)
fit(m90, Xtr, ytr, epochs=20, seed=1)              # 지운 자리는 마스크로 0 에 묶인 채 남은 가중치만 다시 학습
zeros = sum((layer.weight == 0).sum().item() for layer in (m90[0], m90[2], m90[4]))
total = sum(layer.weight.numel() for layer in (m90[0], m90[2], m90[4]))
print("다시 학습 전", before, "→ 뒤", accuracy(m90), f"· 0 인 가중치 {zeros / total:.1%}")
출력
다시 학습 전 0.856 → 뒤 0.985 · 0 인 가중치 90.0%

4단계. 뉴런을 통째로 지우기(정형 프루닝)

Python
def structured(model, keep):
    """숨은층마다 가중치 크기(L1)가 큰 뉴런 keep 개만 남긴 더 작은 신경망을 만든다"""
    W0, W2, W4 = model[0].weight.data, model[2].weight.data, model[4].weight.data
    k1 = W0.abs().sum(1).topk(keep).indices                      # 첫 숨은층에서 남길 뉴런
    k2 = W2[:, k1].abs().sum(1).topk(keep).indices               # 둘째 숨은층에서 남길 뉴런
    small = mlp(keep)
    small[0].weight.data, small[0].bias.data = W0[k1].clone(), model[0].bias.data[k1].clone()
    small[2].weight.data, small[2].bias.data = W2[k2][:, k1].clone(), model[2].bias.data[k2].clone()
    small[4].weight.data, small[4].bias.data = W4[:, k2].clone(), model[4].bias.data.clone()
    return small


def speed_ms(m):
    big_batch = Xte.repeat(50, 1)                                # 540 × 50 = 27,000개
    with torch.no_grad():
        t = time.perf_counter()
        for _ in range(5):
            m(big_batch)
    return round((time.perf_counter() - t) / 5 * 1000, 1)


rows = []
for keep in (256, 128, 64, 32):
    s = structured(dense, keep)
    a0 = accuracy(s)
    fit(s, Xtr, ytr, epochs=20, seed=1)
    rows.append({"남긴 뉴런": keep, "파라미터": sum(p.numel() for p in s.parameters()),
                 "지운 직후": a0, "다시 학습 뒤": accuracy(s), "27,000개 추론 ms": speed_ms(s)})
import pandas as pd
print(pd.DataFrame(rows).to_string(index=False))
출력
 남긴 뉴런  파라미터  지운 직후  다시 학습 뒤  27,000개 추론 ms
   256 85002  0.974    0.974           34.0
   128 26122  0.954    0.980           14.5
    64  8970  0.770    0.981            6.4
    32  3466  0.343    0.976            3.6

이 실습이 뜻하는 것

이 실습은 정원의 나무 가지치기와 같습니다. 나무를 먼저 키우고(1단계), 가는 가지부터 점점 많이 잘라 열매가 얼마나 남는지 봅니다(2단계). 90% 를 자른 나무를 한 철 더 키워 보고(3단계), 가는 가지 대신 굵은 줄기째 잘라 나무 자체를 작게 만듭니다(4단계). 여기서 가는 가지가 가중치 하나, 굵은 줄기가 뉴런, 열매가 시험 정확도입니다. 비유와 다른 점은, 가는 가지를 자른 나무(비정형 프루닝)는 자른 자리를 0 으로 남겨 두어 크기가 그대로라는 것입니다.

단계출력뜻
1. 학습파라미터: 85002 · 시험 정확도: 0.974손글씨 숫자 시험 그림 100장 가운데 97장쯤을 맞히는 신경망입니다
2. 30%, 50% 지우기0.974, 0.976가중치 절반을 0 으로 만들어도 정확도가 그대로입니다
2. 70%, 80% 지우기0.969, 0.952조금씩 떨어지기 시작합니다
2. 90%, 95% 지우기0.856, 0.613여기서부터 크게 떨어집니다
2. 그래프지운 몫과 정확도의 선80% 까지는 거의 평평하다가 그 뒤로 꺾입니다
3. 다시 학습0.856 → 뒤 0.985 · 0 인 가중치 90.0%90% 를 0 으로 묶은 채 남은 10% 만 다시 학습했더니, 지우기 전(0.974)보다도 높아졌습니다
4. 뉴런 128개26122개 · 0.954 → 0.980 · 14.5ms뉴런을 절반 남기자 숫자가 3분의 1 아래로 줄었고, 다시 학습하면 정확도가 돌아옵니다
4. 뉴런 32개3466개 · 0.343 → 0.976 · 3.6ms숫자가 약 25분의 1 이 되었고, 지운 직후 무너졌다가 다시 학습하면 돌아옵니다. 27,000개 추론이 34.0ms 에서 3.6ms(ms 는 1,000분의 1초)로 9배쯤 빨라졌습니다

한 줄로 줄이면, 작은 신경망은 가중치 대부분을 지워도 다시 학습하면 회복되고, 크기와 속도를 실제로 줄이려면 뉴런째 지워야 한다는 것을 보인 실습입니다. 손글씨 숫자처럼 쉬운 문제를 푸는 작은 신경망의 결과이고, 시간은 이 PC 의 CPU(Central Processing Unit, 중앙 처리 장치)에서 잰 것입니다. LLM 도 이렇게 잘 버티는지는 실습 6에서 봅니다.

코드 설명

  • 1단계의 신경망은 숨은층 둘(뉴런 256개씩), 파라미터 85,002개이고 시험 정확도는 0.974 입니다. torch.manual_seed 와 torch.Generator().manual_seed 로 가중치의 처음 값과 섞는 순서를 고정했습니다.
  • prune.global_unstructured 는 세 층의 가중치를 한데 모아 절댓값이 작은 쪽부터 정한 몫을 0 으로 만듭니다. 50% 를 지워도 0.976 으로 그대로였고, 80% 에서 0.952, 90% 에서 0.856, 95% 에서 0.613 으로 떨어졌습니다. 그래프에서 80% 까지는 거의 평평하다가 그 뒤로 꺾입니다.
  • 90% 를 지워 0.856 이 된 모델을 20에포크 다시 학습하자 0.985 로, 지우기 전(0.974)보다도 높아졌습니다. prune 은 지운 자리에 마스크를 씌워 두므로 다시 학습해도 0 은 0 으로 남습니다(0 인 가중치 90.0%). 다만 이 모델은 메모리에 여전히 85,002개의 숫자를 들고 있습니다.
  • 정형 프루닝은 뉴런을 실제로 빼서 더 작은 신경망을 만듭니다. 뉴런을 32개만 남기면 파라미터가 3,466개(25분의 1)가 되고, 지운 직후 0.343 으로 무너졌다가 다시 학습하면 0.976 으로 돌아옵니다. 27,000개를 추론하는 시간도 34.0ms 에서 3.6ms 로 줄었습니다. 비정형과 달리 크기와 속도가 실제로 줄지만, 다시 학습이 꼭 필요합니다.
← 이전 절15장 · 실습 3 — Ollama 의 양자화 단계 견주기다음 절 →15장 · 실습 5 — 지식증류: 큰 모델의 답으로 작은 모델 가르치기