1use 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#[derive(Debug, Clone, PartialEq)]
19pub struct FpcShapValues {
20 pub values: FdMatrix,
22 pub base_value: f64,
24 pub mean_scores: Vec<f64>,
26}
27
28#[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#[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 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#[derive(Debug, Clone, PartialEq)]
233#[non_exhaustive]
234pub struct FriedmanHResult {
235 pub component_j: usize,
237 pub component_k: usize,
239 pub h_squared: f64,
241 pub grid_j: Vec<f64>,
243 pub grid_k: Vec<f64>,
245 pub pdp_2d: FdMatrix,
247}
248
249#[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#[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
457fn 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
486fn 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
512fn 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
531fn 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}