use crate::query::{answer_query, AtomicScorer, Query, QueryConfig};
use crate::truth::Truth;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ConformalThreshold {
pub qhat: f32,
pub alpha: f32,
pub n_calibration: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConformalError {
InvalidAlpha,
NoCalibrationExamples,
AnswerOutOfRange,
}
impl std::fmt::Display for ConformalError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidAlpha => write!(f, "alpha must be in (0, 1)"),
Self::NoCalibrationExamples => write!(f, "calibration set is empty"),
Self::AnswerOutOfRange => write!(f, "calibration answer id out of range"),
}
}
}
impl std::error::Error for ConformalError {}
pub fn calibrate<T: Truth>(
scorer: &dyn AtomicScorer,
examples: &[(Query, usize)],
config: &QueryConfig,
alpha: f32,
) -> Result<ConformalThreshold, ConformalError> {
if !(alpha > 0.0 && alpha < 1.0) {
return Err(ConformalError::InvalidAlpha);
}
if examples.is_empty() {
return Err(ConformalError::NoCalibrationExamples);
}
let n_entities = scorer.num_entities();
let mut scores = Vec::with_capacity(examples.len());
for (query, answer) in examples {
if *answer >= n_entities {
return Err(ConformalError::AnswerOutOfRange);
}
let degrees = answer_query::<T>(scorer, query, config);
scores.push((1.0 - degrees[*answer]).clamp(0.0, 1.0));
}
scores.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = scores.len();
let rank = ((n as f64 + 1.0) * (1.0 - alpha as f64)).ceil() as usize;
let qhat = if rank > n {
f32::INFINITY
} else {
scores[rank - 1]
};
Ok(ConformalThreshold {
qhat,
alpha,
n_calibration: n,
})
}
pub fn answer_set<T: Truth>(
scorer: &dyn AtomicScorer,
query: &Query,
config: &QueryConfig,
threshold: &ConformalThreshold,
) -> Vec<(usize, f32)> {
let cutoff = 1.0 - threshold.qhat; let mut set: Vec<(usize, f32)> = answer_query::<T>(scorer, query, config)
.into_iter()
.enumerate()
.filter(|(_, d)| *d >= cutoff)
.collect();
set.sort_unstable_by(|a, b| {
b.1.partial_cmp(&a.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.0.cmp(&b.0))
});
set
}
pub fn empirical_coverage<T: Truth>(
scorer: &dyn AtomicScorer,
tests: &[(Query, usize)],
config: &QueryConfig,
threshold: &ConformalThreshold,
) -> f32 {
if tests.is_empty() {
return 0.0;
}
let hits = tests
.iter()
.filter(|(query, answer)| {
answer_set::<T>(scorer, query, config, threshold)
.iter()
.any(|(e, _)| e == answer)
})
.count();
hits as f32 / tests.len() as f32
}
#[cfg(test)]
mod tests {
use super::*;
use crate::kg::FuzzyKg;
use crate::truth::Godel;
fn kg() -> FuzzyKg {
let mut kg = FuzzyKg::new(6);
kg.add_edge(0, 0, 1, 0.9); kg.add_edge(2, 0, 3, 0.8); kg.add_edge(4, 0, 5, 0.7); kg.add_edge(1, 0, 3, 0.6); kg
}
fn calibration() -> Vec<(Query, usize)> {
vec![
(Query::anchor(0, 0), 1),
(Query::anchor(2, 0), 3),
(Query::anchor(4, 0), 5),
(Query::anchor(1, 0), 3),
]
}
#[test]
fn quantile_matches_hand_computation() {
let kg = kg();
let cfg = QueryConfig::default();
let t = calibrate::<Godel>(&kg, &calibration(), &cfg, 0.25).unwrap();
assert!((t.qhat - 0.4).abs() < 1e-6, "qhat {}", t.qhat);
let t = calibrate::<Godel>(&kg, &calibration(), &cfg, 0.5).unwrap();
assert!((t.qhat - 0.3).abs() < 1e-6, "qhat {}", t.qhat);
let t = calibrate::<Godel>(&kg, &calibration(), &cfg, 0.01).unwrap();
assert!(t.qhat.is_infinite());
}
#[test]
fn answer_sets_apply_the_cutoff() {
let kg = kg();
let cfg = QueryConfig::default();
let t = calibrate::<Godel>(&kg, &calibration(), &cfg, 0.5).unwrap();
let set = answer_set::<Godel>(&kg, &Query::anchor(0, 0), &cfg, &t);
assert_eq!(set.first().map(|(e, _)| *e), Some(1));
let set = answer_set::<Godel>(&kg, &Query::anchor(1, 0), &cfg, &t);
assert!(
!set.iter().any(|(e, _)| *e == 3),
"0.6 < cutoff 0.7: {set:?}"
);
let cov = empirical_coverage::<Godel>(&kg, &calibration(), &cfg, &t);
assert!((cov - 0.75).abs() < 1e-6, "coverage {cov}");
}
#[test]
fn infinite_threshold_returns_all_entities() {
let kg = kg();
let cfg = QueryConfig::default();
let t = calibrate::<Godel>(&kg, &calibration(), &cfg, 0.01).unwrap();
let set = answer_set::<Godel>(&kg, &Query::anchor(0, 0), &cfg, &t);
assert_eq!(set.len(), kg.num_entities());
let cov = empirical_coverage::<Godel>(&kg, &calibration(), &cfg, &t);
assert!((cov - 1.0).abs() < 1e-6);
}
#[test]
fn rejects_bad_inputs() {
let kg = kg();
let cfg = QueryConfig::default();
assert_eq!(
calibrate::<Godel>(&kg, &calibration(), &cfg, 0.0).unwrap_err(),
ConformalError::InvalidAlpha
);
assert_eq!(
calibrate::<Godel>(&kg, &calibration(), &cfg, 1.0).unwrap_err(),
ConformalError::InvalidAlpha
);
assert_eq!(
calibrate::<Godel>(&kg, &[], &cfg, 0.1).unwrap_err(),
ConformalError::NoCalibrationExamples
);
assert_eq!(
calibrate::<Godel>(&kg, &[(Query::anchor(0, 0), 99)], &cfg, 0.1).unwrap_err(),
ConformalError::AnswerOutOfRange
);
}
}