Skip to main content

renegade_ml/
neighbor.rs

1use crate::predict::ExtrapolatedPrediction;
2
3/// A single nearest neighbor result.
4#[derive(Debug, Clone)]
5pub struct Neighbor {
6    /// Distance from query point (0 = identical).
7    pub distance: f64,
8    /// Output value of this training point.
9    pub output: f64,
10    /// Instance weight (default 1.0). Higher weight means this point
11    /// has more influence on predictions.
12    pub weight: f64,
13}
14
15/// A set of nearest neighbors, sorted by distance.
16#[derive(Debug, Clone)]
17pub struct Neighbors {
18    pub neighbors: Vec<Neighbor>,
19}
20
21impl Neighbors {
22    /// Weighted average of neighbor outputs.
23    /// Combines inverse-distance weighting with instance weights:
24    /// effective_weight = instance_weight / distance.
25    pub fn weighted_mean(&self) -> f64 {
26        if self.neighbors.is_empty() {
27            return f64::NAN;
28        }
29
30        // If any neighbor has distance 0, return weighted average of exact matches.
31        let exact: Vec<&Neighbor> = self
32            .neighbors
33            .iter()
34            .filter(|n| n.distance == 0.0)
35            .collect();
36        if !exact.is_empty() {
37            let total_w: f64 = exact.iter().map(|n| n.weight).sum();
38            if total_w > 0.0 {
39                return exact.iter().map(|n| n.weight * n.output).sum::<f64>() / total_w;
40            }
41            return exact[0].output;
42        }
43
44        let mut weight_sum = 0.0;
45        let mut value_sum = 0.0;
46        for n in &self.neighbors {
47            let w = n.weight / n.distance;
48            weight_sum += w;
49            value_sum += w * n.output;
50        }
51        value_sum / weight_sum
52    }
53
54    /// Gaussian kernel weighted average: w(d) = instance_weight * exp(-d²/(2h²)).
55    /// Unlike hard-k + 1/d, this gives smooth decay — distant neighbors contribute
56    /// proportionally less without a sharp cutoff.
57    pub fn gaussian_weighted_mean(&self, bandwidth: f64) -> f64 {
58        if self.neighbors.is_empty() {
59            return f64::NAN;
60        }
61
62        // Exact matches: same handling as weighted_mean
63        let exact: Vec<&Neighbor> = self
64            .neighbors
65            .iter()
66            .filter(|n| n.distance == 0.0)
67            .collect();
68        if !exact.is_empty() {
69            let total_w: f64 = exact.iter().map(|n| n.weight).sum();
70            if total_w > 0.0 {
71                return exact.iter().map(|n| n.weight * n.output).sum::<f64>() / total_w;
72            }
73            return exact[0].output;
74        }
75
76        let h2 = 2.0 * bandwidth * bandwidth;
77        let mut weight_sum = 0.0;
78        let mut value_sum = 0.0;
79        for n in &self.neighbors {
80            let w = (-n.distance * n.distance / h2).exp() * n.weight;
81            if w < 1e-15 {
82                // Beyond ~6 sigma, negligible contribution — stop early
83                // since neighbors are sorted by distance
84                break;
85            }
86            weight_sum += w;
87            value_sum += w * n.output;
88        }
89        if weight_sum > 0.0 {
90            value_sum / weight_sum
91        } else {
92            // Bandwidth too small for any neighbor to contribute — fall back to nearest
93            self.neighbors[0].output
94        }
95    }
96
97    /// Extrapolate output to distance=0 by fitting a linear trend.
98    pub fn extrapolate(&self) -> ExtrapolatedPrediction {
99        ExtrapolatedPrediction::from_neighbors(&self.neighbors)
100    }
101
102    /// Class probabilities: weighted fraction of neighbors with each distinct output value.
103    /// Combines inverse-distance weighting with instance weights.
104    pub fn class_votes(&self) -> Vec<(f64, f64)> {
105        if self.neighbors.is_empty() {
106            return Vec::new();
107        }
108
109        let mut counts: Vec<(f64, f64)> = Vec::new(); // (class, total_weight)
110        for n in &self.neighbors {
111            let w = if n.distance == 0.0 {
112                n.weight * 1e6 // very large but finite weight for exact matches
113            } else {
114                n.weight / n.distance
115            };
116            if let Some(entry) = counts
117                .iter_mut()
118                .find(|(v, _)| (*v - n.output).abs() < 1e-10)
119            {
120                entry.1 += w;
121            } else {
122                counts.push((n.output, w));
123            }
124        }
125
126        let total: f64 = counts.iter().map(|(_, w)| w).sum();
127        let n_classes = counts.len() as f64;
128        if total > 0.0 {
129            counts
130                .into_iter()
131                .map(|(class, w)| (class, w / total))
132                .collect()
133        } else {
134            counts
135                .into_iter()
136                .map(|(class, _)| (class, 1.0 / n_classes))
137                .collect()
138        }
139    }
140
141    /// Random sample from neighbors (uniform).
142    pub fn sample(&self, rng_value: f64) -> Option<f64> {
143        if self.neighbors.is_empty() {
144            return None;
145        }
146        let idx = (rng_value * self.neighbors.len() as f64) as usize;
147        let idx = idx.min(self.neighbors.len() - 1);
148        Some(self.neighbors[idx].output)
149    }
150}