MayaFlux 0.5.0
Digital-First Multimedia Processing Framework
Loading...
Searching...
No Matches
Estimate.hpp
Go to the documentation of this file.
1#pragma once
2
4
6
7/**
8 * @enum EstimateModel
9 * @brief Strategies for characterizing an evolving stream's statistical behavior
10 *
11 * Stochastic generates a sequence with chosen statistical character.
12 * Estimate consumes an arriving sequence and characterizes its statistical
13 * character as it evolves, since a live stream from a physical source
14 * (a sensor, a device report, any per-frame sample) does not carry a
15 * fixed, known noise floor, spread, or trend the way a synthetic signal
16 * does. Both are stateful processes that evolve over successive calls;
17 * this enum plays the role Algorithm plays for Stochastic, selecting how
18 * that evolution is computed rather than what it produces.
19 */
20enum class EstimateModel : uint8_t {
21 ROLLING_VARIANCE, // Variance and floor tracked over a fixed recent window
22 EWM_VARIANCE, // Variance and floor tracked via exponential weighting, unbounded memory depth
23 MEDIAN_ABSOLUTE_DEVIATION, // Floor tracked via MAD, robust to single outlier spikes
24 QUIET_PERIOD_FLOOR, // Floor only updates during self-detected low-activity stretches
25 TREND // Running linear trend and the variance not explained by it
26};
27
28/**
29 * @struct EstimateState
30 * @brief Persistent state for an Estimate instance
31 *
32 * Mirrors GeneratorState in role: a stateful process that evolves over
33 * successive calls needs somewhere to keep that evolution, exposed for
34 * analysis, visualization, or external nudging rather than hidden
35 * entirely inside the class. Not every field is meaningful for every
36 * EstimateModel; unused fields for a given model stay at their default.
37 */
39 double running_mean { 0.0 };
40 double running_variance { 0.0 };
41 double floor { 0.0 };
42 double filtered_value { 0.0 };
43 double trend_slope { 0.0 };
44 double trend_explained_ratio { 0.0 };
45 double last_raw_sample { 0.0 };
46 uint64_t sample_count { 0 };
47
48 void reset()
49 {
50 running_mean = 0.0;
51 running_variance = 0.0;
52 floor = 0.0;
53 filtered_value = 0.0;
54 trend_slope = 0.0;
56 last_raw_sample = 0.0;
57 sample_count = 0;
58 }
59};
60
61/**
62 * @class Estimate
63 * @brief Stateful statistical characterization of an evolving scalar stream
64 *
65 * Provides mathematical primitives for characterizing controlled
66 * uncertainty in an arriving signal, across all computational domains.
67 * This is the read direction of what Stochastic is for the write
68 * direction: Stochastic produces a signal with chosen statistical
69 * character, Estimate consumes a signal and characterizes its
70 * statistical character, updating that characterization as the
71 * stream's behavior itself changes.
72 *
73 * ## Architectural Philosophy
74 * Treats stream characterization as fundamental mathematical
75 * infrastructure rather than domain-specific processing. The same
76 * primitives that learn a tablet's pressure noise floor can learn a
77 * camera-derived tracking point's jitter, an analysis feature's drift,
78 * or any other per-frame scalar's evolving statistical behavior. The
79 * numbers themselves are discipline-agnostic.
80 *
81 * ## Model Categories
82 *
83 * **Windowed** (bounded recent memory):
84 * - ROLLING_VARIANCE: mean and variance over the last N samples
85 * - MEDIAN_ABSOLUTE_DEVIATION: median-based floor, robust to spikes
86 * - QUIET_PERIOD_FLOOR: floor only updates when the window looks calm
87 * by its own recent standard
88 * - TREND: linear trend and residual variance over the last N samples
89 *
90 * **Unbounded** (exponentially weighted memory):
91 * - EWM_VARIANCE: mean and variance tracked with no fixed window depth,
92 * older samples fade rather than drop off a cliff
93 *
94 * ## Usage Patterns
95 *
96 * Learning a noise floor:
97 * ```cpp
98 * Estimate est(EstimateModel::EWM_VARIANCE);
99 * for (auto sample : incoming_stream) {
100 * double floor = est.update(sample);
101 * double conf = est.confidence(sample - est.state().last_raw_sample);
102 * }
103 * ```
104 *
105 * Feeding Differential with cleaned values:
106 * ```cpp
107 * Estimate est(EstimateModel::EWM_VARIANCE);
108 * Memory::HistoryBuffer<double> position_history(3);
109 * for (auto raw_sample : incoming_stream) {
110 * est.update_into(raw_sample, position_history);
111 * double vel = Differential::velocity(position_history, dt);
112 * double acc = Differential::acceleration(position_history, dt);
113 * }
114 * ```
115 *
116 * One-shot characterization of an already-captured window:
117 * ```cpp
118 * double v = Estimate::variance(samples);
119 * double trend = Estimate::trend_explained_ratio(samples);
120 * ```
121 *
122 * @note Thread-unsafe for maximum performance, matching Stochastic.
123 * Use separate instances per stream per thread.
124 */
125class MAYAFLUX_API Estimate {
126public:
127 /**
128 * @brief Constructs an estimator with the specified model
129 * @param model Characterization strategy (default: EWM_VARIANCE)
130 * @param adapt_rate Blend rate for EWM_VARIANCE, ignored by other
131 * models. Smaller values remember longer, larger values
132 * adapt faster. Range (0, 1].
133 * @param window Sample count for windowed models (ROLLING_VARIANCE,
134 * MEDIAN_ABSOLUTE_DEVIATION, QUIET_PERIOD_FLOOR, TREND).
135 * Ignored by EWM_VARIANCE.
136 */
137 explicit Estimate(EstimateModel model = EstimateModel::EWM_VARIANCE,
138 double adapt_rate = 0.05, size_t window = 32);
139
140 /**
141 * @brief Changes active model
142 * @param model New characterization model
143 *
144 * Resets internal state when switching models, matching
145 * Stochastic::set_algorithm.
146 */
147 void set_model(EstimateModel model);
148
149 /**
150 * @brief Gets current model
151 */
152 [[nodiscard]] inline EstimateModel get_model() const { return m_model; }
153
154 /**
155 * @brief Sets the adapt rate used by EWM_VARIANCE
156 */
157 void set_adapt_rate(double rate) { m_adapt_rate = rate; }
158
159 /**
160 * @brief Gets the current adapt rate
161 */
162 [[nodiscard]] double get_adapt_rate() const { return m_adapt_rate; }
163
164 /**
165 * @brief Sets the window size used by windowed models
166 *
167 * Clears any accumulated window contents; the estimate rebuilds
168 * from the next window worth of samples.
169 */
170 void set_window(size_t window);
171
172 /**
173 * @brief Gets the current window size
174 */
175 [[nodiscard]] size_t get_window() const { return m_window; }
176
177 /**
178 * @brief Feed one new sample, updating the running estimate
179 * @param sample Raw value for this step
180 * @return Current floor after incorporating this sample. For TREND,
181 * returns the current residual (non-trend) standard deviation.
182 */
183 double update(double sample);
184
185 /**
186 * @brief Confidence that a step is signal, not floor
187 * @param step Difference in the same units as floor(): a per-sample
188 * change, e.g. raw_sample - value(), or any other quantity
189 * measured in the same units the stream itself is in.
190 * @return 0.0 (indistinguishable from floor) to 1.0 (well above floor)
191 *
192 * step and floor() must be the same physical quantity. floor() is a
193 * static spread (units of the stream itself, e.g. position), not a
194 * rate. Passing a Kinesis::Differential velocity or acceleration
195 * here compares a rate against a static spread, which are different
196 * units and produces a meaningless, typically saturated result: a
197 * velocity is often numerically enormous relative to a small
198 * per-sample floor regardless of whether the motion is real signal
199 * or noise, since dividing by dt inflates the value independent of
200 * confidence. For a Differential-derived rate, use
201 * confidence_of_rate() instead, which performs the correct
202 * rate-to-step conversion before comparing.
203 *
204 * Not a hard threshold. A caller wanting a binary decision picks
205 * their own cutoff against this; Estimate only reports where the
206 * step sits relative to the learned floor.
207 */
208 [[nodiscard]] double confidence(double step) const;
209
210 /**
211 * @brief Confidence that a Differential-derived rate reflects signal, not floor
212 * @param rate A velocity, acceleration, or other per-second quantity
213 * from Kinesis::Differential
214 * @param dt The same dt passed to the Differential call that produced rate
215 * @return 0.0 to 1.0, as confidence()
216 *
217 * Converts rate back to a per-sample step (rate * dt) before
218 * comparing against floor(), which is what makes the comparison
219 * dimensionally correct. Without this conversion, floor (a static
220 * spread) and rate (a quantity already divided by dt) are different
221 * units, and the ratio between them says nothing about confidence.
222 */
223 [[nodiscard]] double confidence_of_rate(double rate, double dt) const
224 {
225 return confidence(rate * dt);
226 }
227
228 /**
229 * @brief Current learned floor
230 *
231 * For TREND, this is the residual (non-trend) standard deviation
232 * rather than a noise floor in the windowed-variance sense.
233 */
234 [[nodiscard]] double floor() const { return m_state.floor; }
235
236 /**
237 * @brief Current filtered value, the cleaned counterpart to the raw sample
238 *
239 * This is the value a caller should differentiate, not the raw
240 * sample last passed to update(). Estimate exists to turn a noisy
241 * arriving stream into something Kinesis::Differential can safely
242 * take derivatives of: differentiation amplifies noise, so feeding
243 * Differential's HistoryBuffer-based functions a raw high-resolution
244 * device stream directly produces a velocity/acceleration/jerk
245 * signal dominated by sensor noise rather than motion.
246 *
247 * What "filtered" means depends on the active model:
248 * - ROLLING_VARIANCE / MEDIAN_ABSOLUTE_DEVIATION: the window mean
249 * or median, a straightforward smoothing filter
250 * - EWM_VARIANCE: the exponentially weighted running mean
251 * - QUIET_PERIOD_FLOOR: the last quiet-period mean while the stream
252 * looks calm, but the raw sample itself while the stream looks
253 * active, since lagging behind a stale mean during real motion
254 * would corrupt exactly the transient a caller most wants intact
255 * - TREND: the trend line's value at the newest sample, tracking
256 * directional motion while smoothing residual jitter around it
257 */
258 [[nodiscard]] double value() const { return m_state.filtered_value; }
259
260 /**
261 * @brief Feed one sample and push the filtered result into a HistoryBuffer
262 * @param sample Raw value for this step
263 * @param out History buffer to receive the filtered value, so a
264 * caller can hand this buffer directly to Kinesis::Differential
265 * instead of maintaining a separate raw-sample buffer
266 *
267 * Equivalent to update(sample) followed by out.push(value()), given
268 * as one call since running an Estimate purely to feed Differential
269 * is the primary intended usage rather than an incidental one.
270 */
272 {
273 update(sample);
274 out.push(m_state.filtered_value);
275 }
276
277 /**
278 * @brief Resets internal state
279 *
280 * Memoryless usage is unaffected since Estimate has none; this
281 * clears all accumulated running state and window contents.
282 */
283 void reset();
284
285 /**
286 * @brief Gets current internal state
287 * @return Read-only reference to estimator state
288 *
289 * Exposes complete internal state for analysis, visualization,
290 * debugging, or extracting the learned characterization for use
291 * elsewhere (e.g. seeding a second Estimate instance's floor).
292 */
293 [[nodiscard]] const EstimateState& state() const { return m_state; }
294
295 /**
296 * @brief Gets mutable internal state
297 * @return Mutable reference to estimator state
298 *
299 * Enables direct manipulation for seeding a floor from prior
300 * analysis, resetting after a known discontinuity (device
301 * reconnect, deliberate large jump), or externally nudging
302 * the estimate.
303 */
304 [[nodiscard]] EstimateState& state_mutable() { return m_state; }
305
306 // ========================================================================
307 // Stateless span characterization
308 // ========================================================================
309 //
310 // One-shot estimates over a span the caller already holds, with no
311 // persistent state, for the case where an Estimate instance's evolving
312 // memory is not wanted, e.g. characterizing a single already-captured
313 // window rather than tracking a live stream call to call.
314
315 /**
316 * @brief Sample variance of a span
317 * @param samples Values to analyze
318 * @return Variance, or 0.0 for spans of size < 2
319 */
320 [[nodiscard]] static double variance(std::span<const double> samples) noexcept;
321
322 /**
323 * @brief Standard deviation of a span
324 * @param samples Values to analyze
325 * @return sqrt(variance(samples))
326 */
327 [[nodiscard]] static double stddev(std::span<const double> samples) noexcept;
328
329 /**
330 * @brief Median absolute deviation of a span
331 * @param samples Values to analyze
332 * @return Median of |x_i - median(samples)|, scaled by 1.4826 to be
333 * a consistent estimator of standard deviation under a
334 * normal distribution assumption. Robust to single-sample
335 * spikes in a way variance is not, since one wild outlier
336 * can dominate a variance estimate but only shifts a
337 * median by at most one rank.
338 */
339 [[nodiscard]] static double median_absolute_deviation(std::span<const double> samples) noexcept;
340
341 /**
342 * @brief Flags samples whose deviation from the median exceeds a threshold
343 * @param samples Values to analyze
344 * @param threshold_mad Number of scaled-MAD units beyond which a
345 * sample is flagged, typically 2.5 to 3.5 for physical
346 * sensor data
347 * @return Indices into samples considered outliers
348 *
349 * Shape-aware in the sense that a single wild sample and a run of
350 * several consistent high samples are distinguished by the caller
351 * inspecting which indices come back, not by this function alone:
352 * an isolated flagged index surrounded by unflagged ones reads
353 * differently from several consecutive flagged indices, and that
354 * read is left to the caller since the correct interpretation
355 * depends on domain (a genuine fast transition vs. a dropout burst).
356 */
357 [[nodiscard]] static std::vector<size_t> flag_outliers(
358 std::span<const double> samples, double threshold_mad = 3.0) noexcept;
359
360 /**
361 * @brief Fraction of a span's variance attributable to its linear trend
362 * @param samples Values to analyze
363 * @return 1.0 when the span is well explained by a straight line
364 * from first to last sample, closer to 0.0 when the span's
365 * variance is dominated by fluctuation around that line
366 * rather than the trend itself
367 *
368 * A cheap signal/noise split for a window: high trend-explained
369 * ratio means the window looks like real directed change, low
370 * means the window looks like jitter around a roughly fixed point.
371 */
372 [[nodiscard]] static double trend_explained_ratio(std::span<const double> samples) noexcept;
373
374 /**
375 * @brief Linear trend slope across a span
376 * @param samples Values to analyze
377 * @return Least-squares slope in units of (value per sample index),
378 * 0.0 for spans of size < 2
379 */
380 [[nodiscard]] static double trend_slope(std::span<const double> samples) noexcept;
381
382private:
383 double update_rolling_variance(double sample);
384 double update_ewm_variance(double sample);
385 double update_mad(double sample);
386 double update_quiet_period(double sample);
387 double update_trend(double sample);
388
390 double m_adapt_rate;
391 size_t m_window;
392
394
395 // ROLLING_VARIANCE, MEDIAN_ABSOLUTE_DEVIATION, QUIET_PERIOD_FLOOR, and
396 // TREND keep a bounded window of raw samples via HistoryBuffer, which
397 // is fixed-capacity after construction with O(1) push and no per-call
398 // allocation, matching the real-time constraint this class runs under.
399 // EWM_VARIANCE does not use m_history at all.
400 Memory::HistoryBuffer<double> m_history;
401};
402
403} // namespace MayaFlux::Kinesis::Stochastic
float rate
double get_adapt_rate() const
Gets the current adapt rate.
Definition Estimate.hpp:162
double value() const
Current filtered value, the cleaned counterpart to the raw sample.
Definition Estimate.hpp:258
double floor() const
Current learned floor.
Definition Estimate.hpp:234
const EstimateState & state() const
Gets current internal state.
Definition Estimate.hpp:293
EstimateState & state_mutable()
Gets mutable internal state.
Definition Estimate.hpp:304
double confidence_of_rate(double rate, double dt) const
Confidence that a Differential-derived rate reflects signal, not floor.
Definition Estimate.hpp:223
void set_adapt_rate(double rate)
Sets the adapt rate used by EWM_VARIANCE.
Definition Estimate.hpp:157
size_t get_window() const
Gets the current window size.
Definition Estimate.hpp:175
void update_into(double sample, Memory::HistoryBuffer< double > &out)
Feed one sample and push the filtered result into a HistoryBuffer.
Definition Estimate.hpp:271
EstimateModel get_model() const
Gets current model.
Definition Estimate.hpp:152
Stateful statistical characterization of an evolving scalar stream.
Definition Estimate.hpp:125
void push(const T &value)
Push new value to front of history.
History buffer for difference equations and recursive relations.
EstimateModel
Strategies for characterizing an evolving stream's statistical behavior.
Definition Estimate.hpp:20
Persistent state for an Estimate instance.
Definition Estimate.hpp:38