Skip to main content

burn_train/metric/
perplexity.rs

1use super::state::FormatOptions;
2use super::{MetricMetadata, NumericEntry, SerializedEntry, format_float};
3use crate::metric::{Metric, MetricAttributes, MetricName, Numeric, NumericAttributes};
4use burn_core::tensor::{Int, Tensor, TensorReadError};
5
6/// Custom state for perplexity metric that correctly accumulates negative log-likelihood.
7///
8/// Unlike other metrics that can be averaged, perplexity requires special handling:
9/// - Accumulate total negative log-likelihood across all tokens
10/// - Accumulate total number of effective tokens
11/// - Compute perplexity as exp(total_nll / total_tokens) only at the end
12#[derive(Clone)]
13struct PerplexityState {
14    /// Sum of negative log-likelihood across all tokens
15    sum_nll: f64,
16    /// Total number of effective tokens (excluding padding)
17    total_tokens: usize,
18    /// Current batch perplexity (for display purposes)
19    current: f64,
20    current_tokens: usize,
21}
22
23impl PerplexityState {
24    fn new() -> Self {
25        Self {
26            sum_nll: 0.0,
27            total_tokens: 0,
28            current: f64::NAN,
29            current_tokens: 0,
30        }
31    }
32
33    fn reset(&mut self) {
34        self.sum_nll = 0.0;
35        self.total_tokens = 0;
36        self.current = f64::NAN;
37        self.current_tokens = 0;
38    }
39
40    /// Update state with negative log-likelihood and token count from current batch
41    fn update(&mut self, sum_log_prob: f64, effective_tokens: usize) {
42        // sum_log_prob is already the sum of log probabilities (negative values)
43        // We need to negate it to get negative log-likelihood
44        let batch_nll = -sum_log_prob;
45
46        // Accumulate across batches
47        self.sum_nll += batch_nll;
48        self.total_tokens += effective_tokens;
49
50        // Compute current batch perplexity for display
51        let batch_perplexity = if effective_tokens > 0 {
52            (batch_nll / effective_tokens as f64).exp()
53        } else {
54            f64::INFINITY
55        };
56        self.current = batch_perplexity;
57        self.current_tokens = effective_tokens;
58    }
59
60    /// Compute the metric for the current update.
61    pub fn compute_update(&self, format: FormatOptions) -> SerializedEntry {
62        self.compute(format, false)
63    }
64
65    /// Compute the final metric for the accumulated global state.
66    pub fn compute_final(&self, format: FormatOptions) -> SerializedEntry {
67        self.compute(format, true)
68    }
69
70    fn compute(&self, format: FormatOptions, final_entry: bool) -> SerializedEntry {
71        let batch_perplexity = self.current;
72
73        // Compute running epoch perplexity
74        let epoch_perplexity = if self.total_tokens > 0 {
75            (self.sum_nll / self.total_tokens as f64).exp()
76        } else {
77            f64::INFINITY
78        };
79
80        // Format for display
81        let (formatted_current, formatted_running) = match format.precision_value() {
82            Some(precision) => (
83                format_float(batch_perplexity, precision),
84                format_float(epoch_perplexity, precision),
85            ),
86            None => (format!("{batch_perplexity}"), format!("{epoch_perplexity}")),
87        };
88
89        let formatted = match format.unit_value() {
90            Some(unit) => {
91                format!("epoch {formatted_running} {unit} - batch {formatted_current} {unit}")
92            }
93            None => format!("epoch {formatted_running} - batch {formatted_current}"),
94        };
95
96        // Serialize the state for aggregation
97        let serialized = if final_entry {
98            NumericEntry::Final(epoch_perplexity).serialize()
99        } else {
100            NumericEntry::Aggregated {
101                aggregated_value: batch_perplexity,
102                count: self.current_tokens,
103            }
104            .serialize()
105        };
106
107        SerializedEntry::new(formatted, serialized)
108    }
109
110    fn current_value(&self) -> Option<NumericEntry> {
111        Some(NumericEntry::Aggregated {
112            // self.current is already exp(batch_nll / effective_tokens)
113            aggregated_value: self.current,
114            count: self.current_tokens,
115        })
116    }
117
118    fn running_value(&self) -> Option<NumericEntry> {
119        Some(NumericEntry::Aggregated {
120            aggregated_value: entry_value(self.sum_nll, self.total_tokens),
121            count: self.total_tokens,
122        })
123    }
124
125    fn final_value(&self) -> NumericEntry {
126        NumericEntry::Final(entry_value(self.sum_nll, self.total_tokens))
127    }
128}
129
130fn entry_value(value: f64, count: usize) -> f64 {
131    if count > 0 {
132        (value / count as f64).exp()
133    } else {
134        f64::INFINITY
135    }
136}
137
138/// The perplexity metric.
139///
140/// Perplexity is a measure of how well a probability distribution or probability model
141/// predicts a sample. It's commonly used to evaluate language models. A lower perplexity
142/// indicates that the model is more confident in its predictions.
143///
144/// Mathematically, perplexity is defined as the exponentiation of the cross-entropy loss:
145/// PPL = exp(H(p, q)) = exp(-1/N * Σ log(p(x_i)))
146///
147/// where:
148/// - H(p, q) is the cross-entropy between the true distribution p and predicted distribution q
149/// - N is the number of tokens
150/// - p(x_i) is the predicted probability of the i-th token
151///
152/// # Aggregation
153/// Unlike other metrics, perplexity cannot be simply averaged across batches.
154/// This implementation correctly accumulates the total negative log-likelihood and
155/// total token count across batches, then computes perplexity as exp(total_nll / total_tokens).
156#[derive(Clone)]
157pub struct PerplexityMetric {
158    name: MetricName,
159    state: PerplexityState,
160    pad_token: Option<usize>,
161}
162
163/// The [perplexity metric](PerplexityMetric) input type.
164#[derive(new)]
165pub struct PerplexityInput {
166    /// Logits tensor of shape [batch_size * sequence_length, vocab_size]
167    outputs: Tensor<2>,
168    /// Target tokens tensor of shape [batch_size * sequence_length]
169    targets: Tensor<1, Int>,
170}
171
172impl Default for PerplexityMetric {
173    fn default() -> Self {
174        Self::new()
175    }
176}
177
178impl PerplexityMetric {
179    /// Creates the metric.
180    pub fn new() -> Self {
181        Self {
182            name: MetricName::new("Perplexity".to_string()),
183            state: PerplexityState::new(),
184            pad_token: Default::default(),
185        }
186    }
187
188    /// Sets the pad token to exclude from perplexity calculation.
189    ///
190    /// When a pad token is set, predictions for padding tokens are masked out
191    /// and do not contribute to the perplexity calculation. This is important
192    /// for variable-length sequences where padding is used.
193    pub fn with_pad_token(mut self, index: usize) -> Self {
194        self.pad_token = Some(index);
195        self
196    }
197}
198
199impl Metric for PerplexityMetric {
200    type Input = PerplexityInput;
201
202    fn update(
203        &mut self,
204        input: &PerplexityInput,
205        _metadata: &MetricMetadata,
206    ) -> Result<SerializedEntry, TensorReadError> {
207        let targets = input.targets.clone();
208        let outputs = input.outputs.clone();
209
210        let [total_tokens, _vocab_size] = outputs.dims();
211
212        // Convert logits to log probabilities using log_softmax for numerical stability
213        let log_probs = burn_core::tensor::activation::log_softmax(outputs, 1);
214
215        // Gather the log probabilities for the target tokens
216        let target_log_probs = log_probs
217            .gather(1, targets.clone().unsqueeze_dim(1))
218            .squeeze_dim(1);
219
220        let (sum_log_prob, effective_tokens) = match self.pad_token {
221            Some(pad_token) => {
222                // Create a mask for non-padding tokens
223                let mask = targets.clone().not_equal_scalar(pad_token as i64);
224
225                // Apply mask to log probabilities (set padding log probs to 0)
226                let masked_log_probs = target_log_probs.mask_fill(mask.clone().bool_not(), 0.0);
227
228                // Sum the log probabilities and count effective tokens
229                let sum_log_prob = masked_log_probs.sum().try_into_scalar::<f64>()?;
230                let effective_tokens = mask.int().sum().try_into_scalar::<i64>()? as usize;
231
232                (sum_log_prob, effective_tokens)
233            }
234            None => {
235                // No padding, use all tokens
236                let sum_log_prob = target_log_probs.sum().try_into_scalar::<f64>()?;
237                (sum_log_prob, total_tokens)
238            }
239        };
240
241        // Pass the sum_log_prob and effective_tokens to the state
242        // The state will handle the correct accumulation and perplexity calculation
243        self.state.update(sum_log_prob, effective_tokens);
244        Ok(self
245            .state
246            .compute_update(FormatOptions::new(self.name()).precision(2)))
247    }
248
249    fn compute(&mut self) -> Result<SerializedEntry, TensorReadError> {
250        Ok(self
251            .state
252            .compute_final(FormatOptions::new(self.name()).precision(2)))
253    }
254
255    fn clear(&mut self) {
256        self.state.reset()
257    }
258
259    fn name(&self) -> MetricName {
260        self.name.clone()
261    }
262
263    fn attributes(&self) -> MetricAttributes {
264        NumericAttributes {
265            unit: None,
266            higher_is_better: false,
267        }
268        .into()
269    }
270}
271
272impl Numeric for PerplexityMetric {
273    fn value(&self) -> Option<NumericEntry> {
274        self.state.current_value()
275    }
276
277    fn running_value(&self) -> Option<NumericEntry> {
278        self.state.running_value()
279    }
280
281    fn final_value(&self) -> NumericEntry {
282        self.state.final_value()
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn test_perplexity_perfect_prediction() {
292        let device = Default::default();
293        let mut metric = PerplexityMetric::new();
294
295        // Perfect prediction: target is always the highest probability class
296        let input = PerplexityInput::new(
297            Tensor::from_data(
298                [
299                    [10.0, 0.0, 0.0], // Very confident prediction for class 0
300                    [0.0, 10.0, 0.0], // Very confident prediction for class 1
301                    [0.0, 0.0, 10.0], // Very confident prediction for class 2
302                ],
303                &device,
304            ),
305            Tensor::from_data([0, 1, 2], &device),
306        );
307
308        let _entry = metric.update(&input, &MetricMetadata::fake()).unwrap();
309        let perplexity = metric.value().unwrap().current();
310
311        // Perfect predictions should result in very low perplexity (close to 1.0)
312        assert!(
313            perplexity < 1.1,
314            "Perfect predictions should have low perplexity, got {}",
315            perplexity
316        );
317    }
318
319    #[test]
320    fn test_perplexity_uniform_prediction() {
321        let device = Default::default();
322        let mut metric = PerplexityMetric::new();
323
324        // Uniform prediction: all classes have equal probability
325        let input = PerplexityInput::new(
326            Tensor::from_data(
327                [
328                    [0.0, 0.0, 0.0], // Uniform distribution (after softmax)
329                    [0.0, 0.0, 0.0], // Uniform distribution (after softmax)
330                    [0.0, 0.0, 0.0], // Uniform distribution (after softmax)
331                ],
332                &device,
333            ),
334            Tensor::from_data([0, 1, 2], &device),
335        );
336
337        let _entry = metric.update(&input, &MetricMetadata::fake()).unwrap();
338        let perplexity = metric.value().unwrap().current();
339
340        // Uniform distribution over 3 classes should have perplexity ≈ 3.0
341        assert!(
342            (perplexity - 3.0).abs() < 0.1,
343            "Uniform distribution perplexity should be ~3.0, got {}",
344            perplexity
345        );
346    }
347
348    #[test]
349    fn test_perplexity_with_padding() {
350        let device = Default::default();
351        let mut metric = PerplexityMetric::new().with_pad_token(3);
352
353        let input = PerplexityInput::new(
354            Tensor::from_data(
355                [
356                    [10.0, 0.0, 0.0, 0.0], // Good prediction for class 0
357                    [0.0, 10.0, 0.0, 0.0], // Good prediction for class 1
358                    [0.0, 0.0, 0.0, 1.0],  // This is padding - should be ignored
359                    [0.0, 0.0, 0.0, 1.0],  // This is padding - should be ignored
360                ],
361                &device,
362            ),
363            Tensor::from_data([0, 1, 3, 3], &device), // 3 is pad token
364        );
365
366        let _entry = metric.update(&input, &MetricMetadata::fake()).unwrap();
367        let perplexity = metric.value().unwrap().current();
368
369        // Should only consider the first two predictions, both of which are confident
370        assert!(
371            perplexity < 1.1,
372            "Good predictions with padding should have low perplexity, got {}",
373            perplexity
374        );
375    }
376
377    #[test]
378    fn test_perplexity_wrong_prediction() {
379        let device = Default::default();
380        let mut metric = PerplexityMetric::new();
381
382        // Wrong predictions: target class has very low probability
383        let input = PerplexityInput::new(
384            Tensor::from_data(
385                [
386                    [0.0, 10.0, 0.0], // Predicts class 1, but target is 0
387                    [10.0, 0.0, 0.0], // Predicts class 0, but target is 1
388                    [0.0, 0.0, 10.0], // Predicts class 2, but target is 0
389                ],
390                &device,
391            ),
392            Tensor::from_data([0, 1, 0], &device),
393        );
394
395        let _entry = metric.update(&input, &MetricMetadata::fake()).unwrap();
396        let perplexity = metric.value().unwrap().current();
397
398        // Wrong predictions should result in high perplexity
399        assert!(
400            perplexity > 10.0,
401            "Wrong predictions should have high perplexity, got {}",
402            perplexity
403        );
404    }
405
406    #[test]
407    fn test_perplexity_multi_batch_aggregation() {
408        let device = Default::default();
409        let mut metric = PerplexityMetric::new();
410
411        // First batch: 2 tokens with uniform distribution (log_prob ≈ -1.0986 each)
412        let input1 = PerplexityInput::new(
413            Tensor::from_data(
414                [
415                    [0.0, 0.0, 0.0], // Uniform distribution (log_prob ≈ -1.0986)
416                    [0.0, 0.0, 0.0], // Uniform distribution (log_prob ≈ -1.0986)
417                ],
418                &device,
419            ),
420            Tensor::from_data([0, 1], &device),
421        );
422
423        // Second batch: 1 token with uniform distribution
424        let input2 = PerplexityInput::new(
425            Tensor::from_data(
426                [
427                    [0.0, 0.0, 0.0], // Uniform distribution (log_prob ≈ -1.0986)
428                ],
429                &device,
430            ),
431            Tensor::from_data([2], &device),
432        );
433
434        // Update with both batches
435        let _entry1 = metric.update(&input1, &MetricMetadata::fake()).unwrap();
436        let _entry2 = metric.update(&input2, &MetricMetadata::fake()).unwrap();
437
438        let aggregated_perplexity = metric.value().unwrap().current();
439
440        // For uniform distribution over 3 classes: log_prob ≈ -log(3) ≈ -1.0986
441        // Total negative log-likelihood: 3 * 1.0986 ≈ 3.2958
442        // Total tokens: 3
443        // Expected perplexity: exp(3.2958 / 3) = exp(1.0986) ≈ 3.0
444        assert!(
445            (aggregated_perplexity - 3.0).abs() < 0.1,
446            "Multi-batch aggregated perplexity should be ~3.0, got {}",
447            aggregated_perplexity
448        );
449
450        // Compare with single batch containing all data
451        let mut single_batch_metric = PerplexityMetric::new();
452        let single_input = PerplexityInput::new(
453            Tensor::from_data([[0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]], &device),
454            Tensor::from_data([0, 1, 2], &device),
455        );
456
457        let _single_entry = single_batch_metric
458            .update(&single_input, &MetricMetadata::fake())
459            .unwrap();
460        let single_batch_perplexity = single_batch_metric.value().unwrap().current();
461
462        // Multi-batch and single-batch should give the same result
463        assert!(
464            (aggregated_perplexity - single_batch_perplexity).abs() < 0.01,
465            "Multi-batch ({}) and single-batch ({}) perplexity should match",
466            aggregated_perplexity,
467            single_batch_perplexity
468        );
469    }
470
471    #[test]
472    fn test_perplexity_global_aggregation_end_to_end() {
473        let device = Default::default();
474        let mut metric = PerplexityMetric::new();
475
476        // Batch 1 (1 token, confident prediction -> low NLL):
477        // logits lead to log_prob ≈ -0.1 -> NLL = 0.1
478        let input_batch1 = PerplexityInput::new(
479            Tensor::from_data([[5.0, 0.0, 0.0]], &device),
480            Tensor::from_data([0], &device),
481        );
482        let _ = metric
483            .update(&input_batch1, &MetricMetadata::fake())
484            .unwrap();
485
486        let batch1_current = metric.value().unwrap().current();
487        let batch1_running = metric.running_value().unwrap().current();
488        assert_eq!(batch1_current, batch1_running);
489
490        // Batch 2 (5 tokens, uniform predictions -> higher NLL):
491        // uniform over 3 classes -> log_prob ≈ -log(3) ≈ -1.0986 -> NLL = 1.0986 per token
492        let input_batch2 = PerplexityInput::new(
493            Tensor::from_data(
494                [
495                    [0.0, 0.0, 0.0],
496                    [0.0, 0.0, 0.0],
497                    [0.0, 0.0, 0.0],
498                    [0.0, 0.0, 0.0],
499                    [0.0, 0.0, 0.0],
500                ],
501                &device,
502            ),
503            Tensor::from_data([0, 1, 2, 0, 1], &device),
504        );
505        let _ = metric
506            .update(&input_batch2, &MetricMetadata::fake())
507            .unwrap();
508
509        // Batch 2 current batch perplexity should be ≈ 3.0
510        let batch2_current = metric.value().unwrap().current();
511        assert!((batch2_current - 3.0).abs() < 0.1);
512
513        // Compute final epoch entry
514        let _serialized_compute = metric.compute().unwrap();
515        let final_ppl = metric.final_value().current();
516
517        // Total NLL = 0.013416 + 5.493061 = 5.506477
518        // Global PPL = exp(5.506477 / 6 tokens) ≈ 2.5036
519        // Note: Simple unweighted average of batch PPLs would incorrectly give (1.0135 + 3.0) / 2 = 2.0067
520        let expected_global_ppl = 2.5036;
521
522        assert!(
523            (final_ppl - expected_global_ppl).abs() < 1e-3,
524            "Expected final aggregated perplexity ~{}, got {}",
525            expected_global_ppl,
526            final_ppl
527        );
528    }
529}