LabHub
배우기 러닝패스 코스

트랜스포머 — 어텐션을 손으로 계산한다 · 온도·top-k·top-p · 이론

같은 프롬프트인데 답이 매번 다르다

LabHub 에서 이어서 보기

한 줄 요약

모델이 내놓는 것은 답이 아니라 로짓 한 벌이고, 답은 그 로짓을 확률로 바꿔 하나를 뽑는 과정에서 정해진다. 온도·top-k·top-p 는 그 확률 분포를 뽑기 전에 고쳐 쓰는 세 개의 손잡이다.

왜 이게 필요했나

처음 모델을 붙여 보면 이상한 일이 두 가지 생긴다. 하나는 같은 프롬프트를 두 번 넣었는데 답이 다른 것이고, 다른 하나는 답을 길게 뽑으면 같은 말을 되풀이하다 끝나는 것이다.

둘 다 마지막 한 걸음에서 나온다. 트랜스포머 블록이 하는 일은 어휘 크기만 한 실수 목록 하나를 내놓는 것까지다. 이 목록을 로짓이라고 부르는데, 확률이 아니라 정규화되지 않은 점수다. 여기서 실제로 쓸 토큰 하나를 고르는 것은 모델 바깥의 규칙이고, 그 규칙이 무엇이냐에 따라 같은 모델이 전혀 다르게 말한다.

가장 단순한 규칙은 가장 큰 로짓을 고르는 것이다. greedy 라고 부른다. 결과가 언제나 같아 시험하기 좋지만, 길게 뽑으면 같은 구절로 돌아오는 고리에 빠지기 쉽다. [The Curious Case of Neural Text Degeneration](https://arxiv.org/abs/1904.09751) 이 정리한 것이 바로 이 현상이다 — 확률이 가장 높은 길만 따라가면 사람이 쓴 글과 닮지 않은, 눈에 띄게 반복적인 글이 나온다.

그래서 뽑는다. 확률에 비례해 하나를 고르면 되풀이는 줄어든다. 그런데 이번에는 꼬리가 문제다. 어휘가 수만 개면 확률이 0.00001 인 토큰이 수만 개 있고, 그 꼬리 전체의 합은 무시할 수 없다. 한 번이라도 그 안에서 뽑히면 문장이 그 자리에서 무너진다. 세 손잡이는 전부 이 긴장 — 되풀이와 무너짐 사이 — 을 다루는 도구다.

온도는 나누는 자리가 요점이다

온도 T 는 소프트맥스에 넣기 전에 로짓을 나누는 값이다. 확률을 먼저 구해 놓고 거기에 손대는 것이 아니다.

scaled = [value / T for value in logits]top = max(scaled)                      # 빼는 것도 장식이 아니다weights = [math.exp(value - top) for value in scaled]probs = [w / sum(weights) for w in weights]

나누기가 어떻게 작동하는지는 간격으로 보면 쉽다. 1등과 2등의 로짓 차이가 1.1 이라고 하자. T가 0.25 면 그 차이가 4.4 로 벌어지고, 지수를 취하면 2등의 몫이 1등의 1퍼센트대로 줄어든다. T가 4 면 차이가 0.275 로 좁아져 둘이 거의 같아진다. T가 작으면 뾰족해지고 크면 평평해진다. T가 1이면 로짓을 그대로 쓰는 것이다.

최댓값을 빼는 자리도 그냥 관습이 아니다. T를 0.01 로 두면 로짓 5.2 가 520 이 되고 math.exp(520) 은 그대로 넘친다. 온도가 큰 입력을 만들어 낸다는 뜻이다. 최댓값을 빼면 지수의 인자가 0 이하로 내려가 넘칠 자리가 없어지고, 같은 값에서 같은 수를 빼는 것이라 확률은 달라지지 않는다.

한 가지 못 박아 둘 것이 있다. T가 0 인 소프트맥스는 없다. 나눗셈이 되지 않는다. "온도를 0 으로 두면 greedy" 라는 말은 T를 0 으로 보낼 때의 극한을 줄여 말한 것이고, 실제 구현은 그 자리에서 greedy 로 갈아탄다. 음수는 더 나쁘다 — 순위가 뒤집혀 가장 그럴듯하지 않은 토큰이 1등이 된다.

퍼진 정도를 숫자 하나로 보고 싶으면 엔트로피를 쓴다. 밑이 2 인 엔트로피는 확률이 한 곳에 몰리면 0 에 가깝고, n 개에 고르게 퍼지면 log2(n) 이다. 온도를 올리면 이 값이 커진다.

top-k 와 top-p 는 꼬리를 자르는 두 방법

[top-k](https://arxiv.org/abs/1805.04833) 는 단순하다. 확률이 큰 k 개만 남기고 나머지를 버린 뒤 남은 것을 다시 정규화한다. 동점을 어떻게 깰지는 정해 두어야 한다. 이 실습은 번호가 작은 쪽이 남는 것으로 못 박았다.

k 를 고정하는 것의 약점은 분포의 모양을 보지 않는다는 점이다. 확신에 찬 자리에서는 1등 하나면 충분한데도 k 개를 남기고, 갈림길에서는 k 개로 모자란다.

[top-p, 다른 이름으로 nucleus sampling](https://arxiv.org/abs/1904.09751) 은 개수 대신 질량으로 자른다. 확률을 내림차순으로 늘어놓고 누적합을 재다가, 누적합이 p 에 처음 닿는 그 항목까지 남긴다. 여기서 헷갈리기 쉬운 자리가 하나 있다. 그 항목을 빼면 남은 것의 합이 p 에 못 미친다. 그래서 "넘기 직전까지" 가 아니라 "넘는 그 항목까지" 다. 이렇게 하면 남기는 개수가 분포에 따라 저절로 달라진다 — 뾰족한 자리에서는 하나, 평평한 자리에서는 여럿이다.

둘을 같이 쓸 때는 순서가 결과를 바꾼다. 온도를 먼저 먹이면 분포 자체가 달라지므로 같은 p 라도 남는 개수가 달라진다. top-k 를 먼저 하면 살아남은 것들이 다시 정규화되어 확률이 부풀고, 부푼 값으로 누적합을 재므로 top-p 가 더 일찍 멈춘다. 그래서 어느 순서로 적용할지는 정해 놓고 문서에 적어야 하는 값이다. [Hugging Face 의 생성 전략 문서](https://huggingface.co/docs/transformers/en/generation_strategies)도 이 손잡이들을 따로따로가 아니라 하나의 설정 묶음으로 다룬다.

같은 시드는 같은 수열을 준다

분포를 다 고쳤으면 하나를 뽑는다. 역누적분포가 표준적인 방법이다 — 0 이상 1 미만의 실수 u 를 하나 뽑고, 번호 순서대로 확률을 더해 가다 u 를 처음 넘는 자리를 고른다. 확률이 정확히 0 인 자리는 누적합을 늘리지 않으므로 절대 뽑히지 않는다. 잘라낸 토큰이 되살아나지 않는다는 뜻이다.

여기서 중요한 성질이 하나 나온다. u 를 주는 난수 발생기의 시드를 고정하면 같은 입력에서 같은 수열이 나온다. 무작위인데 재현된다. 장애를 다시 만들어 보려면 이것이 반드시 있어야 한다.

현장에서 만나는 모습

첫째, "가끔 이상한 답이 나온다" 는 제보를 재현하지 못한다. 시드를 안 남겼기 때문이다. 요청마다 시드와 온도·k·p 를 로그에 적어 두면 그 요청 하나를 그대로 되살릴 수 있다.

둘째, 온도를 올려 다양성을 얻으려다 정확도를 잃는다. 분류나 추출처럼 답이 하나인 일에서는 온도를 올릴 이유가 없다. 손잡이는 일마다 따로 잡아야 한다.

셋째, 같은 설정인데 라이브러리를 바꾸니 결과가 달라진다. 적용 순서가 다르거나 동점 처리가 다르거나, p 에 닿는 항목을 포함하는지가 다른 것이다. 세 가지 모두 문서에 잘 안 적혀 있다.

넷째, 캐시가 엉뚱하게 맞는다. 프롬프트만 열쇠로 삼고 온도·k·p·시드를 빼먹으면, 설정이 다른 요청이 같은 답을 받는다.

다섯째, 평가 점수가 흔들려 회귀를 못 잡는다. 평가에서는 뽑기를 끄고 greedy 로 고정하는 편이 낫다. 재고 싶은 것이 모델의 변화이지 난수의 변화가 아니기 때문이다.

실무에서 진짜 중요한 것

다음 실습에서 할 것

/root/work/tf-sample/sample.py 를 한 단계씩 키운다. 표준 라이브러리 mathrandom 만 쓴다 — 이 파드의 시스템 파이썬에는 numpy 가 없고 /opt/onnx-lab/bin/python 안에만 있다. 모델도 부르지 않는다. 로짓 한 벌을 직접 적어 두고 그 뒤를 전부 손으로 만든다.

온도로 나눈 뒤의 안정한 소프트맥스에서 시작해 엔트로피, top-k, top-p, 넷을 정해진 순서로 잇는 함수, 역누적분포 추출, greedy 와 온도 훑기까지 만든다. 마지막에는 같은 시드로 두 번 뽑아 같은 수열이 나오는지, 온도를 낮춘 쪽이 greedy 와 같아지는지를 숫자로 남긴다.

채점기는 여러분이 적어 둔 설명을 믿지 않는다. 여러분의 모듈을 실제로 불러 매번 다른 로짓과 다른 온도로 함수를 두드려 보고, 채점기가 따로 계산한 확률 벡터와 대조한다. 무작위 추출 자체를 판정하지는 않는다 — 시드를 고정한 번호 수열과 확률 벡터만 본다. 그래야 맞는 구현이 우연히 떨어지는 일도, 틀린 구현이 우연히 통과하는 일도 없다.