레이더: 왜 로그 밀도의 기울기인가
지금까지는 시간마다 분포를 식으로 알았다. 실제로는 그렇지 않다. 그림들의 분포는 모르고, 신경망으로 밀도를 흉내 내더라도 보통은 「상수배까지만」 안다. 밀도의 합이 1이 되도록 나눠 줄 수를 계산하려면 그림 공간 전체에 걸쳐 적분해야 하는데, 가로세로 32픽셀 컬러 그림(숫자 32 × 32 × 3 = 3072개)의 공간이라면 축마다 점 10개만 찍어도 10³⁰⁷²개의 점에서 값을 구해야 하기 때문이다. 이런 처지에서도 바늘은 읽을 수 있을까? 드래곤볼 레이더 이야기를 조금 더 펼쳐 보자.
드래곤볼 찾기 대회
넓은 평야에 드래곤볼 일곱 개가 숨어 있다. 볼마다 전파를 내보내는데, 신호는 볼에서 멀어질수록 종 모양(가우시안)으로 약해지고 볼마다 신호의 크기가 다르다. 일곱 신호가 겹친 전파 강도 지도가 곧 봉우리가 일곱 개인 밀도다. 참가자 수천 명이 평야 아무 데나 떨어지고, 주최 측은 레이더를 하나씩 나눠 준다. 규칙은 하나, 바늘만 보고 걸어라. 바늘은 신호 자체가 아니라 신호의 로그가 가장 빨리 커지는 쪽을 가리킨다.
상수배를 몰라도 바늘은 같다
밀도를 상수배까지만 아는 경우를 식으로 적으면 이렇다.
밀도를 합이 1이 되게 맞추려고 나누는 수 Z를 정규화 상수 (합을 1로 맞추려고 나누는 수 / normalizing constant)라 한다. 로그를 씌우면 나눗셈이 뺄셈이 되고, Z는 자리 x와 상관없는 상수라 기울기가 0이다. 레이더는 전파 지도의 총량을 몰라도 만들 수 있다.
로그를 씌우는 데는 이유가 하나 더 있다. 스코어는 ∇p/p, 곧 밀도의 비율 변화다. 신호가 0.001인 벌판 한가운데서 0.0001 커지는 것은 10% 변화이고, 신호가 0.5인 볼 근처에서 같은 0.0001 커지는 것은 0.02% 변화다. 밀도의 기울기 ∇p만 보면 벌판의 바늘은 0에 가까워 참가자가 허허벌판에서 길을 잃는다. 비율로 보면 벌판에서도 바늘이 또렷하다.
혼합의 바늘
전파 지형이 가우시안 일곱 개를 섞은 것이면 바늘을 손으로 계산할 수 있다.
μ(뮤)는 위치, π(파이)는 원주율이 아니라 혼합 비중에 흔히 쓰는 글자, γ(감마)는 책임도다. 볼 하나만 있으면 바늘은 곧장 그 볼을 가리킨다((μk − x)/a²). 볼이 여럿이면 바늘은 각 볼을 향한 방향의 가중 평균이고, 그 무게가 책임도 γk, 곧 지금 이 신호가 볼 k에서 왔을 확률이다. 가까운 볼일수록, 신호가 큰 볼일수록 무게가 크다. 분모의 합이 한 번 더 나와 Z가 무엇이든 약분된다.
코드: Z를 바꿔도 바늘은 그대로
import numpy as np
mu = np.array([[-2.5, -1.5], [1.5, -2.0], [-1.0, 2.0], [2.5, 1.5],
[0.0, 0.0], [-2.0, 0.5], [1.0, -0.5]]) # 드래곤볼 일곱 개의 위치
pi = np.array([.18, .14, .16, .14, .12, .13, .13]) # 볼마다 신호 크기(혼합 비중, 합 1)
a = 0.4 # 봉우리 폭
def p_tilde(x): # 정규화 안 한 밀도: 상수 1/(2π a²) 를 일부러 뺐다
d2 = ((x - mu)**2).sum(1)
return (pi * np.exp(-d2 / (2 * a**2))).sum()
def score(x): # 책임도 가중 평균: Σ γ_k (μ_k − x)/a²
d2 = ((x - mu)**2).sum(1)
lg = np.log(pi) - d2 / (2 * a**2)
g = np.exp(lg - lg.max()); g /= g.sum()
return (g[:, None] * (mu - x)).sum(0) / a**2
def fd(f, x, h=1e-5): # 유한차분 기울기
return np.array([(f(x + h * e) - f(x - h * e)) / (2 * h) for e in np.eye(2)])
x = np.array([0.6, 0.9])
print("식으로 낸 스코어 ", score(x).round(4))
print("log p̃ 유한차분 ", fd(lambda u: np.log(p_tilde(u)), x).round(4))
print("log(p̃/Z) 유한차분 ", fd(lambda u: np.log(p_tilde(u) / 123.4), x).round(4))
for x in [np.array([0.6, 0.9]), np.array([3.5, -3.5])]:
print(x, "|∇p̃| =", f"{np.linalg.norm(fd(p_tilde, x)):.1e}",
" |∇log p| =", f"{np.linalg.norm(score(x)):.2f}")
# 식으로 낸 스코어 [-3.4203 -5.7833]
# log p̃ 유한차분 [-3.4203 -5.7833]
# log(p̃/Z) 유한차분 [-3.4203 -5.7833]
# [0.6 0.9] |∇p̃| = 2.2e-02 |∇log p| = 6.72
# [ 3.5 -3.5] |∇p̃| = 7.2e-09 |∇log p| = 15.63
Z를 아무 값(여기서는 123.4)으로 바꿔도 스코어는 한 자리도 변하지 않는다. 구석 (3.5, −3.5)에서 밀도의 기울기는 10⁻⁹ 수준으로 사라지지만 스코어는 오히려 더 크다.
ML에서: 밀도 대신 바늘을 배운다
디퓨전 모델의 신경망은 밀도 pt를 내놓지 않고 바늘 sθ(x, t)를(또는 같은 정보를 담은 다른 양을) 곧장 내놓는다. 바늘만 있으면 거꾸로 걸을 수 있고, 바늘을 내놓는 신경망은 Z를 한 번도 계산할 필요가 없다. 밀도를 배우는 모델이 늘 부딪히던 「전체 합을 어떻게 맞추나」라는 문제를 비켜 가는 것이다.
문제 9. 눈금이 지워진 방문자 그래프
블로그 방문자 수 그래프를 캡처했는데 세로축 눈금이 잘려 나갔다. 막대 높이는 어제 4칸, 오늘 4.4칸이다. 한 칸이 몇 명인지는 모른다. (가) 오늘 방문자는 어제보다 몇 명 늘었는가? (나) 몇 퍼센트 늘었는가? (다) 방문자 수의 로그는 얼마나 늘었는가?

한 칸이 몇 명인지 모르면 아무것도 못 구하는 거 아니에요? 한 칸이 10명이면 4명 늘었고, 1,000명이면 400명 늘었어요.

(가)는 정말 그래요. 그럼 (나)도 한 칸의 크기에 따라 달라져요?

아니요. 한 칸을 c명이라 하면 (4.4c − 4c)/4c = 0.1이라 c가 약분돼요. 10% 늘었어요.

(다)는 log 4.4c − log 4c = log 1.1 = 0.0953이네. 이것도 c가 빠져. 로그를 씌우면 모르는 배율이 그냥 더해지는 상수가 되니까, 차이를 내는 순간 사라지는 거구나.

레이더가 하는 일이 그거예요. 밀도의 눈금, 곧 정규화 상수 Z를 몰라도 로그의 변화는 읽혀요.

조교님이 성적을 100점 만점으로 줬는지 10점 만점으로 줬는지 몰라도, 누가 지난번보다 몇 퍼센트 올랐는지는 말할 수 있는 거랑 같네요.
문제 10. 두 봉우리의 레이더
1차원 혼합 p(x) = 0.8·N(x; 2, 1) + 0.2·N(x; −2, 1)을 생각한다. (가) x = 0에서 스코어는 얼마인가? (나) x = 5, 30, 45에서 log p를 유한차분으로 미분하면 무엇이 나오는가? 식으로 낸 값은 얼마인가?

(가)는 금방이에요. x = 0은 두 봉우리의 한가운데니까 (2 − 0)과 (−2 − 0)이 반반 섞여서 0이에요. 바늘이 멈추는 자리예요.

가운데에서 바늘이 멈춘다는 말이네요. 그럼 x = 0에서 걷기 시작한 참가자는 어디로 가요?

아무 데도요. 기울기가 0인 점이니까요.

근데 신호가 0.8인 볼과 0.2인 볼이 같은 거리에 있으면, 큰 신호 쪽으로 끌려야 하지 않아?

서연 학생, 「반반 섞인다」고 했죠. 그 반반은 어디서 왔어요?

아… 두 볼까지 거리가 같다는 것만 봤어요. 책임도에는 비중 π도 들어가요. γ₁ = 0.8·N₁ / (0.8·N₁ + 0.2·N₂)이고 x = 0에서 N₁ = N₂라서 γ₁ = 0.8이에요. 스코어는 0.8 × 2 + 0.2 × (−2) = 1.2예요.

가운데는 멈추는 자리가 아니에요. 신호가 큰 볼 쪽으로 기울어요. 로그의 합을 미분한 게 아니라 합의 로그를 미분한 거라, 비중이 분자와 분모에 같이 남아요.

그래요. 로그가 곱은 풀어 주지만 합은 못 풀어요. 민준 학생은 (나)를 해 봤어요?

네, 그런데 이상해요. x = 5에서는 −3.000, x = 30에서는 −28.000이 깔끔하게 나오는데, x = 45에서는 nan이 나와요. 스코어가 거기서 정의가 안 되는 건가요?

x = 45에서 p(x) 자체는 얼마쯤이에요?

(45 − 2)²/2가 924.5니까 e^(−924.5)… 아, 컴퓨터가 0이 아닌 수로 담을 수 있는 가장 작은 수가 10^(−324)쯤이니까 0으로 뭉개졌어요. log 0이 −∞고, −∞ 빼기 −∞가 nan이에요.

스코어는 멀쩡해요. 식으로는요?

거기선 γ₁이 사실상 1이니까 (2 − 45)/1 = −43이요. 멀어질수록 직선으로 커지기만 해요. 망가진 건 스코어가 아니라 밀도를 먼저 계산한 제 순서였네요.

조별 과제 점수를 곱해서 평균 내다가 0점이 하나 끼면 다 날아가는 거랑 같네요. 로그로 먼저 바꿔서 더했어야 했는데.

실제 코드도 그래서 로그에서 가장 큰 값을 빼고 계산해요. 이 절의 코드가 책임도를 그렇게 계산했어요.
문제 11. 에너지만 아는 모델의 레이더
ML에는 밀도를 정규화하지 않은 채로 쓰는 모델이 많다. 신경망이 자리 x마다 「에너지」 E(x)라는 숫자 하나를 내놓고, 확률은 p(x) = e^(−E(x)) / Z로 정한다(에너지 기반 모델). 1차원 장난감으로 E(x) = (x² − 1)²를 쓴다. 골짜기가 x = ±1에 있는 에너지다. (가) x = 0.5와 x = 2에서 스코어를 구하라. (나) 이 계산에 Z가 필요했는가? x가 1,000차원이면 Z를 구하는 일은 어떻게 되는가?

log p = −E − log Z니까 미분하면 E′이죠. E′(x) = 4x(x² − 1)이고, x = 0.5에서 4 × 0.5 × (−0.75) = −1.5예요.

0.5에 선 사람에게 가장 가까운 골짜기는 어느 쪽이에요? 바늘은 그쪽을 가리켜야 하죠.

골짜기는 +1이니까 오른쪽인데, −1.5면 왼쪽을 가리켜요. 아, 마이너스를 미분하면서 떨어뜨렸어요. 스코어는 −E′이라 +1.5예요. x = 2에서는 −4 × 2 × 3 = −24로, 골짜기 1 쪽으로 세게 끌어요.

에너지가 낮은 곳이 확률이 높은 곳이니까, 바늘은 에너지의 내리막을 가리키는 게 맞아. (나)는 Z를 한 번도 안 썼어요. 1차원이면 적분해서 Z ≈ 1.974가 나오지만, 1,000차원이면 축마다 점 100개만 찍어도 100^1000개의 점에서 e^(−E)를 계산해야 해요.

그래서 정규화하지 않은 모델을 배우거나 그 모델에서 샘플을 뽑을 때 스코어로 일해요. 눈금을 몰라도 로그의 변화가 읽힌 방문자 그래프와 같은 이유예요.

확률론 수업에서 베이즈 정리의 분모를 계산하지 않고 비율만 비교하던 거랑 같아요. 분모는 모두에게 같으니까요.