LoRA 설정 파일에서 가장 자주 손대는 값이 r입니다. 4인지 8인지 64인지에 따라 학습되는 파라미터 수가 열여섯 배씩 갈리는데, 정하는 근거는 대개 «남들이 8을 쓰더라» 정도입니다. 올려 보고 성능이 안 오르면 내리는 식으로 몇 번 돌려 보는 것 말고는 방법이 없어 보입니다.
그런데 이 값은 감으로 고를 필요가 없습니다. r은 랭크이고, 랭크를 몇으로 잡아야 원본을 얼마나 잃는지는 계산되는 양이기 때문입니다.
지난 글에서 특잇값을 큰 것부터 늘어놓고 «뒤쪽은 잡음이다»라고 읽었습니다. 이 글은 뒤쪽을 실제로 잘라 버리고, 그렇게 만든 근사가 얼마나 좋은지를 정확한 수로 답합니다.
특잇값 분해를 층으로 다시 쓰기
을 성분끼리 곱해 펼치면 다음 꼴이 됩니다.
각 항의 는 열벡터에 행벡터를 곱한 것이라 외적입니다 — 으로 원래 크기의 행렬이 나오고, 모든 행이 의 상수배라 랭크가 1입니다.
즉 어떤 행렬이든 랭크 1짜리 층을 겹쳐 쌓은 것으로 볼 수 있고, 각 층의 무게가 입니다. 특잇값을 큰 것부터 늘어놓았으니 앞의 층이 두껍고 뒤로 갈수록 얇습니다.
여기서 앞의 개만 남기고 나머지를 버린 것을 잘라 낸 특잇값 분해라 하고 로 적습니다.
랭크 1짜리 개의 합이므로 의 랭크는 이하입니다. 이렇게 원래 행렬을 더 낮은 랭크의 행렬로 대신하는 것을 저계수 근사라고 합니다.
하필 앞에서 자르는 것이 최적인가
«큰 것부터 남긴다»는 자연스러워 보이지만, 자연스러움이 최적성의 증거는 아닙니다. 랭크 짜리 행렬은 무수히 많고, 그중 에 가장 가까운 것이 하필 라는 보장은 어디에도 없습니다.
그런데 보장이 있습니다.
에카르트-영 정리. 랭크가 이하인 모든 행렬 에 대해
가 프로베니우스 노름과 스펙트럼 노름 모두에서 성립한다.
두 노름이 무엇인지 여기서 한 번 짚고 갑니다. 프로베니우스 노름은 모든 성분을 제곱해 더하고 제곱근을 씌운 값 으로, 행렬을 길게 편 벡터의 길이입니다. 스펙트럼 노름은 가장 큰 특잇값 로, 어떤 방향이 가장 크게 늘어나는지를 재는 값입니다. 둘의 성질과 관계는 「중급 17번 · 행렬 노름」이 맡고, 여기서는 «행렬 사이의 거리를 재는 두 자»로만 씁니다.
정리가 말하는 것은 강합니다. 두 자로 재도 같은 답이 최적이고, «랭크 로 줄인다»는 문제는 특잇값 분해 한 번으로 완전히 풀립니다. 최적화를 돌릴 필요도, 후보를 비교할 필요도 없습니다.
잃는 양은 남은 특잇값이 그대로 말한다
최적이라는 것보다 실무에 더 쓸모 있는 것은 얼마나 잃는지가 계산된다는 쪽입니다. 는 잘라 낸 층들의 합이므로
이고, 이것 자체가 특잇값 을 가진 행렬의 특잇값 분해입니다. 그러므로 두 노름의 값이 바로 읽힙니다.
앞의 것은 «버린 것 전체의 크기»이고 뒤의 것은 «버린 것 중 가장 큰 하나»입니다. 특잇값 목록만 있으면 를 바꿔 가며 오차를 표로 뽑을 수 있습니다 — 근사를 실제로 만들어 보지 않고도 그렇습니다.
특잇값이 인 행렬로 표를 채워 봅니다. 제곱합이 입니다.
| 남긴 것 | 담긴 에너지 | |||
|---|---|---|---|---|
| 1 | 10 | 6 | 68.5% | |
| 2 | 10, 6 | 3 | 93.2% | |
| 3 | 10, 6, 3 | 1 | 99.3% | |
| 4 | 전부 | 0 | 0 | 100% |
마지막 열의 에너지는 로, 프로베니우스 노름의 제곱을 얼마나 채웠는지를 백분율로 적은 값입니다. 지난 글의 수치 랭크에서 쓴 것과 같은 양입니다.
이 표에서 가 눈에 띕니다. 랭크를 절반으로 줄였는데 93%를 지켰습니다. 특잇값이 빨리 떨어질수록 이 거래가 유리해지고, 그 감소 속도를 보고 를 정하는 것이 이 글의 목표입니다.
손으로 한 번
의 랭크 1 최적 근사를 구합니다. 대칭 행렬이라 지난 글에서 본 대로 특잇값이 고윳값의 절댓값과 같습니다. , 이므로 이고 , 입니다. 고유벡터는 과 이므로
오차를 직접 계산합니다.
공식이 예고한 와 정확히 같습니다.
최적이라는 말이 실제로 무슨 뜻인지 다른 랭크 1 후보와 대 봅니다.
| 후보 | 행렬 | 랭크 | |
|---|---|---|---|
| 잘라 낸 SVD | 열 | 1 | 2 |
| 첫 행만 남기기 | 열 | 1 | 3.16 |
| 첫 열만 남기기 | 열 | 1 | 3.16 |
«원본에서 눈에 띄는 부분을 남긴다»는 직관적인 두 방법이 SVD보다 1.5배 넘게 나쁩니다. 덜어낼 것을 성분 단위로 고르면 안 되고 축 단위로 골라야 한다는 것이 이 표의 내용입니다.
LoRA는 계수 제약이다
이제 첫머리로 갑니다. 사전학습된 가중치 를 파인튜닝하면 그 자리에 가 들어섭니다. 문제는 가 와 같은 크기 — — 라 저장할 것도 학습할 것도 그대로라는 점입니다.
LoRA는 여기에 조건을 하나 겁니다.
을 작게 잡으면 의 랭크가 이하로 강제됩니다. 곱의 랭크는 두 인수의 랭크를 넘을 수 없고 의 랭크가 최대 이기 때문입니다. 앞 절의 말로 하면 업데이트를 랭크 1짜리 층 개의 합으로만 표현하겠다는 선언입니다.
파라미터 수가 얼마나 줄어드는지는 곱셈 두 번이면 나옵니다. 일 때
| 파라미터 수 | 비율 | |
|---|---|---|
| 전체 | 100% | |
| 0.39% | ||
| 3.13% |
여기서 에카르트-영이 하는 말이 분명해집니다. 랭크 로 표현할 수 있는 최선이 의 상위 개 층이고, 잃는 것은 나머지 특잇값의 제곱합입니다. LoRA가 통한다는 것은 «파인튜닝이 실제로 만드는 업데이트의 특잇값이 빨리 떨어진다»는 경험적 사실과 같은 말입니다.
주의할 것이 하나 있습니다. 낮은 랭크로 잘 근사되는 것은 이지 가 아닙니다. 사전학습된 가중치 자체의 특잇값은 대체로 완만하게 떨어져서, 90%를 채우는 데 절반 이상의 축이 필요한 것이 보통입니다. 두 행렬을 섞어 보면 판단이 어긋납니다.
r을 스펙트럼으로 고르는 절차
그러므로 근거로 삼을 것은 의 특잇값 분포입니다. 그런데 를 알려면 파인튜닝을 이미 해 봤어야 한다는 순환이 있으니, 실무에서는 이렇게 끊습니다.
- 한 번은 넉넉하게 돌린다. 전체 파인튜닝을 짧게 하거나, LoRA를 일부러 큰 — 이를테면 64 — 로 한 에폭 돌립니다.
- 를 층마다 꺼내 특잇값을 구한다.
- 누적 에너지 곡선을 그리고 목표 비율을 채우는 를 읽는다. 90%나 95%가 흔한 기준입니다.
- 층마다 나온 중 큰 쪽에 맞춰 을 정한다. 층마다 다르게 줄 수 있으면 그대로 쓰고, 하나로 정해야 하면 여유를 둡니다.
실제로 감쇠하는 하나를 만들어 재 보면 이렇습니다.
| 목표 에너지 | 필요한 |
|---|---|
| 90% | 4 |
| 95% | 5 |
| 99% | 8 |
90%에서 95%로 올리는 데는 축 하나면 되는데 99%까지 가려면 셋이 더 듭니다. 뒤쪽 축의 값이 작아 에너지를 조금씩만 보태기 때문이고, 이 «올라가는 비용»이 어디서 급해지는지가 을 정하는 자리입니다.
곡선이 완만하면 그 층은 저계수와 안 맞는다는 신호입니다. 90%를 채우는 데 절반 이상의 축이 필요하면 LoRA로 줄여 봐야 잃는 것이 많으니, 그 층은 전체 파인튜닝으로 두거나 다른 방법을 씁니다. 감으로 r을 올리는 대신 이 곡선을 보고 결정한다는 것이 이 절의 전부입니다.
α/r 은 무엇을 하는가
LoRA 구현에서 실제로 더해지는 것은 가 아니라 입니다. 앞의 상수는 학습되지 않고 설정으로 고정됩니다.
이 상수의 역할은 앞 절과 이어집니다. 는 랭크 1짜리 층 개의 합이므로, 각 층이 비슷한 크기라면 을 키울수록 합 전체가 커집니다. 로 나누면 합이 아니라 평균이 되어 을 바꿔도 업데이트의 크기가 크게 흔들리지 않습니다. 그래서 을 8에서 32로 올릴 때 학습률을 다시 잡지 않아도 되게 하려는 것이 이 나눗셈의 목적입니다.
는 그 위에 얹는 배율입니다. 실질적으로 어댑터에만 걸리는 학습률 배수처럼 작동하므로, 실무에서는 두 값을 따로 보지 말고 비 을 하나의 값으로 보는 편이 낫습니다.
- 을 두 배로 올리면서 를 그대로 두면 이 절반이 됩니다 — 표현력은 늘렸는데 업데이트 세기는 줄인 것이라 두 변화가 섞여 원인을 못 가립니다.
- 처럼 함께 올려 을 고정하면 바뀐 것이 랭크 하나뿐이 되어, 앞 절의 스펙트럼 실험과 결과를 맞대 볼 수 있습니다.
코드로 확인하기
import numpy as np
B = np.array([[3., 1], [1, 3]])
U, s, Vt = np.linalg.svd(B)
print(s) # [4. 2.]
B1 = s[0] * np.outer(U[:, 0], Vt[0]) # 상위 1개 층만
print(B1) # [[2. 2.] [2. 2.]]
print(np.linalg.norm(B - B1, 'fro'), s[1]) # 2.0 2.0 — 공식과 일치
다른 랭크 1 후보가 정말 더 나쁜지도 바로 확인됩니다.
for name, X in [("SVD", B1),
("첫 행", np.array([[3., 1], [0, 0]])),
("첫 열", np.array([[3., 0], [1, 0]]))]:
print(name, np.linalg.matrix_rank(X), round(np.linalg.norm(B - X, 'fro'), 4))
# SVD 1 2.0
# 첫 행 1 3.1623
# 첫 열 1 3.1623
을 고르는 절차는 함수 하나로 씁니다.
def rank_for_energy(delta_w, target=0.95):
s = np.linalg.svd(delta_w, compute_uv=False)
energy = np.cumsum(s ** 2) / (s ** 2).sum()
return int((energy < target).sum() + 1), s
rng = np.random.default_rng(5)
U, _ = np.linalg.qr(rng.standard_normal((256, 256)))
V, _ = np.linalg.qr(rng.standard_normal((256, 256)))
dW = U @ np.diag(2.0 * 0.72 ** np.arange(256) + 0.004) @ V.T # 감쇠하는 ΔW
for t in (0.90, 0.95, 0.99):
k, s = rank_for_energy(dW, t)
print(t, k)
# 0.9 4 / 0.95 5 / 0.99 8
잘라 낸 근사를 실제로 만들어 오차 공식과 맞대 볼 수도 있습니다.
U2, s2, Vt2 = np.linalg.svd(dW)
for k in (4, 8, 16):
approx = (U2[:, :k] * s2[:k]) @ Vt2[:k]
print(k,
round(np.linalg.norm(dW - approx, 'fro'), 6),
round(np.sqrt((s2[k:] ** 2).sum()), 6), # 프로베니우스 예측
round(s2[k], 6)) # 스펙트럼 예측
# 4 0.786909 0.786909 0.541477
# 8 0.22675 0.22675 0.148441
가운데 두 열이 소수점 여섯 자리까지 같습니다 — 근사를 만들어 재나 특잇값으로 계산하나 답이 하나입니다.
정리
- 특잇값 분해는 랭크 1짜리 층의 합입니다. 이고 각 층의 무게가 입니다.
- 잘라 낸 SVD 는 랭크 이하인 모든 행렬 중 에 가장 가깝습니다(에카르트-영). 프로베니우스 노름과 스펙트럼 노름 어느 자로 재도 그렇습니다.
- 잃는 양이 공식으로 나옵니다. , 이라 특잇값 목록만으로 표를 뽑을 수 있습니다.
- 성분 단위로 덜어내면 안 됩니다. 행이나 열을 지우는 방식은 같은 랭크에서 1.5배 넘게 나빴습니다. 잘라야 할 것은 축입니다.
- LoRA의 는 랭크 제약이고, 층에서 이면 파라미터가 0.39%로 줍니다.
- 낮은 랭크로 근사되는 것은 이지 가 아닙니다. 근거를 볼 때 둘을 섞으면 판단이 어긋납니다.
- 은 누적 에너지 곡선에서 읽습니다. 큰 로 한 번 돌려 를 얻고, 목표 비율을 채우는 를 층마다 재고, 곡선이 완만한 층은 저계수와 안 맞는 층으로 판단합니다.
- 은 함께 봅니다. 로 나누는 것은 층의 합을 평균으로 바꿔 랭크를 바꿔도 업데이트 세기가 유지되게 하려는 것이므로, 비교 실험에서는 그 비를 고정합니다.
이 글에서 두 노름을 «행렬 사이의 거리를 재는 자»로 쓰기만 하고 성질은 미뤄 두었습니다. 다음 글이 그 빚을 갚습니다. 이 왜 그런지, 프로베니우스 노름의 제곱이 왜 특잇값 제곱합인지, 그리고 그 값이 층을 통과할 때 신호가 얼마나 커질 수 있는지를 정하는 립시츠 상수와 어떻게 같은 것인지입니다.
읽어주셔서 감사합니다. 😊

