use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DistributionMetrics {
pub kl_nats: f64,
pub reference_entropy_nats: f64,
pub target_nll_delta_nats: Option<f64>,
pub centered_logit_rmse: f64,
pub top1_agreement: bool,
pub top_k_overlap: f64,
}
pub fn compare_distributions(
reference: &[f32],
candidate: &[f32],
target: Option<usize>,
top_k: usize,
) -> Result<DistributionMetrics, DistributionError> {
if reference.is_empty() {
return Err(DistributionError::Empty);
}
if reference.len() != candidate.len() {
return Err(DistributionError::Length {
reference: reference.len(),
candidate: candidate.len(),
});
}
if top_k == 0 {
return Err(DistributionError::ZeroTopK);
}
if let Some(target) = target {
if target >= reference.len() {
return Err(DistributionError::Target {
target,
classes: reference.len(),
});
}
}
if reference
.iter()
.chain(candidate)
.any(|value| !value.is_finite())
{
return Err(DistributionError::NonFinite);
}
let reference_lse = logsumexp(reference);
let candidate_lse = logsumexp(candidate);
let mut kl = 0.0;
let mut entropy = 0.0;
for (&reference_logit, &candidate_logit) in reference.iter().zip(candidate) {
let log_p = reference_logit as f64 - reference_lse;
let log_q = candidate_logit as f64 - candidate_lse;
let probability = log_p.exp();
kl += probability * (log_p - log_q);
entropy -= probability * log_p;
}
let reference_mean =
reference.iter().map(|value| *value as f64).sum::<f64>() / reference.len() as f64;
let candidate_mean =
candidate.iter().map(|value| *value as f64).sum::<f64>() / candidate.len() as f64;
let centered_logit_rmse = (reference
.iter()
.zip(candidate)
.map(|(&left, &right)| {
let delta = (left as f64 - reference_mean) - (right as f64 - candidate_mean);
delta * delta
})
.sum::<f64>()
/ reference.len() as f64)
.sqrt();
let reference_top = top_indices(reference, top_k);
let candidate_top = top_indices(candidate, top_k);
let overlap = reference_top
.iter()
.filter(|index| candidate_top.contains(index))
.count();
Ok(DistributionMetrics {
kl_nats: kl.max(0.0),
reference_entropy_nats: entropy,
target_nll_delta_nats: target.map(|target| {
(candidate_lse - candidate[target] as f64) - (reference_lse - reference[target] as f64)
}),
centered_logit_rmse,
top1_agreement: reference_top[0] == candidate_top[0],
top_k_overlap: overlap as f64 / reference_top.len() as f64,
})
}
fn logsumexp(values: &[f32]) -> f64 {
let maximum = values.iter().copied().fold(f32::NEG_INFINITY, f32::max) as f64;
maximum
+ values
.iter()
.map(|value| (*value as f64 - maximum).exp())
.sum::<f64>()
.ln()
}
fn top_indices(values: &[f32], count: usize) -> Vec<usize> {
let mut indices = (0..values.len()).collect::<Vec<_>>();
indices.sort_by(|left, right| {
values[*right]
.total_cmp(&values[*left])
.then_with(|| left.cmp(right))
});
indices.truncate(count.min(indices.len()));
indices
}
#[derive(Debug, Clone, Eq, PartialEq, thiserror::Error)]
pub enum DistributionError {
#[error("distribution must contain at least one class")]
Empty,
#[error("distribution lengths differ: reference {reference}, candidate {candidate}")]
Length {
reference: usize,
candidate: usize,
},
#[error("distribution top_k must be positive")]
ZeroTopK,
#[error("target index {target} is outside {classes} classes")]
Target {
target: usize,
classes: usize,
},
#[error("distribution logits must be finite")]
NonFinite,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identical_distributions_have_exact_metrics() {
let metrics =
compare_distributions(&[0.0, 2.0, 1.0], &[0.0, 2.0, 1.0], Some(1), 2).unwrap();
assert_eq!(metrics.kl_nats, 0.0);
assert_eq!(metrics.target_nll_delta_nats, Some(0.0));
assert_eq!(metrics.centered_logit_rmse, 0.0);
assert!(metrics.top1_agreement);
assert_eq!(metrics.top_k_overlap, 1.0);
}
}