use super::advisory::assert_threshold;
use super::question::{Question, QuestionKind};
use super::{DecisionError, DecisionModel};
use serde_json::Value;
use std::fmt;
const BINS: usize = 10;
const NLL_FLOOR: f64 = 1e-6;
const TEMPERATURE_STEPS: i32 = 33;
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum Expected {
Noul(bool),
Choice(String),
Score(usize),
}
#[derive(Debug, Clone, PartialEq)]
pub struct CalibrationExample {
state: Value,
question: Question,
expected: Expected,
}
impl CalibrationExample {
pub fn new(state: impl Into<Value>, question: Question, expected: Expected) -> Self {
Self {
state: state.into(),
question,
expected,
}
}
pub fn noul(state: impl Into<Value>, instructions: impl Into<Value>, expected: bool) -> Self {
Self::new(
state,
Question::noul(instructions),
Expected::Noul(expected),
)
}
pub fn choice<I, S>(
state: impl Into<Value>,
instructions: impl Into<Value>,
options: I,
expected: impl Into<String>,
) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self::new(
state,
Question::choice(instructions, options),
Expected::Choice(expected.into()),
)
}
pub fn score<I, S>(
state: impl Into<Value>,
instructions: impl Into<Value>,
levels: I,
expected: usize,
) -> Self
where
I: IntoIterator<Item = S>,
S: Into<Value>,
{
Self::new(
state,
Question::score(instructions, levels),
Expected::Score(expected),
)
}
pub fn state(&self) -> &Value {
&self.state
}
pub fn question(&self) -> &Question {
&self.question
}
pub fn expected(&self) -> &Expected {
&self.expected
}
fn truth(&self) -> Option<usize> {
match (&self.expected, self.question.kind()) {
(Expected::Noul(yes), QuestionKind::Noul) => Some(if *yes { 0 } else { 1 }),
(Expected::Choice(option), QuestionKind::Choice) => self
.question
.options()
.and_then(|o| o.iter().position(|x| x == option)),
(Expected::Score(level), QuestionKind::Score) => {
(*level < self.question.levels().map_or(0, <[_]>::len)).then_some(*level)
}
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct CalibrationOptions {
concurrency: usize,
target_precision: Option<f64>,
}
impl Default for CalibrationOptions {
fn default() -> Self {
Self {
concurrency: 4,
target_precision: None,
}
}
}
impl CalibrationOptions {
pub fn new() -> Self {
Self::default()
}
pub fn with_concurrency(mut self, n: usize) -> Self {
assert!(n > 0, "calibration concurrency must be at least 1");
self.concurrency = n;
self
}
pub fn with_target_precision(mut self, p: f64) -> Self {
assert_threshold("target precision", p);
self.target_precision = Some(p);
self
}
pub fn concurrency(&self) -> usize {
self.concurrency
}
pub fn target_precision(&self) -> Option<f64> {
self.target_precision
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct CalibrationError {
index: usize,
error: DecisionError,
}
impl CalibrationError {
pub fn index(&self) -> usize {
self.index
}
pub fn error(&self) -> &DecisionError {
&self.error
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub struct ThresholdPoint {
pub threshold: f64,
pub precision: f64,
pub recall: f64,
pub f1: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub struct ReliabilityBin {
pub lower: f64,
pub upper: f64,
pub count: usize,
pub mean_confidence: f64,
pub accuracy: f64,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct CalibrationReport {
pub count: usize,
pub accuracy: Option<f64>,
pub brier: Option<f64>,
pub ece: Option<f64>,
pub bins: Vec<ReliabilityBin>,
pub best_f1: Option<ThresholdPoint>,
pub precision_threshold: Option<ThresholdPoint>,
pub suggested_temperature: Option<f64>,
pub models: Vec<(String, usize)>,
pub errors: Vec<CalibrationError>,
pub skipped: Vec<usize>,
}
impl CalibrationReport {
pub fn mixed_models(&self) -> bool {
self.models.len() > 1
}
}
impl fmt::Display for CalibrationReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let num = |x: Option<f64>| x.map_or_else(|| "n/a".to_string(), |x| format!("{x:.3}"));
write!(
f,
"{} evaluated ({} errors, {} skipped): accuracy {}, Brier {}, ECE {}",
self.count,
self.errors.len(),
self.skipped.len(),
num(self.accuracy),
num(self.brier),
num(self.ece)
)?;
if self.mixed_models() {
let list: Vec<String> = self
.models
.iter()
.map(|(m, n)| format!("{m} ×{n}"))
.collect();
write!(f, "\nmixed models: {}", list.join(", "))?;
}
if let Some(t) = self.suggested_temperature {
write!(f, "\nsuggested temperature {t:.2}")?;
}
for (label, point) in [
("best-F1 threshold", self.best_f1),
("target-precision threshold", self.precision_threshold),
] {
if let Some(p) = point {
write!(
f,
"\n{label} {:.3} (precision {:.3}, recall {:.3}, F1 {:.3})",
p.threshold, p.precision, p.recall, p.f1
)?;
}
}
Ok(())
}
}
pub async fn calibrate(
model: &DecisionModel,
examples: impl IntoIterator<Item = CalibrationExample>,
) -> CalibrationReport {
calibrate_with(model, examples, CalibrationOptions::default()).await
}
pub async fn calibrate_with(
model: &DecisionModel,
examples: impl IntoIterator<Item = CalibrationExample>,
options: CalibrationOptions,
) -> CalibrationReport {
use futures::StreamExt;
let mut skipped = Vec::new();
let mut runnable = Vec::new();
for (i, example) in examples.into_iter().enumerate() {
match example.truth() {
Some(truth) => runnable.push((i, example, truth)),
None => skipped.push(i),
}
}
let outcomes: Vec<(usize, Result<Scored, DecisionError>)> =
futures::stream::iter(runnable.into_iter().map(|(i, example, truth)| async move {
let result = model
.evaluate(
example.state.clone(),
vec![("q".to_string(), example.question.clone())],
)
.await
.and_then(|eval| scored(&example, &eval, truth));
(i, result)
}))
.buffer_unordered(options.concurrency.max(1))
.collect()
.await;
let mut scored_all = Vec::new();
let mut errors = Vec::new();
for (index, outcome) in outcomes {
match outcome {
Ok(s) => scored_all.push(s),
Err(error) => errors.push(CalibrationError { index, error }),
}
}
errors.sort_by_key(|e| e.index);
report(&scored_all, &options, errors, skipped)
}
struct Scored {
probs: Vec<f64>,
truth: usize,
noul: Option<(f64, bool)>,
model: String,
}
fn scored(
example: &CalibrationExample,
eval: &super::Evaluation,
truth: usize,
) -> Result<Scored, DecisionError> {
let missing = || DecisionError::BadResponse("answers.q: missing".into());
let model = eval.model().to_string();
match example.question.kind() {
QuestionKind::Noul => {
let p = eval.p_true("q").ok_or_else(missing)?;
Ok(Scored {
probs: vec![p, 1.0 - p],
truth,
noul: Some((p, truth == 0)),
model,
})
}
QuestionKind::Choice => {
let answer = eval.choice("q").ok_or_else(missing)?;
let options = example.question.options().unwrap_or_default();
Ok(Scored {
probs: options.iter().map(|o| answer.probability(o)).collect(),
truth,
noul: None,
model,
})
}
QuestionKind::Score => Ok(Scored {
probs: eval
.score("q")
.ok_or_else(missing)?
.probabilities()
.to_vec(),
truth,
noul: None,
model,
}),
}
}
fn argmax(probs: &[f64]) -> usize {
let mut best = 0;
for (i, p) in probs.iter().enumerate() {
if *p > probs[best] {
best = i;
}
}
best
}
fn report(
scored: &[Scored],
options: &CalibrationOptions,
errors: Vec<CalibrationError>,
skipped: Vec<usize>,
) -> CalibrationReport {
let n = scored.len();
let mut bins: Vec<(usize, f64, usize)> = vec![(0, 0.0, 0); BINS]; let mut correct = 0usize;
let mut brier = 0.0;
for s in scored {
let top = argmax(&s.probs);
let conf = s.probs[top].clamp(0.0, 1.0);
let right = top == s.truth;
correct += usize::from(right);
brier += match s.noul {
Some((p, yes)) => (p - if yes { 1.0 } else { 0.0 }).powi(2),
None => s
.probs
.iter()
.enumerate()
.map(|(k, p)| (p - if k == s.truth { 1.0 } else { 0.0 }).powi(2))
.sum(),
};
let b = ((conf * BINS as f64) as usize).min(BINS - 1);
bins[b].0 += 1;
bins[b].1 += conf;
bins[b].2 += usize::from(right);
}
let nf = n as f64;
let (accuracy, brier) = if n == 0 {
(None, None)
} else {
(Some(correct as f64 / nf), Some(brier / nf))
};
let bins: Vec<ReliabilityBin> = bins
.iter()
.enumerate()
.map(|(i, (count, sum, right))| {
let c = *count as f64;
ReliabilityBin {
lower: i as f64 / BINS as f64,
upper: (i + 1) as f64 / BINS as f64,
count: *count,
mean_confidence: if *count == 0 { 0.0 } else { sum / c },
accuracy: if *count == 0 { 0.0 } else { *right as f64 / c },
}
})
.collect();
let ece = (n > 0).then(|| {
bins.iter()
.map(|b| b.count as f64 / nf * (b.accuracy - b.mean_confidence).abs())
.sum()
});
let mut models: Vec<(String, usize)> = Vec::new();
for s in scored {
match models.iter_mut().find(|(m, _)| *m == s.model) {
Some(entry) => entry.1 += 1,
None => models.push((s.model.clone(), 1)),
}
}
models.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
let nouls: Vec<(f64, bool)> = scored.iter().filter_map(|s| s.noul).collect();
let points = threshold_points(&nouls);
let best_f1 = points
.iter()
.copied()
.fold(None::<ThresholdPoint>, |best, p| match best {
Some(b) if b.f1 >= p.f1 => Some(b),
_ => Some(p),
});
let precision_threshold = options
.target_precision
.and_then(|target| points.iter().copied().find(|p| p.precision >= target));
CalibrationReport {
count: n,
accuracy,
brier,
ece,
bins,
best_f1,
precision_threshold,
suggested_temperature: suggest_temperature(scored),
models,
errors,
skipped,
}
}
fn threshold_points(nouls: &[(f64, bool)]) -> Vec<ThresholdPoint> {
let positives = nouls.iter().filter(|(_, y)| *y).count();
if positives == 0 {
return Vec::new();
}
let mut thresholds: Vec<f64> = nouls
.iter()
.map(|(p, _)| *p)
.filter(|p| p.is_finite())
.collect();
thresholds.sort_by(f64::total_cmp);
thresholds.dedup();
thresholds
.into_iter()
.filter_map(|t| {
let tp = nouls.iter().filter(|(p, y)| *y && *p >= t).count();
let fp = nouls.iter().filter(|(p, y)| !*y && *p >= t).count();
if tp == 0 {
return None;
}
let precision = tp as f64 / (tp + fp) as f64;
let recall = tp as f64 / positives as f64;
let f1 = 2.0 * precision * recall / (precision + recall);
Some(ThresholdPoint {
threshold: t,
precision,
recall,
f1,
})
})
.collect()
}
fn nll(scored: &[Scored], t: f64) -> f64 {
let total: f64 = scored
.iter()
.map(|s| {
let logits: Vec<Option<f64>> = s
.probs
.iter()
.map(|p| (*p > 0.0).then(|| p.min(1.0).ln() / t))
.collect();
let max = logits
.iter()
.flatten()
.copied()
.fold(f64::NEG_INFINITY, f64::max);
if !max.is_finite() {
return -NLL_FLOOR.ln();
}
let sum: f64 = logits.iter().flatten().map(|l| (l - max).exp()).sum();
let q = logits[s.truth].map_or(0.0, |l| (l - max).exp() / sum);
-q.max(NLL_FLOOR).ln()
})
.sum();
total / scored.len() as f64
}
fn suggest_temperature(scored: &[Scored]) -> Option<f64> {
if scored.is_empty() {
return None;
}
let mut best = (1.0, nll(scored, 1.0));
for k in 1..=TEMPERATURE_STEPS {
for t in [1.05f64.powi(k), 1.05f64.powi(-k)] {
let loss = nll(scored, t);
if loss < best.1 - 1e-12 {
best = (t, loss);
}
}
}
best.1.is_finite().then_some(best.0)
}