亲手实现温度、top-k 与 top-p
한국어 원문으로 표시합니다.
목표
로짓 한 벌에서 다음 토큰 하나를 고르기까지, 분포가 거치는 자리를 표준 라이브러리만으로 전부 만든다. 온도로 나눈 뒤의 안정한 소프트맥스, 엔트로피, top-k, top-p, 넷을 정해진 순서로 잇는 함수, 역누적분포 추출, greedy 와 온도 훑기까지다. 마지막에 같은 시드로 두 번 뽑아 같은 수열이 나오는지와 온도를 낮춘 쪽이 greedy 와 같아지는지를 기록으로 남긴다.
왜 중요한가
트랜스포머 블록이 하는 일은 어휘 크기만 한 실수 목록 하나를 내놓는 것까지다. 실제로 쓸 토큰을 고르는 것은 모델 바깥의 규칙이고, 같은 모델이 매번 다르게 말하는 이유도 매번 같은 말을 되풀이하는 이유도 전부 거기 있다.
이 실습은 모델을 부르지 않는다. 이 파드에는 transformers 도 torch 도 없고 numpy 는 /opt/onnx-lab/bin/python 안에만 있어 시스템 파이썬에서는 import numpy 가 되지 않는다. 대신 로짓 한 벌을 직접 적어 두고 그 뒤를 손으로 만든다. 그래서 여기 나오는 숫자는 전부 여러분이 적은 로짓에서 잰 것이다.
어려운 것은 세부다. 온도를 나누는 자리가 소프트맥스 앞인지 뒤인지, 최댓값을 왜 빼는지, top-k 의 동점을 어떻게 깨는지, top-p 가 p 에 닿는 항목을 포함하는지, 온도와 top-p 와 top-k 를 어느 순서로 먹이는지. 하나만 달라도 같은 설정이 다른 시스템이 된다.
채점기는 여러분이 적어 둔 설명을 믿지 않는다. 여러분의 모듈을 실제로 불러 매번 다른 로짓과 다른 온도로 함수를 두드려 보고, 채점기가 따로 계산한 확률 벡터와 대조한다. 무작위 추출 자체는 판정하지 않는다 — 시드를 고정한 번호 수열과 확률 벡터만 본다.
단계
- /root/work/tf-sample/sample.py 에
VOCAB·LOGITS와softmax_t(logits, temperature)를 만드세요. 로짓을 온도로 나눈 뒤 최댓값을 빼고 소프트맥스합니다. entropy_bits(probs)를 더해 분포가 퍼진 정도를 밑이 2 인 엔트로피로 재게 하세요. 확률이 0 인 자리는 0 으로 칩니다.top_k_filter(probs, k)를 더해 확률이 큰 k 개만 남기고 나머지를 정확히0.0으로 두고 다시 정규화하게 하세요. 동점이면 번호가 작은 쪽이 남습니다.top_p_filter(probs, p)를 더해 내림차순 누적합이 p 에 처음 닿는 그 항목까지 남기게 하세요. 그 항목을 빼면 합이 p 에 못 미칩니다.filtered_probs(logits, temperature, top_k=None, top_p=None)를 만들어 온도 → 소프트맥스 → top-k → top-p 순서로 잇게 하세요.None이면 그 단계를 건너뜁니다.sample_index(probs, u)와sample_sequence(logits, temperature, top_k, top_p, n, seed)를 만드세요. 역누적분포로 뽑고,random.Random(seed)에서random()을 n 번 불러 같은 시드면 같은 수열이 나오게 합니다.greedy(logits)와sweep(logits, temps, p)를 만드세요.greedy는 가장 큰 로짓의 번호(동점이면 작은 번호)이고,sweep은 온도마다(온도, 엔트로피, top-p 가 남기는 개수)를 잽니다.- 정해진 설정으로 재어 /root/work/tf-sample/sample_report.json 과 /root/work/tf-sample/sample_report.md 에 결과를 기록하세요.
참고
- 실행 계약: 채점기는
/root/work/tf-sample/sample.py를 파이썬 모듈로 불러VOCAB·LOGITS·softmax_t·entropy_bits·top_k_filter·top_p_filter·filtered_probs·sample_index·sample_sequence·greedy·sweep를 직접 씁니다. 스크립트로 실행하지 않으므로if __name__ == "__main__"은 없어도 됩니다. VOCAB은 서로 다른 짧은 문자열 12개 이상의 목록입니다. 내용은 자유롭게 정하세요.LOGITS는VOCAB과 길이가 같은 실수 목록이고 값이 서로 달라야 합니다. 확률이 아니라 정규화되지 않은 점수입니다. 1등과 2등의 차이가 0.5 이상, 최댓값과 최솟값의 차이가 3.0 이상이어야 온도를 바꿀 때 분포가 움직이는 것이 보입니다.softmax_t(logits, temperature)는 먼저 로짓을temperature로 나누고, 나눈 값에서 최댓값을 뺀 뒤 지수를 취해 정규화합니다. 최댓값을 빼지 않으면 온도가 작을 때math.exp가 넘칩니다.temperature가 0 이하이면 예외를 내세요.entropy_bits(probs)는-sum(p * log2(p))입니다.p가 0 인 자리는 건너뜁니다. 고르게 퍼진 n 개면log2(n)이 나옵니다.top_k_filter(probs, k)는 버린 자리를 정확히0.0으로 두고 남은 것만 합이 1 이 되게 정규화합니다. 동점이면 번호가 작은 쪽이 남습니다.k가 목록 길이 이상이면 아무것도 버리지 않고 정규화만 합니다.k가 1 보다 작으면 예외를 내세요.top_p_filter(probs, p)는 확률을 내림차순(동점이면 번호가 작은 쪽이 앞)으로 늘어놓고 누적합을 재다가, 누적합이 p 이상이 되는 첫 항목까지 포함하고 멈춥니다. 나머지는 정확히0.0이고 남은 것을 정규화합니다.p는 0 보다 크고 1 이하여야 하며, 그 밖이면 예외를 내세요.filtered_probs의 순서는 온도 → 소프트맥스 → top-k → top-p 입니다. 이 순서가 이 실습의 계약이고, 순서를 바꾸면 남는 토큰이 달라집니다.sample_index(probs, u)는 번호 순서대로 확률을 더해 가다u를 처음 넘는 자리를 돌려줍니다(u < 누적합이 참이 되는 첫 자리).u는 0 이상 1 미만입니다. 확률이0.0인 자리는 절대 돌려주지 않습니다.sample_sequence(logits, temperature, top_k, top_p, n, seed)는 분포를 한 번만 만들고random.Random(seed)의random()을 n 번 불러 번호 n 개의 목록을 돌려줍니다. 시드가 같으면 결과도 같아야 합니다.sweep(logits, temps, p)는temps의 온도마다(온도, 엔트로피, top-p 가 남기는 개수)세 쌍의 목록을 돌려줍니다. 개수는top_p_filter를 거친 뒤 0 이 아닌 자리의 수입니다.- 8단계 설정은 고정입니다. 시드 20260917, 뽑는 횟수 24,
top_k는 5,top_p는 0.9 입니다. 더운 쪽은 온도 1.0, 찬 쪽은 온도 0.2 로 같은 시드에서 뽑습니다. - 8단계의
sweep온도는 0.25 · 0.5 · 1.0 · 2.0 · 4.0 이고 그때의p는 0.9 입니다.nucleus는 온도 1.0 에서 p 를 0.5 · 0.8 · 0.9 · 0.95 로 바꿔 가며 잰[p, 남은 개수]이고,topk_mass는 온도 1.0 의 원래 확률에서 상위 k 개가 차지하는 합을 k 1 · 3 · 5 · 10 에 대해 잰[k, 합]입니다. sample_report.json의 키:vocab_size·greedy_index·greedy_token·top_prob_t1·entropy_t1·sweep·nucleus·topk_mass·seed·draw_count·hot_draws·cold_draws·cold_is_greedy·hot_distinct·cold_distinct·repeat_matches.sample_report.md는## 무엇을 쟀나## 온도가 분포를 어떻게 바꾸나## top-k 와 top-p 가 남기는 것## 같은 시드는 같은 수열을 준다네 절로 씁니다.- 이 파드에는 인터넷이 없습니다.
pip install은 되지 않고 transformers·torch 도 없습니다. numpy 는/opt/onnx-lab/bin/python안에만 있으므로 시스템 파이썬에서는import numpy가 되지 않습니다.math와random이면 충분합니다. - 공식 문서: nucleus sampling 원논문 · top-k 원논문 · Hugging Face — Generation strategies · Python — math
- 흔한 실수: 확률에 온도를 먹이기, 최댓값 빼기를 생략해 낮은 온도에서 넘치기, 잘라낸 뒤 정규화를 잊기, top-p 에서 p 를 넘는 첫 항목을 빼 버리기, 동점 규칙을 안 정하기, top-p 를 top-k 보다 먼저 적용하기, 뽑을 때마다 분포를 새로 만들기.
온도로 나눈 뒤 소프트맥스
/root/work/tf-sample/sample.py 에 VOCAB(서로 다른 짧은 문자열 12개 이상)과 LOGITS(같은 길이, 값이 서로 다름, 1등과 2등의 차이 0.5 이상, 최댓값과 최솟값의 차이 3.0 이상)을 두고 softmax_t(logits, temperature) 를 만드세요. 로짓을 temperature 로 나눈 뒤 최댓값을 빼고 지수를 취해 정규화합니다. temperature 가 0 이하이면 예외를 내세요.
나누는 자리가 요점입니다. 확률을 먼저 구해 놓고 거기에 온도를 먹이면 전혀 다른 결과가 나옵니다. 최댓값 빼기는 온도가 작을 때 살아납니다 — 0.01 로 나누면 로짓 5.2 가 520 이 되고 math.exp(520) 은 그대로 넘칩니다. 같은 값에서 같은 수를 빼는 것이라 확률은 달라지지 않습니다. 온도 0 은 나눗셈이 되지 않으므로 ValueError 를 내는 편이 낫습니다 — greedy 는 '온도 0' 이 아니라 별개의 규칙입니다.
퍼진 정도를 비트로 잰다
entropy_bits(probs) 를 더하세요. 밑이 2 인 엔트로피 -sum(p * log2(p)) 입니다. 확률이 0 인 자리는 0 으로 칩니다. 고르게 퍼진 n 개면 log2(n) 이 나옵니다.
math.log2(0) 은 예외를 냅니다. 그래서 0 인 자리는 건너뛰어야 하는데, 이것은 편법이 아니라 맞는 처리입니다 — p * log2(p) 의 p 가 0 으로 갈 때의 극한이 0 이기 때문입니다. 온도를 올리며 이 값을 재 보면 커지는 것이 보입니다. 분포가 얼마나 퍼졌는지를 숫자 하나로 요약하는 자리입니다.
위에서 k 개만 남긴다
top_k_filter(probs, k) 를 더하세요. 확률이 큰 k 개만 남기고 나머지는 정확히 0.0 으로 두고 남은 것을 합이 1 이 되게 정규화합니다. 동점이면 번호가 작은 쪽이 남습니다. k 가 목록 길이 이상이면 아무것도 버리지 않고 정규화만 하고, k 가 1 보다 작으면 예외를 내세요.
번호를 확률 내림차순으로 정렬해 앞의 k 개를 집합으로 잡아 두면 나머지는 그 집합을 보는 것뿐입니다. 정렬 열쇠를 (-확률, 번호) 로 두면 동점 규칙까지 한 줄에 들어갑니다. 정규화를 잊으면 합이 1 이 안 되고, 그러면 뒤에서 뽑을 때 마지막 자리로 쏠립니다. 버린 자리를 아주 작은 값으로 두면 안 됩니다 — 낮은 확률로 되살아나기 때문입니다.
누적합이 p 에 닿는 그 항목까지
top_p_filter(probs, p) 를 더하세요. 확률을 내림차순(동점이면 번호가 작은 쪽이 앞)으로 늘어놓고 누적합을 재다가 누적합이 p 이상이 되는 첫 항목까지 포함하고 멈춥니다. 나머지는 정확히 0.0 이고 남은 것을 정규화합니다. p 가 0 이하이거나 1 보다 크면 예외를 내세요.
포함하느냐 빼느냐가 이 단계의 전부입니다. p 를 넘게 만든 그 항목을 빼 버리면 남은 것의 합이 p 에 못 미칩니다 — 그러면 'p 만큼의 질량을 남긴다' 는 말 자체가 성립하지 않습니다. 누적합이 p 에 닿는 순간 그 번호를 이미 넣어 둔 뒤에 멈추면 됩니다. p 가 아무리 작아도 적어도 하나는 남습니다.
네 가지를 정해진 순서로 잇는다
filtered_probs(logits, temperature, top_k=None, top_p=None) 를 만드세요. 온도 → 소프트맥스 → top-k → top-p 순서로 잇고, None 인 단계는 건너뜁니다.
순서가 결과를 바꿉니다. 온도를 먼저 먹이면 분포 자체가 달라지므로 같은 p 라도 남는 개수가 달라지고, top-k 를 먼저 하면 살아남은 것들이 다시 정규화되어 확률이 부풀기 때문에 top-p 가 더 일찍 멈춥니다. 함수는 네 줄이면 끝납니다 — 앞에서 만든 세 함수를 이 순서로 부르기만 하면 됩니다. top_k 나 top_p 가 None 인지 보는 조건을 잊지 마세요.
같은 시드는 같은 수열
sample_index(probs, u) 와 sample_sequence(logits, temperature, top_k, top_p, n, seed) 를 만드세요. sample_index 는 번호 순서대로 확률을 더해 가다 u 를 처음 넘는 자리를 돌려주고, sample_sequence 는 분포를 한 번만 만든 뒤 random.Random(seed) 의 random() 을 n 번 불러 번호 n 개를 돌려줍니다.
u < 누적합 이 참이 되는 첫 자리입니다. 확률이 0.0 인 자리는 누적합을 늘리지 않으므로 절대 뽑히지 않습니다 — 잘라낸 토큰이 되살아나지 않는다는 뜻이고, 이것이 이 구조의 안전장치입니다. random.Random(seed) 를 함수 안에서 한 번만 만드세요. 뽑을 때마다 새 발생기를 만들면 같은 번호만 n 개 나옵니다. 분포도 반복문 밖에서 한 번만 만듭니다.
greedy 와 온도 훑기
greedy(logits) 와 sweep(logits, temps, p) 를 만드세요. greedy 는 가장 큰 로짓의 번호이고 동점이면 작은 번호입니다. sweep 은 temps 의 온도마다 (온도, 엔트로피, top-p 가 남기는 개수) 세 쌍의 목록을 돌려줍니다.
greedy 는 확률을 구할 필요조차 없습니다 — 소프트맥스는 순서를 바꾸지 않기 때문입니다. 동점에서 max 가 어느 쪽을 주는지 헷갈리면 직접 훑으며 더 클 때만 갱신하세요. 그러면 작은 번호가 남습니다. sweep 의 개수는 top_p_filter 를 거친 뒤 0 이 아닌 자리의 수입니다. 온도를 올릴수록 엔트로피가 커지고 남는 개수도 늘어나는 것을 눈으로 보세요.
손잡이를 돌린 결과를 남긴다
고정된 설정으로 재세요. 시드 20260917, 뽑는 횟수 24, top_k 는 5, top_p 는 0.9 이고 더운 쪽은 온도 1.0, 찬 쪽은 온도 0.2 입니다. 결과를 /root/work/tf-sample/sample_report.json 에 vocab_size·greedy_index·greedy_token·top_prob_t1·entropy_t1·sweep·nucleus·topk_mass·seed·draw_count·hot_draws·cold_draws·cold_is_greedy·hot_distinct·cold_distinct·repeat_matches 로, /root/work/tf-sample/sample_report.md 에 ## 무엇을 쟀나 ## 온도가 분포를 어떻게 바꾸나 ## top-k 와 top-p 가 남기는 것 ## 같은 시드는 같은 수열을 준다 네 절로 적으세요.
숫자는 손으로 적지 말고 여러분의 코드를 실제로 돌려 얻은 값으로 채우세요. sweep 은 온도 0.25·0.5·1.0·2.0·4.0 에 p 는 0.9 입니다. nucleus 는 온도 1.0 에서 p 를 0.5·0.8·0.9·0.95 로 바꿔 가며 잰 [p, 남은 개수] 이고, topk_mass 는 온도 1.0 의 원래 확률에서 상위 k 개가 차지하는 합을 k 1·3·5·10 에 대해 잰 [k, 합] 입니다 — 자른 뒤 정규화한 값이 아니라 자르기 전의 합입니다. cold_is_greedy 는 찬 쪽 수열이 전부 greedy(LOGITS) 와 같은지이고, repeat_matches 는 같은 시드로 한 번 더 뽑았을 때 같은 수열이 나오는지입니다. 여러분이 적은 로짓에 따라 cold_is_greedy 가 거짓일 수도 있습니다 — 그러면 그대로 적고 본문에서 왜 그런지 설명하세요.