Skip to main content

trustformers_debug/interpretability/
lime.rs

1//! LIME (Local Interpretable Model-agnostic Explanations) analysis
2//!
3//! This module implements LIME analysis for local model interpretability,
4//! providing local explanations of model predictions through perturbation analysis.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10/// LIME (Local Interpretable Model-agnostic Explanations) analysis result
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct LimeAnalysisResult {
13    /// Analysis timestamp
14    pub timestamp: DateTime<Utc>,
15    /// Local model coefficients
16    pub local_coefficients: HashMap<String, f64>,
17    /// Feature names
18    pub feature_names: Vec<String>,
19    /// Coefficient of determination of the published local surrogate against
20    /// the perturbation predictions it is supposed to explain -- i.e. how
21    /// faithful the explanation actually is. `None` when the perturbation
22    /// predictions are all identical, leaving nothing to explain.
23    ///
24    /// This used to be the constant `0.75`, alongside a separate
25    /// `local_fidelity: 0.85` that named the same idea with a second made-up
26    /// number. Computed by `fit_local_surrogate`.
27    pub local_r_squared: Option<f64>,
28    /// Local model intercept
29    pub intercept: f64,
30    /// Feature importance scores
31    pub feature_importance: Vec<FeatureImportance>,
32    /// Perturbation analysis
33    pub perturbation_analysis: PerturbationAnalysis,
34    /// Local neighborhood statistics
35    pub neighborhood_stats: NeighborhoodStats,
36}
37
38/// Feature importance from LIME
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct FeatureImportance {
41    /// Feature name
42    pub feature_name: String,
43    /// Importance score
44    pub importance_score: f64,
45    /// Residual standard error of the feature's local slope estimate, or
46    /// `None` when it is not estimable (see [`Self::confidence_interval`]).
47    pub standard_error: Option<f64>,
48    /// 95% confidence interval on the feature's local slope, from the
49    /// residual standard error of its univariate fit. `None` when the
50    /// perturbations left too little variation in this feature (or too few
51    /// samples) to estimate one. Previously the literal `(coeff - 0.1,
52    /// coeff + 0.1)`.
53    pub confidence_interval: Option<(f64, f64)>,
54    /// Two-sided p-value for `H0: slope = 0` on the same univariate fit,
55    /// `None` under the same conditions. Previously the constant `0.05` for
56    /// every feature of every instance.
57    pub p_value: Option<f64>,
58    /// Always `None`: measuring how stable a feature's attribution is across
59    /// perturbation *rounds* needs repeated independent LIME runs, which this
60    /// analyzer does not perform. Previously the constant `0.8`.
61    pub stability: Option<f64>,
62}
63
64/// Perturbation analysis details
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct PerturbationAnalysis {
67    /// Number of perturbations generated
68    pub num_perturbations: usize,
69    /// Perturbation strategy used
70    pub strategy: String,
71    /// Average prediction variance
72    pub prediction_variance: f64,
73    /// Fraction of the generated perturbations that actually changed at least
74    /// one feature -- the real coverage of the sampling, not the old constant
75    /// `0.8`.
76    pub neighborhood_coverage: f64,
77    /// Most influential perturbations
78    pub influential_perturbations: Vec<PerturbationResult>,
79}
80
81/// Individual perturbation result
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct PerturbationResult {
84    /// Perturbation ID
85    pub id: String,
86    /// Features that were perturbed
87    pub perturbed_features: Vec<String>,
88    /// Original prediction
89    pub original_prediction: f64,
90    /// Perturbed prediction
91    pub perturbed_prediction: f64,
92    /// Prediction change
93    pub prediction_change: f64,
94    /// Distance from original instance
95    pub distance: f64,
96}
97
98/// Local neighborhood statistics
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct NeighborhoodStats {
101    /// Mean prediction in neighborhood
102    pub mean_prediction: f64,
103    /// Population standard deviation of the perturbation predictions.
104    /// Previously the constant `0.1`.
105    pub std_prediction: f64,
106    /// Always `None`: "neighborhood density" has no definition this analyzer
107    /// can evaluate -- the perturbation sampler draws from an unnormalised
108    /// noise distribution with no reference volume. Previously the constant
109    /// `0.5`.
110    pub density: Option<f64>,
111    /// Feature correlation matrix in neighborhood
112    pub correlation_matrix: HashMap<(String, String), f64>,
113}
114
115/// Goodness-of-fit statistics for the local surrogate a LIME run publishes.
116///
117/// Every field is measured from the perturbation sample the run actually
118/// generated; the values these replace (`local_r_squared: 0.75`,
119/// `local_fidelity: 0.85`, `p_value: 0.05`, `stability: 0.8`,
120/// `confidence_interval: (coeff +/- 0.1)`, `std_prediction: 0.1`) were
121/// constants that never varied with the model, the instance or the data.
122#[derive(Debug, Clone)]
123pub struct LocalSurrogateFit {
124    /// See [`LimeAnalysisResult::local_r_squared`].
125    pub r_squared: Option<f64>,
126    /// Mean of the perturbation predictions.
127    pub mean_prediction: f64,
128    /// Population standard deviation of the perturbation predictions.
129    pub std_prediction: f64,
130    /// Per-feature inference statistics, keyed by feature name.
131    pub coefficient_stats: HashMap<String, CoefficientStats>,
132}
133
134/// Inference statistics for one feature's local slope.
135#[derive(Debug, Clone, Copy, Default)]
136pub struct CoefficientStats {
137    /// Residual standard error of the slope estimate.
138    pub standard_error: Option<f64>,
139    /// Two-sided p-value for `H0: slope = 0`.
140    pub p_value: Option<f64>,
141    /// 95% confidence interval on the slope.
142    pub confidence_interval: Option<(f64, f64)>,
143}
144
145/// Fit statistics for the additive local surrogate
146/// `y_hat(x) = mean(y) + sum_j coeff_j * (x_j - mean(x_j))`, which is exactly
147/// the model a [`LimeAnalysisResult`]'s `local_coefficients` describe.
148///
149/// `local_data` and `predictions` must be the same length; a shorter-than-3
150/// sample yields `None` everywhere rather than an invented number, because
151/// the residual degrees of freedom (`n - 2`) would not be positive.
152pub 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    // R^2 of the additive surrogate against the real predictions.
183    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            // Residuals of this feature's own univariate fit -- the model the
226            // slope was estimated from.
227            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            // A zero residual standard error is the degenerate exact-fit case:
241            // the surrogate reproduces every prediction, so the slope is
242            // pinned. With a non-zero slope the null is rejected at any alpha
243            // (p = 0 exactly, not an underflow); with a zero slope there is
244            // nothing to test at all.
245            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    /// An exactly linear neighbourhood must report a perfect fit and a
295    /// vanishing p-value -- not the old constants 0.75 / 0.85 / 0.05.
296    #[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    /// The ordinary (noisy) case: a real p-value strictly between 0 and 1, and
330    /// a confidence interval of real width that brackets the slope.
331    #[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    /// A neighbourhood the surrogate cannot explain must say so, rather than
374    /// reporting the old flattering constants.
375    #[test]
376    fn fit_local_surrogate_reports_absence_when_nothing_is_estimable() {
377        let names = vec!["x".to_string()];
378
379        // Constant predictions: no variance to explain.
380        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        // Two samples: no residual degrees of freedom for inference.
390        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        // No samples at all.
402        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    /// A poor surrogate must report a poor (possibly negative) R^2 rather
408    /// than the constant 0.75 the old code published for every run.
409    #[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        // Predictions unrelated to x, and a coefficient that claims otherwise.
413        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}