학습 로그를 보고 있는데 손실이 갑자기 nan이 됩니다. 그 직전 몇 스텝을 되짚어 보면 이런 게 찍혀 있습니다.
step 8412 loss 2.31 grad_norm 1.84
step 8413 loss 2.29 grad_norm 2.07
step 8414 loss 2.34 grad_norm 9284.1
step 8415 loss nan grad_norm nan
해결책은 대부분 한 줄입니다.
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
넣으면 잡힙니다. 그런데 이 함수가 재는 norm이 정확히 무엇인지, 그리고 1.0이라는 문턱이 무엇을 넘을 때 작동하는지는 대체로 그냥 넘어갑니다. 옆자리에는 weight_decay=0.01이 있고, 어떤 코드에는 clip_grad_value_가 대신 들어 있습니다. 셋 다 "너무 커지는 것을 막는다"고 설명되는데 막는 방식이 다릅니다.
지난 글에서 임베딩이 벡터라는 것이 무엇을 보장하는지 봤습니다. 그런데 벡터공간의 조건 어디에도 길이는 없었습니다. 길이는 따로 얹어야 하고, 얹는 방법이 하나가 아닙니다. 어떤 것을 얹느냐가 위 세 줄의 차이를 전부 만듭니다.
크기를 잰다는 것 — 노름의 세 공리
벡터 하나에 음이 아닌 실수 하나를 대응시키는 함수 가 다음 셋을 만족하면 노름이라 부릅니다.
말로 풀면 이렇습니다.
첫째, 크기는 음수가 될 수 없고 0인 것은 영벡터뿐입니다. 이 조건이 있어야 "노름이 0이다"와 "벡터가 0이다"가 같은 말이 됩니다. 그래디언트 노름이 0으로 수렴한다는 문장이 "그래디언트가 사라진다"를 뜻하는 근거가 이것입니다.
둘째, 벡터를 배 하면 크기도 정확히 배가 됩니다. 이것이 절대적 동차성이고, 클리핑이 성립하는 근거입니다. 벡터에 를 곱하면 노름이 정확히 가 된다는 것을 이 공리가 보장합니다. 이 성질이 없으면 "비율로 줄인다"는 조작 자체가 성립하지 않습니다.
셋째, 두 벡터를 더한 것의 크기는 각각의 크기 합을 넘지 않습니다. 삼각부등식이고, 곧 보겠지만 노름에서 거리를 만들 때 그 거리가 거리의 자격을 갖추게 하는 조건입니다.
이 셋만 만족하면 무엇이든 노름입니다. 그래서 노름은 여러 개 있을 수 있고, 실제로 자주 쓰는 것이 셋입니다.
L1, L2, L∞
| 이름 | 정의 | 다른 이름 | NumPy |
|---|---|---|---|
| L1 | 맨해튼 노름 | np.linalg.norm(x, 1) |
|
| L2 | 유클리드 노름 | np.linalg.norm(x, 2) |
|
| L∞ | 최대 노름 | np.linalg.norm(x, np.inf) |
세 정의는 사실 한 식의 특수한 경우입니다.
이면 L1, 면 L2입니다. 로 보내면 가장 큰 성분이 나머지를 압도해서 최댓값만 남고, 그래서 L∞라는 이름이 붙었습니다.
숫자를 넣어 봅니다. 입니다.
import numpy as np
x = np.array([3.0, -4.0, 0.0, 1.0])
np.abs(x).sum() # 8.0 = 3 + 4 + 0 + 1
np.sqrt((x**2).sum()) # 5.099 = √(9 + 16 + 0 + 1)
np.abs(x).max() # 4.0 = |-4|
같은 벡터인데 크기가 8이기도 하고 5.1이기도 하고 4이기도 합니다. "이 벡터가 크다"는 문장은 어떤 노름을 쓰는지 밝히기 전까지 의미가 없습니다.
단위구의 모양
세 노름의 차이를 가장 선명하게 보는 방법은 "크기가 정확히 1인 점들"을 전부 그려 보는 것입니다. 그 모임을 단위구라 부릅니다.
- L1의 단위구는 마름모입니다. 을 만족하는 점들이고, 꼭짓점이 축 위 과 에 있습니다. 꼭짓점이 축 위에 있다 — 이 한 문장에서 L1 희소성이 전부 나옵니다.
- L2의 단위구는 원입니다. 모든 방향이 대등합니다. 회전시켜도 모양이 변하지 않는 유일한 노름이고, 그래서 "특별히 좋아하는 방향이 없는" 정규화가 필요할 때 기본값이 됩니다.
- L∞의 단위구는 정사각형입니다. 대각선 방향으로 가장 멀리 갈 수 있습니다. 은 L∞로 재면 크기가 1이지만 L2로 재면 입니다.
같은 벡터의 크기가 노름마다 다르다는 것을 그림으로 보면 이렇습니다. 어떤 벡터가 L1 단위구 안에 있으면 L2 단위구 안에도 반드시 있습니다(마름모가 원 안에 들어가니까). 반대는 성립하지 않습니다.
노름이 만드는 거리
노름이 있으면 거리는 공짜로 따라옵니다. 두 점의 차이의 크기를 거리로 쓰면 됩니다.
이렇게 정의한 가 거리로서 갖춰야 할 조건들이 노름의 세 공리에서 그대로 나옵니다.
| 거리의 조건 | 어느 공리에서 오는가 |
|---|---|
| , 0이면 같은 점 | 첫째 공리 |
| 둘째 공리에 | |
| 셋째 공리 |
세 번째가 우리가 아는 삼각부등식입니다. 로 쪼개고 노름의 삼각부등식을 쓰면 곧바로 나옵니다.
노름마다 거리의 이름이 따로 있습니다. L2에서 온 것이 유클리드 거리, L1에서 온 것이 맨해튼 거리 — 격자 도로를 따라 가는 거리라 그렇게 부릅니다 — L∞에서 온 것이 체비셰프 거리입니다. 벡터 데이터베이스의 거리 지표 설정에서 보는 이름들이 이것이고, 그중 무엇을 고를지는 벡터 유사도 지표가 실전 기준으로 다룹니다.
여기서 한 가지 짚을 것이 있습니다. 코사인 유사도는 이 방식으로 만들어진 거리가 아닙니다. 어떤 노름의 차이로 정의된 것이 아니라 각도를 재는 다른 물건이고, 그래서 삼각부등식이 보장되지 않습니다. 코사인이 언제 L2 거리와 같은 순위를 주고 언제 갈리는지는 바로 다음 글의 주제입니다.
노름 동치 — 이론과 실무가 갈리는 자리
수학에는 이런 정리가 있습니다.
유한 차원 벡터공간에서 모든 노름은 서로 동치다.
동치라는 것은 상수 이 있어서 모든 에 대해 가 성립한다는 뜻입니다. L1과 L2의 경우 상수를 이렇게 잡을 수 있습니다.
왼쪽 부등식은 제곱합이 절댓값 합의 제곱보다 작다는 것에서, 오른쪽은 코시-슈바르츠에서 나옵니다. 그래서 이론적으로는 "어떤 노름으로 재도 수렴하면 다 수렴하고, 유계면 다 유계"입니다. 노름 선택이 수렴 여부를 바꾸지 못합니다.
그런데 실무에서는 전혀 같지 않습니다. 상수 가 차원에 따라 커지기 때문입니다. 이면 입니다. 실제로 어느 정도인지 재 봅니다.
rng = np.random.default_rng(1)
d = 768
V = rng.normal(size=(20000, d))
l1 = np.abs(V).sum(1)
l2 = np.sqrt((V**2).sum(1))
li = np.abs(V).max(1)
print((l1/l2).min(), (l1/l2).max()) # 21.272 22.955
print((l2/li).min(), (l2/li).max()) # 5.106 10.990
정규분포에서 뽑은 768차원 벡터의 L1은 L2보다 항상 21배 이상 큽니다. 최댓값(L∞)보다는 L2가 5~11배 큽니다. 이론적 상한 27.7 근처에 실제 값이 몰려 있는 것이고, 이 배율은 차원이 커질수록 커집니다.
그래서 이런 일이 생깁니다. L2 노름 문턱 1.0으로 클리핑하던 설정을 L1으로 바꾸면 사실상 모든 스텝이 클리핑됩니다. 정규화 세기 를 L2에서 L1으로 옮기면서 값을 그대로 두면 모델이 죽습니다. 노름을 바꿀 때는 세기도 함께 다시 잡아야 한다 — 동치 정리가 알려 주는 실무적 함의는 "같다"가 아니라 "상수만큼 다르고 그 상수가 차원에 비례해 커진다"입니다.
그래디언트 클리핑이 재는 양
이제 처음의 함수로 돌아갑니다. 클리핑의 정의는 이렇습니다.
노름이 문턱 보다 작으면 이 1이 되어 아무 일도 일어나지 않습니다. 넘으면 라는 1보다 작은 수를 전체에 곱합니다. 결과의 노름은 정확히 가 되는데, 그것을 보장하는 것이 앞의 둘째 공리입니다.
g = np.array([6.0, 8.0])
c = 5.0
np.linalg.norm(g) # 10.0
g * min(1.0, c / np.linalg.norm(g)) # [3., 4.]
np.linalg.norm(g * min(1.0, c/np.linalg.norm(g))) # 5.0
이 가 됐습니다. 비율이 그대로입니다. 모든 성분에 같은 수를 곱하므로 방향이 보존되고, 학습이 가리키던 방향은 그대로 두고 보폭만 줄인 것이 됩니다. 그래디언트가 폭발했을 때 방향 정보까지 버리지 않는다는 것이 이 방식의 핵심입니다.
여기서 두 가지 실무적 구별이 나옵니다.
전체 노름인가 파라미터별 노름인가. clip_grad_norm_은 모델의 모든 파라미터의 그래디언트를 하나의 긴 벡터로 이어 붙인 다음 그 전체의 L2 노름을 잽니다. 층마다 따로 재는 것이 아닙니다. 그래서 한 층에서 폭발이 나면 다른 층들의 그래디언트도 같은 비율로 줄어듭니다. 층별 상대 크기는 유지되고 전체 보폭만 줄어듭니다.
clip_grad_value_는 다른 것입니다. 이쪽은 각 성분을 개별적으로 에 가둡니다.
이것은 사실상 L∞ 기준 클리핑이고, 방향이 바뀝니다. 큰 성분만 잘리고 작은 성분은 그대로라 잘린 뒤의 벡터가 원래와 다른 쪽을 가리킵니다. 단위구로 보면 L2 클리핑은 원 위로 끌어당기는 것이고 값 클리핑은 정사각형 안으로 밀어 넣는 것인데, 정사각형의 면에 눌리면 방향이 틀어집니다. 둘 다 폭발을 막지만 성질이 다르고, 트랜스포머 학습에서 노름 클리핑이 기본값인 이유가 이 방향 보존입니다.
가중치 감쇠가 벌하는 양
정규화는 손실에 항을 하나 더하는 방식으로 씁니다.
L2 노름의 제곱을 씁니다. 제곱근을 벗겨 두면 미분이 깔끔해지기 때문입니다. 추가된 항을 로 미분하면 그냥 입니다.
이것을 경사하강에 넣으면 업데이트가 이렇게 됩니다.
매 스텝마다 에 라는 1보다 조금 작은 수가 곱해집니다. 가중치 감쇠라는 이름이 여기서 나옵니다 — 손실이 아무 말도 안 하면 가중치는 저절로 0을 향해 줄어듭니다.
한 가지만 짚고 넘어갑니다. 이 유도는 순수한 경사하강에서만 그대로 성립합니다. Adam처럼 그래디언트를 크기로 나누는 최적화기에서는 항도 함께 나눠져서 "일정 비율로 감쇠"가 깨집니다. AdamW가 정규화 항을 손실이 아니라 업데이트 단계에 따로 붙이는 이유가 그것이고, 그 유도는 48번 글(모멘트로 본 Adam)에서 합니다.
L1이 0을 만드는 이유
L2 대신 L1을 벌점으로 쓰면 결과가 질적으로 달라집니다.
의 미분은 의 부호입니다 — 이면 , 이면 . 크기와 무관하게 항상 같은 힘으로 0을 향해 밉니다. L2가 라는 크기에 비례하는 힘으로 미는 것과 정반대입니다. 0에 가까운 가중치를 L2는 거의 안 건드리지만 L1은 끝까지 같은 힘으로 밉니다.
그 결과가 이렇게 생겼습니다.
def soft(w, t):
return np.sign(w) * np.maximum(np.abs(w) - t, 0.0)
w = np.array([-1.5, -0.4, 0.0, 0.3, 2.0])
soft(w, 0.5) # [-1.0, -0.0, 0.0, 0.0, 1.5] ← 0.5 안쪽은 정확히 0
w / (1 + 2*0.5) # [-0.75, -0.2, 0.0, 0.15, 1.0] ← 비율로 줄어들 뿐
L1은 와 을 정확히 0으로 만들었습니다. L2는 와 로 줄였을 뿐 0이 아닙니다. 부동소수점 오차로 작아진 것이 아니라 정확히 0이라 실제로 그 자리를 저장하지 않아도 됩니다. 이것이 희소성이고, 가지치기와 희소 모델의 근거입니다.
그리고 이 성질은 단위구의 모양으로 돌아갑니다. L1 단위구의 꼭짓점이 축 위에 있다는 것은 "제약을 만족하면서 가장 멀리 갈 수 있는 점이 축 위"라는 뜻이고, 축 위의 점은 다른 좌표가 0인 점입니다. 최적점이 뾰족한 꼭짓점에 걸리기 쉬우니 해에 0이 많이 생깁니다. L2의 단위구는 매끄러운 원이라 특별히 걸릴 자리가 없고, 그래서 모든 좌표가 조금씩 작아질 뿐입니다.
손으로 따라가기 — 같은 L2, 다른 L1
세 노름이 무엇을 다르게 벌하는지 벡터 두 개로 확인합니다. 하나는 한 성분에 몰려 있고, 하나는 고르게 퍼져 있습니다.
| 벡터 | L1 | L2 | L∞ |
|---|---|---|---|
| (몰려 있음) | 1.00 | 1.00 | 1.00 |
| (퍼져 있음) | 2.00 | 1.00 | 0.50 |
L2로 재면 둘은 완전히 같은 크기입니다. 그런데 L1으로 재면 가 두 배 크고, L∞로 재면 가 두 배 큽니다.
읽는 법은 이렇습니다.
- L2 벌점은 두 벡터를 구별하지 않습니다. 크기가 같으면 어떻게 분포하든 같은 벌을 받습니다. 그래서 L2는 "전체적으로 작게"만 요구합니다.
- L1 벌점은 퍼진 것을 더 벌합니다. 같은 L2 크기라면 몰려 있는 쪽이 유리하고, 그것이 희소해에 유리하게 작용합니다.
- L∞ 벌점은 몰려 있는 것을 더 벌합니다. 가장 큰 성분만 보므로 고르게 퍼뜨리는 쪽으로 밉니다.
같은 "크게 하지 마라"인데 요구하는 모양이 정반대입니다. 정규화를 고르는 일이 결국 어떤 모양의 해를 원하는가를 고르는 일인 이유가 이것입니다.
다시 그 로그로
처음의 로그를 다시 봅니다.
step 8414 loss 2.34 grad_norm 9284.1
이제 grad_norm이 모든 파라미터의 그래디언트를 이어 붙인 하나의 긴 벡터의 L2 노름이라는 것을 압니다. max_norm=1.0은 그 값이 1을 넘으면 을 전체에 곱한다는 뜻이고, 방향은 그대로 남습니다. 옆자리의 weight_decay=0.01은 같은 L2를 쓰지만 대상이 그래디언트가 아니라 가중치이고, 매 스텝 를 곱하는 일을 합니다. clip_grad_value_를 쓰는 코드는 사실 L∞로 재고 있고 방향이 바뀝니다.
세 줄이 전부 "무엇을 크기로 부를 것인가"의 선택이었습니다.
이제 크기는 잴 수 있게 됐는데 아직 못 재는 것이 있습니다. 두 벡터가 이루는 각도입니다. 임베딩 검색에서 "가깝다"를 판단할 때 실제로 쓰는 것은 거리보다 각도인 경우가 많고, 어텐션의 가 관련도로 읽히는 것도 각도 때문입니다. 다음 글이 내적을 정의하고, 내적·코사인·L2 거리 셋이 언제 정확히 같은 순위를 주고 언제 갈리는지를 증명과 반례로 가릅니다.
지난 글: 벡터: 임베딩 한 줄이 숫자 배열이 아니라 벡터인 이유
읽어주셔서 감사합니다. 😊

