Skip to main content

renegade_ml/
diagnostics.rs

1use crate::metric::LearnedMetric;
2use crate::{DataPoint, Renegade};
3
4/// Snapshot of the model's current state for diagnostics/dashboards.
5#[derive(Debug, Clone)]
6pub struct ModelDiagnostics {
7    /// Number of training points.
8    pub num_entries: usize,
9    /// Current auto-selected K (None if not yet trained).
10    pub optimal_k: Option<usize>,
11    /// Whether the learned metric is active (vs baseline Gower distance).
12    pub metric_active: bool,
13    /// Gaussian kernel bandwidth (None = using hard-k + 1/d weighting).
14    pub kernel_bandwidth: Option<f64>,
15    /// Number of entries when the model was last trained.
16    pub trained_at: usize,
17    /// Number of entries added since last training.
18    pub entries_since_training: usize,
19    /// Whether the model detected a classification task (vs regression).
20    pub is_classification: bool,
21    /// Per-feature metric diagnostics (only available when metric is active).
22    pub feature_metrics: Option<Vec<FeatureDiagnostics>>,
23    /// Output value statistics.
24    pub output_stats: OutputStats,
25}
26
27/// Diagnostics for a single feature's learned metric.
28#[derive(Debug, Clone)]
29pub struct FeatureDiagnostics {
30    /// Feature index.
31    pub index: usize,
32    /// Weight assigned to this feature (0.0 = noise, higher = more predictive).
33    pub weight: f64,
34    /// The effect curve: (feature_value, predicted_output) points from isotonic regression.
35    /// Sorted by feature_value.
36    pub effect_curve: Vec<(f64, f64)>,
37}
38
39/// Statistics about the output values in the training set.
40#[derive(Debug, Clone)]
41pub struct OutputStats {
42    pub min: f64,
43    pub max: f64,
44    pub mean: f64,
45    /// Number of distinct output values.
46    pub num_distinct: usize,
47}
48
49/// Diagnostics for a single prediction query.
50#[derive(Debug, Clone)]
51pub struct PredictionDiagnostics {
52    /// The predicted output value (weighted mean).
53    pub prediction: f64,
54    /// The K used for this prediction.
55    pub k: usize,
56    /// The nearest neighbors used, sorted by distance.
57    pub neighbors: Vec<NeighborDetail>,
58}
59
60/// Detail about a single neighbor in a prediction.
61#[derive(Debug, Clone)]
62pub struct NeighborDetail {
63    /// Distance from the query point.
64    pub distance: f64,
65    /// Output value of this neighbor.
66    pub output: f64,
67    /// Per-feature distances (only available when not using learned metric).
68    pub feature_distances: Option<Vec<f64>>,
69}
70
71impl<P: DataPoint + Clone> Renegade<P> {
72    /// Get a snapshot of the model's current state for diagnostics.
73    pub fn diagnostics(&self) -> ModelDiagnostics {
74        let output_stats = self.compute_output_stats();
75        let is_classification = self.detect_classification();
76
77        let feature_metrics = self
78            .learned_metric
79            .as_ref()
80            .map(|metric| metric.feature_diagnostics());
81
82        ModelDiagnostics {
83            num_entries: self.len(),
84            optimal_k: self.optimal_k,
85            metric_active: self.learned_metric.is_some(),
86            kernel_bandwidth: self.kernel_bandwidth,
87            trained_at: self.computed_at,
88            entries_since_training: self.len().saturating_sub(self.computed_at),
89            is_classification,
90            feature_metrics,
91            output_stats,
92        }
93    }
94
95    /// Get detailed diagnostics for a specific prediction.
96    pub fn predict_with_diagnostics(&self, query: &P, k: usize) -> PredictionDiagnostics {
97        let query_values = query.feature_values();
98        let n = self.len();
99        let mut distances: Vec<(usize, f64)> = Vec::with_capacity(n);
100        for i in 0..n {
101            let dist = self.distance_to_entry(&query_values, query, i);
102            distances.push((i, dist));
103        }
104
105        distances.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
106        distances.truncate(k);
107
108        let neighbors: Vec<NeighborDetail> = distances
109            .iter()
110            .map(|&(i, dist)| {
111                let feature_distances = if self.learned_metric.is_none() {
112                    Some(query.feature_distances(&self.points[i]))
113                } else {
114                    None
115                };
116                NeighborDetail {
117                    distance: dist,
118                    output: self.outputs[i],
119                    feature_distances,
120                }
121            })
122            .collect();
123
124        // Compute prediction using the same method as predict()
125        let prediction = if neighbors.is_empty() {
126            f64::NAN
127        } else if let Some(h) = self.kernel_bandwidth {
128            // Gaussian kernel — matches predict() behavior
129            let h2 = 2.0 * h * h;
130            let exact: Vec<&NeighborDetail> =
131                neighbors.iter().filter(|n| n.distance == 0.0).collect();
132            if !exact.is_empty() {
133                exact[0].output
134            } else {
135                let mut ws = 0.0;
136                let mut vs = 0.0;
137                for n in &neighbors {
138                    let w = (-n.distance * n.distance / h2).exp();
139                    if w < 1e-15 {
140                        break;
141                    }
142                    ws += w;
143                    vs += w * n.output;
144                }
145                if ws > 0.0 {
146                    vs / ws
147                } else {
148                    neighbors[0].output
149                }
150            }
151        } else {
152            // Hard-k + inverse-distance
153            let mut exact = None;
154            let mut ws = 0.0;
155            let mut vs = 0.0;
156            for n in &neighbors {
157                if n.distance == 0.0 {
158                    exact = Some(n.output);
159                    break;
160                }
161                let w = 1.0 / n.distance;
162                ws += w;
163                vs += w * n.output;
164            }
165            exact.unwrap_or_else(|| if ws > 0.0 { vs / ws } else { f64::NAN })
166        };
167
168        PredictionDiagnostics {
169            prediction,
170            k,
171            neighbors,
172        }
173    }
174
175    fn compute_output_stats(&self) -> OutputStats {
176        if self.is_empty() {
177            return OutputStats {
178                min: f64::NAN,
179                max: f64::NAN,
180                mean: f64::NAN,
181                num_distinct: 0,
182            };
183        }
184
185        let mut min = f64::MAX;
186        let mut max = f64::MIN;
187        let mut sum = 0.0;
188        let mut distinct: Vec<f64> = Vec::new();
189
190        for &o in &self.outputs {
191            min = min.min(o);
192            max = max.max(o);
193            sum += o;
194            if !distinct.iter().any(|&v| (v - o).abs() < 1e-10) {
195                distinct.push(o);
196            }
197        }
198
199        OutputStats {
200            min,
201            max,
202            mean: sum / self.outputs.len() as f64,
203            num_distinct: distinct.len(),
204        }
205    }
206}
207
208impl LearnedMetric {
209    /// Get per-feature diagnostics including effect curves.
210    pub fn feature_diagnostics(&self) -> Vec<FeatureDiagnostics> {
211        self.effect_regressions
212            .iter()
213            .zip(self.weights.iter())
214            .enumerate()
215            .map(|(i, (reg, &weight))| {
216                let points = reg.get_points_sorted();
217                let effect_curve: Vec<(f64, f64)> =
218                    points.iter().map(|p| (*p.x(), *p.y())).collect();
219
220                FeatureDiagnostics {
221                    index: i,
222                    weight,
223                    effect_curve,
224                }
225            })
226            .collect()
227    }
228}