pub struct RocGroup {
pub label: String,
pub raw_predictions: Option<Vec<(f64, bool)>>,
pub precomputed_points: Option<Vec<(f64, f64)>>,
pub color: Option<String>,
pub show_ci: bool,
pub ci_alpha: f64,
pub pauc_range: Option<(f64, f64)>,
pub show_optimal_point: bool,
pub show_auc_label: bool,
pub line_width: f64,
pub dasharray: Option<String>,
}
impl RocGroup {
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
raw_predictions: None,
precomputed_points: None,
color: None,
show_ci: false,
ci_alpha: 0.15,
pauc_range: None,
show_optimal_point: false,
show_auc_label: true,
line_width: 2.0,
dasharray: None,
}
}
pub fn with_raw(mut self, predictions: impl IntoIterator<Item = (f64, bool)>) -> Self {
self.raw_predictions = Some(predictions.into_iter().collect());
self
}
pub fn with_points(mut self, pts: impl IntoIterator<Item = (f64, f64)>) -> Self {
self.precomputed_points = Some(pts.into_iter().collect());
self
}
pub fn with_color(mut self, color: impl Into<String>) -> Self {
self.color = Some(color.into());
self
}
pub fn with_ci(mut self, show: bool) -> Self {
self.show_ci = show;
self
}
pub fn with_ci_alpha(mut self, alpha: f64) -> Self {
self.ci_alpha = alpha;
self
}
pub fn with_pauc(mut self, lo: f64, hi: f64) -> Self {
self.pauc_range = Some((lo, hi));
self
}
pub fn with_optimal_point(mut self) -> Self {
self.show_optimal_point = true;
self
}
pub fn with_auc_label(mut self, show: bool) -> Self {
self.show_auc_label = show;
self
}
pub fn with_line_width(mut self, w: f64) -> Self {
self.line_width = w;
self
}
pub fn with_dasharray(mut self, d: impl Into<String>) -> Self {
self.dasharray = Some(d.into());
self
}
}
pub struct RocPlot {
pub groups: Vec<RocGroup>,
pub color: String,
pub show_diagonal: bool,
pub diagonal_color: String,
pub diagonal_dasharray: String,
pub legend_label: Option<String>,
}
impl Default for RocPlot {
fn default() -> Self {
Self::new()
}
}
impl RocPlot {
pub fn new() -> Self {
Self {
groups: Vec::new(),
color: "steelblue".to_string(),
show_diagonal: true,
diagonal_color: "#aaaaaa".to_string(),
diagonal_dasharray: "5,3".to_string(),
legend_label: None,
}
}
pub fn with_group(mut self, group: RocGroup) -> Self {
self.groups.push(group);
self
}
pub fn with_groups(mut self, groups: impl IntoIterator<Item = RocGroup>) -> Self {
self.groups.extend(groups);
self
}
pub fn with_color(mut self, color: impl Into<String>) -> Self {
self.color = color.into();
self
}
pub fn with_diagonal(mut self, show: bool) -> Self {
self.show_diagonal = show;
self
}
pub fn with_legend(mut self, label: impl Into<String>) -> Self {
self.legend_label = Some(label.into());
self
}
}
#[derive(Clone)]
pub struct RocPoint {
pub fpr: f64,
pub tpr: f64,
pub threshold: f64,
}
pub struct RocComputed {
pub points: Vec<RocPoint>,
pub auc: f64,
pub pauc: Option<f64>,
pub ci_lo: f64,
pub ci_hi: f64,
pub optimal_idx: Option<usize>,
}
pub fn compute_roc_points(predictions: &[(f64, bool)]) -> Vec<RocPoint> {
if predictions.is_empty() {
return Vec::new();
}
let mut sorted = predictions.to_vec();
sorted.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
let n_pos = sorted.iter().filter(|p| p.1).count();
let n_neg = sorted.len() - n_pos;
if n_pos == 0 || n_neg == 0 {
return Vec::new();
}
let mut points = vec![RocPoint {
fpr: 0.0,
tpr: 0.0,
threshold: f64::INFINITY,
}];
let mut tp = 0usize;
let mut fp = 0usize;
let mut i = 0usize;
while i < sorted.len() {
let thresh = sorted[i].0;
while i < sorted.len() && (sorted[i].0 - thresh).abs() < f64::EPSILON * 100.0 {
if sorted[i].1 {
tp += 1;
} else {
fp += 1;
}
i += 1;
}
points.push(RocPoint {
fpr: fp as f64 / n_neg as f64,
tpr: tp as f64 / n_pos as f64,
threshold: thresh,
});
}
let last = points.last().unwrap();
if (last.fpr - 1.0).abs() > 1e-9 || (last.tpr - 1.0).abs() > 1e-9 {
points.push(RocPoint {
fpr: 1.0,
tpr: 1.0,
threshold: f64::NEG_INFINITY,
});
}
points
}
pub fn auc_trapz(points: &[RocPoint]) -> f64 {
let mut auc = 0.0;
for w in points.windows(2) {
let dx = w[1].fpr - w[0].fpr;
let dy = (w[1].tpr + w[0].tpr) / 2.0;
auc += dx * dy;
}
auc.abs()
}
pub fn delong_auc(predictions: &[(f64, bool)]) -> (f64, f64) {
let pos: Vec<f64> = predictions.iter().filter(|p| p.1).map(|p| p.0).collect();
let neg: Vec<f64> = predictions.iter().filter(|p| !p.1).map(|p| p.0).collect();
let n_pos = pos.len();
let n_neg = neg.len();
if n_pos == 0 || n_neg == 0 {
return (0.5, 0.0);
}
let v10: Vec<f64> = pos
.iter()
.map(|&s| {
let less = neg.iter().filter(|&&n| n < s).count();
let tied = neg
.iter()
.filter(|&&n| (n - s).abs() < f64::EPSILON * 100.0)
.count();
(less as f64 + 0.5 * tied as f64) / n_neg as f64
})
.collect();
let v01: Vec<f64> = neg
.iter()
.map(|&s| {
let greater = pos.iter().filter(|&&p| p > s).count();
let tied = pos
.iter()
.filter(|&&p| (p - s).abs() < f64::EPSILON * 100.0)
.count();
(greater as f64 + 0.5 * tied as f64) / n_pos as f64
})
.collect();
let auc = v10.iter().sum::<f64>() / n_pos as f64;
let var10 = variance(&v10);
let var01 = variance(&v01);
let auc_var = var10 / n_pos as f64 + var01 / n_neg as f64;
(auc, auc_var)
}
fn variance(v: &[f64]) -> f64 {
if v.len() < 2 {
return 0.0;
}
let mean = v.iter().sum::<f64>() / v.len() as f64;
v.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (v.len() - 1) as f64
}
pub fn partial_auc(points: &[RocPoint], fpr_lo: f64, fpr_hi: f64) -> f64 {
let mut clipped: Vec<(f64, f64)> = Vec::new();
for w in points.windows(2) {
let (f0, t0) = (w[0].fpr, w[0].tpr);
let (f1, t1) = (w[1].fpr, w[1].tpr);
if f1 <= fpr_lo || f0 >= fpr_hi {
continue;
}
let fa = f0.max(fpr_lo);
let fb = f1.min(fpr_hi);
let interp = |f: f64| -> f64 {
if (f1 - f0).abs() < 1e-12 {
t0
} else {
t0 + (t1 - t0) * (f - f0) / (f1 - f0)
}
};
clipped.push((fa, interp(fa)));
clipped.push((fb, interp(fb)));
}
clipped.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-12);
let raw: f64 = clipped
.windows(2)
.map(|w| (w[1].0 - w[0].0) * (w[0].1 + w[1].1) / 2.0)
.sum();
let width = fpr_hi - fpr_lo;
if width > 0.0 {
raw / width
} else {
0.0
}
}
pub(crate) fn optimal_threshold_idx(points: &[RocPoint]) -> usize {
points
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| {
let ja = a.tpr - a.fpr;
let jb = b.tpr - b.fpr;
ja.partial_cmp(&jb).unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(i, _)| i)
.unwrap_or(0)
}
pub fn compute_group(group: &RocGroup) -> RocComputed {
let points = if let Some(raw) = &group.raw_predictions {
compute_roc_points(raw)
} else if let Some(pts) = &group.precomputed_points {
pts.iter()
.map(|&(f, t)| RocPoint {
fpr: f,
tpr: t,
threshold: f64::NAN,
})
.collect()
} else {
Vec::new()
};
if points.is_empty() {
return RocComputed {
points,
auc: 0.0,
pauc: None,
ci_lo: 0.0,
ci_hi: 0.0,
optimal_idx: None,
};
}
let (auc, ci_lo, ci_hi) = if let Some(raw) = &group.raw_predictions {
let (a, var) = delong_auc(raw);
let margin = 1.96 * var.sqrt();
(a, (a - margin).max(0.0), (a + margin).min(1.0))
} else {
let a = auc_trapz(&points);
(a, f64::NAN, f64::NAN)
};
let pauc = group
.pauc_range
.map(|(lo, hi)| partial_auc(&points, lo, hi));
let optimal_idx = if group.show_optimal_point {
Some(optimal_threshold_idx(&points))
} else {
None
};
RocComputed {
points,
auc,
pauc,
ci_lo,
ci_hi,
optimal_idx,
}
}