use crate::explain::helpers::{clone_scores_matrix, shuffle_global};
use crate::jfpca_model::{jfpca_fit, JfpcaModel, JfpcaTransform};
use crate::matrix::FdMatrix;
use crate::FdarError;
use rand::prelude::*;
use std::sync::Arc;
#[non_exhaustive]
pub enum PfiMetric {
Mse,
Mae,
Accuracy,
Custom(Arc<dyn Fn(&[f64], &[f64]) -> f64 + Send + Sync>),
}
impl std::fmt::Debug for PfiMetric {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PfiMetric::Mse => write!(f, "Mse"),
PfiMetric::Mae => write!(f, "Mae"),
PfiMetric::Accuracy => write!(f, "Accuracy"),
PfiMetric::Custom(_) => write!(f, "Custom(<closure>)"),
}
}
}
impl Clone for PfiMetric {
fn clone(&self) -> Self {
match self {
PfiMetric::Mse => PfiMetric::Mse,
PfiMetric::Mae => PfiMetric::Mae,
PfiMetric::Accuracy => PfiMetric::Accuracy,
PfiMetric::Custom(f) => PfiMetric::Custom(Arc::clone(f)),
}
}
}
impl PartialEq for PfiMetric {
fn eq(&self, other: &Self) -> bool {
matches!(
(self, other),
(PfiMetric::Mse, PfiMetric::Mse)
| (PfiMetric::Mae, PfiMetric::Mae)
| (PfiMetric::Accuracy, PfiMetric::Accuracy)
)
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ElasticPfiResult {
pub importance: Vec<f64>,
pub baseline_metric: f64,
pub permuted_metric: Vec<f64>,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct VeesaPipelineResult {
pub model: JfpcaModel,
pub training_scores: JfpcaTransform,
pub pfi: ElasticPfiResult,
}
fn compute_metric(y: &[f64], pred: &[f64], metric: &PfiMetric) -> f64 {
let n = y.len();
if n == 0 {
return 0.0;
}
match metric {
PfiMetric::Mse => {
y.iter()
.zip(pred.iter())
.map(|(&a, &b)| (a - b).powi(2))
.sum::<f64>()
/ n as f64
}
PfiMetric::Mae => {
y.iter()
.zip(pred.iter())
.map(|(&a, &b)| (a - b).abs())
.sum::<f64>()
/ n as f64
}
PfiMetric::Accuracy => {
y.iter()
.zip(pred.iter())
.filter(|(&a, &b)| (a - b.round()).abs() < 1e-10)
.count() as f64
/ n as f64
}
PfiMetric::Custom(f) => f(y, pred),
}
}
#[must_use = "expensive computation whose result should not be discarded"]
pub fn elastic_pfi(
scores: &FdMatrix,
y: &[f64],
predict: impl Fn(&FdMatrix) -> Vec<f64>,
metric: &PfiMetric,
n_repeats: usize,
seed: u64,
) -> Result<ElasticPfiResult, FdarError> {
let (n, ncomp) = scores.shape();
if n == 0 || ncomp == 0 {
return Err(FdarError::InvalidDimension {
parameter: "scores",
expected: "at least 1 row and 1 column".to_string(),
actual: format!("({}, {})", n, ncomp),
});
}
if n != y.len() {
return Err(FdarError::InvalidDimension {
parameter: "y",
expected: format!("length {} (== scores.nrows())", n),
actual: format!("length {}", y.len()),
});
}
if n_repeats == 0 {
return Err(FdarError::InvalidParameter {
parameter: "n_repeats",
message: "must be >= 1 (zero repeats produce no permutation samples)".to_string(),
});
}
let baseline_pred = predict(scores);
if baseline_pred.len() != n {
return Err(FdarError::InvalidDimension {
parameter: "predict() output",
expected: format!("length {} (== scores.nrows())", n),
actual: format!("length {}", baseline_pred.len()),
});
}
let baseline_metric = compute_metric(y, &baseline_pred, metric);
let mut rng = StdRng::seed_from_u64(seed);
let mut importance = vec![0.0; ncomp];
let mut permuted_metric = vec![0.0; ncomp];
for k in 0..ncomp {
let mut sum_metric = 0.0;
for _ in 0..n_repeats {
let mut perm = clone_scores_matrix(scores, n, ncomp);
shuffle_global(&mut perm, scores, k, n, &mut rng);
let pred = predict(&perm);
sum_metric += compute_metric(y, &pred, metric);
}
let mean_perm = sum_metric / n_repeats as f64;
permuted_metric[k] = mean_perm;
importance[k] = baseline_metric - mean_perm;
}
Ok(ElasticPfiResult {
importance,
baseline_metric,
permuted_metric,
})
}
#[must_use = "expensive computation whose result should not be discarded"]
pub fn veesa_pipeline(
data: &FdMatrix,
argvals: &[f64],
ncomp: usize,
balance_c: Option<f64>,
lambda: f64,
max_iter: usize,
y: &[f64],
predict: impl Fn(&FdMatrix) -> Vec<f64>,
metric: &PfiMetric,
n_repeats: usize,
seed: u64,
) -> Result<VeesaPipelineResult, FdarError> {
let model = jfpca_fit(data, argvals, ncomp, balance_c, lambda, max_iter)?;
let training_scores = model.score_training()?;
let pfi = elastic_pfi(&training_scores.scores, y, predict, metric, n_repeats, seed)?;
Ok(VeesaPipelineResult {
model,
training_scores,
pfi,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::f64::consts::PI;
fn spanning_fixture(n: usize, m: usize) -> (FdMatrix, Vec<f64>) {
let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
let mut data = FdMatrix::zeros(n, m);
for i in 0..n {
let fi = i as f64;
let a1 = 1.0 + 0.4 * fi;
let a2 = 0.6 - 0.08 * fi;
let a3 = 0.35 + 0.07 * fi;
let a4 = 0.2 - 0.03 * fi;
for j in 0..m {
let t = argvals[j];
data[(i, j)] = a1 * (2.0 * PI * t).sin()
+ a2 * (4.0 * PI * t).cos()
+ a3 * (6.0 * PI * t).sin()
+ a4 * (8.0 * PI * t).cos();
}
}
(data, argvals)
}
#[test]
fn smoke_veesa_pipeline() {
let n = 10usize;
let m = 14usize;
let (data, argvals) = spanning_fixture(n, m);
let y: Vec<f64> = (0..n).map(|i| i as f64).collect();
let result = veesa_pipeline(
&data,
&argvals,
3,
None,
0.0,
10,
&y,
|s: &FdMatrix| -> Vec<f64> { (0..n).map(|i| s[(i, 0)]).collect() },
&PfiMetric::Mse,
5,
42,
)
.expect("veesa_pipeline should succeed on spanning fixture");
assert_eq!(
result.pfi.importance.len(),
result.model.ncomp,
"importance length should equal ncomp"
);
assert!(
!result.pfi.importance.is_empty(),
"importance should be non-empty"
);
}
#[test]
fn pfi_seed_determinism() {
let n = 10usize;
let m = 14usize;
let (data, argvals) = spanning_fixture(n, m);
let y: Vec<f64> = (0..n).map(|i| i as f64).collect();
let model = jfpca_fit(&data, &argvals, 3, None, 0.0, 10).expect("jfpca_fit should succeed");
let tr = model
.score_training()
.expect("score_training should succeed");
let run1 = elastic_pfi(
&tr.scores,
&y,
|s: &FdMatrix| -> Vec<f64> { (0..n).map(|i| s[(i, 0)]).collect() },
&PfiMetric::Mse,
10,
42,
)
.expect("elastic_pfi run 1 should succeed");
let run2 = elastic_pfi(
&tr.scores,
&y,
|s: &FdMatrix| -> Vec<f64> { (0..n).map(|i| s[(i, 0)]).collect() },
&PfiMetric::Mse,
10,
42,
)
.expect("elastic_pfi run 2 should succeed");
assert_eq!(
run1.importance, run2.importance,
"two runs with the same seed must produce identical importance vectors"
);
}
#[test]
fn pfi_known_signal_ranking() {
let n = 10usize;
let m = 14usize;
let (data, argvals) = spanning_fixture(n, m);
let model = jfpca_fit(&data, &argvals, 3, None, 0.0, 10).expect("jfpca_fit should succeed");
let tr = model
.score_training()
.expect("score_training should succeed");
let y: Vec<f64> = (0..n).map(|i| tr.scores[(i, 0)] * 2.0).collect();
let result = elastic_pfi(
&tr.scores,
&y,
|s: &FdMatrix| -> Vec<f64> { (0..n).map(|i| s[(i, 0)] * 2.0).collect() },
&PfiMetric::Custom(Arc::new(|y_true: &[f64], y_pred: &[f64]| {
let n = y_true.len() as f64;
let mse = y_true
.iter()
.zip(y_pred.iter())
.map(|(&a, &b)| (a - b).powi(2))
.sum::<f64>()
/ n;
-mse })),
20,
42,
)
.expect("elastic_pfi should succeed on known-signal design");
assert!(
result.importance[0] > result.importance[1],
"PC 0 importance ({}) must be strictly above PC 1 importance ({})",
result.importance[0],
result.importance[1]
);
assert!(
result.importance[0] > result.importance[2],
"PC 0 importance ({}) must be strictly above PC 2 importance ({})",
result.importance[0],
result.importance[2]
);
}
#[test]
fn pfi_rejects_zero_repeats() {
let n = 8usize;
let m = 12usize;
let (data, argvals) = spanning_fixture(n, m);
let y: Vec<f64> = vec![0.0; n];
let model = jfpca_fit(&data, &argvals, 2, None, 0.0, 10).expect("jfpca_fit should succeed");
let tr = model
.score_training()
.expect("score_training should succeed");
let res = elastic_pfi(
&tr.scores,
&y,
|s: &FdMatrix| -> Vec<f64> { vec![0.0; s.nrows()] },
&PfiMetric::Mse,
0, 42,
);
assert!(
matches!(res, Err(FdarError::InvalidParameter { .. })),
"n_repeats=0 should return Err(InvalidParameter), got: {:?}",
res
);
}
}