15장. 모델 경량화: 양자화, 프루닝, 지식증류
프루닝
정원사는 나무가 잘 자라도록 약한 가지를 잘라 냅니다. 신경망에도 거의 0 에 가까워 결과에 별 영향이 없는 가중치가 많습니다. 프루닝은 그런 가중치를 0 으로 만들거나, 뉴런째 지워 모델을 가볍게 합니다.
왼쪽 비정형 프루닝은 크기가 작은 가중치를 하나씩 골라 0 으로 만듭니다. 많이 지워도 정확도가 잘 버티지만, 0 이 흩어져 있을 뿐 행렬의 크기는 그대로라 보통의 GPU 계산에서는 빨라지지도 작아지지도 않습니다. 오른쪽 정형 프루닝은 뉴런(행렬의 행과 열)을 통째로 지워 행렬 자체가 작아지므로 실제로 빨라지지만, 정확도가 더 쉽게 떨어집니다. 여기서 약한 가지가 크기가 작은 가중치입니다.
두 방법 견주기
| 견줄 것 | 비정형 프루닝 | 정형 프루닝 |
|---|---|---|
| 지우는 단위 | 가중치 하나 | 뉴런, 채널, 층 |
| 정확도 | 잘 버틴다 | 더 떨어진다 |
| 파일 크기, 속도 | 그대로(희소 형식과 전용 계산이 있어야 준다) | 실제로 준다 |
| 회복 | 지운 뒤 조금 더 학습하면 회복된다 | 다시 학습이 꼭 필요하다 |
크기로 고르기
import numpy as np
w = np.array([0.8, -0.02, 0.05, -0.6, 0.01, 0.3])
threshold = np.quantile(np.abs(w), 0.5) # 절댓값이 작은 쪽 절반을 지운다(희소도 50%)
pruned = np.where(np.abs(w) >= threshold, w, 0.0)
print(pruned, "· 0 의 몫:", (pruned == 0).mean())코드에 나오는 함수
| 코드 | 쉬운 뜻 |
|---|---|
np.abs(w) | 가중치마다 절댓값을 구한다. 부호를 떼고 크기만 본다 |
np.quantile(값들, 0.5) | quantile(분위수)이다. 0.5 면 값들을 크기순으로 늘어놓았을 때 한가운데 값이다. 이보다 작은 쪽이 절반이다 |
np.where(조건, A, B) | 조건이 맞는 칸은 A, 아닌 칸은 B 를 고른다. 여기서는 큰 가중치는 그대로, 작은 가중치는 0 이다 |
(pruned == 0).mean() | 0 인 칸은 True(1), 아닌 칸은 False(0)로 보고 평균을 낸다. 곧 0 의 몫, 희소도다 |
이 코드가 뜻하는 것
정원사가 가지 여섯 개를 굵기순으로 세워 보고, 가는 쪽 절반을 잘라 내는 것과 같습니다. 여기서 가지의 굵기가 가중치의 절댓값, 잘라 낼 기준 굵기가 threshold 입니다.
| 줄 | 하는 일 | 쉽게 말하면 |
|---|---|---|
w = np.array([...]) | 가중치 여섯 개를 둔다 | 가지 여섯 개 |
threshold = np.quantile(np.abs(w), 0.5) | 크기의 한가운데 값을 기준으로 정한다 | 이보다 가늘면 자른다 |
pruned = np.where(...) | 기준보다 작은 가중치를 0 으로 만든다 | 가는 가지를 잘라 낸다 |
print(pruned, "· 0 의 몫:", ...) | 남은 가중치와 0 의 몫을 찍는다 | 남은 가지와 자른 몫 |
결과에서 -0.02, 0.05, 0.01 이 0 이 됐고 0.8, -0.6, 0.3 이 남아 0 의 몫이 0.5 입니다. 비유와 다른 점은, 잘라 낸 가지는 사라지지만 프루닝한 가중치는 0 이 적힌 채 자리를 그대로 차지한다는 것입니다.
작게 줄여서 눈으로 보기
가중치를 절반 지웠을 때 출력과 배열 크기가 어떻게 되는지 봅니다. 입력을 모두 1 로 둔 것은 설명을 위해 정한 것입니다.
import numpy as np
w = np.array([0.8, -0.02, 0.05, -0.6, 0.01, 0.3])
x = np.array([1.0, 1.0, 1.0, 1.0, 1.0, 1.0]) # 모든 입력이 1 이라고 치자(설명용)
threshold = np.quantile(np.abs(w), 0.5)
pruned = np.where(np.abs(w) >= threshold, w, 0.0)
print("원래 출력:", round(float(w @ x), 2), "· 지운 뒤 출력:", round(float(pruned @ x), 2))
print("배열 크기:", w.size, "→", pruned.size)원래 출력: 0.54 · 지운 뒤 출력: 0.5
배열 크기: 6 → 6| 결과 | 읽는 법 |
|---|---|
원래 출력: 0.54 · 지운 뒤 출력: 0.5 | 가중치를 절반 지웠는데 출력은 0.04 만 달라졌다. 지운 것들이 원래 작았기 때문이다 |
배열 크기: 6 → 6 | 0 을 적었을 뿐 칸 수는 그대로다. 비정형 프루닝만으로는 파일이 작아지지 않는다 |
w @ x 는 가중치와 입력을 짝지어 곱해 더한 값(내적)이고, .size 는 배열의 칸 수입니다. 위 주의할 점처럼 "몇 % 지웠다"보다 "몇 MB 줄었나"를 재야 하는 까닭이 이것입니다.
주의할 점
- LLM 은 작은 신경망보다 프루닝에 약합니다. 몇십 %만 지워도 글이 무너질 수 있습니다(실습 6). LLM 의 프루닝은 지운 뒤 다시 학습하는 비용까지 생각해야 합니다.
- "몇 % 지웠다"보다 "몇 MB 줄고 몇 ms 빨라졌나"를 잽니다. 비정형 프루닝은 0 이 많아져도 크기와 속도가 그대로일 수 있습니다.