Skip to main content

renegade_ml/
lib.rs

1mod diagnostics;
2mod metric;
3mod neighbor;
4mod predict;
5/// Vantage-point tree for metric-space nearest neighbor search.
6pub mod vptree;
7
8pub use diagnostics::{
9    FeatureDiagnostics, ModelDiagnostics, NeighborDetail, OutputStats, PredictionDiagnostics,
10};
11pub use metric::LearnedMetric;
12pub use neighbor::{Neighbor, Neighbors};
13pub use predict::ExtrapolatedPrediction;
14
15/// User implements this trait to define how distances are computed between data points.
16///
17/// Two methods must be implemented:
18/// - `feature_distances`: per-feature distances in [0, 1] (for base KNN)
19/// - `feature_values`: raw feature values (for metric learning)
20///
21/// **Important**: Both methods must describe the same features in the same order.
22/// `feature_distances` returns pairwise distances while `feature_values` returns
23/// raw values, but they must correspond to the same underlying features.
24///
25/// For numeric features, distances are typically |a - b| / (max - min).
26/// For categorical features: 0.0 if same, 1.0 if different.
27/// Custom distance functions (edit distance, Jaccard, etc.) are fine as long as
28/// they're normalized to [0, 1].
29pub trait DataPoint {
30    /// Per-feature distances between this point and another, each in [0, 1].
31    fn feature_distances(&self, other: &Self) -> Vec<f64>;
32
33    /// Raw feature values for this point, used by the metric learner.
34    /// Each feature should be a numeric value. For categorical features,
35    /// use a numeric encoding (e.g., 0, 1, 2, ...).
36    fn feature_values(&self) -> Vec<f64>;
37}
38
39/// The core learner. Stores labeled training data and answers queries via KNN.
40///
41/// Designed for datasets up to ~100k points. Uses brute-force neighbor search
42/// which is efficient up to this scale. For larger datasets, consider
43/// data retention strategies (e.g., sliding window over recent events).
44///
45/// `query()` and `predict()` require `&mut self` because they trigger lazy
46/// training (metric learning + K selection) on first call. Use `query_k()` and
47/// `predict_k()` for immutable access with a manually specified K.
48///
49/// Training is amortized: the metric and K are only recomputed when the dataset
50/// has doubled in size since the last computation. Call `force_retrain()` to
51/// trigger recomputation manually.
52pub struct Renegade<P: DataPoint> {
53    // --- SoA layout for cache-friendly iteration ---
54    /// Original data points (cold path — only accessed for feature_distances fallback).
55    points: Vec<P>,
56    /// Flat contiguous array of all feature values: [p0_f0, p0_f1, ..., p1_f0, p1_f1, ...].
57    /// Length = num_entries * num_features. Indexed by `i * num_features + f`.
58    values_flat: Vec<f64>,
59    /// Output values, one per entry. Contiguous for cache-friendly access.
60    outputs: Vec<f64>,
61    /// Instance weights, one per entry. Default 1.0.
62    instance_weights: Vec<f64>,
63    /// Number of features per data point (0 until first point is added).
64    num_features: usize,
65
66    // --- Training state ---
67    optimal_k: Option<usize>,
68    learned_metric: Option<LearnedMetric>,
69    /// Gaussian kernel bandwidth for regression. When set, predict() uses
70    /// Gaussian-weighted mean over max_k neighbors instead of hard-k + 1/d.
71    kernel_bandwidth: Option<f64>,
72    /// VP-tree index for fast queries.
73    vp_index: Option<vptree::VpTree>,
74    /// Number of entries when optimal_k / metric were last computed.
75    computed_at: usize,
76    /// Number of entries when the VP-tree was last built.
77    vp_built_at: usize,
78}
79
80/// Minimum number of data points before learning a metric.
81const MIN_POINTS_FOR_METRIC: usize = 10;
82
83/// Minimum entries to build a VP-tree (below this, brute force is fine).
84const VP_TREE_THRESHOLD: usize = 3;
85
86impl<P: DataPoint + Clone> Renegade<P> {
87    /// Create a new empty learner.
88    pub fn new() -> Self {
89        Renegade {
90            points: Vec::new(),
91            values_flat: Vec::new(),
92            outputs: Vec::new(),
93            instance_weights: Vec::new(),
94            num_features: 0,
95            optimal_k: None,
96            learned_metric: None,
97            kernel_bandwidth: None,
98            vp_index: None,
99            computed_at: 0,
100            vp_built_at: 0,
101        }
102    }
103
104    /// Add a labeled data point with default weight 1.0.
105    pub fn add(&mut self, point: P, output: f64) {
106        self.add_weighted(point, output, 1.0);
107    }
108
109    /// Add a labeled data point with a specific instance weight.
110    /// Higher weight means this point has more influence on predictions.
111    /// Weight must be positive.
112    pub fn add_weighted(&mut self, point: P, output: f64, weight: f64) {
113        debug_assert!(weight > 0.0, "Instance weight must be positive");
114        let values = point.feature_values();
115        if self.num_features == 0 {
116            self.num_features = values.len();
117            debug_assert_eq!(
118                values.len(),
119                point.feature_distances(&point).len(),
120                "feature_values() and feature_distances() must return the same number of features"
121            );
122        } else {
123            debug_assert_eq!(
124                values.len(),
125                self.num_features,
126                "All data points must have the same number of features"
127            );
128        }
129        self.values_flat.extend_from_slice(&values);
130        self.outputs.push(output);
131        self.instance_weights.push(weight);
132        self.points.push(point);
133
134        // Invalidate metric/K if dataset has grown 50% since last training
135        if self.computed_at > 0 && self.len() >= self.computed_at + self.computed_at / 2 {
136            self.optimal_k = None;
137            self.learned_metric = None;
138            self.kernel_bandwidth = None;
139            self.vp_index = None;
140            self.vp_built_at = 0;
141        }
142
143        // Rebuild VP-tree (cheap) when unindexed tail exceeds 20% of indexed points
144        if self.vp_built_at > 0 {
145            let tail = self.len() - self.vp_built_at;
146            if tail > self.vp_built_at / 5 {
147                self.rebuild_vp_tree();
148            }
149        }
150    }
151
152    /// Number of training points.
153    #[inline]
154    pub fn len(&self) -> usize {
155        self.outputs.len()
156    }
157
158    /// Whether the learner has no training data.
159    #[inline]
160    pub fn is_empty(&self) -> bool {
161        self.outputs.is_empty()
162    }
163
164    /// Remove entries that don't satisfy the predicate. Useful for expiring
165    /// stale data (e.g., sliding window over recent events).
166    /// Invalidates cached K and metric.
167    pub fn retain<F>(&mut self, mut f: F)
168    where
169        F: FnMut(&P, f64) -> bool,
170    {
171        let n = self.len();
172        let nf = self.num_features;
173        let mut write = 0;
174        for read in 0..n {
175            if f(&self.points[read], self.outputs[read]) {
176                if write != read {
177                    self.points.swap(write, read);
178                    self.outputs.swap(write, read);
179                    self.instance_weights.swap(write, read);
180                    self.values_flat
181                        .copy_within(read * nf..(read + 1) * nf, write * nf);
182                }
183                write += 1;
184            }
185        }
186        self.points.truncate(write);
187        self.outputs.truncate(write);
188        self.instance_weights.truncate(write);
189        self.values_flat.truncate(write * nf);
190        self.invalidate();
191    }
192
193    /// Force recomputation of the metric and K on the next query.
194    pub fn force_retrain(&mut self) {
195        self.invalidate();
196    }
197
198    /// Clear all cached training state.
199    fn invalidate(&mut self) {
200        self.optimal_k = None;
201        self.learned_metric = None;
202        self.kernel_bandwidth = None;
203        self.vp_index = None;
204        self.vp_built_at = 0;
205    }
206
207    /// Rebuild just the VP-tree (cheap) without retraining metric/K.
208    fn rebuild_vp_tree(&mut self) {
209        let n = self.len();
210        if n >= VP_TREE_THRESHOLD {
211            self.vp_index = Some(vptree::VpTree::build(n, &|a, b| {
212                self.distance_between(a, b)
213            }));
214            self.vp_built_at = n;
215        }
216    }
217
218    /// Get the cached feature values for entry i as a slice.
219    #[inline]
220    fn entry_values(&self, i: usize) -> &[f64] {
221        let nf = self.num_features;
222        &self.values_flat[i * nf..(i + 1) * nf]
223    }
224
225    /// Compute distance between a query (given as values slice) and entry i.
226    #[inline]
227    fn distance_to_entry(&self, query_values: &[f64], query: &P, i: usize) -> f64 {
228        match &self.learned_metric {
229            Some(metric) => metric.distance(query_values, self.entry_values(i)),
230            None => {
231                let feat_dists = query.feature_distances(&self.points[i]);
232                if feat_dists.is_empty() {
233                    return 0.0;
234                }
235                feat_dists.iter().sum::<f64>() / feat_dists.len() as f64
236            }
237        }
238    }
239
240    /// Compute distance between entries i and j.
241    #[inline]
242    fn distance_between(&self, i: usize, j: usize) -> f64 {
243        match &self.learned_metric {
244            Some(metric) => metric.distance(self.entry_values(i), self.entry_values(j)),
245            None => {
246                let feat_dists = self.points[i].feature_distances(&self.points[j]);
247                if feat_dists.is_empty() {
248                    return 0.0;
249                }
250                feat_dists.iter().sum::<f64>() / feat_dists.len() as f64
251            }
252        }
253    }
254
255    /// Find the k nearest neighbors to a query point.
256    /// Returns neighbors sorted by distance (closest first).
257    /// Uses VP-tree for indexed points, plus brute-force scan of any points
258    /// added since the tree was built.
259    pub fn query_k(&self, query: &P, k: usize) -> Neighbors {
260        let query_values = query.feature_values();
261        let n = self.len();
262
263        let results = if let Some(ref vp) = self.vp_index {
264            let query_dist = |i: usize| self.distance_to_entry(&query_values, query, i);
265
266            // Search VP-tree for indexed points
267            let mut results = vp.query_nearest(k, &query_dist);
268
269            // Brute-force scan any points added after the tree was built
270            if self.vp_built_at < n {
271                for i in self.vp_built_at..n {
272                    let dist = self.distance_to_entry(&query_values, query, i);
273                    if results.len() < k {
274                        results.push((i, dist));
275                        results.sort_by(|a, b| {
276                            a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)
277                        });
278                    } else if let Some(worst) = results.last() {
279                        if dist < worst.1 {
280                            results.pop();
281                            results.push((i, dist));
282                            results.sort_by(|a, b| {
283                                a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)
284                            });
285                        }
286                    }
287                }
288            }
289
290            results
291        } else {
292            // No VP-tree: brute force all points
293            let mut distances: Vec<(usize, f64)> = Vec::with_capacity(n);
294            for i in 0..n {
295                let dist = self.distance_to_entry(&query_values, query, i);
296                distances.push((i, dist));
297            }
298            distances.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
299            distances.truncate(k);
300            distances
301        };
302
303        let neighbors = results
304            .into_iter()
305            .map(|(i, dist)| Neighbor {
306                distance: dist,
307                output: self.outputs[i],
308                weight: self.instance_weights[i],
309            })
310            .collect();
311
312        Neighbors { neighbors }
313    }
314
315    /// Find nearest neighbors using automatically determined K.
316    /// Learns the metric and computes optimal K if needed.
317    pub fn query(&mut self, query: &P) -> Neighbors {
318        self.ensure_trained();
319        let k = self.optimal_k.unwrap();
320        self.query_k(query, k)
321    }
322
323    /// Predict output using automatically determined K and weighted mean.
324    /// For regression, may use Gaussian kernel weighting if it was selected
325    /// during training as superior to hard-k + inverse-distance.
326    pub fn predict(&mut self, query: &P) -> f64 {
327        self.ensure_trained();
328        let k = self.optimal_k.unwrap();
329        if let Some(h) = self.kernel_bandwidth {
330            // Gaussian kernel: query max_k neighbors so the kernel has a full
331            // neighborhood to weight. The kernel itself does the "soft cutoff" —
332            // distant neighbors contribute exponentially less.
333            let max_k = (self.len() as f64).sqrt().ceil() as usize;
334            let neighbors = self.query_k(query, max_k);
335            neighbors.gaussian_weighted_mean(h)
336        } else {
337            let neighbors = self.query_k(query, k);
338            neighbors.weighted_mean()
339        }
340    }
341
342    /// Predict output using distance-trend extrapolation (auto K).
343    pub fn predict_extrapolated(&mut self, query: &P) -> ExtrapolatedPrediction {
344        let neighbors = self.query(query);
345        neighbors.extrapolate()
346    }
347
348    /// Predict output for a query point using specified k and weighted mean.
349    pub fn predict_k(&self, query: &P, k: usize) -> f64 {
350        let neighbors = self.query_k(query, k);
351        neighbors.weighted_mean()
352    }
353
354    /// Predict output for a query point using specified k and distance-trend extrapolation.
355    pub fn predict_k_extrapolated(&self, query: &P, k: usize) -> ExtrapolatedPrediction {
356        let neighbors = self.query_k(query, k);
357        neighbors.extrapolate()
358    }
359
360    /// Ensure the metric and K are trained. Recomputes if needed.
361    /// Learns the metric, then compares LOO error with and without it.
362    /// Only keeps the metric if it actually improves predictions.
363    /// For regression, also evaluates Gaussian kernel weighting and uses it
364    /// if it outperforms hard-k + inverse-distance.
365    fn ensure_trained(&mut self) {
366        if self.optimal_k.is_some() {
367            return;
368        }
369
370        if self.len() >= MIN_POINTS_FOR_METRIC {
371            // Compute best K (and bandwidth for regression) without metric.
372            self.learned_metric = None;
373            let (k_no_metric, error_no_metric, bw_no_metric) =
374                self.compute_optimal_k_and_bandwidth();
375
376            // Learn metric and compute best K (and bandwidth) with it
377            let candidate_metric = self.learn_metric();
378            self.learned_metric = Some(candidate_metric);
379            let (k_with_metric, error_with_metric, bw_with_metric) =
380                self.compute_optimal_k_and_bandwidth();
381
382            // Pick the globally best configuration across all 4 combinations:
383            // {no-metric, metric} × {hard-k, gaussian}
384            let best_no_metric = match bw_no_metric {
385                Some((_, bw_err)) if bw_err < error_no_metric => bw_err,
386                _ => error_no_metric,
387            };
388            let best_with_metric = match bw_with_metric {
389                Some((_, bw_err)) if bw_err < error_with_metric => bw_err,
390                _ => error_with_metric,
391            };
392
393            if best_with_metric < best_no_metric {
394                // Keep metric
395                self.optimal_k = Some(k_with_metric);
396                if let Some((h, bw_err)) = bw_with_metric {
397                    if bw_err < error_with_metric {
398                        self.kernel_bandwidth = Some(h);
399                    }
400                }
401            } else {
402                // No metric
403                self.learned_metric = None;
404                self.optimal_k = Some(k_no_metric);
405                if let Some((h, bw_err)) = bw_no_metric {
406                    if bw_err < error_no_metric {
407                        self.kernel_bandwidth = Some(h);
408                    }
409                }
410            }
411        } else {
412            self.learned_metric = None;
413            let k = self.compute_optimal_k();
414            self.optimal_k = Some(k);
415        }
416
417        // Build VP-tree index for fast queries
418        self.rebuild_vp_tree();
419
420        self.computed_at = self.len();
421    }
422
423    /// Get the current optimal K, training if necessary.
424    pub fn get_optimal_k(&mut self) -> usize {
425        self.ensure_trained();
426        self.optimal_k.unwrap()
427    }
428
429    /// Learn the metric from training data using effect-space isotonic regressions.
430    fn learn_metric(&self) -> LearnedMetric {
431        use metric::TrainingPoint;
432
433        let points: Vec<TrainingPoint> = (0..self.len())
434            .map(|i| TrainingPoint {
435                features: self.entry_values(i).to_vec(),
436                output: self.outputs[i],
437            })
438            .collect();
439
440        LearnedMetric::learn(&points)
441    }
442
443    /// Compute optimal K via leave-one-out cross-validation.
444    /// Computes distances once per eval point, then evaluates all K values
445    /// from the sorted distance list.
446    /// For regression, also sweeps Gaussian bandwidth candidates in the same
447    /// pass (zero extra distance computations).
448    /// Returns (best_k, Option<(bandwidth, bandwidth_error)>).
449    fn compute_optimal_k(&self) -> usize {
450        self.compute_optimal_k_and_bandwidth().0
451    }
452
453    /// Joint optimization of k and bandwidth. Returns:
454    /// (best_k, best_k_mse, Option<(best_bandwidth, best_bandwidth_mse)>)
455    fn compute_optimal_k_and_bandwidth(&self) -> (usize, f64, Option<(f64, f64)>) {
456        let n = self.len();
457        if n <= 2 {
458            return (n.max(1), f64::MAX, None);
459        }
460
461        let max_k = (n as f64).sqrt().ceil() as usize;
462        let max_k = max_k.max(1).min(n - 1);
463
464        let is_classification = self.detect_classification();
465
466        let max_eval = 200.min(n);
467        let step = if n > max_eval { n / max_eval } else { 1 };
468
469        // Collect sorted distances for each eval point (shared by k and bandwidth sweeps)
470        let eval_data: Vec<(usize, Vec<(usize, f64)>)> = (0..n)
471            .step_by(step)
472            .take(max_eval)
473            .map(|i| {
474                let mut distances: Vec<(usize, f64)> = (0..n)
475                    .filter(|&j| j != i)
476                    .map(|j| (j, self.distance_between(i, j)))
477                    .collect();
478                distances
479                    .sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
480                distances.truncate(max_k);
481                (i, distances)
482            })
483            .collect();
484
485        let count = eval_data.len();
486        if count == 0 {
487            return (1, f64::MAX, None);
488        }
489
490        // Sweep k values
491        let mut errors_by_k = vec![0.0f64; max_k + 1];
492
493        for &(i, ref distances) in &eval_data {
494            if is_classification {
495                // Weighted class voting — matches class_votes() behavior
496                let mut votes: Vec<(f64, f64)> = Vec::new(); // (class, total_weight)
497                for k in 1..=max_k.min(distances.len()) {
498                    let (j, dist) = distances[k - 1];
499                    let val = self.outputs[j];
500                    let w = if dist == 0.0 {
501                        self.instance_weights[j] * 1e6
502                    } else {
503                        self.instance_weights[j] / dist
504                    };
505                    if let Some(entry) = votes.iter_mut().find(|(v, _)| (*v - val).abs() < 1e-10) {
506                        entry.1 += w;
507                    } else {
508                        votes.push((val, w));
509                    }
510                    let predicted = votes
511                        .iter()
512                        .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
513                        .unwrap()
514                        .0;
515                    if (predicted - self.outputs[i]).abs() > 0.5 {
516                        errors_by_k[k] += 1.0;
517                    }
518                }
519            } else {
520                // Inverse-distance weighting with instance weights — matches weighted_mean()
521                let mut weight_sum = 0.0;
522                let mut value_sum = 0.0;
523                let mut exact_w = 0.0;
524                let mut exact_v = 0.0;
525                let mut has_exact = false;
526
527                for k in 1..=max_k.min(distances.len()) {
528                    let (j, dist) = distances[k - 1];
529
530                    if dist == 0.0 {
531                        has_exact = true;
532                        exact_w += self.instance_weights[j];
533                        exact_v += self.instance_weights[j] * self.outputs[j];
534                    } else if !has_exact {
535                        let w = self.instance_weights[j] / dist;
536                        weight_sum += w;
537                        value_sum += w * self.outputs[j];
538                    }
539
540                    let predicted = if has_exact {
541                        if exact_w > 0.0 {
542                            exact_v / exact_w
543                        } else {
544                            self.outputs[j]
545                        }
546                    } else if weight_sum > 0.0 {
547                        value_sum / weight_sum
548                    } else {
549                        continue;
550                    };
551
552                    let err = predicted - self.outputs[i];
553                    errors_by_k[k] += err * err;
554                }
555            }
556        }
557
558        let mut best_k = 1;
559        let mut best_k_error = f64::MAX;
560        for (k, &err) in errors_by_k.iter().enumerate().skip(1) {
561            let error = err / count as f64;
562            if error < best_k_error {
563                best_k_error = error;
564                best_k = k;
565            }
566        }
567
568        // For regression, also sweep Gaussian bandwidth candidates (no extra distance computation)
569        let bandwidth_result = if !is_classification {
570            // Build bandwidth candidates from distance percentiles
571            let mut all_dists: Vec<f64> = Vec::new();
572            for (_, distances) in &eval_data {
573                for &(_, d) in distances {
574                    if d > 0.0 {
575                        all_dists.push(d);
576                    }
577                }
578            }
579
580            if all_dists.is_empty() {
581                None
582            } else {
583                all_dists.sort_by(|a, b| a.partial_cmp(b).unwrap());
584                let h_candidates: Vec<f64> = (1..=20)
585                    .map(|t| {
586                        let pct = t as f64 / 21.0;
587                        let idx = (pct * all_dists.len() as f64) as usize;
588                        all_dists[idx.min(all_dists.len() - 1)]
589                    })
590                    .collect();
591
592                let mut best_h = h_candidates[0];
593                let mut best_h_error = f64::MAX;
594
595                for &h in &h_candidates {
596                    let h2 = 2.0 * h * h;
597                    let mut total_error = 0.0;
598
599                    for &(i, ref distances) in &eval_data {
600                        let mut weight_sum = 0.0;
601                        let mut value_sum = 0.0;
602                        let mut exact_match = None;
603
604                        for &(j, dist) in distances {
605                            if dist == 0.0 {
606                                exact_match = Some(self.outputs[j]);
607                                break;
608                            }
609                            let w = (-dist * dist / h2).exp() * self.instance_weights[j];
610                            if w < 1e-15 {
611                                break;
612                            }
613                            weight_sum += w;
614                            value_sum += w * self.outputs[j];
615                        }
616
617                        let predicted = if let Some(v) = exact_match {
618                            v
619                        } else if weight_sum > 0.0 {
620                            value_sum / weight_sum
621                        } else if let Some(&(j, _)) = distances.first() {
622                            self.outputs[j]
623                        } else {
624                            continue;
625                        };
626
627                        let err = predicted - self.outputs[i];
628                        total_error += err * err;
629                    }
630
631                    let avg_error = total_error / count as f64;
632                    if avg_error < best_h_error {
633                        best_h_error = avg_error;
634                        best_h = h;
635                    }
636                }
637
638                Some((best_h, best_h_error))
639            }
640        } else {
641            None
642        };
643
644        (best_k, best_k_error, bandwidth_result)
645    }
646
647    /// Detect whether this is a classification or regression problem.
648    /// Heuristic: all integer outputs, ≤20 distinct values, AND the ratio of
649    /// distinct values to dataset size is low enough to look categorical.
650    /// This avoids misfiring on integer-valued regression targets like
651    /// ratings (1-5), counts, or ages.
652    fn detect_classification(&self) -> bool {
653        if self.is_empty() {
654            return false;
655        }
656
657        let all_integer = self.outputs.iter().all(|&o| (o - o.round()).abs() < 1e-6);
658
659        if !all_integer {
660            return false;
661        }
662
663        let mut distinct: Vec<f64> = Vec::new();
664        for &o in &self.outputs {
665            let val = o.round();
666            if !distinct.iter().any(|&v| (v - val).abs() < 1e-10) {
667                distinct.push(val);
668                if distinct.len() > 20 {
669                    return false;
670                }
671            }
672        }
673
674        let n = self.len();
675        let n_distinct = distinct.len();
676
677        // With very few data points, can't reliably distinguish — default to regression
678        // unless there are clearly only 2-3 classes.
679        if n < 10 {
680            return n_distinct <= 3;
681        }
682
683        // For larger datasets: if distinct values are a large fraction of the data,
684        // it's more likely integer regression (e.g., 50 distinct values out of 200 points).
685        // Classification datasets typically have n_distinct << sqrt(n).
686        let max_classes = (n as f64).sqrt().ceil() as usize;
687        n_distinct <= max_classes.min(20)
688    }
689}
690
691impl<P: DataPoint + Clone> Default for Renegade<P> {
692    fn default() -> Self {
693        Self::new()
694    }
695}
696
697#[cfg(test)]
698mod tests;