Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 7 additions & 50 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 1 addition & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
cargo-features = ["edition2024"]

[package]
name = "sliding_features"
version = "9.2.0"
version = "10.0.0"
authors = ["MathisWellmann <wellmannmathis@gmail.com>"]
edition = "2024"
license-file = "LICENSE"
Expand All @@ -22,7 +20,6 @@ exclude = ["img/"]
[dependencies]
getset = "0.1"
num = "0.4"
watermill = "0.1.2"

[dev-dependencies]
ballpark = "1"
Expand Down
2 changes: 1 addition & 1 deletion benches/mad_scaler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use sliding_features::{
};

const N: usize = 100_000;
const WINDOW_LENS: &[usize] = &[128, 256, 512, 1024, 2048, 4096, 8192];
const WINDOW_LENS: &[usize] = &[128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768];

fn standard_normal(rng: &mut SmallRng) -> f64 {
// Box-Muller transform. Clamp u1 away from zero so ln(u1) is finite.
Expand Down
92 changes: 54 additions & 38 deletions src/sliding_windows/mad_scaler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
//! and works well for skewed distributions.

use std::{
collections::VecDeque,
num::NonZeroUsize,
ops::{
AddAssign,
Expand All @@ -22,7 +23,6 @@ use num::{
Float,
FromPrimitive,
};
use watermill::sorted_window::SortedWindow;

use crate::View;

Expand All @@ -38,8 +38,10 @@ pub struct MadScaler<T: Float + FromPrimitive + AddAssign + SubAssign, V> {
view: V,
/// Sliding window length (for warm-up tracking).
window_len: NonZeroUsize,
/// Sliding window storing the sorted values.
sorted: SortedWindow<T>,
/// Sorted values in the window (contiguous `Vec` — faster than `VecDeque<NotNan>`).
sorted: Vec<T>,
/// FIFO insertion order for O(1) eviction decisions.
order: VecDeque<T>,
/// Cached median of the window (recomputed when the sliding median changes).
cached_median: T,
/// Cached MAD (recomputed when the sliding median changes).
Expand Down Expand Up @@ -79,18 +81,43 @@ where
/// window length.
#[inline]
pub fn new(view: V, window_len: NonZeroUsize) -> Self {
let w = window_len.get();
Self {
view,
window_len,
sorted: SortedWindow::new(window_len.get()),
sorted: Vec::with_capacity(w),
order: VecDeque::with_capacity(w),
cached_median: F::zero(),
cached_mad: F::zero(),
out: None,
count: 0,
mad_buf: vec![F::zero(); window_len.get()],
mad_buf: vec![F::zero(); w],
}
}

/// Slide the window: evict the oldest value (if full), insert `val` in sorted position.
#[inline]
fn slide_window(&mut self, val: F) {
if self.sorted.len() >= self.window_len.get() {
let oldest = self.order.pop_front().unwrap();
// partition_point requires monotonic predicate; sorted[len..] >= oldest after this.
let pos = self
.sorted
.partition_point(|p| p.partial_cmp(&oldest).expect("NaN") == std::cmp::Ordering::Less);
debug_assert_eq!(
self.sorted[pos].partial_cmp(&oldest),
Some(std::cmp::Ordering::Equal),
"oldest value missing from sorted window"
);
self.sorted.remove(pos);
}
self.order.push_back(val);
let pos = self
.sorted
.partition_point(|p| p.partial_cmp(&val).expect("NaN") == std::cmp::Ordering::Less);
self.sorted.insert(pos, val);
}

/// The sliding window length.
#[inline(always)]
pub fn window_len(&self) -> NonZeroUsize {
Expand All @@ -107,7 +134,8 @@ where
fn recompute_cache(&mut self) {
let n = self.sorted.len();
debug_assert!(n > 0, "recompute_cache called on empty window");
debug_assert_eq!(n, self.mad_buf.len());
// mad_buf may be larger than current window during warm-up ramp; use n.
debug_assert!(n <= self.mad_buf.len());

// --- median (from already-sorted window) ---
let median = if n % 2 == 0 {
Expand All @@ -119,9 +147,14 @@ where
};
self.cached_median = median;

// --- median absolute deviation via O(w) merge ---
// --- median absolute deviation via O(w) branchless merge ---
// Walk the sorted window outward from the median, merging the
// monotonically-increasing left and right absolute deviations.
//
// ponytail: branchless — both sides computed unconditionally,
// exhausted side returns infinity sentinel so the other always wins.
// The hot-path `.get()` always returns Some (predictable branch).
// Selection and pointer advance compile to cmov.
let mad_buf = &mut self.mad_buf;
let mid = n / 2;
let mut out_idx = 0usize;
Expand All @@ -136,37 +169,20 @@ where
let mut left: isize = mid.checked_sub(1).map_or(-1, |v| v as isize);
let mut right: usize = if n % 2 == 0 { mid } else { mid + 1 };

loop {
let l_valid = left >= 0;
let r_valid = right < n;
match (l_valid, r_valid) {
(true, true) => {
let l_abs = (self.sorted[left as usize] - median).abs();
let r_abs = (self.sorted[right] - median).abs();
if l_abs <= r_abs {
mad_buf[out_idx] = l_abs;
out_idx += 1;
left -= 1;
} else {
mad_buf[out_idx] = r_abs;
out_idx += 1;
right += 1;
}
}
(true, false) => {
mad_buf[out_idx] = (self.sorted[left as usize] - median).abs();
out_idx += 1;
left -= 1;
}
(false, true) => {
mad_buf[out_idx] = (self.sorted[right] - median).abs();
out_idx += 1;
right += 1;
}
(false, false) => break,
}
while out_idx < n {
let l_abs = self
.sorted
.get(left as usize)
.map_or(F::infinity(), |v| (*v - median).abs());
let r_abs = self
.sorted
.get(right)
.map_or(F::infinity(), |v| (*v - median).abs());
let pick_left = l_abs <= r_abs;
mad_buf[out_idx] = if pick_left { l_abs } else { r_abs };
if pick_left { left -= 1 } else { right += 1 }
out_idx += 1;
}
debug_assert_eq!(out_idx, n, "merge must fill entire buffer");

self.cached_mad = median_from_sorted(mad_buf);
}
Expand Down Expand Up @@ -218,7 +234,7 @@ where

// Slide window: push current value into the sorted window.
// This makes it part of the *next* normalization's reference.
self.sorted.push_back(val);
self.slide_window(val);

self.count = (self.count + 1).min(self.window_len.get());
}
Expand Down
Loading