수학

MATH / 중급 28번

교차엔트로피 = 음의 로그가능도: 사전학습 손실 한 줄의 유도

코드에는 CrossEntropyLoss라고 적혀 있는데 유도해서 나온 것은 음의 로그가능도였습니다. 두 이름이 왜 같은 식인지 원-핫 정답 한 줄로 확인하고, 라벨 스무딩이 그 식의 어느 자리를 건드리는지, 배치 손실이 대체 무엇의 추정량인지까지 갑니다.

PALDYN Team34 MIN READ

학습 스크립트를 열면 손실은 대개 한 줄입니다.

criterion = nn.CrossEntropyLoss(label_smoothing=0.1)

그리고 학습을 돌리면 로그 첫 줄에 10.8 언저리의 수가 찍힙니다. 어휘가 50,257개인 GPT-2 토크나이저로 새 모델을 학습시키면 대개 그 근처에서 출발합니다. 그 수가 어디서 오는지, 왜 거기가 출발점인지는 이 글의 끝에서 풀립니다.

먼저 이름이 걸립니다. 코드는 «교차엔트로피»라고 부르는데, 지난 글에서 범주분포를 가정하고 최대가능도를 밀어붙였을 때 떨어져 나온 것은 음의 로그가능도였습니다. 이름이 둘인데 코드는 하나입니다. 게다가 괄호 안의 label_smoothing=0.1은 유도 어디에도 없던 값입니다.

이 글에서 그 셋을 붙입니다. 교차엔트로피를 정의하고, 정답이 원-핫일 때 그것이 음의 로그가능도와 글자 그대로 같은 식이 됨을 보이고, 라벨 스무딩이 그 식의 어느 자리를 건드리는지 봅니다. 그다음 배치 손실이 무엇의 추정량인지, 로그에 찍히는 수를 어떤 눈금으로 읽는지까지 갑니다.

교차엔트로피의 정의

부호화 비용

엔트로피 글에서 분포 pp 의 엔트로피를 H(p)=−∑ipilog⁡piH(p) = -\sum_i p_i \log p_i 로 정의했습니다. 「pp 에서 사건이 나올 때 놀람 −log⁡pi-\log p_i 의 평균」이었습니다.

여기서 분포를 둘로 늘립니다. 사건은 pp 에서 나오는데, 놀람은 qq 를 믿고 잽니다.

정의. 분포 pp 와 qq 의 교차엔트로피(cross entropy)는 H(p,q)=Ei∼p[−log⁡qi]=−∑ipilog⁡qiH(p, q) = \mathbb{E}_{i \sim p}\big[-\log q_i\big] = -\sum_i p_i \log q_i 이다.

H(p)H(p) 와 다른 곳은 로그 안쪽 하나뿐입니다. 앞의 pip_i 는 「실제로 얼마나 자주 일어나는가」이고 로그 안의 qiq_i 는 「모델이 얼마나 그럴 것이라 보는가」입니다. 그래서 교차엔트로피는 틀린 확률표를 믿고 부호화했을 때 드는 평균 비용으로 읽힙니다. 자주 나오는 기호에 짧은 부호를 주는 것이 좋은 부호화인데, 빈도를 잘못 알고 있으면 자주 나오는 기호에 긴 부호를 붙이게 되고 그만큼 비용이 붙습니다.

로그의 밑은 단위를 정합니다. 밑이 2이면 비트이고, 자연로그로 잰 값의 단위는 내트(nat)입니다. 1내트는 약 1.443비트입니다. 이론을 셀 때는 비트가 손에 잡히고, 프레임워크의 손실은 언제나 내트로 나옵니다.

세 모델의 비교

어휘가 넷인 장난감으로 세어 봅시다. 실제 분포는 p=(0.5, 0.25, 0.125, 0.125)p = (0.5,\, 0.25,\, 0.125,\, 0.125) 이고, 이 분포의 엔트로피는 앞 글에서 1.75비트로 계산했습니다. 여기에 모델을 셋 대 봅니다.

같은 p에 세 모델 분포를 대면 교차엔트로피가 갈린다

모델이 정확해서 q=pq = p 이면 H(p,q)=H(p)=1.75H(p,q) = H(p) = 1.75 비트입니다. 모델이 아무것도 몰라서 q=(0.25,0.25,0.25,0.25)q = (0.25, 0.25, 0.25, 0.25) 이면 놀람이 전부 2비트이므로

H(p,q)=2∑ipi=2 비트H(p, q) = 2\sum_i p_i = 2 \text{ 비트}

입니다. 모델이 거꾸로 알아서 q=(0.125,0.125,0.25,0.5)q = (0.125, 0.125, 0.25, 0.5) 이면 놀람이 각각 3, 3, 2, 1비트이므로

H(p,q)=0.5(3)+0.25(3)+0.125(2)+0.125(1)=2.625 비트H(p, q) = 0.5(3) + 0.25(3) + 0.125(2) + 0.125(1) = 2.625 \text{ 비트}

입니다. 셋 다 H(p)=1.75H(p) = 1.75 보다 크거나 같고, q=pq = p 일 때만 등호입니다. 이것이 우연이 아니라는 것, 곧 초과분 H(p,q)−H(p)H(p,q) - H(p) 가 언제나 0 이상이라는 것은 KL 발산 글이 증명합니다. 지금 챙길 것은 하나입니다. 교차엔트로피는 최소화할 대상으로 쓸 수 있고, 그 최솟값은 qq 를 pp 에 맞췄을 때 나옵니다.

이진 교차엔트로피

스팸인가 아닌가처럼 답이 둘뿐인 문제에서 쓰는 손실은 따로 이름이 있습니다. 정답 yy 가 0 또는 1이고 모델이 「1일 확률」을 qq 로 내놓을 때

−[ylog⁡q+(1−y)log⁡(1−q)]-\big[y \log q + (1 - y)\log(1 - q)\big]

를 이진 교차엔트로피(binary cross entropy)라고 부릅니다. 새 식처럼 보이지만 위 정의에 V=2V = 2 를 넣은 것에 지나지 않습니다. 클래스가 둘이면 목표 분포는 p=(1−y,  y)p = (1-y,\; y) 이고 모델 분포는 (1−q,  q)(1-q,\; q) 이므로, 정의의 합 −∑ipilog⁡qi-\sum_i p_i \log q_i 를 두 항으로 풀어 적으면 괄호 안이 그대로 나옵니다.

정답이 1이고 모델이 q=0.8q = 0.8 을 내면 둘째 항이 사라져 손실은 −ln⁡0.8=0.223-\ln 0.8 = 0.223 내트입니다. PyTorch에서 BCELoss와 CrossEntropyLoss가 따로 있는 것은 입력 모양(확률 하나냐, 클래스마다 점수냐)이 달라서이지 재는 양이 달라서가 아닙니다.

원-핫에서의 항 접힘

원-핫 분포

분류에서 정답은 분포가 아니라 클래스 하나입니다. 그래도 분포로 적을 수는 있습니다. 정답 자리만 1이고 나머지가 0인 벡터를 원-핫(one-hot) 분포라고 합니다. 「yy 번 클래스가 확률 1로 일어난다」고 믿는 분포입니다.

어휘 넷에 정답이 2번 클래스라면 p=(0,1,0,0)p = (0, 1, 0, 0) 입니다. 이것을 정의에 그대로 넣습니다.

H(p,q)=−(0⋅log⁡q1+1⋅log⁡q2+0⋅log⁡q3+0⋅log⁡q4)H(p, q) = -\big(0 \cdot \log q_1 + 1 \cdot \log q_2 + 0 \cdot \log q_3 + 0 \cdot \log q_4\big)

0이 곱해진 항은 통째로 사라지고 하나만 남습니다.

H(p,q)=−log⁡qyH(p, q) = -\log q_y

원-핫 정답이 합을 한 항으로 접는다

q=(0.5,0.25,0.125,0.125)q = (0.5, 0.25, 0.125, 0.125) 이고 정답이 2번이면 −log⁡20.25=2-\log_2 0.25 = 2 비트, 자연로그로는 −ln⁡0.25=1.386-\ln 0.25 = 1.386 내트입니다. 모델이 나머지 세 자리에 확률을 어떻게 나눴는지는 이 값에 직접 들어가지 않습니다. 정답 자리에 얼마를 줬는지만 셉니다. 나머지 자리는 softmax의 합이 1이라는 제약을 통해서만 간접으로 끌려 내려갑니다.

음의 로그가능도

오른쪽 식을 데이터 전체에 대해 더하면 지난 글의 마지막 줄이 됩니다.

arg⁡min⁡θ(−∑i=1nlog⁡qθ(yi∣xi))\arg\min_\theta \left(-\sum_{i=1}^{n} \log q_\theta(y_i \mid x_i)\right)

지난 글에서 이 괄호 안을 음의 로그가능도(negative log-likelihood, NLL)라고 불렀습니다. 가능도에 로그를 씌우고 부호를 뒤집은 값이고, 이것을 최소화하는 것이 가능도를 최대화하는 것과 같았습니다. 같은 식에 이름이 둘 붙어 있었던 것이고, 부르는 방향이 달랐을 뿐입니다.

이름 어디서 왔나 무엇을 강조하나
음의 로그가능도 최대가능도 추정 「이 데이터가 나올 가능도를 키운다」
교차엔트로피 정보이론 「정답 분포와 모델 분포의 거리를 줄인다」

프레임워크가 «교차엔트로피» 쪽 이름을 쓰는 이유는 실용적입니다. 정답이 원-핫이 아닐 때도 그대로 통하기 때문입니다. 뒤에서 볼 라벨 스무딩이나 지식 증류처럼 목표 분포 pp 가 원-핫이 아니면 합이 항 하나로 접히지 않지만, H(p,q)=−∑ipilog⁡qiH(p,q) = -\sum_i p_i \log q_i 는 그대로 성립합니다. 더 넓은 쪽 이름이 붙어 있는 셈입니다.

로짓 입력

CrossEntropyLoss는 확률 qq 를 받지 않습니다. 받는 것은 로짓(logit), 곧 softmax에 들어가기 전의 클래스별 점수 zz 입니다. softmax 유도 글에서 본 대로 qi=ezi/∑jezjq_i = e^{z_i} / \sum_j e^{z_j} 이므로, 정답 자리의 음의 로그를 로짓으로 바로 적을 수 있습니다.

−log⁡qy=log⁡∑jezj−zy-\log q_y = \log\sum_j e^{z_j} - z_y

오른쪽 첫 항을 log-sum-exp라고 부릅니다. 이 꼴로 계산하면 확률을 한 번도 만들지 않고 손실에 닿습니다. 프레임워크가 log_softmax와 음의 로그가능도 두 연산을 한 함수로 붙여 둔 이유가 여기 있습니다.

따로 계산하면 무엇이 깨지는지 봅시다. 로짓이 (0,200)(0, 200) 이고 정답이 0번이면 q0=e−200q_0 = e^{-200} 인데, 이 값은 32비트 부동소수의 가장 작은 수보다 작아 0으로 떨어집니다. 거기에 로그를 씌우면 무한대가 나오고, 그래디언트도 같이 망가집니다. 붙인 식으로는 log⁡(e0+e200)−0≈200\log(e^{0} + e^{200}) - 0 \approx 200 이라 멀쩡한 유한값입니다. 모델이 정답을 크게 틀린 바로 그 순간이 학습 신호가 가장 필요한 때인데, 따로 계산하면 정확히 그때 값이 사라집니다.

로짓에서 손실까지 한 연산으로 가야 log 0이 안 나온다

패딩과 ignore_index

언어 모델은 길이가 다른 문장을 한 배치에 묶으려고 짧은 문장 뒤를 빈 토큰으로 채웁니다. 이 채움 토큰을 패딩(padding)이라고 합니다. 패딩 자리에도 로짓과 정답이 있으니 그대로 두면 손실에 섞여 들어갑니다.

ignore_index는 그 자리를 합에서 빼는 장치입니다. 기본값이 −100이고, 정답이 −100인 칸은 손실 계산에서 통째로 빠집니다. 목표 분포를 바꾸는 것이 아니라 항을 없애는 것이고, 평균을 낼 때도 빠진 칸은 세지 않아 분모가 유효 토큰 수가 됩니다.

빼지 않으면 무슨 일이 생기는지 수로 봅시다. 토큰 여덟 칸 중 두 칸이 패딩이고, 진짜 토큰 여섯의 손실 합이 4.844입니다. 패딩 칸은 늘 같은 토큰이 이어지므로 모델이 금방 맞혀 칸마다 손실이 0.007쯤입니다. 패딩을 빼면 평균이 4.844/6=0.8074.844 / 6 = 0.807 이고, 넣으면 4.859/8=0.6074.859 / 8 = 0.607 입니다.

패딩을 셈에 넣으면 평균이 묽어진다

같은 모델, 같은 문장인데 손실이 25% 낮게 찍힙니다. 더 나쁜 점은 이 비율이 배치마다 다르다는 것입니다. 짧은 문장이 많이 섞인 배치는 패딩이 많아 손실이 낮게, 긴 문장끼리 묶인 배치는 높게 나옵니다. 로그에서 손실이 출렁이는데 그 출렁임의 일부가 모델이 아니라 배치 구성에서 옵니다.

라벨 스무딩과 목표 분포

섞인 목표 p′

label_smoothing=0.1이 하는 일은 하나입니다. 모델을 건드리지 않고 목표 분포 pp 를 바꿉니다.

정의. 클래스가 VV 개일 때 라벨 스무딩(label smoothing)은 원-핫 목표 pp 를 균등분포와 섞어 p′=(1−ε) p+εV1p' = (1 - \varepsilon)\, p + \frac{\varepsilon}{V}\mathbf{1} 로 바꾸는 조작이다. ε\varepsilon 은 섞는 정도이고 보통 0.1을 쓴다.

V=4V = 4, ε=0.1\varepsilon = 0.1, 정답이 2번이면 정답 자리는 1−0.1+0.1/4=0.9251 - 0.1 + 0.1/4 = 0.925 이고 나머지 세 자리는 각각 0.1/4=0.0250.1/4 = 0.025 입니다.

p′=(0.025, 0.925, 0.025, 0.025)p' = (0.025,\, 0.925,\, 0.025,\, 0.025)

원-핫 목표와 스무딩된 목표

같은 모델 q=(0.5,0.25,0.125,0.125)q = (0.5, 0.25, 0.125, 0.125) 에 대한 손실을 다시 셉니다. 놀람은 각각 1, 2, 3, 3비트였으므로

H(p′,q)=0.025(1)+0.925(2)+0.025(3)+0.025(3)=2.025 비트H(p', q) = 0.025(1) + 0.925(2) + 0.025(3) + 0.025(3) = 2.025 \text{ 비트}

원-핫일 때의 2비트에서 조금 올랐습니다. 목표가 원-핫이 아니게 되었으니 이제 합이 한 항으로 접히지 않고, 오답 자리의 log⁡qi\log q_i 도 손실에 직접 들어옵니다.

최적점과 바닥

값이 조금 오른 것은 요점이 아닙니다. 요점은 손실이 최소가 되는 자리가 옮겨 갔다는 것입니다. 교차엔트로피는 qq 를 목표에 맞출 때 최소이므로, 원-핫 목표의 최소는 q=(0,1,0,0)q = (0, 1, 0, 0) 입니다. 그런데 softmax의 출력은 정확히 1이 될 수 없습니다. qy→1q_y \to 1 이 되려면 정답 로짓이 나머지보다 무한히 커져야 하고, 학습은 닿지 못할 목표를 향해 로짓을 계속 벌립니다.

스무딩한 목표의 최소는 q=p′q = p' 이고, 이것은 닿을 수 있는 자리입니다. 정답과 오답의 확률비가 0.925/0.025=370.925/0.025 = 37 이면 되므로 필요한 로짓 차는 ln⁡37=3.61\ln 37 = 3.61 로 유한합니다. 일반적으로는 ln⁡1−ε+ε/Vε/V\ln\frac{1-\varepsilon+\varepsilon/V}{\varepsilon/V} 이고, 어휘가 50,257개면 13.02입니다. 라벨 스무딩이 과신을 줄인다는 설명은 여기서 나옵니다. 모델을 규제한 것이 아니라 목표를 닿을 수 있는 자리로 옮긴 것입니다.

대신 값을 치릅니다. p′p' 자체가 엔트로피를 가지므로 손실은 아무리 잘 맞혀도 H(p′)H(p') 에서 멈춥니다. 어휘 넷에서는

H(p′)=−[0.925log⁡20.925+3×0.025log⁡20.025]=0.503 비트H(p') = -\big[0.925 \log_2 0.925 + 3 \times 0.025 \log_2 0.025\big] = 0.503 \text{ 비트}

로, 내트로는 0.349입니다. 어휘가 커지면 이 바닥이 작아질 것 같지만 거꾸로입니다. V=50,257V = 50{,}257 에서 오답 자리 하나의 몫은 ε/V≈2×10−6\varepsilon/V \approx 2 \times 10^{-6} 으로 작지만, 그런 자리가 5만 개이고 자리마다 놀람이 −ln⁡(2×10−6)≈13.1-\ln(2 \times 10^{-6}) \approx 13.1 내트나 됩니다. 합치면 바닥이 1.41내트, 2.03비트입니다. 언어 모델 손실이 2 안팎에서 논의되는 것을 생각하면 결코 작은 값이 아닙니다.

그래서 스무딩을 켜고 끈 두 학습의 손실은 맞대 놓고 비교할 수 없습니다. 번역용 트랜스포머를 처음 낸 논문도 ε=0.1\varepsilon = 0.1 을 쓰면서 perplexity는 나빠지고 번역 점수는 좋아졌다고 적었습니다. 손실 눈금 자체가 옮겨 갔으니 perplexity가 나빠지는 것은 당연한 결과입니다.

클래스 가중치

라벨 스무딩과 자주 헷갈리는 옵션이 weight=입니다. 드문 클래스를 더 챙기려고 클래스마다 무게 wcw_c 를 주는 것을 클래스 가중치라고 하는데, 이것은 목표 분포를 건드리지 않습니다. 원-핫 목표에서 접힌 항 −log⁡qy-\log q_{y} 하나하나에 저울을 달 뿐입니다.

L=∑twyt(−log⁡qyt)∑twytL = \frac{\sum_t w_{y_t}\big(-\log q_{y_t}\big)}{\sum_t w_{y_t}}

PyTorch의 평균도 이렇게, 토큰 수가 아니라 무게의 합으로 나눕니다. 토큰 둘의 손실이 0.495와 0.599이고 둘째 토큰의 정답 클래스에만 무게 3을 주면 평균이 (0.495+3×0.599)/4=0.573(0.495 + 3 \times 0.599)/4 = 0.573 이 됩니다. 무게 없이 평균하면 0.547이니, 그 클래스의 틀림이 세 배로 쳐진 만큼 올라갑니다.

둘의 자리를 나란히 놓으면 이렇습니다. 스무딩은 pp 를 바꾸어 최적점을 옮기고, 가중치는 pp 를 그대로 둔 채 어느 토큰의 틀림을 더 비싸게 칠지를 바꿉니다. 원-핫 목표만 있을 때는 가중치가 최적점 q=pq = p 를 옮기지 못합니다. 둘을 함께 켜면 이야기가 달라지는데, 그건 코드로 재 봅니다.

지식 증류와 온도

목표 분포를 통째로 다른 모델에서 가져오는 경우도 있습니다. 큰 모델(교사)의 출력 분포를 pp 자리에 놓고 작은 모델(학생)을 거기 맞추는 것을 지식 증류(knowledge distillation)라고 합니다. 교사 분포는 원-핫보다 정보가 많습니다. 「고양이」 사진에서 「호랑이」에 0.05, 「자동차」에 0.0001을 준다는 것 자체가 클래스 사이의 닮음을 알려 줍니다.

그 차이를 키우려고 로짓을 온도 TT 로 나눈 뒤 softmax를 씌웁니다. TT 가 1보다 크면 분포가 평평해져 작은 확률들이 드러납니다. 문제는 그래디언트입니다. 학생 로짓에 대한 그래디언트가 (q−p)/T(q - p)/T 꼴인데, 온도가 높으면 두 분포가 모두 평평해져 q−pq - p 자체도 대략 1/T1/T 만큼 줄어듭니다. 합치면 그래디언트가 1/T21/T^2 로 줄어듭니다.

로짓 열 개로 재 보면 그래디언트 크기가 T=1T = 1 에서 0.815, T=2T = 2 에서 0.193, T=4T = 4 에서 0.044입니다. 여기에 T2T^2 을 곱하면 0.815, 0.771, 0.711로 대략 제자리에 돌아옵니다. 그래서 증류 손실에는 T2T^2 을 곱해 둡니다. 온도를 바꿀 때마다 학습률을 다시 고르지 않으려는 보정입니다.

배치 손실의 추정 오차

몬테카를로 추정

사전학습 로그에 찍히는 손실은 토큰 하나의 값이 아니라 배치 안 토큰들의 평균입니다.

L^=1N∑t=1N(−log⁡qθ(xt∣x<t))\widehat{L} = \frac{1}{N}\sum_{t=1}^{N}\big(-\log q_\theta(x_t \mid x_{<t})\big)

이 값이 재려는 참값은 데이터 전체에 대한 기댓값입니다.

L=Ex∼pdata[−log⁡qθ(x∣문맥)]=H(pdata, qθ)L = \mathbb{E}_{x \sim p_{\text{data}}}\big[-\log q_\theta(x \mid \text{문맥})\big] = H(p_{\text{data}},\, q_\theta)

즉 배치 손실은 데이터 분포와 모델 분포의 교차엔트로피를 표본평균으로 대신 잰 값입니다. 확률적으로 뽑은 표본의 평균으로 기댓값을 어림하는 이 방법을 몬테카를로 추정이라고 합니다. 앞 절의 ignore_index가 중요했던 것도 이 때문입니다. 패딩을 넣으면 표본에 데이터 분포에서 나오지 않은 칸이 섞여, 추정하려던 기댓값과 다른 것을 재게 됩니다.

배치 평균은 기댓값을 겨냥한 흔들리는 화살이다

표준오차

그렇게 보면 분산과 표준오차 글의 결과가 그대로 적용됩니다. 토큰별 손실의 표준편차가 ss 일 때 배치 손실의 표준오차는 s/Ns/\sqrt{N} 입니다. 토큰 손실의 표준편차가 2.0쯤이고 배치에 토큰이 2,048개 들어 있으면

표준오차=2.02048=0.044\text{표준오차} = \frac{2.0}{\sqrt{2048}} = 0.044

입니다. 연속한 두 스텝에서 손실이 0.04쯤 오르내리는 것은 학습이 나빠진 것이 아니라 표본이 바뀐 것입니다. 이 눈금을 갖고 있으면 로그를 읽을 때 잡음과 신호를 가릅니다. 배치를 네 배로 늘리면 표준오차가 절반이 되고, 손실 곡선이 그만큼 매끈해집니다. 곡선이 매끈해졌다고 모델이 좋아진 것은 아닙니다.

한 가지 단서가 붙습니다. 한 문장 안의 토큰들은 서로 독립이 아니어서, 같은 문서에서 나온 2,048토큰은 서로 다른 문서에서 뽑은 2,048토큰보다 정보가 적습니다. 실제 흔들림은 이 공식보다 조금 클 수 있습니다.

평균과 합

손실을 배치에서 모으는 방법은 reduction 인자가 정합니다. 기본값 'mean'은 평균을, 'sum'은 합을, 'none'은 토큰별 값을 그대로 돌려줍니다.

합을 쓰면 배치를 두 배로 늘렸을 때 손실도 그래디언트도 두 배가 됩니다. 한 스텝에 파라미터가 움직이는 거리는 학습률과 그래디언트의 곱이므로, 배치 크기를 바꾸면 사실상 학습률을 바꾼 것이 됩니다. 평균을 쓰면 그래디언트가 토큰 하나당 평균의 크기로 고정되어, 배치 크기와 학습률을 따로 고를 수 있습니다. 평균이 기본값인 이유입니다. 그리고 평균이어야 앞 절의 몬테카를로 추정이 성립합니다. 합은 NN 에 비례해 커지므로 무엇의 추정량도 아닙니다.

손실·perplexity·bpt

세 눈금

손실 하나를 세 가지로 적을 수 있습니다. 엔트로피 글에서 다룬 환산이지만, 이제 그 값이 교차엔트로피라는 것을 알고 보게 됩니다.

눈금 식 손실 1.85일 때
손실 (내트) LL 1.85
perplexity exp⁡(L)\exp(L) 6.36
bits-per-token L/ln⁡2L / \ln 2 2.67

bits-per-token은 토큰 하나를 적는 데 드는 평균 비트 수이고, perplexity는 모델이 매 토큰마다 사실상 몇 개의 후보 사이에서 고민하는가로 읽힙니다. 셋은 로그의 밑과 지수를 바꾼 것일 뿐 재는 양이 같습니다. 다만 라벨 스무딩을 켰다면 그때의 손실은 H(p′,q)H(p', q) 라서 이 환산의 뜻이 사라집니다. 보고용 perplexity는 스무딩 없이 다시 재야 합니다.

눈금의 0점

손실 눈금에는 양 끝이 있습니다. 아래쪽 끝 0은 정답에 확률 1을 주는 완벽한 모델이고, 위쪽 끝은 아무것도 모르는 모델입니다. 학습이 얼마나 왔는지는 이 위쪽 끝을 기준점으로 삼아 읽습니다. 어휘 전체에 똑같이 1/V1/V 를 주면 어느 토큰이 와도 손실이 ln⁡V\ln V 이고, V=50,257V = 50{,}257 이면 10.82내트, perplexity로는 정확히 50,257, 비트로는 15.62비트입니다.

어휘 50,257개짜리 모델의 손실 눈금

글머리의 10.8이 이것입니다. 초기화 직후의 출력층은 로짓이 거의 0이라 softmax가 균등분포에 가깝고, 그래서 첫 손실이 ln⁡V\ln V 근처에서 찍힙니다. 첫 손실이 이보다 훨씬 크면 초기화가 이미 어떤 토큰을 과신하고 있다는 뜻이라, 학습이 잘못 시작됐는지 가리는 가장 싼 점검이 됩니다. 학습은 이 오른쪽 끝에서 출발해 왼쪽으로 내려오고, 손실 2는 「5만 개 중에서 7개쯤으로 좁혔다」로 읽힙니다.

토큰과 문서

perplexity를 어느 단위로 평균했는지도 따져야 합니다. 말뭉치 전체의 토큰 손실을 평균한 뒤 지수를 씌우는 것과, 문서마다 perplexity를 구해 그것을 평균하는 것은 다른 값이 됩니다.

토큰이 100개씩인 문서 둘이 있고 평균 손실이 각각 1.0과 3.0이라고 합시다. 토큰 단위로는 손실 평균이 2.0이므로 perplexity가 e2=7.39e^{2} = 7.39 입니다. 문서 단위로는 e1=2.72e^{1} = 2.72 와 e3=20.09e^{3} = 20.09 의 평균이라 11.40입니다. 지수함수가 볼록해서 평균의 지수보다 지수의 평균이 크기 때문입니다. 문서 길이가 다르면 또 달라집니다. 첫 문서가 300토큰이면 토큰 단위 perplexity는 e1.5=4.48e^{1.5} = 4.48 로 내려가지만 문서 단위 평균은 11.40 그대로입니다.

두 논문의 perplexity를 비교할 때는 수보다 먼저 이 셋을 맞춥니다. 어느 토크나이저인가(어휘가 다르면 토큰 하나가 뜻하는 글자 수가 다릅니다), 어느 단위로 평균했는가, 스무딩을 뺐는가입니다.

코드로 확인하기

numpy 검산

위의 장난감 수를 numpy로 다시 셉니다.

import numpy as np

def H(p, q):                                  # 비트 단위 교차엔트로피
    return -(p * np.log2(q)).sum()

q = np.array([0.5, 0.25, 0.125, 0.125])
p = np.array([0.5, 0.25, 0.125, 0.125])
print(H(p, q), H(p, np.full(4, 0.25)),
      H(p, np.array([0.125, 0.125, 0.25, 0.5])))   # 1.75 2.0 2.625

# ① 원-핫이면 항 하나로 접힌다
y = 1
onehot = np.eye(4)[y]
print(H(onehot, q), -np.log2(q[y]))               # 2.0 2.0

# ② 라벨 스무딩은 목표 분포를 바꾼다
eps = 0.1
smooth = (1 - eps) * onehot + eps / 4
print(smooth)                                     # [0.025 0.925 0.025 0.025]
print(round(H(smooth, q), 4))                     # 2.025
print(round(H(smooth, smooth), 4))                # 0.5032  바닥 H(p′)
print(round(np.log(0.925 / 0.025), 4))            # 3.6109  필요한 로짓 차

# ③ 로짓에서 바로: log-sum-exp − 정답 로짓
z = np.array([1.0, 2.0, 0.5, -1.0])
print(round(np.log(np.exp(z).sum()) - z[y], 4))   # 0.4952  내트

세 번째 블록이 프레임워크가 로짓을 받아 하는 일의 전부입니다. softmax를 거쳐 qq 를 만드는 대신 log-sum-exp에서 정답 로짓을 뺍니다.

패딩과 가중치

이번에는 PyTorch로 ignore_index와 weight=를 켜고 끄며 잽니다. 둘째 문장의 뒤 두 칸이 패딩입니다.

import torch
import torch.nn.functional as F

# 문장 둘, 길이 4. 둘째 문장은 뒤 두 칸이 패딩(토큰 id 0)이다
logits = torch.tensor([[[1.0, 2.0, 0.5, -1.0], [0.2, 0.1, 1.5, 0.3],
                        [0.3, 0.9, 0.1, 0.4], [1.2, 0.0, 0.2, 0.8]],
                       [[0.5, 0.4, 1.1, 0.2], [0.1, 1.3, 0.2, 0.0],
                        [6.0, 0.0, 0.0, 0.0], [6.0, 0.0, 0.0, 0.0]]])
target = torch.tensor([[1, 2, 1, 3],
                       [2, 1, 0, 0]])
z, y = logits.reshape(-1, 4), target.reshape(-1)

# ① 패딩을 정답으로 셈에 넣은 것
print(round(F.cross_entropy(z, y).item(), 4))                 # 0.6073
# ② 패딩 자리를 -100으로 가려 빼 낸 것
y_masked = y.clone()
y_masked[6:] = -100
print(round(F.cross_entropy(z, y_masked).item(), 4))          # 0.8073
print(round(F.cross_entropy(z, y_masked, reduction='sum').item() / 6, 4))  # 0.8073

# ③ 라벨 스무딩의 바닥: 로짓을 log p′로 두면 손실이 H(p′)에서 멈춘다
p_s = torch.tensor([[0.025, 0.925, 0.025, 0.025]])
one = torch.tensor([1])
print(round(F.cross_entropy(p_s.log(), one, label_smoothing=0.1).item(), 4))  # 0.3488
# ④ weight=를 함께 켜면 같은 로짓의 값이 달라진다
w = torch.tensor([1.0, 2.0, 1.0, 1.0])
print(round(F.cross_entropy(p_s.log(), one, label_smoothing=0.1, weight=w).item(), 4))  # 0.2104

①과 ②의 차이가 앞 절 그림의 두 점선입니다. ②의 두 줄이 같은 값이라는 데서 'mean'이 패딩을 뺀 여섯 칸으로 나눈다는 것이 확인됩니다. ③은 0.3488내트, 곧 앞에서 손으로 센 0.503비트입니다.

④가 앞 절에서 미뤄 둔 이야기입니다. PyTorch는 가중치를 스무딩의 오답 몫에도 곱하므로, 둘을 함께 켜면 목표가 무게 쪽으로 기울어집니다. 같은 로짓에서 값이 0.3488에서 0.2104로 바뀌었고, 이 설정에서 손실이 가장 낮은 자리를 최적화로 찾으면 정답 확률이 0.925가 아니라 0.961입니다. 바닥도 최적점도 p′p' 에서 벗어납니다. 두 옵션을 함께 쓴 학습의 손실은 어느 쪽 눈금으로도 바로 읽을 수 없습니다.

처음의 한 줄로 돌아갑니다. nn.CrossEntropyLoss(label_smoothing=0.1)은 이제 세 부분으로 읽힙니다. CrossEntropy는 목표 분포와 모델 분포를 대는 방식이고, 기본값인 원-핫에서는 그것이 음의 로그가능도로 접히며, label_smoothing은 그 목표 분포를 닿을 수 있는 자리로 옮기는 대신 손실에 H(p′)H(p') 만큼의 바닥을 까는 조작입니다. 로그 첫 줄의 10.8은 균등분포에서 출발한 모델의 ln⁡V\ln V 이고, 그 뒤 스텝마다 0.04쯤 흔들리는 것은 표본이 바뀐 탓입니다. 스무딩을 켠 채라면 곡선은 0이 아니라 1.41내트 위 어딘가에서 멈춥니다.

남은 것은 「그래서 두 분포가 얼마나 떨어져 있는가」를 재는 양입니다. 위에서 초과분 H(p,q)−H(p)H(p,q) - H(p) 가 언제나 0 이상이라고 적어 두고 증명을 미뤘는데, 그 증명에 필요한 도구를 다음 글이 먼저 세웁니다. 볼록함수와 기댓값의 순서를 뒤집는 부등식 하나입니다. 문서 단위 perplexity가 토큰 단위보다 컸던 것도 같은 부등식의 한 모습입니다.


읽어주셔서 감사합니다. 😊

LATEST

수학의 최신 글

수학2026.09.07

양자화 오차: 격자 사상, 오차 분산, 이상치 채널

실수를 2^b개 격자에 사상할 때 오차의 분산이 왜 Δ²/12인지 유도하고, 그것이 비트당 6.02dB라는 SNR로 번역되는 과정을 실측과 대조했습니다. 이상치 하나가 나머지 값의 유효 비트를 어떻게 먹는지, 그리고 int4에서 성능이 무너지는 지점을 오차 예산으로 미리 계산하는 법까지.

중급18 MIN
수학2026.09.07

수치적으로 안정한 계산 패턴 모음

최댓값 빼기, 로그 공간, log1p·expm1, 분산의 두 공식, 정규화의 ε, fp32 누산, 역행렬 대신 solve — 프레임워크가 몰래 해 주는 일곱 가지를 하나씩 꺼내 각각 어떤 고장을 막는지 직접 재 봤습니다. 수식을 그대로 옮긴 코드가 왜 라이브러리보다 나쁜지에 대한 목록입니다.

중급22 MIN
수학2026.09.07

부동소수점은 어디서 새는가: 반올림, 상쇄, 더하는 순서

0.1 + 0.2가 0.3이 아닌 이유부터 시작해 머신 엡실론을 유도하고, 같은 16비트인데 fp16과 bf16이 서로 다른 지점에서 터지는 이유, 비슷한 수를 뺄 때 유효자리가 사라지는 파괴적 상쇄, 그리고 1,000만 개를 순서만 바꿔 더했을 때 오차가 백만 배 갈리는 실험까지 직접 재 봤습니다.

중급23 MIN