Skip to main content

fdars_core/explain/
shap.rs

1//! SHAP values and Friedman H-statistic.
2
3use super::helpers::{
4    accumulate_kernel_shap_sample, build_coalition_scores, compute_column_means, compute_h_squared,
5    compute_mean_scalar, get_obs_scalar, logistic_pdp_mean, make_grid, project_scores,
6    sample_random_coalition, shapley_kernel_weight, solve_kernel_shap_obs,
7};
8use crate::error::FdarError;
9use crate::matrix::FdMatrix;
10use crate::scalar_on_function::{sigmoid, FregreLmResult, FunctionalLogisticResult};
11use rand::prelude::*;
12
13// ===========================================================================
14// SHAP Values (FPC-level)
15// ===========================================================================
16
17/// FPC-level SHAP values for model interpretability.
18#[derive(Debug, Clone, PartialEq)]
19pub struct FpcShapValues {
20    /// SHAP values (n x ncomp).
21    pub values: FdMatrix,
22    /// Base value (mean prediction).
23    pub base_value: f64,
24    /// Mean FPC scores (length ncomp).
25    pub mean_scores: Vec<f64>,
26}
27
28/// Exact SHAP values for a linear functional regression model.
29///
30/// For linear models, SHAP values are exact: `values[(i,k)] = coef[1+k] * (score_i_k - mean_k)`.
31/// The efficiency property holds: `base_value + sum_k values[(i,k)] ~ fitted_values[i]`
32/// (with scalar covariate effects absorbed into the base value).
33///
34/// # Errors
35///
36/// Returns [`FdarError::InvalidDimension`] if `data` has zero rows or its column
37/// count does not match `fit.fpca.mean`.
38/// Returns [`FdarError::InvalidParameter`] if `fit.ncomp` is zero.
39///
40/// # Examples
41///
42/// ```
43/// use fdars_core::matrix::FdMatrix;
44/// use fdars_core::scalar_on_function::fregre_lm;
45/// use fdars_core::explain::fpc_shap_values;
46///
47/// let (n, m) = (20, 30);
48/// let data = FdMatrix::from_column_major(
49///     (0..n * m).map(|k| {
50///         let i = (k % n) as f64;
51///         let j = (k / n) as f64;
52///         ((i + 1.0) * j * 0.2).sin()
53///     }).collect(),
54///     n, m,
55/// ).unwrap();
56/// let y: Vec<f64> = (0..n).map(|i| (i as f64 * 0.5).sin()).collect();
57/// let fit = fregre_lm(&data, &y, None, 3).unwrap();
58/// let shap = fpc_shap_values(&fit, &data, None).unwrap();
59/// assert_eq!(shap.values.shape(), (20, 3));
60/// ```
61#[must_use = "expensive computation whose result should not be discarded"]
62pub fn fpc_shap_values(
63    fit: &FregreLmResult,
64    data: &FdMatrix,
65    scalar_covariates: Option<&FdMatrix>,
66) -> Result<FpcShapValues, FdarError> {
67    let (n, m) = data.shape();
68    if n == 0 {
69        return Err(FdarError::InvalidDimension {
70            parameter: "data",
71            expected: ">0 rows".into(),
72            actual: "0".into(),
73        });
74    }
75    if m != fit.fpca.mean.len() {
76        return Err(FdarError::InvalidDimension {
77            parameter: "data",
78            expected: format!("{} columns", fit.fpca.mean.len()),
79            actual: format!("{m}"),
80        });
81    }
82    let ncomp = fit.ncomp;
83    if ncomp == 0 {
84        return Err(FdarError::InvalidParameter {
85            parameter: "ncomp",
86            message: "must be > 0".into(),
87        });
88    }
89    let scores = project_scores(
90        data,
91        &fit.fpca.mean,
92        &fit.fpca.rotation,
93        ncomp,
94        &fit.fpca.weights,
95    );
96    let mean_scores = compute_column_means(&scores, ncomp);
97
98    let mut base_value = fit.intercept;
99    for k in 0..ncomp {
100        base_value += fit.coefficients[1 + k] * mean_scores[k];
101    }
102    let p_scalar = fit.gamma.len();
103    let mean_z = compute_mean_scalar(scalar_covariates, p_scalar, n);
104    for j in 0..p_scalar {
105        base_value += fit.gamma[j] * mean_z[j];
106    }
107
108    let mut values = FdMatrix::zeros(n, ncomp);
109    for i in 0..n {
110        for k in 0..ncomp {
111            values[(i, k)] = fit.coefficients[1 + k] * (scores[(i, k)] - mean_scores[k]);
112        }
113    }
114
115    Ok(FpcShapValues {
116        values,
117        base_value,
118        mean_scores,
119    })
120}
121
122/// Kernel SHAP values for a functional logistic regression model.
123///
124/// Uses sampling-based Kernel SHAP approximation since the logistic link is nonlinear.
125///
126/// # Errors
127///
128/// Returns [`FdarError::InvalidDimension`] if `data` has zero rows or its column
129/// count does not match `fit.fpca.mean`.
130/// Returns [`FdarError::InvalidParameter`] if `n_samples` is zero or `fit.ncomp`
131/// is zero.
132#[must_use = "expensive computation whose result should not be discarded"]
133pub fn fpc_shap_values_logistic(
134    fit: &FunctionalLogisticResult,
135    data: &FdMatrix,
136    scalar_covariates: Option<&FdMatrix>,
137    n_samples: usize,
138    seed: u64,
139) -> Result<FpcShapValues, FdarError> {
140    let (n, m) = data.shape();
141    if n == 0 {
142        return Err(FdarError::InvalidDimension {
143            parameter: "data",
144            expected: ">0 rows".into(),
145            actual: "0".into(),
146        });
147    }
148    if m != fit.fpca.mean.len() {
149        return Err(FdarError::InvalidDimension {
150            parameter: "data",
151            expected: format!("{} columns", fit.fpca.mean.len()),
152            actual: format!("{m}"),
153        });
154    }
155    if n_samples == 0 {
156        return Err(FdarError::InvalidParameter {
157            parameter: "n_samples",
158            message: "must be > 0".into(),
159        });
160    }
161    let ncomp = fit.ncomp;
162    if ncomp == 0 {
163        return Err(FdarError::InvalidParameter {
164            parameter: "ncomp",
165            message: "must be > 0".into(),
166        });
167    }
168    let p_scalar = fit.gamma.len();
169    let scores = project_scores(
170        data,
171        &fit.fpca.mean,
172        &fit.fpca.rotation,
173        ncomp,
174        &fit.fpca.weights,
175    );
176    let mean_scores = compute_column_means(&scores, ncomp);
177    let mean_z = compute_mean_scalar(scalar_covariates, p_scalar, n);
178
179    let predict_proba = |obs_scores: &[f64], obs_z: &[f64]| -> f64 {
180        let mut eta = fit.intercept;
181        for k in 0..ncomp {
182            eta += fit.coefficients[1 + k] * obs_scores[k];
183        }
184        for j in 0..p_scalar {
185            eta += fit.gamma[j] * obs_z[j];
186        }
187        sigmoid(eta)
188    };
189
190    let base_value = predict_proba(&mean_scores, &mean_z);
191    let mut values = FdMatrix::zeros(n, ncomp);
192    // Phase-49 CONS-02: NOT migrated to helpers::seed_for_thread — this is a single
193    // plain-`seed` RNG advanced sequentially across all observations, not the
194    // per-thread `seed + k` offset contract. Reseeding per observation would change
195    // the coalition-sampling stream and thus the SHAP values.
196    let mut rng = StdRng::seed_from_u64(seed);
197
198    for i in 0..n {
199        let obs_scores: Vec<f64> = (0..ncomp).map(|k| scores[(i, k)]).collect();
200        let obs_z = get_obs_scalar(scalar_covariates, i, p_scalar, &mean_z);
201
202        let mut ata = vec![0.0; ncomp * ncomp];
203        let mut atb = vec![0.0; ncomp];
204
205        for _ in 0..n_samples {
206            let (coalition, s_size) = sample_random_coalition(&mut rng, ncomp);
207            let weight = shapley_kernel_weight(ncomp, s_size);
208            let coal_scores = build_coalition_scores(&coalition, &obs_scores, &mean_scores);
209
210            let f_coal = predict_proba(&coal_scores, &obs_z);
211            let f_base = predict_proba(&mean_scores, &obs_z);
212            let y_val = f_coal - f_base;
213
214            accumulate_kernel_shap_sample(&mut ata, &mut atb, &coalition, weight, y_val, ncomp);
215        }
216
217        solve_kernel_shap_obs(&mut ata, &atb, ncomp, &mut values, i);
218    }
219
220    Ok(FpcShapValues {
221        values,
222        base_value,
223        mean_scores,
224    })
225}
226
227// ===========================================================================
228// Friedman H-statistic
229// ===========================================================================
230
231/// Result of the Friedman H-statistic for interaction between two FPC components.
232#[derive(Debug, Clone, PartialEq)]
233#[non_exhaustive]
234pub struct FriedmanHResult {
235    /// First component index.
236    pub component_j: usize,
237    /// Second component index.
238    pub component_k: usize,
239    /// Interaction strength H^2.
240    pub h_squared: f64,
241    /// Grid values for component j.
242    pub grid_j: Vec<f64>,
243    /// Grid values for component k.
244    pub grid_k: Vec<f64>,
245    /// 2D partial dependence surface (n_grid x n_grid).
246    pub pdp_2d: FdMatrix,
247}
248
249/// Friedman H-statistic for interaction between two FPC components (linear model).
250///
251/// # Errors
252///
253/// Returns [`FdarError::InvalidParameter`] if `component_j == component_k`,
254/// `n_grid < 2`, or either component index is `>= fit.ncomp`.
255/// Returns [`FdarError::InvalidDimension`] if `data` has zero rows or its column
256/// count does not match `fit.fpca.mean`.
257#[must_use = "expensive computation whose result should not be discarded"]
258pub fn friedman_h_statistic(
259    fit: &FregreLmResult,
260    data: &FdMatrix,
261    component_j: usize,
262    component_k: usize,
263    n_grid: usize,
264) -> Result<FriedmanHResult, FdarError> {
265    if component_j == component_k {
266        return Err(FdarError::InvalidParameter {
267            parameter: "component_j/component_k",
268            message: "must be different".into(),
269        });
270    }
271    let (n, m) = data.shape();
272    if n == 0 {
273        return Err(FdarError::InvalidDimension {
274            parameter: "data",
275            expected: ">0 rows".into(),
276            actual: "0".into(),
277        });
278    }
279    if m != fit.fpca.mean.len() {
280        return Err(FdarError::InvalidDimension {
281            parameter: "data",
282            expected: format!("{} columns", fit.fpca.mean.len()),
283            actual: format!("{m}"),
284        });
285    }
286    if n_grid < 2 {
287        return Err(FdarError::InvalidParameter {
288            parameter: "n_grid",
289            message: "must be >= 2".into(),
290        });
291    }
292    if component_j >= fit.ncomp || component_k >= fit.ncomp {
293        return Err(FdarError::InvalidParameter {
294            parameter: "component",
295            message: format!(
296                "component_j={} or component_k={} >= ncomp={}",
297                component_j, component_k, fit.ncomp
298            ),
299        });
300    }
301    let ncomp = fit.ncomp;
302    let scores = project_scores(
303        data,
304        &fit.fpca.mean,
305        &fit.fpca.rotation,
306        ncomp,
307        &fit.fpca.weights,
308    );
309
310    let grid_j = make_grid(&scores, component_j, n_grid);
311    let grid_k = make_grid(&scores, component_k, n_grid);
312    let coefs = &fit.coefficients;
313
314    let pdp_j = pdp_1d_linear(&scores, coefs, ncomp, component_j, &grid_j, n);
315    let pdp_k = pdp_1d_linear(&scores, coefs, ncomp, component_k, &grid_k, n);
316    let pdp_2d = pdp_2d_linear(
317        &scores,
318        coefs,
319        ncomp,
320        component_j,
321        component_k,
322        &grid_j,
323        &grid_k,
324        n,
325        n_grid,
326    );
327
328    let f_bar: f64 = fit.fitted_values.iter().sum::<f64>() / n as f64;
329    let h_squared = compute_h_squared(&pdp_2d, &pdp_j, &pdp_k, f_bar, n_grid);
330
331    Ok(FriedmanHResult {
332        component_j,
333        component_k,
334        h_squared,
335        grid_j,
336        grid_k,
337        pdp_2d,
338    })
339}
340
341/// Friedman H-statistic for interaction between two FPC components (logistic model).
342///
343/// # Errors
344///
345/// Returns [`FdarError::InvalidParameter`] if `component_j == component_k`,
346/// `n_grid < 2`, either component index is `>= fit.ncomp`, or
347/// `scalar_covariates` is `None` when the model has scalar covariates.
348/// Returns [`FdarError::InvalidDimension`] if `data` has zero rows or its column
349/// count does not match `fit.fpca.mean`.
350#[must_use = "expensive computation whose result should not be discarded"]
351pub fn friedman_h_statistic_logistic(
352    fit: &FunctionalLogisticResult,
353    data: &FdMatrix,
354    scalar_covariates: Option<&FdMatrix>,
355    component_j: usize,
356    component_k: usize,
357    n_grid: usize,
358) -> Result<FriedmanHResult, FdarError> {
359    let (n, m) = data.shape();
360    let ncomp = fit.ncomp;
361    let p_scalar = fit.gamma.len();
362    if component_j == component_k {
363        return Err(FdarError::InvalidParameter {
364            parameter: "component_j/component_k",
365            message: "must be different".into(),
366        });
367    }
368    if n == 0 {
369        return Err(FdarError::InvalidDimension {
370            parameter: "data",
371            expected: ">0 rows".into(),
372            actual: "0".into(),
373        });
374    }
375    if m != fit.fpca.mean.len() {
376        return Err(FdarError::InvalidDimension {
377            parameter: "data",
378            expected: format!("{} columns", fit.fpca.mean.len()),
379            actual: format!("{m}"),
380        });
381    }
382    if n_grid < 2 {
383        return Err(FdarError::InvalidParameter {
384            parameter: "n_grid",
385            message: "must be >= 2".into(),
386        });
387    }
388    if component_j >= ncomp || component_k >= ncomp {
389        return Err(FdarError::InvalidParameter {
390            parameter: "component",
391            message: format!(
392                "component_j={component_j} or component_k={component_k} >= ncomp={ncomp}"
393            ),
394        });
395    }
396    if p_scalar > 0 && scalar_covariates.is_none() {
397        return Err(FdarError::InvalidParameter {
398            parameter: "scalar_covariates",
399            message: "required when model has scalar covariates".into(),
400        });
401    }
402    let scores = project_scores(
403        data,
404        &fit.fpca.mean,
405        &fit.fpca.rotation,
406        ncomp,
407        &fit.fpca.weights,
408    );
409
410    let grid_j = make_grid(&scores, component_j, n_grid);
411    let grid_k = make_grid(&scores, component_k, n_grid);
412
413    let pm = |replacements: &[(usize, f64)]| {
414        logistic_pdp_mean(
415            &scores,
416            fit.intercept,
417            &fit.coefficients,
418            &fit.gamma,
419            scalar_covariates,
420            n,
421            ncomp,
422            replacements,
423        )
424    };
425
426    let pdp_j: Vec<f64> = grid_j.iter().map(|&gj| pm(&[(component_j, gj)])).collect();
427    let pdp_k: Vec<f64> = grid_k.iter().map(|&gk| pm(&[(component_k, gk)])).collect();
428
429    let pdp_2d = logistic_pdp_2d(
430        &scores,
431        fit.intercept,
432        &fit.coefficients,
433        &fit.gamma,
434        scalar_covariates,
435        n,
436        ncomp,
437        component_j,
438        component_k,
439        &grid_j,
440        &grid_k,
441        n_grid,
442    );
443
444    let f_bar: f64 = fit.probabilities.iter().sum::<f64>() / n as f64;
445    let h_squared = compute_h_squared(&pdp_2d, &pdp_j, &pdp_k, f_bar, n_grid);
446
447    Ok(FriedmanHResult {
448        component_j,
449        component_k,
450        h_squared,
451        grid_j,
452        grid_k,
453        pdp_2d,
454    })
455}
456
457// ---------------------------------------------------------------------------
458// Private H-statistic helpers
459// ---------------------------------------------------------------------------
460
461/// Compute 1D PDP for a linear model along one component.
462fn pdp_1d_linear(
463    scores: &FdMatrix,
464    coefs: &[f64],
465    ncomp: usize,
466    component: usize,
467    grid: &[f64],
468    n: usize,
469) -> Vec<f64> {
470    grid.iter()
471        .map(|&gval| {
472            let mut sum = 0.0;
473            for i in 0..n {
474                let mut yhat = coefs[0];
475                for c in 0..ncomp {
476                    let s = if c == component { gval } else { scores[(i, c)] };
477                    yhat += coefs[1 + c] * s;
478                }
479                sum += yhat;
480            }
481            sum / n as f64
482        })
483        .collect()
484}
485
486/// Compute 2D PDP for a linear model along two components.
487fn pdp_2d_linear(
488    scores: &FdMatrix,
489    coefs: &[f64],
490    ncomp: usize,
491    comp_j: usize,
492    comp_k: usize,
493    grid_j: &[f64],
494    grid_k: &[f64],
495    n: usize,
496    n_grid: usize,
497) -> FdMatrix {
498    let mut pdp_2d = FdMatrix::zeros(n_grid, n_grid);
499    for (gj_idx, &gj) in grid_j.iter().enumerate() {
500        for (gk_idx, &gk) in grid_k.iter().enumerate() {
501            let replacements = [(comp_j, gj), (comp_k, gk)];
502            let mut sum = 0.0;
503            for i in 0..n {
504                sum += linear_predict_replaced(scores, coefs, ncomp, i, &replacements);
505            }
506            pdp_2d[(gj_idx, gk_idx)] = sum / n as f64;
507        }
508    }
509    pdp_2d
510}
511
512/// Compute linear prediction with optional component replacements.
513fn linear_predict_replaced(
514    scores: &FdMatrix,
515    coefs: &[f64],
516    ncomp: usize,
517    i: usize,
518    replacements: &[(usize, f64)],
519) -> f64 {
520    let mut yhat = coefs[0];
521    for c in 0..ncomp {
522        let s = replacements
523            .iter()
524            .find(|&&(comp, _)| comp == c)
525            .map_or(scores[(i, c)], |&(_, val)| val);
526        yhat += coefs[1 + c] * s;
527    }
528    yhat
529}
530
531/// Compute 2D logistic PDP on a grid using logistic_pdp_mean.
532fn logistic_pdp_2d(
533    scores: &FdMatrix,
534    intercept: f64,
535    coefficients: &[f64],
536    gamma: &[f64],
537    scalar_covariates: Option<&FdMatrix>,
538    n: usize,
539    ncomp: usize,
540    comp_j: usize,
541    comp_k: usize,
542    grid_j: &[f64],
543    grid_k: &[f64],
544    n_grid: usize,
545) -> FdMatrix {
546    let mut pdp_2d = FdMatrix::zeros(n_grid, n_grid);
547    for (gj_idx, &gj) in grid_j.iter().enumerate() {
548        for (gk_idx, &gk) in grid_k.iter().enumerate() {
549            pdp_2d[(gj_idx, gk_idx)] = logistic_pdp_mean(
550                scores,
551                intercept,
552                coefficients,
553                gamma,
554                scalar_covariates,
555                n,
556                ncomp,
557                &[(comp_j, gj), (comp_k, gk)],
558            );
559        }
560    }
561    pdp_2d
562}