use crate::{AutodiffError, Tensor};
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Metrics {
Accuracy,
Precision,
Recall,
F1,
ConfusionMatrix,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq)]
pub struct MetricsResult {
pub num_classes: usize,
pub accuracy: Option<f32>,
pub precision: Option<f32>,
pub recall: Option<f32>,
pub f1: Option<f32>,
pub confusion_matrix: Option<Vec<u64>>,
}
impl MetricsResult {
pub fn compute(
metrics: &[Metrics],
logits: &Tensor<f32>,
target: &Tensor<i32>,
) -> Result<MetricsResult, AutodiffError> {
let logits_shape = logits.shape();
if logits_shape.len() != 2 {
return Err(AutodiffError::InvalidArgument(format!(
"MetricsResult::compute: logits の shape は rank 2 [N, C] である必要がある \
(実際は {logits_shape:?})"
)));
}
let n = logits_shape[0];
let num_classes = logits_shape[1];
let target_shape = target.shape();
if target_shape != [n] {
return Err(AutodiffError::InvalidArgument(format!(
"MetricsResult::compute: target の shape は [{n}] である必要がある \
(実際は {target_shape:?})"
)));
}
if n == 0 {
return Err(AutodiffError::InvalidArgument(
"MetricsResult::compute: logits/target のサンプル数が 0".to_string(),
));
}
let tape = crate::tape();
let logits_var = tape.var_no_grad(logits);
let pred_classes = logits_var.argmax(Some(1))?;
let mut acc = ConfusionAccumulator::new(num_classes)?;
acc.observe(&pred_classes, target)?;
acc.finish(metrics)
}
}
pub(super) struct ConfusionAccumulator {
num_classes: usize,
counts: Vec<u64>,
total: u64,
}
impl ConfusionAccumulator {
pub(super) fn new(num_classes: usize) -> Result<Self, AutodiffError> {
if num_classes == 0 {
return Err(AutodiffError::InvalidArgument(
"MetricsResult: logits のクラス数(shape[1])が 0".to_string(),
));
}
let cells = num_classes.checked_mul(num_classes).ok_or_else(|| {
AutodiffError::InvalidArgument(format!(
"MetricsResult: num_classes={num_classes} の混同行列サイズが usize で表現できない"
))
})?;
let mut counts = Vec::new();
counts
.try_reserve_exact(cells)
.map_err(|_| super::alloc_failed())?;
counts.resize(cells, 0u64);
Ok(ConfusionAccumulator {
num_classes,
counts,
total: 0,
})
}
pub(super) fn observe(
&mut self,
pred_classes: &Tensor<i32>,
targets: &Tensor<i32>,
) -> Result<(), AutodiffError> {
let pred_dense = pred_classes.contiguous();
let target_dense = targets.contiguous();
let pred_slice = pred_dense.as_slice().ok_or_else(|| {
AutodiffError::InvalidArgument(
"MetricsResult: 予測クラステンソルの実体化に失敗した(contiguous() 直後)"
.to_string(),
)
})?;
let target_slice = target_dense.as_slice().ok_or_else(|| {
AutodiffError::InvalidArgument(
"MetricsResult: target テンソルの実体化に失敗した(contiguous() 直後)".to_string(),
)
})?;
if pred_slice.len() != target_slice.len() {
return Err(AutodiffError::InvalidArgument(format!(
"MetricsResult: 予測クラス件数 ({}) != target 件数 ({})",
pred_slice.len(),
target_slice.len()
)));
}
for (&pred, &tgt) in pred_slice.iter().zip(target_slice.iter()) {
if tgt < 0 || (tgt as usize) >= self.num_classes {
return Err(AutodiffError::InvalidArgument(format!(
"MetricsResult: target 添字 {tgt} が範囲 [0, {}) を外れている",
self.num_classes
)));
}
if pred < 0 || (pred as usize) >= self.num_classes {
return Err(AutodiffError::InvalidArgument(format!(
"MetricsResult: 予測クラス添字 {pred} が範囲 [0, {}) を外れている\
(Var::argmax の契約違反)",
self.num_classes
)));
}
let row = tgt as usize;
let col = pred as usize;
self.counts[row * self.num_classes + col] += 1;
self.total += 1;
}
Ok(())
}
pub(super) fn finish(&self, metrics: &[Metrics]) -> Result<MetricsResult, AutodiffError> {
let want = |m: Metrics| metrics.contains(&m);
let c = self.num_classes;
let mut tp = vec![0f64; c];
let mut fp = vec![0f64; c];
let mut fn_ = vec![0f64; c];
let mut correct = 0f64;
for row in 0..c {
for (col, fp_col) in fp.iter_mut().enumerate() {
let count = self.counts[row * c + col] as f64;
if row == col {
tp[row] += count;
correct += count;
} else {
fn_[row] += count;
*fp_col += count;
}
}
}
let accuracy = want(Metrics::Accuracy).then(|| {
let total = self.total.max(1) as f64;
(correct / total) as f32
});
let need_precision = want(Metrics::Precision);
let need_recall = want(Metrics::Recall);
let need_f1 = want(Metrics::F1);
let mut precision_sum = 0f64;
let mut recall_sum = 0f64;
let mut f1_sum = 0f64;
if need_precision || need_recall || need_f1 {
for k in 0..c {
let p_denom = tp[k] + fp[k];
let p_k = if p_denom > 0.0 { tp[k] / p_denom } else { 0.0 };
let r_denom = tp[k] + fn_[k];
let r_k = if r_denom > 0.0 { tp[k] / r_denom } else { 0.0 };
precision_sum += p_k;
recall_sum += r_k;
let f1_denom = p_k + r_k;
f1_sum += if f1_denom > 0.0 {
2.0 * p_k * r_k / f1_denom
} else {
0.0
};
}
}
let classes = c.max(1) as f64;
let precision = need_precision.then(|| (precision_sum / classes) as f32);
let recall = need_recall.then(|| (recall_sum / classes) as f32);
let f1 = need_f1.then(|| (f1_sum / classes) as f32);
let confusion_matrix = want(Metrics::ConfusionMatrix).then(|| self.counts.clone());
Ok(MetricsResult {
num_classes: c,
accuracy,
precision,
recall,
f1,
confusion_matrix,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn build_fixture() -> ConfusionAccumulator {
let preds = crate::Tensor::new(vec![0i32, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1], &[11]).unwrap();
let targets = crate::Tensor::new(vec![0i32, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1], &[11]).unwrap();
let mut acc = ConfusionAccumulator::new(3).unwrap();
acc.observe(&preds, &targets).unwrap();
acc
}
#[test]
fn accuracy_matches_hand_computation() {
let acc = build_fixture();
let result = acc
.finish(&[Metrics::Accuracy])
.expect("test fixture: finish は成功するはず");
assert_eq!(result.accuracy, Some(8.0 / 11.0));
assert_eq!(result.precision, None);
assert_eq!(result.recall, None);
assert_eq!(result.f1, None);
assert_eq!(result.confusion_matrix, None);
}
#[test]
fn macro_precision_recall_match_hand_computation() {
let acc = build_fixture();
let result = acc
.finish(&[Metrics::Precision, Metrics::Recall])
.expect("test fixture: finish は成功するはず");
let expected_precision = ((5.0 / 7.0) + (3.0 / 4.0) + 0.0) / 3.0;
let expected_recall = ((5.0 / 6.0) + (3.0 / 5.0) + 0.0) / 3.0;
assert!((result.precision.unwrap() - expected_precision as f32).abs() < 1e-6);
assert!((result.recall.unwrap() - expected_recall as f32).abs() < 1e-6);
}
#[test]
fn macro_f1_is_mean_of_per_class_f1_not_harmonic_mean_of_macro_pr() {
let acc = build_fixture();
let result = acc
.finish(&[Metrics::Precision, Metrics::Recall, Metrics::F1])
.expect("test fixture: finish は成功するはず");
let p0 = 5.0 / 7.0_f64;
let r0 = 5.0 / 6.0_f64;
let f1_0 = 2.0 * p0 * r0 / (p0 + r0);
let p1 = 3.0 / 4.0_f64;
let r1 = 3.0 / 5.0_f64;
let f1_1 = 2.0 * p1 * r1 / (p1 + r1);
let expected_macro_f1 = (f1_0 + f1_1 + 0.0) / 3.0;
let macro_p = result.precision.unwrap() as f64;
let macro_r = result.recall.unwrap() as f64;
let harmonic_of_macro = 2.0 * macro_p * macro_r / (macro_p + macro_r);
assert!((result.f1.unwrap() as f64 - expected_macro_f1).abs() < 1e-6);
assert!((expected_macro_f1 - harmonic_of_macro).abs() > 1e-4);
}
#[test]
fn confusion_matrix_layout_is_row_true_col_pred_and_only_requested_when_asked() {
let acc = build_fixture();
let result = acc
.finish(&[Metrics::ConfusionMatrix])
.expect("test fixture: finish は成功するはず");
let cm = result.confusion_matrix.expect("要求したので Some のはず");
assert_eq!(cm, vec![5, 1, 0, 2, 3, 0, 0, 0, 0]);
assert_eq!(result.accuracy, None);
}
#[test]
fn compute_rejects_non_rank2_logits() {
let logits = crate::Tensor::<f32>::new(vec![1.0, 2.0, 3.0], &[3]).unwrap();
let target = crate::Tensor::<i32>::new(vec![0], &[1]).unwrap();
let err = MetricsResult::compute(&[Metrics::Accuracy], &logits, &target).unwrap_err();
assert!(matches!(err, AutodiffError::InvalidArgument(_)));
}
#[test]
fn compute_rejects_target_shape_mismatch() {
let logits = crate::Tensor::<f32>::new(vec![1.0, 2.0, 3.0, 4.0], &[2, 2]).unwrap();
let target = crate::Tensor::<i32>::new(vec![0, 1, 0], &[3]).unwrap();
let err = MetricsResult::compute(&[Metrics::Accuracy], &logits, &target).unwrap_err();
assert!(matches!(err, AutodiffError::InvalidArgument(_)));
}
#[test]
fn compute_rejects_out_of_range_target_index() {
let logits = crate::Tensor::<f32>::new(vec![1.0, 2.0, 3.0, 4.0], &[2, 2]).unwrap();
let target = crate::Tensor::<i32>::new(vec![0, 2], &[2]).unwrap();
let err = MetricsResult::compute(&[Metrics::Accuracy], &logits, &target).unwrap_err();
assert!(matches!(err, AutodiffError::InvalidArgument(_)));
}
#[test]
fn compute_rejects_zero_samples() {
let logits = crate::Tensor::<f32>::new(Vec::<f32>::new(), &[0, 2]).unwrap();
let target = crate::Tensor::<i32>::new(Vec::<i32>::new(), &[0]).unwrap();
let err = MetricsResult::compute(&[Metrics::Accuracy], &logits, &target).unwrap_err();
assert!(matches!(err, AutodiffError::InvalidArgument(_)));
}
#[test]
fn compute_end_to_end_matches_manual_argmax_accuracy() {
let logits = crate::Tensor::<f32>::new(vec![0.1, 0.9, 0.8, 0.2], &[2, 2]).unwrap();
let target = crate::Tensor::<i32>::new(vec![1, 0], &[2]).unwrap();
let result = MetricsResult::compute(&[Metrics::Accuracy], &logits, &target)
.expect("test fixture: compute は成功するはず");
assert_eq!(result.accuracy, Some(1.0));
}
#[test]
#[cfg(target_pointer_width = "64")]
fn new_rejects_huge_num_classes_without_panicking() {
match ConfusionAccumulator::new(1usize << 31) {
Ok(_) => panic!("test fixture: 確保失敗を期待したが成功した"),
Err(err) => assert!(matches!(
err,
AutodiffError::Shape(crate::ShapeError::ElementCountOverflow)
)),
}
}
}