use anyhow::Context;
use frink_core::cache::KvCache;
use frink_gguf::ShardedGguf;
use std::path::Path;
pub const DEFAULT_CTX: usize = 512;
pub struct PerplexityArgs {
pub model: String,
pub file: String,
pub ctx_size: usize,
pub chunks: Option<usize>,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct Plan {
n_ctx: usize,
first: usize,
per_window: usize,
n_window: usize,
}
impl Plan {
fn scored_total(&self) -> usize {
self.per_window * self.n_window
}
pub(crate) fn window_start(&self, window: usize) -> usize {
window * self.n_ctx
}
pub(crate) fn scored_positions(&self) -> std::ops::Range<usize> {
self.first..self.first + self.per_window
}
}
pub(crate) fn plan(n_tokens: usize, n_ctx: usize, chunks: Option<usize>) -> anyhow::Result<Plan> {
let first = n_ctx / 2;
let per_window = n_ctx.saturating_sub(first).saturating_sub(1);
if per_window == 0 {
anyhow::bail!(
"--ctx-size {n_ctx} scores no tokens: llama.cpp scores window positions \
{first}..{} against their successors, which is empty below --ctx-size 3",
n_ctx.saturating_sub(1)
);
}
if n_tokens < 2 * n_ctx {
anyhow::bail!(
"corpus is {n_tokens} tokens; llama.cpp needs at least 2 * --ctx-size = {} \
to evaluate perplexity at --ctx-size {n_ctx}. Use a longer corpus or a \
smaller --ctx-size",
2 * n_ctx
);
}
let n_window_max = n_tokens / n_ctx;
let n_window = match chunks {
Some(0) => anyhow::bail!("--chunks 0 would score nothing"),
Some(want) => want.min(n_window_max),
None => n_window_max,
};
Ok(Plan {
n_ctx,
first,
per_window,
n_window,
})
}
pub(crate) fn nll_nats(logits: &[f32], token: usize) -> anyhow::Result<f64> {
let logit = *logits.get(token).with_context(|| {
format!(
"token id {token} is outside this model's {} logits",
logits.len()
)
})?;
let max = logits.iter().copied().fold(f32::NEG_INFINITY, f32::max);
let mut sum_exp = 0.0f64;
for &l in logits {
sum_exp += (l - max).exp() as f64;
}
Ok(-((logit - max) as f64 - sum_exp.ln()))
}
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct Estimate {
nll: f64,
nll2: f64,
count: usize,
}
impl Estimate {
fn observe(&mut self, nll: f64) {
self.nll += nll;
self.nll2 += nll * nll;
self.count += 1;
}
pub(crate) fn ppl(&self) -> Option<f64> {
(self.count > 0).then(|| (self.nll / self.count as f64).exp())
}
pub(crate) fn stderr(&self) -> Option<f64> {
if self.count < 2 {
return None;
}
let n = self.count as f64;
let mean = self.nll / n;
let var = self.nll2 / n - mean * mean;
if var <= 0.0 {
return None;
}
Some((var / (n - 1.0)).sqrt() * mean.exp())
}
}
pub fn run(args: PerplexityArgs) -> anyhow::Result<()> {
let path = crate::pull::resolve_model_path(&args.model)?;
let text = std::fs::read_to_string(&args.file)
.with_context(|| format!("reading corpus {}", args.file))?;
let (decoder, tokens, _eos) = crate::verify_engine::load_and_tokenize(
Path::new(&path),
&text,
frink_models::tokenizer::SpecialTokens::AsText,
None,
)
.context("loading the model and tokenizing the corpus")?;
let file = ShardedGguf::open(&path)?;
let bos = if frink_models::tokenizer::should_add_bos_token(&file) {
file.metadata_u64("tokenizer.ggml.bos_token_id")
.map(|v| v as usize)
} else {
None
};
drop(file);
let plan = plan(tokens.len(), args.ctx_size, args.chunks)?;
println!(
"perplexity: {} tokens, n_ctx = {}, {} windows, scoring {} tokens each ({} total)",
tokens.len(),
plan.n_ctx,
plan.n_window,
plan.per_window,
plan.scored_total()
);
println!(
"perplexity: window positions {}..{} are scored; BOS reset per window: {}",
plan.first,
plan.n_ctx - 1,
match bos {
Some(b) => format!("yes (id {b})"),
None => "no (this checkpoint does not add BOS)".to_string(),
}
);
let mut est = Estimate::default();
for window in 0..plan.n_window {
let start = plan.window_start(window);
let mut ids = tokens[start..start + plan.n_ctx].to_vec();
if let Some(b) = bos {
ids[0] = b;
}
let mut caches: Vec<KvCache> = decoder.config.new_kv_caches();
let logits = decoder.forward_batch(&ids, 0, &mut caches);
anyhow::ensure!(
logits.len() == plan.n_ctx,
"forward_batch returned {} logit rows for a {}-token window",
logits.len(),
plan.n_ctx
);
for pos in plan.scored_positions() {
est.observe(nll_nats(&logits[pos], tokens[start + pos + 1])?);
}
match est.ppl() {
Some(p) => println!("[{}]{p:.4}", window + 1),
None => unreachable!("a window always scores at least one token"),
}
}
let ppl = est
.ppl()
.context("no tokens were scored, so there is no perplexity to report")?;
match est.stderr() {
Some(se) => println!("Final estimate: PPL = {ppl:.4} +/- {se:.5}"),
None => println!("Final estimate: PPL = {ppl:.4} (no standard error: too few tokens)"),
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{nll_nats, plan, Estimate, DEFAULT_CTX};
#[test]
fn the_committed_corpus_is_long_enough_for_the_default_context() {
const CORPUS: &str = include_str!("../tests/corpus/alice-ch1-2.txt");
let floor = 2 * DEFAULT_CTX * 5;
assert!(
CORPUS.len() >= floor,
"corpus is {} bytes, below the {floor} that guarantees 2 * {DEFAULT_CTX} tokens",
CORPUS.len()
);
}
#[test]
fn the_default_window_scores_the_second_half_minus_one() {
let p = plan(4096, DEFAULT_CTX, None).unwrap();
assert_eq!(p.first, 256);
assert_eq!(p.per_window, 255);
assert_eq!(p.n_window, 8);
assert_eq!(p.scored_total(), 2040);
}
#[test]
fn windows_tile_the_corpus_without_overlap() {
let p = plan(383, 64, None).unwrap();
assert_eq!(p.n_window, 5);
assert_eq!(p.n_ctx, 64);
assert_eq!(p.per_window, 31);
}
#[test]
fn consecutive_windows_abut_exactly_and_stay_inside_the_corpus() {
let n_tokens = 383;
let p = plan(n_tokens, 64, None).unwrap();
assert_eq!(p.window_start(0), 0);
for w in 0..p.n_window {
let start = p.window_start(w);
assert_eq!(
start % p.n_ctx,
0,
"window {w} starts mid-stride at {start}"
);
if w > 0 {
assert_eq!(p.window_start(w - 1) + p.n_ctx, start);
}
assert!(
start + p.n_ctx <= n_tokens,
"window {w} runs past the corpus"
);
}
}
#[test]
fn every_scored_position_has_its_successor_inside_the_window() {
let p = plan(4096, DEFAULT_CTX, None).unwrap();
let scored: Vec<usize> = p.scored_positions().collect();
assert_eq!(scored.first().copied(), Some(256));
assert_eq!(scored.last().copied(), Some(510));
assert_eq!(scored.len(), p.per_window);
for pos in scored {
assert!(pos + 1 < p.n_ctx, "position {pos} has no target");
}
}
#[test]
fn odd_context_sizes_round_the_scored_half_down() {
let p = plan(64, 7, None).unwrap();
assert_eq!(p.first, 3);
assert_eq!(p.per_window, 3);
}
#[test]
fn a_corpus_shorter_than_two_contexts_is_refused() {
let err = plan(1023, 512, None).unwrap_err().to_string();
assert!(err.contains("1023"), "{err}");
assert!(err.contains("1024"), "{err}");
assert!(plan(1024, 512, None).is_ok());
}
#[test]
fn a_context_that_scores_nothing_is_refused_rather_than_reported_as_nan() {
assert!(plan(100_000, 2, None).is_err());
assert!(plan(100_000, 1, None).is_err());
assert!(plan(100_000, 3, None).is_ok());
}
#[test]
fn chunks_clamps_down_but_never_up_and_zero_is_refused() {
assert_eq!(plan(4096, 512, Some(3)).unwrap().n_window, 3);
assert_eq!(plan(4096, 512, Some(99)).unwrap().n_window, 8);
assert!(plan(4096, 512, Some(0)).is_err());
}
#[test]
fn a_uniform_distribution_costs_the_natural_log_of_the_vocabulary() {
let flat = vec![0.0f32; 4];
for token in 0..4 {
let v = nll_nats(&flat, token).unwrap();
assert!((v - 4.0f64.ln()).abs() < 1e-9, "{v}");
}
let mut est = Estimate::default();
for token in 0..4 {
est.observe(nll_nats(&flat, token).unwrap());
}
assert!((est.ppl().unwrap() - 4.0).abs() < 1e-9);
}
#[test]
fn adding_a_constant_to_every_logit_does_not_move_the_score() {
let a = [1.0f32, -2.0, 0.5, 3.25, -0.75];
let b: Vec<f32> = a.iter().map(|v| v + 200.0).collect();
for token in 0..a.len() {
let (x, y) = (nll_nats(&a, token).unwrap(), nll_nats(&b, token).unwrap());
assert!(y.is_finite(), "token {token}: shifted score is {y}");
assert!((x - y).abs() < 1e-6, "token {token}: {x} vs {y}");
}
}
#[test]
fn confidence_in_the_right_token_costs_less_than_confidence_in_the_wrong_one() {
let logits = [10.0f32, 0.0, 0.0];
let right = nll_nats(&logits, 0).unwrap();
let wrong = nll_nats(&logits, 1).unwrap();
assert!(right > 0.0 && right < 0.01, "{right}");
assert!(wrong > 9.0, "{wrong}");
}
#[test]
fn the_estimate_exponentiates_the_mean_rather_than_averaging_exponentials() {
let mut est = Estimate::default();
est.observe(0.0);
est.observe(4.0);
let geometric = (2.0f64).exp();
assert!((est.ppl().unwrap() - geometric).abs() < 1e-12);
let arithmetic = (0.0f64.exp() + 4.0f64.exp()) / 2.0;
assert!((est.ppl().unwrap() - arithmetic).abs() > 1.0);
}
#[test]
fn the_standard_error_is_the_error_of_the_mean_pushed_through_exp() {
let mut est = Estimate::default();
for v in [1.0, 2.0, 3.0] {
est.observe(v);
}
let expected = (2.0f64 / 3.0 / 2.0).sqrt() * 2.0f64.exp();
assert!((est.stderr().unwrap() - expected).abs() < 1e-12, "{est:?}");
}
#[test]
fn too_few_or_identical_observations_report_no_standard_error() {
assert_eq!(Estimate::default().ppl(), None);
assert_eq!(Estimate::default().stderr(), None);
let mut one = Estimate::default();
one.observe(1.5);
assert!(one.ppl().is_some());
assert_eq!(one.stderr(), None);
let mut flat = Estimate::default();
flat.observe(1.5);
flat.observe(1.5);
assert_eq!(flat.stderr(), None);
}
#[test]
fn a_target_outside_the_logit_row_is_refused_by_name() {
let err = nll_nats(&[0.0, 1.0], 7).unwrap_err().to_string();
assert!(err.contains('7'), "{err}");
}
}