1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct LimeAnalysisResult {
13 pub timestamp: DateTime<Utc>,
15 pub local_coefficients: HashMap<String, f64>,
17 pub feature_names: Vec<String>,
19 pub local_r_squared: Option<f64>,
28 pub intercept: f64,
30 pub feature_importance: Vec<FeatureImportance>,
32 pub perturbation_analysis: PerturbationAnalysis,
34 pub neighborhood_stats: NeighborhoodStats,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct FeatureImportance {
41 pub feature_name: String,
43 pub importance_score: f64,
45 pub standard_error: Option<f64>,
48 pub confidence_interval: Option<(f64, f64)>,
54 pub p_value: Option<f64>,
58 pub stability: Option<f64>,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct PerturbationAnalysis {
67 pub num_perturbations: usize,
69 pub strategy: String,
71 pub prediction_variance: f64,
73 pub neighborhood_coverage: f64,
77 pub influential_perturbations: Vec<PerturbationResult>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct PerturbationResult {
84 pub id: String,
86 pub perturbed_features: Vec<String>,
88 pub original_prediction: f64,
90 pub perturbed_prediction: f64,
92 pub prediction_change: f64,
94 pub distance: f64,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct NeighborhoodStats {
101 pub mean_prediction: f64,
103 pub std_prediction: f64,
106 pub density: Option<f64>,
111 pub correlation_matrix: HashMap<(String, String), f64>,
113}
114
115#[derive(Debug, Clone)]
123pub struct LocalSurrogateFit {
124 pub r_squared: Option<f64>,
126 pub mean_prediction: f64,
128 pub std_prediction: f64,
130 pub coefficient_stats: HashMap<String, CoefficientStats>,
132}
133
134#[derive(Debug, Clone, Copy, Default)]
136pub struct CoefficientStats {
137 pub standard_error: Option<f64>,
139 pub p_value: Option<f64>,
141 pub confidence_interval: Option<(f64, f64)>,
143}
144
145pub fn fit_local_surrogate(
153 feature_names: &[String],
154 local_data: &[HashMap<String, f64>],
155 predictions: &[f64],
156 coefficients: &HashMap<String, f64>,
157) -> LocalSurrogateFit {
158 let n = predictions.len().min(local_data.len());
159 if n == 0 {
160 return LocalSurrogateFit {
161 r_squared: None,
162 mean_prediction: 0.0,
163 std_prediction: 0.0,
164 coefficient_stats: HashMap::new(),
165 };
166 }
167 let predictions = &predictions[..n];
168 let local_data = &local_data[..n];
169
170 let mean_prediction = predictions.iter().sum::<f64>() / n as f64;
171 let ss_tot: f64 = predictions.iter().map(|y| (y - mean_prediction).powi(2)).sum();
172 let std_prediction = (ss_tot / n as f64).sqrt();
173
174 let feature_means: HashMap<&str, f64> = feature_names
175 .iter()
176 .map(|name| {
177 let sum: f64 = local_data.iter().map(|row| row.get(name).copied().unwrap_or(0.0)).sum();
178 (name.as_str(), sum / n as f64)
179 })
180 .collect();
181
182 let ss_res: f64 = local_data
184 .iter()
185 .zip(predictions.iter())
186 .map(|(row, y)| {
187 let fitted = mean_prediction
188 + feature_names
189 .iter()
190 .map(|name| {
191 let beta = coefficients.get(name).copied().unwrap_or(0.0);
192 let mean = feature_means.get(name.as_str()).copied().unwrap_or(0.0);
193 beta * (row.get(name).copied().unwrap_or(0.0) - mean)
194 })
195 .sum::<f64>();
196 (y - fitted).powi(2)
197 })
198 .sum();
199 let r_squared = if ss_tot > 0.0 { Some(1.0 - ss_res / ss_tot) } else { None };
200
201 let degrees_of_freedom = n as f64 - 2.0;
202 let t_critical = if degrees_of_freedom > 0.0 {
203 statrs::distribution::StudentsT::new(0.0, 1.0, degrees_of_freedom)
204 .ok()
205 .map(|dist| {
206 use statrs::distribution::ContinuousCDF;
207 dist.inverse_cdf(0.975)
208 })
209 } else {
210 None
211 };
212
213 let coefficient_stats = feature_names
214 .iter()
215 .map(|name| {
216 let beta = coefficients.get(name).copied().unwrap_or(0.0);
217 let mean = feature_means.get(name.as_str()).copied().unwrap_or(0.0);
218 let sxx: f64 = local_data
219 .iter()
220 .map(|row| (row.get(name).copied().unwrap_or(0.0) - mean).powi(2))
221 .sum();
222 if degrees_of_freedom <= 0.0 || sxx <= 0.0 {
223 return (name.clone(), CoefficientStats::default());
224 }
225 let sse: f64 = local_data
228 .iter()
229 .zip(predictions.iter())
230 .map(|(row, y)| {
231 let fitted =
232 mean_prediction + beta * (row.get(name).copied().unwrap_or(0.0) - mean);
233 (y - fitted).powi(2)
234 })
235 .sum();
236 let standard_error = (sse / degrees_of_freedom / sxx).sqrt();
237 if !standard_error.is_finite() {
238 return (name.clone(), CoefficientStats::default());
239 }
240 let (p_value, confidence_interval) = if standard_error == 0.0 {
246 if beta == 0.0 {
247 (None, None)
248 } else {
249 (Some(0.0), Some((beta, beta)))
250 }
251 } else {
252 let t_statistic = beta / standard_error;
253 (
254 trustformers_core::statistics::student_t_two_sided_p_value(
255 t_statistic,
256 degrees_of_freedom,
257 ),
258 t_critical.map(|t| (beta - t * standard_error, beta + t * standard_error)),
259 )
260 };
261 (
262 name.clone(),
263 CoefficientStats {
264 standard_error: Some(standard_error),
265 p_value,
266 confidence_interval,
267 },
268 )
269 })
270 .collect();
271
272 LocalSurrogateFit {
273 r_squared,
274 mean_prediction,
275 std_prediction,
276 coefficient_stats,
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 fn sample(xs: &[f64]) -> Vec<HashMap<String, f64>> {
285 xs.iter()
286 .map(|&x| {
287 let mut row = HashMap::new();
288 row.insert("x".to_string(), x);
289 row
290 })
291 .collect()
292 }
293
294 #[test]
297 fn fit_local_surrogate_recovers_an_exact_linear_relationship() {
298 let xs = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0];
299 let local_data = sample(&xs);
300 let predictions: Vec<f64> = xs.iter().map(|x| 3.0 * x + 1.0).collect();
301 let names = vec!["x".to_string()];
302 let mut coefficients = HashMap::new();
303 coefficients.insert("x".to_string(), 3.0);
304
305 let fit = fit_local_surrogate(&names, &local_data, &predictions, &coefficients);
306
307 let r2 = fit.r_squared.expect("the predictions vary, so R^2 is defined");
308 assert!(
309 (r2 - 1.0).abs() < 1e-12,
310 "an exact fit has R^2 = 1, got {r2}"
311 );
312 assert!(
313 (fit.mean_prediction - 8.5).abs() < 1e-12,
314 "got {}",
315 fit.mean_prediction
316 );
317 assert!(fit.std_prediction > 0.0);
318
319 let stats = fit.coefficient_stats.get("x").expect("stats for the only feature");
320 assert_eq!(
321 stats.standard_error,
322 Some(0.0),
323 "an exact fit leaves no residual"
324 );
325 assert_eq!(stats.p_value, Some(0.0));
326 assert_eq!(stats.confidence_interval, Some((3.0, 3.0)));
327 }
328
329 #[test]
332 fn fit_local_surrogate_reports_real_inference_statistics_under_noise() {
333 let xs = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0];
334 let noise = [0.30, -0.25, 0.10, 0.40, -0.35, 0.20, -0.15, 0.05];
335 let local_data = sample(&xs);
336 let predictions: Vec<f64> =
337 xs.iter().zip(noise.iter()).map(|(x, e)| 3.0 * x + 1.0 + e).collect();
338 let names = vec!["x".to_string()];
339 let mut coefficients = HashMap::new();
340 let beta = {
341 let mean_x = xs.iter().sum::<f64>() / xs.len() as f64;
342 let mean_y = predictions.iter().sum::<f64>() / predictions.len() as f64;
343 let num: f64 = xs
344 .iter()
345 .zip(predictions.iter())
346 .map(|(x, y)| (x - mean_x) * (y - mean_y))
347 .sum();
348 let den: f64 = xs.iter().map(|x| (x - mean_x).powi(2)).sum();
349 num / den
350 };
351 coefficients.insert("x".to_string(), beta);
352
353 let fit = fit_local_surrogate(&names, &local_data, &predictions, &coefficients);
354 let r2 = fit.r_squared.expect("defined");
355 assert!(
356 r2 > 0.99 && r2 < 1.0,
357 "a nearly-linear neighbourhood, got {r2}"
358 );
359
360 let stats = fit.coefficient_stats.get("x").expect("present");
361 let se = stats.standard_error.expect("estimable");
362 assert!(se > 0.0 && se.is_finite(), "got {se}");
363 let p = stats.p_value.expect("estimable");
364 assert!(p > 0.0 && p < 1e-6, "a strong but noisy slope, got {p}");
365 let (lo, hi) = stats.confidence_interval.expect("estimable");
366 assert!(hi - lo > 0.0);
367 assert!(
368 lo < beta && beta < hi,
369 "the CI must bracket the estimate: ({lo}, {hi})"
370 );
371 }
372
373 #[test]
376 fn fit_local_surrogate_reports_absence_when_nothing_is_estimable() {
377 let names = vec!["x".to_string()];
378
379 let flat = fit_local_surrogate(
381 &names,
382 &sample(&[0.0, 1.0, 2.0, 3.0]),
383 &[5.0, 5.0, 5.0, 5.0],
384 &HashMap::from([("x".to_string(), 0.0)]),
385 );
386 assert_eq!(flat.r_squared, None);
387 assert_eq!(flat.std_prediction, 0.0);
388
389 let tiny = fit_local_surrogate(
391 &names,
392 &sample(&[0.0, 1.0]),
393 &[1.0, 4.0],
394 &HashMap::from([("x".to_string(), 3.0)]),
395 );
396 let stats = tiny.coefficient_stats.get("x").expect("present");
397 assert_eq!(stats.p_value, None);
398 assert_eq!(stats.confidence_interval, None);
399 assert_eq!(stats.standard_error, None);
400
401 let empty = fit_local_surrogate(&names, &[], &[], &HashMap::new());
403 assert_eq!(empty.r_squared, None);
404 assert!(empty.coefficient_stats.is_empty());
405 }
406
407 #[test]
410 fn fit_local_surrogate_reports_a_poor_fit_as_a_poor_fit() {
411 let xs = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0];
412 let predictions = vec![10.0, -4.0, 7.0, -9.0, 2.0, 12.0];
414 let fit = fit_local_surrogate(
415 &["x".to_string()],
416 &sample(&xs),
417 &predictions,
418 &HashMap::from([("x".to_string(), 5.0)]),
419 );
420 let r2 = fit.r_squared.expect("the predictions vary");
421 assert!(
422 r2 < 0.5,
423 "a surrogate this wrong must not report a good fit, got {r2}"
424 );
425 }
426}