고정 길이 모델의 퍼플렉서티
고정 길이 모델의 퍼플렉서티 (Perplexity)
퍼플렉서티(Perplexity, PPL)는 언어 모델을 평가하는 가장 흔한 지표 중 하나예요. 본격적으로 들어가기 전에 짚어둘 점이 있는데, 이 지표는 고전적인 언어 모델(때로는 자기회귀(autoregressive) 또는 인과(causal) 언어 모델이라고 불러요)에만 해당하고, BERT 같은 마스크 언어 모델에는 잘 정의되지 않습니다.
출처: 문서
본문
퍼플렉서티는 시퀀스의 음의 로그 우도(negative log-likelihood)를 지수화한 평균으로 정의됩니다. 토큰화된 시퀀스 $X = (x_0, x_1, \dots, x_t)$가 있다면 $X$의 퍼플렉서티는,
$$ \text{PPL}(X) = \exp\left{ -\frac{1}{t}\sum_i^t \log p_\theta (x_i|x_{<i}) \right} $$
여기서 $\log p_\theta (x_i|x_{<i})$는 우리 모델 기준으로 이전 토큰 $x_{<i}$가 주어졌을 때 i번째 토큰의 로그 우도입니다. 직관적으로는 모델이 말뭉치(corpus)에서 지정된 토큰 집합에 대해 균등하게 예측하는 능력을 평가하는 것으로 생각할 수 있어요. 중요한 점은, 이 말이 토큰화 절차가 모델의 퍼플렉서티에 직접적인 영향을 준다는 뜻이며, 서로 다른 모델을 비교할 때 항상 고려해야 한다는 것입니다.
이는 데이터와 모델 예측 사이의 크로스 엔트로피(cross-entropy)를 지수화한 것과도 같아요. 퍼플렉서티와 Bits Per Character(BPC), 데이터 압축 사이의 관계에 대한 더 많은 직관은 The Gradient의 훌륭한 블로그 포스트를 확인해 보세요.
고정 길이 모델로 PPL 계산하기 (Calculating PPL with fixed-length models)
모델의 컨텍스트 크기에 제약이 없었다면, 시퀀스를 자기회귀적으로 분해하고 각 단계에서 전체 이전 부분 시퀀스에 조건화하여 모델의 퍼플렉서티를 평가할 수 있을 거예요. 아래와 같이 말이죠.
하지만 근사(approximate) 모델을 다룰 때는 모델이 처리할 수 있는 토큰 수에 제약이 있는 게 일반적입니다. 예를 들어 가장 큰 GPT-2 버전은 고정 길이가 1024 토큰이라서, $t$가 1024보다 크면 $p_\theta(x_t|x_{<t})$를 직접 계산할 수 없어요.
대신 시퀀스를 보통 모델의 최대 입력 크기와 같은 부분 시퀀스로 나눕니다. 모델의 최대 입력 크기가 $k$라면, 토큰 $x_t$의 우도를 전체 컨텍스트가 아니라 그 앞에 있는 $k-1$개 토큰에만 조건화해서 근사해요. 시퀀스의 퍼플렉서티를 평가할 때 유혹적이지만 차선인 접근 방식은, 시퀀스를 겹치지 않는(disjoint) 덩어리로 나누고 각 세그먼트의 분해된 로그 우도를 독립적으로 더하는 것입니다.
이 방식은 각 세그먼트의 퍼플렉서티를 한 번의 포워드 패스로 계산할 수 있어 계산이 빠르지만, 완전히 분해된 퍼플렉서티를 잘 근사하지 못하고 보통 더 높은(더 나쁜) PPL을 만들어요. 대부분의 예측 단계에서 모델이 컨텍스트를 더 적게 가지기 때문입니다.
대신 고정 길이 모델의 PPL은 슬라이딩 윈도우(sliding-window) 전략으로 평가해야 합니다. 이는 컨텍스트 윈도우를 반복적으로 밀어서, 각 예측을 할 때 모델이 더 많은 컨텍스트를 갖게 하는 방식입니다.
이는 시퀀스 확률의 실제 분해에 더 가까운 근사이고, 보통 더 좋은 점수를 만듭니다. 단점은 말뭉치의 모든 토큰에 대해 별도의 포워드 패스가 필요하다는 점이에요. 실제로 좋은 절충안은 스트라이드(stride) 슬라이딩 윈도우를 쓰는 것입니다. 윈도우를 1토큰씩이 아니라 더 큰 스트라이드로 움직이는 방식이죠. 이렇게 하면 각 단계에서 모델에 큰 컨텍스트를 제공하면서도 계산을 훨씬 빠르게 진행할 수 있어요.
예시: 🤗 Transformers에서 GPT-2로 퍼플렉서티 계산하기
이 과정을 GPT-2로 보여드릴게요.
from transformers import GPT2LMHeadModel, GPT2TokenizerFast
from accelerate import Accelerator
device = Accelerator().device
model_id = "openai-community/gpt2-large"
model = GPT2LMHeadModel.from_pretrained(model_id).to(device)
tokenizer = GPT2TokenizerFast.from_pretrained(model_id)
WikiText-2 데이터셋을 불러와서 몇 가지 서로 다른 슬라이딩 윈도우 전략으로 퍼플렉서티를 평가해 봅니다. 이 데이터셋은 작고 집합에 대해 포워드 패스를 한 번만 수행하므로, 데이터셋 전체를 메모리에 로드하고 인코딩하면 됩니다.
from datasets import load_dataset
test = load_dataset("wikitext", "wikitext-2-raw-v1", split="test")
encodings = tokenizer("\n\n".join(test["text"]), return_tensors="pt")
🤗 Transformers에서는 input_ids를 labels로 그냥 모델에 넘기면, 각 토큰의 평균 음의 로그 우도가 loss로 반환됩니다. 하지만 슬라이딩 윈도우 방식은 각 반복에서 모델에 넘기는 토큰에 겹침이 생겨요. 컨텍스트로만 취급하는 토큰의 로그 우도는 loss에 포함하고 싶지 않으므로, 이 대상들을 -100으로 설정해 무시하게 합니다. 아래는 스트라이드 512로 이를 수행하는 예시입니다. 이는 어떤 토큰 하나의 조건부 우도를 계산할 때 모델이 최소 512개 토큰의 컨텍스트를 가지게 된다는 뜻입니다(조건화할 수 있는 앞선 토큰이 512개 있다는 전제하에요).
import torch
from tqdm import tqdm
max_length = model.config.n_positions
stride = 512
seq_len = encodings.input_ids.size(1)
nll_sum = 0.0
n_tokens = 0
prev_end_loc = 0
for begin_loc in tqdm(range(0, seq_len, stride)):
end_loc = min(begin_loc + max_length, seq_len)
trg_len = end_loc - prev_end_loc # may be different from stride on last loop
input_ids = encodings.input_ids[:, begin_loc:end_loc].to(device)
target_ids = input_ids.clone()
target_ids[:, :-trg_len] = -100
with torch.no_grad():
outputs = model(input_ids, labels=target_ids)
# loss is calculated using CrossEntropyLoss which averages over valid labels
# N.B. the model only calculates loss over trg_len - 1 labels, because it internally shifts the labels
# to the left by 1.
neg_log_likelihood = outputs.loss
# Accumulate the total negative log-likelihood and the total number of tokens
num_valid_tokens = (target_ids != -100).sum().item() # number of valid tokens in target_ids
batch_size = target_ids.size(0)
num_loss_tokens = num_valid_tokens - batch_size # subtract batch_size due to internal label shift
nll_sum += neg_log_likelihood * num_loss_tokens
n_tokens += num_loss_tokens
prev_end_loc = end_loc
if end_loc == seq_len:
break
avg_nll = nll_sum / n_tokens # average negative log-likelihood per token
ppl = torch.exp(avg_nll)
스트라이드 길이를 최대 입력 길이와 같게 하여 실행하면 위에서 논의한 차선의, 비슬라이딩 윈도우 전략과 동일합니다. 스트라이드가 작을수록 각 예측을 할 때 모델이 더 많은 컨텍스트를 갖고, 보통 보고되는 퍼플렉서티도 더 좋아져요.
위 코드를 stride = 1024(겹침 없음)로 실행하면 결과 PPL은 19.44로, GPT-2 논문에 보고된 19.93과 거의 같습니다. stride = 512로 스트라이딩 윈도우 전략을 적용하면 이 값이 16.44로 내려가요. 이는 더 좋은 점수일 뿐만 아니라, 시퀀스 우도의 실제 자기회귀 분해에 더 가까운 방식으로 계산된 것입니다.
더 알아보기 (Learn more)
- The Gradient — Understanding evaluation metrics for language models: 퍼플렉서티와 BPC·데이터 압축의 관계
- GPT-2 문서: GPT-2 모델 개요