Skip to main content

eventcv_core/
feast.rs

1//! **FEAST** — Feature Extraction using Adaptive Selection Thresholds (Afshar et al.,
2//! *Sensors* 2020, [arXiv:1907.07853](https://arxiv.org/abs/1907.07853)). An unsupervised,
3//! online, event-by-event feature learner: the event-domain analogue of a learned descriptor /
4//! bag-of-visual-words extractor. Unlike the stateless corner detectors in [`crate::features`],
5//! a [`Feast`] carries a *trained model* — `N` feature prototypes (`w×w` weight patches on the
6//! unit hypersphere) each with an adaptive selection threshold — that persists across calls.
7//!
8//! For every event it maintains a per-polarity Surface of Active Events (SAE, the latest
9//! timestamp per pixel — the same machinery [`crate::features::EventStream::efast`] uses), reads
10//! the exponentially-decayed `w×w` time-surface patch around the pixel, and L2-normalises it into
11//! a descriptor `d`. During [`Feast::fit`] the closest feature *within its threshold* wins:
12//! its weights move toward `d` and its threshold **contracts** (`−ΔI`); a **miss** (no feature in
13//! range) **expands** every threshold in that population (`+ΔE`). This competition drives all
14//! features to roughly equal firing rates. During [`Feast::transform`] the thresholds are
15//! discarded and each event is simply assigned its nearest feature.
16//!
17//! Streams are assumed to arrive in **ascending time order** (what the readers produce); call
18//! [`crate::EventStream::sort_by_time`] first otherwise.
19
20use std::{error::Error, fmt};
21
22use rand::{rngs::StdRng, Rng, SeedableRng};
23use rand_distr::StandardNormal;
24
25use crate::EventStream;
26
27/// Parameters for a [`Feast`] model. Defaults follow the paper (`patch = 11`, `eta = 0.001`,
28/// `ΔI = 0.001`, `ΔE = 0.003`); `tau_ms` defaults to 30 ms to match [`crate::representation::
29/// TimeSurface`] and is the exponential decay constant of the time-surface patch.
30#[derive(Clone, Copy, Debug)]
31pub struct FeastConfig {
32    /// Number of feature prototypes **per polarity population** (`b` in the paper).
33    pub n_features: usize,
34    /// Side length `w` of the square ROI patch; must be odd so it centres on the event.
35    pub patch: usize,
36    /// Time-surface decay constant, milliseconds.
37    pub tau_ms: f64,
38    /// Weight mixing rate `η` in `w ← (1−η)w + η·d`.
39    pub eta: f32,
40    /// Threshold contraction on a match (`ΔI`).
41    pub delta_i: f32,
42    /// Threshold expansion on a miss (`ΔE`).
43    pub delta_e: f32,
44    /// When `true`, ON and OFF events train independent feature populations (as in the paper's
45    /// N-MNIST setup); when `false`, a single population learns from both polarities on one merged
46    /// surface. For the paper's ON-only mode, pass an ON-only stream with `per_polarity = false`.
47    pub per_polarity: bool,
48    /// Seed for the random on-hypersphere feature initialisation — fixes reproducibility.
49    pub seed: u64,
50}
51
52impl Default for FeastConfig {
53    fn default() -> Self {
54        Self {
55            n_features: 100,
56            patch: 11,
57            tau_ms: 30.0,
58            eta: 0.001,
59            delta_i: 0.001,
60            delta_e: 0.003,
61            per_polarity: true,
62            seed: 0,
63        }
64    }
65}
66
67/// A FEAST feature-extractor model. Build with [`Feast::new`], adapt online with [`Feast::fit`]
68/// (repeatable across recordings/epochs), then map events to feature ids with [`Feast::transform`].
69#[derive(Clone, Debug)]
70pub struct Feast {
71    config: FeastConfig,
72    /// 1 (merged) or 2 (per-polarity) independent feature populations.
73    banks: usize,
74    /// `patch * patch` — descriptor/feature dimensionality.
75    dim: usize,
76    /// Row-major `[bank][feature][dim]` unit-norm feature weights.
77    weights: Vec<f32>,
78    /// Row-major `[bank][feature]` selection thresholds (cosine-distance acceptance radii).
79    thresholds: Vec<f32>,
80    /// Miss rate of the most recent [`Feast::fit`] call — a convergence proxy.
81    last_missed_rate: f64,
82}
83
84impl Feast {
85    /// Builds a model with random unit-hypersphere feature weights and random thresholds in
86    /// `[0, 1)`, seeded by `config.seed`. Fails if any parameter is out of range.
87    pub fn new(config: FeastConfig) -> Result<Self, FeastError> {
88        validate(&config)?;
89        let banks = if config.per_polarity { 2 } else { 1 };
90        let dim = config.patch * config.patch;
91        let total = banks * config.n_features;
92
93        let mut rng = StdRng::seed_from_u64(config.seed);
94        let mut weights = vec![0.0_f32; total * dim];
95        for feature in weights.chunks_mut(dim) {
96            let mut norm = 0.0_f32;
97            for value in feature.iter_mut() {
98                let sample: f32 = rng.sample(StandardNormal);
99                *value = sample;
100                norm += sample * sample;
101            }
102            let inv = 1.0 / norm.sqrt();
103            feature.iter_mut().for_each(|value| *value *= inv);
104        }
105        let thresholds = (0..total).map(|_| rng.gen::<f32>()).collect();
106
107        Ok(Self {
108            config,
109            banks,
110            dim,
111            weights,
112            thresholds,
113            last_missed_rate: 0.0,
114        })
115    }
116
117    /// Rehydrates a model from saved state (used by the Python `save`/`load` round-trip). `weights`
118    /// is the flat `[bank][feature][dim]` array and `thresholds` the flat `[bank][feature]` array;
119    /// both must match `config`'s implied sizes.
120    pub fn from_state(
121        config: FeastConfig,
122        weights: Vec<f32>,
123        thresholds: Vec<f32>,
124    ) -> Result<Self, FeastError> {
125        validate(&config)?;
126        let banks = if config.per_polarity { 2 } else { 1 };
127        let dim = config.patch * config.patch;
128        let total = banks * config.n_features;
129        if weights.len() != total * dim || thresholds.len() != total {
130            return Err(FeastError::StateShapeMismatch);
131        }
132        Ok(Self {
133            config,
134            banks,
135            dim,
136            weights,
137            thresholds,
138            last_missed_rate: 0.0,
139        })
140    }
141
142    /// Trains the model on `stream` for `epochs` passes (each a fresh time surface, weights and
143    /// thresholds carried over), adapting weights and thresholds online. Returns the **miss rate**
144    /// of the final epoch — the fraction of in-bounds events that matched no feature, which settles
145    /// to a low steady state (a few percent in the paper) as the network converges.
146    pub fn fit(&mut self, stream: &EventStream, epochs: usize) -> f64 {
147        let (width, height) = stream.sensor_size();
148        let scale = stream.timestamp_scale_ms();
149        let plane = width * height;
150        let n = self.config.n_features;
151        let (eta, delta_i, delta_e) = (self.config.eta, self.config.delta_i, self.config.delta_e);
152
153        let mut descriptor = vec![0.0_f32; self.dim];
154        for _ in 0..epochs.max(1) {
155            let mut sae = vec![i64::MIN; self.banks * plane];
156            let (mut seen, mut missed) = (0_u64, 0_u64);
157
158            for event in stream.iter() {
159                let bank = self.bank_of(event.polarity);
160                let surface = &mut sae[bank * plane..(bank + 1) * plane];
161                surface[event.y * width + event.x] = event.timestamp as i64;
162
163                if !self.fill_descriptor(surface, &event, width, height, scale, &mut descriptor) {
164                    continue;
165                }
166                seen += 1;
167
168                match self.best_within_threshold(bank, &descriptor) {
169                    Some(feature) => {
170                        let base = (bank * n + feature) * self.dim;
171                        let weights = &mut self.weights[base..base + self.dim];
172                        let mut norm = 0.0_f32;
173                        for (weight, &input) in weights.iter_mut().zip(&descriptor) {
174                            *weight = (1.0 - eta) * *weight + eta * input;
175                            norm += *weight * *weight;
176                        }
177                        let inv = 1.0 / norm.sqrt();
178                        weights.iter_mut().for_each(|weight| *weight *= inv);
179
180                        let threshold = &mut self.thresholds[bank * n + feature];
181                        *threshold = (*threshold - delta_i).max(0.0);
182                    }
183                    None => {
184                        missed += 1;
185                        for threshold in &mut self.thresholds[bank * n..(bank + 1) * n] {
186                            *threshold += delta_e;
187                        }
188                    }
189                }
190            }
191            self.last_missed_rate = if seen > 0 {
192                missed as f64 / seen as f64
193            } else {
194                0.0
195            };
196        }
197        self.last_missed_rate
198    }
199
200    /// Maps each event to the id of its **nearest** feature (smallest cosine distance), ignoring
201    /// thresholds — the paper's inference rule. Returns one id per input event, aligned to the
202    /// stream; events too close to the border to extract a patch get `-1`. Ids are global:
203    /// population `b`, feature `f` → `b * n_features + f`, so per-polarity OFF ids start at
204    /// `n_features`. Does not modify the model.
205    pub fn transform(&self, stream: &EventStream) -> Vec<i32> {
206        let (width, height) = stream.sensor_size();
207        let scale = stream.timestamp_scale_ms();
208        let plane = width * height;
209
210        let mut ids = vec![-1_i32; stream.len()];
211        let mut descriptor = vec![0.0_f32; self.dim];
212        let mut sae = vec![i64::MIN; self.banks * plane];
213
214        for (index, event) in stream.iter().enumerate() {
215            let bank = self.bank_of(event.polarity);
216            let surface = &mut sae[bank * plane..(bank + 1) * plane];
217            surface[event.y * width + event.x] = event.timestamp as i64;
218
219            if self.fill_descriptor(surface, &event, width, height, scale, &mut descriptor) {
220                let feature = self.nearest(bank, &descriptor);
221                ids[index] = (bank * self.config.n_features + feature) as i32;
222            }
223        }
224        ids
225    }
226
227    /// Pooled feature-event counts over `stream` (the classifier input in the paper): a histogram
228    /// of length `n_features_total` counting how many events each feature won under
229    /// [`Self::transform`].
230    pub fn histogram(&self, stream: &EventStream) -> Vec<u32> {
231        let mut counts = vec![0_u32; self.n_features_total()];
232        for id in self.transform(stream) {
233            if id >= 0 {
234                counts[id as usize] += 1;
235            }
236        }
237        counts
238    }
239
240    /// The learned feature weights, flat `[bank][feature][dim]` (reshape to
241    /// `(n_features_total, patch, patch)` for the per-feature patch images).
242    pub fn weights(&self) -> &[f32] {
243        &self.weights
244    }
245
246    /// The current selection thresholds, flat `[bank][feature]`.
247    pub fn thresholds(&self) -> &[f32] {
248        &self.thresholds
249    }
250
251    /// Total feature count across populations (`banks * n_features`).
252    pub fn n_features_total(&self) -> usize {
253        self.banks * self.config.n_features
254    }
255
256    pub fn config(&self) -> &FeastConfig {
257        &self.config
258    }
259
260    /// Miss rate recorded by the most recent [`Self::fit`] (0 before any training).
261    pub fn missed_rate(&self) -> f64 {
262        self.last_missed_rate
263    }
264
265    /// Which feature population an event of the given polarity trains: OFF → 1 only when
266    /// `per_polarity` is set, else the single merged population 0.
267    fn bank_of(&self, polarity: bool) -> usize {
268        if self.config.per_polarity && !polarity {
269            1
270        } else {
271            0
272        }
273    }
274
275    /// Fills `descriptor` with the L2-normalised exponentially-decayed `w×w` patch centred on the
276    /// event, reading `surface` (its own population's SAE, already updated at the centre pixel).
277    /// Returns `false` — leaving `descriptor` untouched for the caller to skip — when the patch
278    /// would cross a border. The centre pixel just fired, so the patch norm is always positive.
279    fn fill_descriptor(
280        &self,
281        surface: &[i64],
282        event: &crate::Event,
283        width: usize,
284        height: usize,
285        scale: f64,
286        descriptor: &mut [f32],
287    ) -> bool {
288        let radius = self.config.patch / 2;
289        if event.x < radius
290            || event.y < radius
291            || event.x + radius >= width
292            || event.y + radius >= height
293        {
294            return false;
295        }
296        let now = event.timestamp as i64;
297        let mut norm = 0.0_f32;
298        let mut slot = 0;
299        for dy in -(radius as i32)..=radius as i32 {
300            for dx in -(radius as i32)..=radius as i32 {
301                let x = (event.x as i32 + dx) as usize;
302                let y = (event.y as i32 + dy) as usize;
303                let last = surface[y * width + x];
304                let value = if last == i64::MIN {
305                    0.0
306                } else {
307                    let age_ms = (now - last).max(0) as f64 * scale;
308                    (-age_ms / self.config.tau_ms).exp() as f32
309                };
310                descriptor[slot] = value;
311                norm += value * value;
312                slot += 1;
313            }
314        }
315        if norm <= 0.0 {
316            return false;
317        }
318        let inv = 1.0 / norm.sqrt();
319        descriptor.iter_mut().for_each(|value| *value *= inv);
320        true
321    }
322
323    /// Best-matching feature in `bank` whose cosine distance is within its threshold (smallest
324    /// distance wins), or `None` if the descriptor lands outside every threshold (a miss).
325    fn best_within_threshold(&self, bank: usize, descriptor: &[f32]) -> Option<usize> {
326        let n = self.config.n_features;
327        let mut best = None;
328        let mut best_distance = f32::INFINITY;
329        for feature in 0..n {
330            let distance = self.cosine_distance(bank * n + feature, descriptor);
331            if distance <= self.thresholds[bank * n + feature] && distance < best_distance {
332                best_distance = distance;
333                best = Some(feature);
334            }
335        }
336        best
337    }
338
339    /// Nearest feature in `bank` by cosine distance, ignoring thresholds (inference rule).
340    fn nearest(&self, bank: usize, descriptor: &[f32]) -> usize {
341        let n = self.config.n_features;
342        (0..n)
343            .min_by(|&a, &b| {
344                let da = self.cosine_distance(bank * n + a, descriptor);
345                let db = self.cosine_distance(bank * n + b, descriptor);
346                da.total_cmp(&db)
347            })
348            .expect("n_features is at least 1")
349    }
350
351    /// Cosine distance `1 − w·d` between global feature `index` and a unit-norm descriptor.
352    fn cosine_distance(&self, index: usize, descriptor: &[f32]) -> f32 {
353        let base = index * self.dim;
354        let dot: f32 = self.weights[base..base + self.dim]
355            .iter()
356            .zip(descriptor)
357            .map(|(weight, input)| weight * input)
358            .sum();
359        1.0 - dot
360    }
361}
362
363fn validate(config: &FeastConfig) -> Result<(), FeastError> {
364    if config.n_features == 0 {
365        return Err(FeastError::InvalidParameter("n_features must be at least 1"));
366    }
367    if config.patch == 0 || config.patch.is_multiple_of(2) {
368        return Err(FeastError::InvalidParameter("patch must be odd and at least 1"));
369    }
370    if !config.tau_ms.is_finite() || config.tau_ms <= 0.0 {
371        return Err(FeastError::InvalidParameter("tau_ms must be finite and positive"));
372    }
373    if !config.eta.is_finite() || config.eta <= 0.0 || config.eta > 1.0 {
374        return Err(FeastError::InvalidParameter("eta must be in (0, 1]"));
375    }
376    if !config.delta_i.is_finite() || config.delta_i < 0.0 {
377        return Err(FeastError::InvalidParameter("delta_i must be finite and non-negative"));
378    }
379    if !config.delta_e.is_finite() || config.delta_e < 0.0 {
380        return Err(FeastError::InvalidParameter("delta_e must be finite and non-negative"));
381    }
382    Ok(())
383}
384
385#[derive(Debug, PartialEq, Eq)]
386pub enum FeastError {
387    InvalidParameter(&'static str),
388    StateShapeMismatch,
389}
390
391impl fmt::Display for FeastError {
392    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
393        match self {
394            Self::InvalidParameter(message) => formatter.write_str(message),
395            Self::StateShapeMismatch => {
396                formatter.write_str("feature/threshold arrays do not match the configuration")
397            }
398        }
399    }
400}
401
402impl Error for FeastError {}
403
404#[cfg(test)]
405mod tests {
406    use super::{Feast, FeastConfig, FeastError};
407    use crate::EventStream;
408
409    /// A vertical bar sweeping left→right across a `size×size` sensor, repeated `sweeps` times.
410    /// Column `x` fires (all rows) one step after column `x−1`, so the local time surface around
411    /// each event is a clean spatial ramp — a learnable moving-edge feature.
412    fn sweeping_bar(size: usize, sweeps: usize) -> EventStream {
413        let mut rows = Vec::new();
414        let mut t = 0_u64;
415        for _ in 0..sweeps {
416            for x in 0..size as u64 {
417                for y in 0..size as u64 {
418                    rows.push([x, y, t, 1]);
419                }
420                t += 1_000; // 1 ms/column
421            }
422        }
423        let flat: Vec<u64> = rows.concat();
424        let events = ndarray::Array2::from_shape_vec((rows.len(), 4), flat).unwrap();
425        EventStream::from_array2(events, size, size, 0.001)
426    }
427
428    fn config(n_features: usize, patch: usize, per_polarity: bool) -> FeastConfig {
429        FeastConfig {
430            n_features,
431            patch,
432            per_polarity,
433            ..FeastConfig::default()
434        }
435    }
436
437    #[test]
438    fn new_rejects_invalid_parameters() {
439        assert_eq!(
440            Feast::new(config(0, 5, false)).unwrap_err(),
441            FeastError::InvalidParameter("n_features must be at least 1")
442        );
443        assert_eq!(
444            Feast::new(config(4, 4, false)).unwrap_err(),
445            FeastError::InvalidParameter("patch must be odd and at least 1")
446        );
447        assert!(matches!(
448            Feast::new(FeastConfig {
449                tau_ms: 0.0,
450                ..config(4, 5, false)
451            }),
452            Err(FeastError::InvalidParameter(_))
453        ));
454    }
455
456    #[test]
457    fn init_is_deterministic_and_unit_norm() {
458        let a = Feast::new(config(8, 5, false)).unwrap();
459        let b = Feast::new(config(8, 5, false)).unwrap();
460        assert_eq!(a.weights(), b.weights());
461        assert_eq!(a.thresholds(), b.thresholds());
462        // Every feature lies on the unit hypersphere.
463        for feature in a.weights().chunks(5 * 5) {
464            let norm: f32 = feature.iter().map(|value| value * value).sum();
465            assert!((norm - 1.0).abs() < 1e-5, "‖w‖² = {norm}");
466        }
467    }
468
469    #[test]
470    fn fit_converges_to_a_low_miss_rate() {
471        // Short epochs so the cold-start burst of misses (random features/thresholds reject most
472        // early inputs) dominates epoch 1's average, then falls away as the network adapts.
473        let stream = sweeping_bar(16, 4);
474        let mut feast = Feast::new(config(8, 5, false)).unwrap();
475        let first = feast.fit(&stream, 1);
476        let mut last = first;
477        for _ in 0..14 {
478            last = feast.fit(&stream, 1);
479        }
480        // A cold network misses heavily; adaptation drives it to a low steady state (paper: a few
481        // percent), so the converged rate is both far below the first epoch and small in absolute
482        // terms.
483        assert!(first > 0.1, "cold network should miss heavily, got {first}");
484        assert!(last < first, "miss rate should fall with training: {first} → {last}");
485        assert!(last < 0.05, "converged miss rate too high: {last}");
486    }
487
488    #[test]
489    fn transform_labels_are_aligned_and_in_range() {
490        let stream = sweeping_bar(16, 20);
491        let mut feast = Feast::new(config(6, 5, false)).unwrap();
492        feast.fit(&stream, 4);
493
494        let ids = feast.transform(&stream);
495        assert_eq!(ids.len(), stream.len());
496        assert!(ids.iter().all(|&id| (-1..feast.n_features_total() as i32).contains(&id)));
497        // Most events sit inside the border, so most are labelled.
498        let labelled = ids.iter().filter(|&&id| id >= 0).count();
499        assert!(labelled > stream.len() / 2);
500
501        // The histogram is exactly the tally of the non-border labels.
502        let histogram = feast.histogram(&stream);
503        assert_eq!(histogram.len(), feast.n_features_total());
504        assert_eq!(histogram.iter().sum::<u32>() as usize, labelled);
505    }
506
507    #[test]
508    fn per_polarity_splits_populations() {
509        let mut rows = Vec::new();
510        let mut t = 0_u64;
511        for x in 0..16_u64 {
512            for y in 0..16_u64 {
513                rows.push([x, y, t, (x % 2)]); // alternate ON/OFF columns
514            }
515            t += 1_000;
516        }
517        let flat: Vec<u64> = rows.concat();
518        let events = ndarray::Array2::from_shape_vec((rows.len(), 4), flat).unwrap();
519        let stream = EventStream::from_array2(events, 16, 16, 0.001);
520
521        let mut feast = Feast::new(config(5, 5, true)).unwrap();
522        feast.fit(&stream, 4);
523        assert_eq!(feast.n_features_total(), 10);
524        // OFF events must be able to land in the second population (ids ≥ n_features).
525        assert!(feast.transform(&stream).iter().any(|&id| id >= 5));
526    }
527
528    #[test]
529    fn from_state_round_trips_the_model() {
530        let stream = sweeping_bar(16, 10);
531        let mut feast = Feast::new(config(6, 5, false)).unwrap();
532        feast.fit(&stream, 3);
533
534        let rebuilt = Feast::from_state(
535            *feast.config(),
536            feast.weights().to_vec(),
537            feast.thresholds().to_vec(),
538        )
539        .unwrap();
540        assert_eq!(feast.transform(&stream), rebuilt.transform(&stream));
541
542        assert_eq!(
543            Feast::from_state(*feast.config(), vec![0.0; 3], vec![0.0; 2]).unwrap_err(),
544            FeastError::StateShapeMismatch
545        );
546    }
547}