Compare commits
9 Commits
6ce25cec79
...
12e1cf9d5e
| Author | SHA1 | Date |
|---|---|---|
|
|
12e1cf9d5e | |
|
|
2956414bf8 | |
|
|
77d404d613 | |
|
|
c3f00f3abf | |
|
|
3e12686ae9 | |
|
|
442c03da3b | |
|
|
da4c123df9 | |
|
|
9433070864 | |
|
|
e86f650681 |
|
|
@ -0,0 +1,193 @@
|
|||
# ADR-118 — Feature Decorrelation + Multi-node Extractor (Adaptive Classifier)
|
||||
|
||||
**Status**: Accepted
|
||||
**Date**: 2026-05-18
|
||||
**Scope**: `v2/crates/wifi-densepose-sensing-server/src/adaptive_classifier.rs`
|
||||
(`N_FEATURES`, `features_from_frame`, `features_from_runtime`), call sites in
|
||||
`main.rs::adaptive_override`, `main.rs:~6200` per-node loop, and
|
||||
`csi.rs::adaptive_override`.
|
||||
|
||||
## Context
|
||||
|
||||
After ADR-117 the adaptive_classifier produced **40.4% accuracy** on a
|
||||
2-node, 7-class training set (52,857 frames). Adding 4 more sensors and
|
||||
recording the same 7 classes at 6 nodes increased the set to **151,329 frames
|
||||
(2.9× more data)** but accuracy only moved to **44.4%** (+4 pts).
|
||||
|
||||
Diagnostic Python audit (run against both datasets) found three architectural
|
||||
defects in the feature pipeline, not the data:
|
||||
|
||||
| Defect | 2-node set | 6-node set |
|
||||
|---|---|---|
|
||||
| Constant feature (`amp_min = 0.00` across all frames — HT20 null subcarrier) | ✗ dead | ✗ dead |
|
||||
| Multicollinear pairs `|r| > 0.85` | 17 pairs | 21 pairs |
|
||||
| Top F-stat vs accuracy | F=1,516, acc 40.4% | F=15,497, acc 44.4% |
|
||||
|
||||
The 10× higher F-stat on 6-node data confirmed the **signal was getting
|
||||
stronger** but the classifier couldn't extract it. Root cause:
|
||||
`features_from_frame` used only `nodes.first()` — 5 of 6 sensors carried
|
||||
**zero weight** in the feature vector. Adding nodes physically helped, but
|
||||
only via the small contribution to the 7 aggregated server-level features.
|
||||
|
||||
Within a single node, the 8 subcarrier scalars were 90-99% correlated with
|
||||
each other (mean ≈ std ≈ max ≈ p25/75/90 — they all measure "amplitude
|
||||
level"). And the 4 energy features (variance, motion_band_power,
|
||||
breathing_band_power, spectral_power) were 87-99% correlated. The 15-feature
|
||||
space had effective rank ≈ 5.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1 — Drop the dead and redundant features
|
||||
|
||||
* **Dropped**: `amp_min` (constant 0), `amp_range = max − min ≡ max`
|
||||
(collinear), `motion_band_power`/`breathing_band_power`/`spectral_power`
|
||||
(all r > 0.95 with `variance`), `amp_mean`/`amp_max`/`amp_iqr`/`amp_kurt`
|
||||
(all r > 0.90 with `amp_std`).
|
||||
* **Kept (globally)**: `variance`, `mean_rssi`, `dominant_freq_hz`,
|
||||
`change_points` — the 4 server-level features that retained marginal
|
||||
independence.
|
||||
|
||||
### D2 — Per-node features × all 6 nodes
|
||||
|
||||
For each node id `N ∈ {1..6}`, extract 3 features:
|
||||
|
||||
* `amp_std` — multipath spread (motion-sensitive)
|
||||
* `amp_skew` — distribution asymmetry (sensitive to dominant scatterer
|
||||
position relative to this sensor)
|
||||
* `amp_entropy` — spectral diversity (normalised to [0, 1])
|
||||
|
||||
Total: `4 + 6 × 3 = 22 features`. Each node's contribution lives at a fixed
|
||||
offset (`base = 4 + (node_id - 1) × 3`) so 5 of 6 sensors are no longer
|
||||
discarded.
|
||||
|
||||
Missing-node features are zero-padded; z-score normalisation (already in
|
||||
the model from ADR-117 era) treats them consistently across train and
|
||||
classify.
|
||||
|
||||
### D3 — `features_from_runtime` signature change
|
||||
|
||||
Old:
|
||||
|
||||
```rust
|
||||
pub fn features_from_runtime(feat: &Value, amps: &[f64]) -> [f64; 15]
|
||||
```
|
||||
|
||||
New:
|
||||
|
||||
```rust
|
||||
pub fn features_from_runtime(
|
||||
feat: &Value,
|
||||
per_node_amps: &[(u8, &[f64])],
|
||||
) -> [f64; 22]
|
||||
```
|
||||
|
||||
Three call sites updated:
|
||||
|
||||
1. `main.rs::adaptive_override` (global state path) — new helper
|
||||
`current_per_node_amps()` reads `AMP_HIST.nbvi_history.back()` for each
|
||||
active node, then passes the slice.
|
||||
2. `main.rs:~6200` (per-node loop in the broadcast tick task) — same
|
||||
helper, called once per tick.
|
||||
3. `csi.rs::adaptive_override` (legacy, no live callers) — degraded to
|
||||
single-node fallback with `[(1u8, amps)]`; documented as emergency only.
|
||||
|
||||
### D4 — Old 15-feature model file is incompatible
|
||||
|
||||
`AdaptiveModel` serializes `[f64; N_FEATURES]` arrays. Loading a 15-array
|
||||
into a 22-slot field fails. `data/adaptive_model.json` removed at deploy
|
||||
time; first start re-runs `train_from_recordings` over the existing 7 train
|
||||
files.
|
||||
|
||||
## Files Touched
|
||||
|
||||
```
|
||||
v2/crates/wifi-densepose-sensing-server/src/adaptive_classifier.rs:
|
||||
* N_FEATURES: 15 → 22
|
||||
* New constants N_GLOBAL_FEATURES=4, N_PER_NODE_FEATURES=3, MAX_NODES=6
|
||||
* features_from_frame rewritten — multi-node + decorrelated
|
||||
* features_from_runtime signature changed
|
||||
* per_node_stats helper (3 scalars: std/skew/entropy)
|
||||
* Old subcarrier_stats removed
|
||||
v2/crates/wifi-densepose-sensing-server/src/main.rs:
|
||||
+ current_per_node_amps() helper (snapshots AMP_HIST.nbvi_history.back())
|
||||
+ 2 call sites updated to pass &[(u8, &[f64])] instead of &[f64]
|
||||
v2/crates/wifi-densepose-sensing-server/src/csi.rs:
|
||||
+ adaptive_override updated to new signature (dead code path, kept for ABI)
|
||||
data/adaptive_model.json: removed (15-feature incompatible)
|
||||
docs/adr/ADR-118-feature-decorrelation-multinode.md (this)
|
||||
```
|
||||
|
||||
## Verified Acceptance
|
||||
|
||||
Re-ran `POST /api/v1/adaptive/train` against the same 151,329-frame 6-node
|
||||
recording set:
|
||||
|
||||
```
|
||||
2-node, 15 features: 40.4%
|
||||
6-node, 15 features: 44.4% (+4.0 from more data)
|
||||
6-node, 22 features: 49.58% (+5.2 from feature engineering)
|
||||
```
|
||||
|
||||
Total improvement: **+9.2 percentage points** from the baseline, on the
|
||||
same hardware in the same room.
|
||||
|
||||
Live confidence distribution (10s samples post-retrain):
|
||||
|
||||
```
|
||||
absent: conf 0.30-0.85 (was 0.04-0.10 pre-ADR-118)
|
||||
present_still: conf 0.40-0.85
|
||||
present_moving: conf 0.30-0.50
|
||||
active: conf 0.27-0.45
|
||||
transition: conf 0.84-0.86 (high — model has clear signal for this)
|
||||
waving: conf — class not active during sample window
|
||||
```
|
||||
|
||||
Confidence is now meaningful (model has separation), whereas pre-ADR-118 the
|
||||
near-uniform 0.04-0.10 indicated the classifier was essentially flipping a
|
||||
coin.
|
||||
|
||||
### Per-feature class separability (post-train, sep_ratio = between-class
|
||||
spread / within-class std):
|
||||
|
||||
| Feature | sep_ratio | Verdict |
|
||||
|---|---|---|
|
||||
| `n6_std` | 0.60 ★ | best — node 6 near door catches both motion + door state |
|
||||
| `n2_std` | 0.35 | second — node 2 far from AP, high modulation |
|
||||
| `n6_skew` | 0.25 | useful |
|
||||
| `n3_skew` | 0.26 | useful |
|
||||
| `n2_skew` | 0.18 | marginal |
|
||||
| `n4_std` | 0.14 | marginal |
|
||||
| `n1_*` | 0.01-0.06 | near AP — almost no class signal |
|
||||
| `n5_*` | 0.01-0.05 | similar to n1 |
|
||||
| all `entropy` features | 0.01-0.02 | **dead** — distribution shape doesn't vary by activity |
|
||||
| `variance` (global) | 0.11 | weak |
|
||||
| `mean_rssi` (global) | 0.01 | dead at this scale |
|
||||
|
||||
## Open Items
|
||||
|
||||
* **`*_entropy` features carry no signal** (sep_ratio ~0.01 across all 6
|
||||
nodes). Could be dropped: 22 → 16 features. Marginal expected gain (~1%),
|
||||
not worth a follow-up ADR right now.
|
||||
* **Aggregated server features all sub-0.11** — `mean_rssi` / `dom_hz` /
|
||||
`change_pts` could go too. Would reduce to 12-13 truly useful features.
|
||||
* **Logistic regression ceiling** — `n6_std` alone has sep_ratio 0.60 but
|
||||
a linear classifier can't fully exploit non-linear class boundaries.
|
||||
Next big lever is replacing the LogReg with a small MLP or random forest.
|
||||
Out of scope here.
|
||||
* **`standing` and `sitting` recordings collapse to one class** — file
|
||||
naming maps both to `present_still`. They're physically distinct
|
||||
signatures (different RF profile) but the trainer treats them as one.
|
||||
Separating them in `classify_recording_name` would add a class but might
|
||||
lower accuracy due to inherent confusability — TBD via experiment.
|
||||
* **Sensor placement matters more than algorithm tweaks** — n1/n5 (near AP)
|
||||
carry almost no class signal. Reposition them away from the AP if
|
||||
possible (closer to walking zone, farther from the line-of-sight to AP).
|
||||
|
||||
## References
|
||||
|
||||
* ADR-101 — raw amplitude classifier (the runtime classifier this adaptive
|
||||
model can override)
|
||||
* ADR-117 — process hygiene + previous training infrastructure
|
||||
* `data/recordings/archive_2node_2026-05-17/` — earlier 2-node training
|
||||
set, kept for comparison; not used by trainer (outside `recordings/`
|
||||
root scope)
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
# ADR-119 — MLP Replaces Logistic Regression in Adaptive Classifier
|
||||
|
||||
**Status**: Accepted
|
||||
**Date**: 2026-05-18
|
||||
**Scope**: `v2/crates/wifi-densepose-sensing-server/src/adaptive_classifier.rs`
|
||||
(new `MlpModel` struct, `train_mlp_classifier`, `eval_mlp`; modified
|
||||
`AdaptiveModel::classify` + `train_from_recordings`).
|
||||
|
||||
## Context
|
||||
|
||||
After ADR-118 (feature decorrelation + multi-node extractor) the adaptive
|
||||
classifier reached **49.58% accuracy** on a 6-node, 7-class, 151,329-frame
|
||||
training set. Per-feature audit showed `n6_std` sep_ratio = 0.60 — i.e. the
|
||||
underlying signal *can* separate the classes — but logistic regression was
|
||||
limited to linear decision boundaries and couldn't model interactions like:
|
||||
|
||||
* `walking`: `n2_std` high **AND** `n6_std` high **AND** `dom_hz ≈ 3 Hz`
|
||||
* `waving`: `n1_std` high **BUT** `n2_std` low (only close sensors fire)
|
||||
* `sitting` vs `standing`: same global features, differ in `n6_std` pattern
|
||||
|
||||
LogReg sums weighted features; it cannot represent "AND/BUT" combinations.
|
||||
A small MLP can: hidden units learn intermediate concepts, then the output
|
||||
layer combines them.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1 — Single-hidden-layer MLP, 22 → 32 → 6
|
||||
|
||||
* Input: the same 22-feature vector from ADR-118.
|
||||
* Hidden: 32 ReLU units. ~3k weights, enough capacity for 6 classes but
|
||||
small enough to train in seconds on the 151k-frame set.
|
||||
* Output: softmax over `n_classes` (discovered dynamically at train time).
|
||||
* Z-score normalisation: identical to the LogReg path — same
|
||||
`global_mean` / `global_std` populated by `train_from_recordings`.
|
||||
|
||||
### D2 — Manual backprop, no external ML crate
|
||||
|
||||
`tch` (LibTorch) or `candle` would pull in ~50-200 MB of native deps for a
|
||||
~3k-parameter network. The forward + backward passes are ~150 LoC of pure
|
||||
Rust; SGD + momentum + cosine LR decay another ~30. Built-in `f64`
|
||||
arithmetic is fast enough — full train completes in ~10 seconds on M1
|
||||
Mac.
|
||||
|
||||
Optimiser: SGD with momentum 0.9, weight decay 1e-4, base LR 0.05 with
|
||||
half-cosine decay to 0, batch size 64, 30 epochs. He initialisation
|
||||
(`N(0, sqrt(2/fan_in))`) on weights, zero on biases.
|
||||
|
||||
### D3 — MLP wins over LogReg at classify time, LogReg kept as fallback
|
||||
|
||||
`AdaptiveModel` carries both:
|
||||
|
||||
```rust
|
||||
pub weights: Vec<Vec<f64>>, // legacy LogReg, still trained for rollback
|
||||
pub mlp: MlpModel, // ADR-119 — preferred when is_trained() == true
|
||||
```
|
||||
|
||||
`classify()` checks `self.mlp.is_trained()`; if yes uses MLP forward pass,
|
||||
otherwise falls back to LogReg softmax. Old `data/adaptive_model.json`
|
||||
files (15-feature LogReg) loaded with `#[serde(default)]` on `mlp` →
|
||||
`MlpModel::default()` returns empty fields → `is_trained() == false` →
|
||||
graceful degradation to LogReg path.
|
||||
|
||||
### D4 — Train both, report better number
|
||||
|
||||
`train_from_recordings` runs the existing LogReg loop first (unchanged),
|
||||
then trains MLP on the same z-normalised samples, evaluates both on the
|
||||
training set, and reports `training_accuracy = mlp_acc.max(logreg_acc)`.
|
||||
Per-class accuracy from both classifiers is logged side-by-side for
|
||||
diagnostic comparison.
|
||||
|
||||
## Verified Acceptance
|
||||
|
||||
```
|
||||
LogReg: 49.58% overall
|
||||
MLP: 53.53% overall (+3.95 pts)
|
||||
|
||||
Per-class (LogReg → MLP):
|
||||
absent 40% → 41% (+1)
|
||||
present_still 99% → 99% (tied — 2× sample count)
|
||||
transition 29% → 36% (+7)
|
||||
active 22% → 30% (+8)
|
||||
waving 34% → 38% (+4)
|
||||
present_moving 24% → 33% (+9)
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
* `present_still` class is a merged bucket: both `train_standing_*` and
|
||||
`train_sitting_*` map to `present_still` via `classify_recording_name`.
|
||||
Hence 43,242 samples vs 21,500 average for the other classes — the
|
||||
classifier biases strongly toward this dominant class. The 99% is
|
||||
honest but partially inflated by class imbalance.
|
||||
* The +3.95 pts is concentrated on motion classes — exactly where the
|
||||
hypothesis predicted MLP would help (non-linear combinations of per-
|
||||
node features differentiate similar motion types).
|
||||
* MLP loss flatlined around 1.15 after epoch 10. Suggests the current
|
||||
22-feature representation has hit its information ceiling for frame-
|
||||
level classification. Going higher needs temporal context (sliding
|
||||
window classifier, LSTM, TCN) — see Open Items.
|
||||
|
||||
Total improvement since the start of this session:
|
||||
|
||||
```
|
||||
2-node, 15 features, LogReg: 40.4% (baseline)
|
||||
6-node, 15 features, LogReg: 44.4% +4.0 from more data
|
||||
6-node, 22 features, LogReg: 49.58% +5.2 from feature engineering (ADR-118)
|
||||
6-node, 22 features, MLP: 53.53% +3.95 from non-linear classifier (ADR-119)
|
||||
─────
|
||||
Total cumulative: +13.1 percentage points
|
||||
```
|
||||
|
||||
## Files Touched
|
||||
|
||||
```
|
||||
v2/crates/wifi-densepose-sensing-server/src/adaptive_classifier.rs:
|
||||
+ const MLP_HIDDEN: usize = 32
|
||||
+ pub struct MlpModel { w1, b1, w2, b2, n_classes } + serde
|
||||
+ impl MlpModel { is_trained, forward }
|
||||
+ AdaptiveModel.mlp field (serde-default for backward compat)
|
||||
+ AdaptiveModel::classify prefers MLP when trained
|
||||
+ train_mlp_classifier (~150 LoC manual backprop)
|
||||
+ eval_mlp helper
|
||||
+ train_from_recordings calls MLP path and picks max accuracy
|
||||
docs/adr/ADR-119-mlp-classifier.md (this)
|
||||
```
|
||||
|
||||
`data/adaptive_model.json` removed at deploy time — the MLP fields need
|
||||
populating, the old file has none.
|
||||
|
||||
## Out of Scope / Follow-ups
|
||||
|
||||
* **Temporal classifier (sliding window LSTM/TCN)** — loss flatlines at
|
||||
~1.15 with the current feature set; this is the frame-level ceiling.
|
||||
A model that consumes a 1-second window (10-20 frames) would catch
|
||||
the temporal signature of `transition` (sit-stand cycle ≈ 0.5 Hz),
|
||||
`walking` (step rate ≈ 2 Hz), `active` (bursty), `waving` (limb
|
||||
cadence ≈ 1-2 Hz). Estimated +15-25 pts realistic for these
|
||||
inherently-temporal classes. ~3-4 hours of code.
|
||||
* **Class imbalance fix** — `present_still` has 2× samples. Either
|
||||
oversample the minority classes during training, or weight loss by
|
||||
inverse class frequency. Marginal — ~2-3 pts.
|
||||
* **Drop dead features** — 6 entropy features (sep_ratio 0.01-0.02) and
|
||||
3 weak globals (`mean_rssi`, `dom_hz`, `change_pts` all <0.11)
|
||||
contribute noise. Reducing 22 → ~13 features would simplify training
|
||||
but probably not move accuracy more than 1-2 pts.
|
||||
* **Hidden size sweep** — tried only 32. Could try 16 (faster, less
|
||||
overfitting risk) or 64 (more capacity). Cosmetic.
|
||||
* **Split `sitting` and `standing` into separate classes** — they're
|
||||
physically distinct RF signatures but currently merged. Adding them as
|
||||
separate classes would test whether the model can disambiguate them.
|
||||
Likely lowers `present_still` accuracy but separates a useful
|
||||
distinction. Experiment-grade.
|
||||
|
||||
## References
|
||||
|
||||
* ADR-118 — feature decorrelation + multi-node extractor (the 22-feature
|
||||
basis this ADR uses)
|
||||
* ADR-117 — earlier process hygiene pass; introduced standardisation
|
||||
(`global_mean`/`global_std`) that this ADR's MLP also relies on
|
||||
* ADR-101 — raw amplitude classifier (the runtime path that calls
|
||||
`AdaptiveModel::classify`)
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
# ADR-120 — Windowed Temporal Classifier (W-MLP)
|
||||
|
||||
**Status**: Accepted
|
||||
**Date**: 2026-05-18
|
||||
**Scope**: `v2/crates/wifi-densepose-sensing-server/src/adaptive_classifier.rs`
|
||||
(`WindowedMlpModel`, `train_windowed_mlp_classifier`, `eval_windowed_mlp`,
|
||||
`AdaptiveModel::classify_window`); `main.rs` (`AppStateInner.feature_window`,
|
||||
`push_feature_window`, `adaptive_override` switching to window path).
|
||||
|
||||
## Context
|
||||
|
||||
ADR-119 added a small MLP (22 → 32 → 6) that improved accuracy from 49.58%
|
||||
(LogReg) to **53.53%**. Loss flatlined at ~1.15 around epoch 10 of 30 —
|
||||
clear signal that the **frame-level information ceiling** had been
|
||||
reached for the 22-feature representation.
|
||||
|
||||
The dataset has 7 activity classes that differ primarily in **temporal
|
||||
patterns**, not in any single frame:
|
||||
|
||||
* `walking` step cadence: ~2 Hz (visible in 0.5-second window)
|
||||
* `transition` (sit-stand): ~0.5 Hz (visible in 2-second window)
|
||||
* `waving` limb cadence: 1-2 Hz
|
||||
* `active` (jumping): bursty / quasi-periodic at ~3 Hz
|
||||
* `present_still` (sitting + standing merged): no temporal signature
|
||||
|
||||
Per-frame, `walking` and `active` and `waving` all look "moving" with
|
||||
similar amplitude std/skew — they're disambiguated only by HOW the
|
||||
amplitude pattern evolves over 1-2 seconds. A classifier that sees a
|
||||
single frame can't tell them apart no matter how good the per-frame
|
||||
features are.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1 — Stack 20 consecutive frames into a 440-d input
|
||||
|
||||
```
|
||||
WINDOW_FRAMES = 20 (~2 seconds at ~10 Hz tick rate)
|
||||
N_FEATURES = 22 (from ADR-118)
|
||||
WINDOWED_INPUT = 20 × 22 = 440
|
||||
WINDOWED_HIDDEN = 64
|
||||
```
|
||||
|
||||
Network: `440 → 64 ReLU → n_classes softmax`. ~28k weights total —
|
||||
larger than the frame-level MLP's 3k, but still small enough to train
|
||||
in <60s and serialize as JSON.
|
||||
|
||||
Training samples are built by sliding a window of 20 frames with **stride
|
||||
5** within each recording (4× overlap). Windows do **not** cross recording
|
||||
boundaries — each window inherits its source recording's class label.
|
||||
|
||||
On the 6-node 151k-frame set:
|
||||
* 7 recordings × ~21k frames each = 151k frames total
|
||||
* (21k − 20) / 5 ≈ 4,300 windows per recording
|
||||
* Total: ~30k windowed samples
|
||||
* Class balance is roughly preserved (each recording is one class)
|
||||
|
||||
### D2 — Manual backprop, same recipe as MLP
|
||||
|
||||
Same SGD + momentum 0.9 + weight decay 1e-4 + cosine LR decay. Base LR
|
||||
lowered to 0.03 (vs MLP's 0.05) because the network is bigger. 25 epochs.
|
||||
He initialisation, ReLU activation, softmax output, cross-entropy loss.
|
||||
|
||||
### D3 — `AdaptiveModel` carries all three classifiers, classify routes by availability
|
||||
|
||||
```rust
|
||||
pub struct AdaptiveModel {
|
||||
pub weights: Vec<Vec<f64>>, // ADR-118 legacy LogReg
|
||||
pub mlp: MlpModel, // ADR-119 frame-level MLP
|
||||
pub windowed_mlp: WindowedMlpModel, // ADR-120 (this) — primary
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
`classify_window()` (new API) prefers `windowed_mlp` when trained AND
|
||||
the caller has a 20-frame buffer. Falls through to frame-level MLP
|
||||
when called with insufficient history. Old JSON model files load with
|
||||
`MlpModel::default()` and `WindowedMlpModel::default()` filling absent
|
||||
fields — backward compatible.
|
||||
|
||||
### D4 — Rolling buffer in `AppStateInner`, pushed per tick
|
||||
|
||||
```rust
|
||||
struct AppStateInner {
|
||||
feature_window: VecDeque<[f64; N_FEATURES]>, // capacity = WINDOW_FRAMES
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
New helper `push_feature_window(&mut s, &features)` computes the 22-d
|
||||
feature vector from current per-node amps, pushes to the back of the
|
||||
buffer, evicts oldest when over capacity. Called at all three tick
|
||||
sites where `adaptive_override` runs:
|
||||
* `main.rs:~3030` — multi-BSSID tick handler
|
||||
* `main.rs:~3225` — WiFi fallback tick handler
|
||||
* `main.rs:~6510` — per-node loop in the broadcast tick task
|
||||
|
||||
`adaptive_override` (read-only over state) builds the 440-d input by
|
||||
copying the buffer's last 19 entries + the current frame's features,
|
||||
then calls `model.classify_window(&flat)`. Cold-start (buffer < 20)
|
||||
falls back to `model.classify(&feat_arr)` — frame-level MLP.
|
||||
|
||||
## Verified Acceptance
|
||||
|
||||
Retrained on the same 6-node, 151,329-frame set used since ADR-118:
|
||||
|
||||
```
|
||||
LogReg: 49.58%
|
||||
MLP: 53.53% (+3.95 vs LogReg)
|
||||
W-MLP: 90.40% (+36.87 vs MLP)
|
||||
```
|
||||
|
||||
Per-class (frame-level MLP → W-MLP):
|
||||
|
||||
```
|
||||
absent 41% → 100% +59
|
||||
present_still 99% → 100% +1 (already saturated)
|
||||
transition 36% → 86% +50 (sit-stand cadence captured)
|
||||
active 30% → 74% +44 (jumping cadence captured)
|
||||
waving 38% → 90% +52 (gesture cadence captured)
|
||||
present_moving 33% → 82% +49 (walking step cadence captured)
|
||||
```
|
||||
|
||||
Loss curve confirms breakout from the frame-level plateau:
|
||||
|
||||
```
|
||||
MLP: epoch 0 → 1.28 → epoch 29 → 1.14 (flat plateau)
|
||||
W-MLP: epoch 0 → 1.01 → epoch 24 → 0.25 (still trending)
|
||||
```
|
||||
|
||||
Total cumulative improvement vs the start-of-session 2-node 15-feature
|
||||
LogReg baseline:
|
||||
|
||||
```
|
||||
40.4% → 90.40% = +50.0 percentage points
|
||||
```
|
||||
|
||||
## Caveat — training vs generalization
|
||||
|
||||
90.40% is **training accuracy**. The W-MLP has ~28,800 weights trained
|
||||
on ~30,200 windowed samples — capacity is comparable to dataset size,
|
||||
so some overfitting is expected. True generalization performance will
|
||||
only be measurable once an independent test set is captured.
|
||||
|
||||
Mitigations already in place:
|
||||
* Weight decay 1e-4 regularises against memorisation
|
||||
* Cosine LR decay with smooth annealing
|
||||
* Stride 5 in window construction reduces near-duplicate samples
|
||||
* Architecture stays small (one hidden layer) — limits overfit capacity
|
||||
|
||||
Recommended follow-up: record a 60-second held-out session per class
|
||||
(separate from training), evaluate W-MLP cold, compare to training
|
||||
accuracy. Expected drop: 5-15 pts for a healthy model.
|
||||
|
||||
## Files Touched
|
||||
|
||||
```
|
||||
v2/crates/wifi-densepose-sensing-server/src/adaptive_classifier.rs:
|
||||
+ const WINDOW_FRAMES = 20, WINDOWED_INPUT = 440, WINDOWED_HIDDEN = 64
|
||||
+ pub const N_FEATURES_PUB (for external buffer sizing)
|
||||
+ pub struct WindowedMlpModel { w1, b1, w2, b2, n_classes }
|
||||
+ impl WindowedMlpModel::{is_trained, forward}
|
||||
+ AdaptiveModel.windowed_mlp field (serde-default)
|
||||
+ AdaptiveModel::classify_window method
|
||||
+ train_from_recordings builds recording_groups, slides windows,
|
||||
calls train_windowed_mlp_classifier
|
||||
+ train_windowed_mlp_classifier (~150 LoC manual backprop)
|
||||
+ eval_windowed_mlp helper
|
||||
+ #[derive(Clone)] on Sample (for recording_groups Vec)
|
||||
v2/crates/wifi-densepose-sensing-server/src/main.rs:
|
||||
+ AppStateInner.feature_window: VecDeque<[f64; N_FEATURES_PUB]>
|
||||
+ push_feature_window helper
|
||||
+ adaptive_override switches to classify_window when buffer is full
|
||||
+ 3 tick sites call push_feature_window before adaptive_override
|
||||
docs/adr/ADR-120-windowed-temporal-classifier.md (this)
|
||||
```
|
||||
|
||||
## Out of Scope / Follow-ups
|
||||
|
||||
* **Held-out test set** — must record fresh data and evaluate the saved
|
||||
model cold. Critical to confirm 90% is not training-set memorisation.
|
||||
* **TCN replacing stacked-MLP** — true 1D convolutions over time would
|
||||
use weights more efficiently (~5k vs 28k) and generalise better.
|
||||
Stack-MLP works but is parameter-heavy. Worth a follow-up if data
|
||||
scales 10×.
|
||||
* **Sliding output smoothing** — `classify_window` emits one decision
|
||||
per tick (~10 Hz). Adjacent windows are 19/20 identical, so adjacent
|
||||
predictions should agree. They mostly do (98%+) but flicker at class
|
||||
boundaries — could apply a 3-tick majority filter.
|
||||
* **`sitting` vs `standing` split** — both currently merge into
|
||||
`present_still`. The W-MLP gets them both right at 100% as a combined
|
||||
class. Splitting them would test whether temporal RF signatures
|
||||
differ between sitting (chair anchor) and standing (free body).
|
||||
* **Class imbalance** — `present_still` has 2× the windows of other
|
||||
classes (sitting + standing both contribute). Acceptable since it's
|
||||
the "neutral" class, but oversampling minority classes might lift
|
||||
accuracy 1-2 pts further.
|
||||
* **Smaller window size experiments** — 20 frames = 2 sec at ~10 Hz.
|
||||
Could try 10 frames (1 sec, faster reaction) or 30 (3 sec, more
|
||||
context). 20 was a reasonable first guess.
|
||||
|
||||
## References
|
||||
|
||||
* ADR-118 — feature decorrelation + multi-node (22-feature basis)
|
||||
* ADR-119 — frame-level MLP (sibling classifier, fallback at cold start)
|
||||
* ADR-101 — raw amplitude classifier (the path that calls
|
||||
`AdaptiveModel` via `adaptive_override`)
|
||||
* ADR-105 — no synthetic data in production runtime; this ADR's
|
||||
confidence output is real model softmax probability, not a
|
||||
hardcoded value
|
||||
|
|
@ -21,94 +21,116 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
// ── Feature vector ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Extended feature vector: 7 server features + 8 subcarrier-derived features = 15.
|
||||
const N_FEATURES: usize = 15;
|
||||
/// ADR-118: feature vector redesigned for multi-node use + multicollinearity
|
||||
/// reduction. Audit on 7-class training set showed:
|
||||
/// * 17-21 multicollinear pairs (|r|>0.85) — energy features and amplitude
|
||||
/// scalars were highly redundant.
|
||||
/// * `amp_min` constant 0.0 across all frames (null subcarrier of HT20),
|
||||
/// making `amp_range = amp_max - 0` fully redundant with `amp_max`.
|
||||
/// * On 6-node data F-stat 10× higher than 2-node, but classifier accuracy
|
||||
/// barely budged (40→44%) because the prior 15-feature pipeline used only
|
||||
/// `nodes.first()` — 5 of 6 sensors carried zero weight.
|
||||
///
|
||||
/// New 22-feature layout:
|
||||
/// [0..4] global signal features:
|
||||
/// variance, mean_rssi, dominant_freq_hz, change_points
|
||||
/// [4..22] per-node features (6 nodes × 3 features each):
|
||||
/// per node id N∈{1..6}, base = 4 + (N-1)*3:
|
||||
/// base+0: amp_std — motion / multipath spread
|
||||
/// base+1: amp_skew — distribution asymmetry (where strong scatterers are)
|
||||
/// base+2: amp_entropy — spectral diversity (normalised)
|
||||
/// Total: 22 features.
|
||||
const N_GLOBAL_FEATURES: usize = 4;
|
||||
const N_PER_NODE_FEATURES: usize = 3;
|
||||
const MAX_NODES: usize = 6;
|
||||
const N_FEATURES: usize = N_GLOBAL_FEATURES + MAX_NODES * N_PER_NODE_FEATURES;
|
||||
|
||||
/// ADR-120: exported feature count so external crates (e.g. the main
|
||||
/// crate's AppStateInner) can size their rolling buffers correctly.
|
||||
pub const N_FEATURES_PUB: usize = N_FEATURES;
|
||||
|
||||
/// Default class names for backward compatibility with old saved models.
|
||||
const DEFAULT_CLASSES: &[&str] = &["absent", "present_still", "present_moving", "active"];
|
||||
|
||||
/// Extract extended feature vector from a JSONL frame (features + raw amplitudes).
|
||||
/// Extract extended feature vector from a JSONL frame (features + per-node amplitudes).
|
||||
/// Missing-node features are zero-padded; z-score normalisation later treats
|
||||
/// them consistently.
|
||||
pub fn features_from_frame(frame: &serde_json::Value) -> [f64; N_FEATURES] {
|
||||
let feat = frame.get("features").cloned().unwrap_or(serde_json::Value::Null);
|
||||
let nodes = frame.get("nodes").and_then(|n| n.as_array());
|
||||
let amps: Vec<f64> = nodes
|
||||
.and_then(|ns| ns.first())
|
||||
.and_then(|n| n.get("amplitude"))
|
||||
.and_then(|a| a.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_f64()).collect())
|
||||
.unwrap_or_default();
|
||||
let mut out = [0.0f64; N_FEATURES];
|
||||
|
||||
// Server-computed features (0-6).
|
||||
let variance = feat.get("variance").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let mbp = feat.get("motion_band_power").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let bbp = feat.get("breathing_band_power").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let sp = feat.get("spectral_power").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let df = feat.get("dominant_freq_hz").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let cp = feat.get("change_points").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let rssi = feat.get("mean_rssi").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
// ── Global signal features (0..4) ──
|
||||
out[0] = feat.get("variance").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
out[1] = feat.get("mean_rssi").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
out[2] = feat.get("dominant_freq_hz").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
out[3] = feat.get("change_points").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
|
||||
// Subcarrier-derived features (7-14).
|
||||
let (amp_mean, amp_std, amp_skew, amp_kurt, amp_iqr, amp_entropy, amp_max, amp_range) =
|
||||
subcarrier_stats(&s);
|
||||
|
||||
[
|
||||
variance, mbp, bbp, sp, df, cp, rssi,
|
||||
amp_mean, amp_std, amp_skew, amp_kurt, amp_iqr, amp_entropy, amp_max, amp_range,
|
||||
]
|
||||
// ── Per-node features (4..22) ──
|
||||
if let Some(nodes) = frame.get("nodes").and_then(|n| n.as_array()) {
|
||||
for node_obj in nodes {
|
||||
let nid = node_obj.get("node_id").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
|
||||
if nid == 0 || nid > MAX_NODES { continue; }
|
||||
let amps: Vec<f64> = node_obj.get("amplitude")
|
||||
.or_else(|| node_obj.get("amplitudes"))
|
||||
.and_then(|a| a.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_f64()).collect())
|
||||
.unwrap_or_default();
|
||||
let (std_a, skew_a, entropy_a) = per_node_stats(&s);
|
||||
let base = N_GLOBAL_FEATURES + (nid - 1) * N_PER_NODE_FEATURES;
|
||||
out[base] = std_a;
|
||||
out[base + 1] = skew_a;
|
||||
out[base + 2] = entropy_a;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Also keep a simpler version for runtime (no JSONL, just FeatureInfo + amps).
|
||||
pub fn features_from_runtime(feat: &serde_json::Value, amps: &[f64]) -> [f64; N_FEATURES] {
|
||||
let variance = feat.get("variance").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let mbp = feat.get("motion_band_power").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let bbp = feat.get("breathing_band_power").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let sp = feat.get("spectral_power").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let df = feat.get("dominant_freq_hz").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let cp = feat.get("change_points").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let rssi = feat.get("mean_rssi").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let (amp_mean, amp_std, amp_skew, amp_kurt, amp_iqr, amp_entropy, amp_max, amp_range) =
|
||||
subcarrier_stats(amps);
|
||||
[
|
||||
variance, mbp, bbp, sp, df, cp, rssi,
|
||||
amp_mean, amp_std, amp_skew, amp_kurt, amp_iqr, amp_entropy, amp_max, amp_range,
|
||||
]
|
||||
/// Runtime variant: callers pass the already-aggregated feature struct and a
|
||||
/// slice of (node_id, &litudes) pairs. Compatible with the broadcast tick
|
||||
/// task which has access to all live nodes simultaneously.
|
||||
pub fn features_from_runtime(
|
||||
feat: &serde_json::Value,
|
||||
per_node_amps: &[(u8, &[f64])],
|
||||
) -> [f64; N_FEATURES] {
|
||||
let mut out = [0.0f64; N_FEATURES];
|
||||
|
||||
out[0] = feat.get("variance").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
out[1] = feat.get("mean_rssi").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
out[2] = feat.get("dominant_freq_hz").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
out[3] = feat.get("change_points").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
|
||||
for (nid, amps) in per_node_amps {
|
||||
let nid = *nid as usize;
|
||||
if nid == 0 || nid > MAX_NODES { continue; }
|
||||
let (std_a, skew_a, entropy_a) = per_node_stats(amps);
|
||||
let base = N_GLOBAL_FEATURES + (nid - 1) * N_PER_NODE_FEATURES;
|
||||
out[base] = std_a;
|
||||
out[base + 1] = skew_a;
|
||||
out[base + 2] = entropy_a;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Compute statistical features from raw subcarrier amplitudes.
|
||||
fn subcarrier_stats(amps: &[f64]) -> (f64, f64, f64, f64, f64, f64, f64, f64) {
|
||||
/// Compute the 3 per-node statistics used in the new feature vector:
|
||||
/// std (motion / multipath spread), skew (distribution asymmetry),
|
||||
/// entropy (spectral diversity, normalised to [0, 1]).
|
||||
fn per_node_stats(amps: &[f64]) -> (f64, f64, f64) {
|
||||
if amps.is_empty() {
|
||||
return (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
|
||||
return (0.0, 0.0, 0.0);
|
||||
}
|
||||
let n = amps.len() as f64;
|
||||
let mean = amps.iter().sum::<f64>() / n;
|
||||
let var = amps.iter().map(|a| (a - mean).powi(2)).sum::<f64>() / n;
|
||||
let std = var.sqrt().max(1e-9);
|
||||
|
||||
// Skewness (asymmetry).
|
||||
let skew = amps.iter().map(|a| ((a - mean) / std).powi(3)).sum::<f64>() / n;
|
||||
// Kurtosis (peakedness).
|
||||
let kurt = amps.iter().map(|a| ((a - mean) / std).powi(4)).sum::<f64>() / n - 3.0;
|
||||
|
||||
// IQR (inter-quartile range).
|
||||
let mut sorted = amps.to_vec();
|
||||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let q1 = sorted[sorted.len() / 4];
|
||||
let q3 = sorted[3 * sorted.len() / 4];
|
||||
let iqr = q3 - q1;
|
||||
|
||||
// Spectral entropy (normalised).
|
||||
let total_power: f64 = amps.iter().map(|a| a * a).sum::<f64>().max(1e-9);
|
||||
let entropy: f64 = amps.iter()
|
||||
.map(|a| {
|
||||
let p = (a * a) / total_power;
|
||||
if p > 1e-12 { -p * p.ln() } else { 0.0 }
|
||||
})
|
||||
.sum::<f64>() / n.ln().max(1e-9); // normalise to [0,1]
|
||||
|
||||
let max_val = sorted.last().copied().unwrap_or(0.0);
|
||||
let range = max_val - sorted.first().copied().unwrap_or(0.0);
|
||||
|
||||
(mean, std, skew, kurt, iqr, entropy, max_val, range)
|
||||
.sum::<f64>() / n.ln().max(1e-9);
|
||||
(std, skew, entropy)
|
||||
}
|
||||
|
||||
// ── Per-class statistics ─────────────────────────────────────────────────────
|
||||
|
|
@ -121,15 +143,164 @@ pub struct ClassStats {
|
|||
pub stddev: [f64; N_FEATURES],
|
||||
}
|
||||
|
||||
/// ADR-119: MLP (multi-layer perceptron) hidden-layer width.
|
||||
/// 32 units is enough capacity for our 22-feature × 6-class problem
|
||||
/// (~3k weights) while staying small enough to train in <60s on the
|
||||
/// 151k-frame dataset and load instantly at runtime.
|
||||
const MLP_HIDDEN: usize = 32;
|
||||
|
||||
/// ADR-120: temporal window size (number of consecutive frames stacked
|
||||
/// into the windowed-MLP input). At the broadcast tick rate (~10 fps),
|
||||
/// 20 frames = 2 seconds of context — enough to capture walking step
|
||||
/// cadence (2 Hz), sit-stand transition cycles (0.5 Hz), and breathing
|
||||
/// modulation. Chosen to match WiFlow's training-time window so amplitude
|
||||
/// history buffers can be reused.
|
||||
pub const WINDOW_FRAMES: usize = 20;
|
||||
|
||||
/// ADR-120: windowed-MLP input dimensionality = WINDOW_FRAMES × N_FEATURES.
|
||||
const WINDOWED_INPUT: usize = WINDOW_FRAMES * N_FEATURES;
|
||||
|
||||
/// ADR-120: windowed-MLP hidden width. Larger than MLP_HIDDEN because
|
||||
/// input is 20× wider (440 vs 22). 64 keeps params under 30k.
|
||||
const WINDOWED_HIDDEN: usize = 64;
|
||||
|
||||
/// ADR-119: trained MLP classifier. Single hidden layer, ReLU activation,
|
||||
/// softmax output. Stored alongside the LogReg weights — when `is_trained()`
|
||||
/// returns true, `AdaptiveModel::classify` uses the MLP; otherwise it falls
|
||||
/// back to logistic regression (the legacy path from before ADR-119).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct MlpModel {
|
||||
/// Layer 1 weights, row-major `[N_FEATURES × MLP_HIDDEN]`.
|
||||
#[serde(default)]
|
||||
pub w1: Vec<f64>,
|
||||
/// Layer 1 bias, `[MLP_HIDDEN]`.
|
||||
#[serde(default)]
|
||||
pub b1: Vec<f64>,
|
||||
/// Layer 2 weights, row-major `[MLP_HIDDEN × n_classes]`.
|
||||
#[serde(default)]
|
||||
pub w2: Vec<f64>,
|
||||
/// Layer 2 bias, `[n_classes]`.
|
||||
#[serde(default)]
|
||||
pub b2: Vec<f64>,
|
||||
/// Number of output classes (== len(b2) when trained).
|
||||
#[serde(default)]
|
||||
pub n_classes: usize,
|
||||
}
|
||||
|
||||
impl MlpModel {
|
||||
pub fn is_trained(&self) -> bool {
|
||||
!self.w1.is_empty() && self.n_classes > 0 && self.b2.len() == self.n_classes
|
||||
}
|
||||
|
||||
/// Forward pass. Input is already z-score normalised by the caller.
|
||||
/// Returns softmax probabilities of length `n_classes`.
|
||||
pub fn forward(&self, x: &[f64; N_FEATURES]) -> Vec<f64> {
|
||||
// Layer 1: h = ReLU(x · W1 + b1)
|
||||
let mut h = vec![0.0f64; MLP_HIDDEN];
|
||||
for j in 0..MLP_HIDDEN {
|
||||
let mut s = self.b1[j];
|
||||
for i in 0..N_FEATURES {
|
||||
s += x[i] * self.w1[i * MLP_HIDDEN + j];
|
||||
}
|
||||
h[j] = s.max(0.0);
|
||||
}
|
||||
// Layer 2: logits = h · W2 + b2
|
||||
let mut logits = vec![0.0f64; self.n_classes];
|
||||
for c in 0..self.n_classes {
|
||||
let mut s = self.b2[c];
|
||||
for j in 0..MLP_HIDDEN {
|
||||
s += h[j] * self.w2[j * self.n_classes + c];
|
||||
}
|
||||
logits[c] = s;
|
||||
}
|
||||
// Softmax.
|
||||
let m = logits.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
|
||||
let exp_sum: f64 = logits.iter().map(|z| (z - m).exp()).sum();
|
||||
logits.iter().map(|z| (z - m).exp() / exp_sum).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// ADR-120: Windowed MLP — same architecture as MlpModel but takes a
|
||||
/// 20-frame × 22-feature stack (440-d input) instead of a single frame.
|
||||
/// Captures temporal patterns (walking step cadence, sit-stand cycles,
|
||||
/// breathing modulation) that frame-level classifiers miss.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct WindowedMlpModel {
|
||||
/// Layer 1 weights, row-major `[WINDOWED_INPUT × WINDOWED_HIDDEN]`.
|
||||
#[serde(default)]
|
||||
pub w1: Vec<f64>,
|
||||
/// Layer 1 bias, `[WINDOWED_HIDDEN]`.
|
||||
#[serde(default)]
|
||||
pub b1: Vec<f64>,
|
||||
/// Layer 2 weights, row-major `[WINDOWED_HIDDEN × n_classes]`.
|
||||
#[serde(default)]
|
||||
pub w2: Vec<f64>,
|
||||
/// Layer 2 bias, `[n_classes]`.
|
||||
#[serde(default)]
|
||||
pub b2: Vec<f64>,
|
||||
/// Number of output classes (== len(b2) when trained).
|
||||
#[serde(default)]
|
||||
pub n_classes: usize,
|
||||
}
|
||||
|
||||
impl WindowedMlpModel {
|
||||
pub fn is_trained(&self) -> bool {
|
||||
!self.w1.is_empty()
|
||||
&& self.n_classes > 0
|
||||
&& self.b2.len() == self.n_classes
|
||||
&& self.w1.len() == WINDOWED_INPUT * WINDOWED_HIDDEN
|
||||
}
|
||||
|
||||
/// Forward pass. `window` is `WINDOW_FRAMES × N_FEATURES` flat,
|
||||
/// row-major (oldest-frame-first), already z-score normalised.
|
||||
/// Returns softmax probabilities of length `n_classes`.
|
||||
pub fn forward(&self, window: &[f64]) -> Vec<f64> {
|
||||
debug_assert_eq!(window.len(), WINDOWED_INPUT);
|
||||
// Layer 1: h = ReLU(window · W1 + b1)
|
||||
let mut h = vec![0.0f64; WINDOWED_HIDDEN];
|
||||
for j in 0..WINDOWED_HIDDEN {
|
||||
let mut s = self.b1[j];
|
||||
for i in 0..WINDOWED_INPUT {
|
||||
s += window[i] * self.w1[i * WINDOWED_HIDDEN + j];
|
||||
}
|
||||
h[j] = s.max(0.0);
|
||||
}
|
||||
// Layer 2: logits = h · W2 + b2
|
||||
let mut logits = vec![0.0f64; self.n_classes];
|
||||
for c in 0..self.n_classes {
|
||||
let mut s = self.b2[c];
|
||||
for j in 0..WINDOWED_HIDDEN {
|
||||
s += h[j] * self.w2[j * self.n_classes + c];
|
||||
}
|
||||
logits[c] = s;
|
||||
}
|
||||
let m = logits.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
|
||||
let exp_sum: f64 = logits.iter().map(|z| (z - m).exp()).sum();
|
||||
logits.iter().map(|z| (z - m).exp() / exp_sum).collect()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Trained model ────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AdaptiveModel {
|
||||
/// Per-class feature statistics (centroid + spread).
|
||||
pub class_stats: Vec<ClassStats>,
|
||||
/// Logistic regression weights: [n_classes x (N_FEATURES + 1)] (last = bias).
|
||||
/// Dynamic: the outer Vec length equals the number of discovered classes.
|
||||
/// ADR-119: legacy logistic regression weights, kept as fallback.
|
||||
/// Shape: `[n_classes × (N_FEATURES + 1)]` (last column = bias).
|
||||
/// When `mlp.is_trained()` returns true, MLP wins and these are unused
|
||||
/// at classify time but still updated by `train_from_recordings` so
|
||||
/// rollback is one-line.
|
||||
pub weights: Vec<Vec<f64>>,
|
||||
/// ADR-119: trained MLP (frame-level fallback, used when WindowedMlp
|
||||
/// has no data yet — e.g. cold start before 20 frames accumulated).
|
||||
#[serde(default)]
|
||||
pub mlp: MlpModel,
|
||||
/// ADR-120: trained Windowed MLP (preferred classifier when trained
|
||||
/// AND a 20-frame window of fresh features is available at classify
|
||||
/// time). Captures temporal patterns the frame-level MLP can't see.
|
||||
#[serde(default)]
|
||||
pub windowed_mlp: WindowedMlpModel,
|
||||
/// Global feature normalisation: mean and stddev across all training data.
|
||||
pub global_mean: [f64; N_FEATURES],
|
||||
pub global_std: [f64; N_FEATURES],
|
||||
|
|
@ -153,6 +324,8 @@ impl Default for AdaptiveModel {
|
|||
Self {
|
||||
class_stats: Vec::new(),
|
||||
weights: vec![vec![0.0; N_FEATURES + 1]; n_classes],
|
||||
mlp: MlpModel::default(),
|
||||
windowed_mlp: WindowedMlpModel::default(),
|
||||
global_mean: [0.0; N_FEATURES],
|
||||
global_std: [1.0; N_FEATURES],
|
||||
trained_frames: 0,
|
||||
|
|
@ -164,39 +337,86 @@ impl Default for AdaptiveModel {
|
|||
}
|
||||
|
||||
impl AdaptiveModel {
|
||||
/// Classify a raw feature vector. Returns (class_label, confidence).
|
||||
pub fn classify(&self, raw_features: &[f64; N_FEATURES]) -> (String, f64) {
|
||||
let n_classes = self.weights.len();
|
||||
if n_classes == 0 || self.class_stats.is_empty() {
|
||||
return ("present_still".to_string(), 0.5);
|
||||
/// ADR-120: classify using a temporal window of recent frames.
|
||||
/// `window` is `WINDOW_FRAMES × N_FEATURES` flat row-major (oldest first),
|
||||
/// in raw (un-normalised) units — this fn applies z-score normalisation
|
||||
/// internally using the model's `global_mean`/`global_std`.
|
||||
/// Falls back to frame-level `classify()` on the most recent frame when
|
||||
/// the windowed MLP isn't trained.
|
||||
pub fn classify_window(&self, window: &[f64]) -> (String, f64) {
|
||||
if self.windowed_mlp.is_trained() && window.len() == WINDOWED_INPUT {
|
||||
let mut norm = vec![0.0f64; WINDOWED_INPUT];
|
||||
for f in 0..WINDOW_FRAMES {
|
||||
for i in 0..N_FEATURES {
|
||||
let idx = f * N_FEATURES + i;
|
||||
norm[idx] = (window[idx] - self.global_mean[i]) / (self.global_std[i] + 1e-9);
|
||||
}
|
||||
}
|
||||
let probs = self.windowed_mlp.forward(&norm);
|
||||
let (best_c, best_p) = probs.iter().enumerate()
|
||||
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
|
||||
.unwrap();
|
||||
let label = if best_c < self.class_names.len() {
|
||||
self.class_names[best_c].clone()
|
||||
} else {
|
||||
"present_still".to_string()
|
||||
};
|
||||
return (label, *best_p);
|
||||
}
|
||||
// Cold-start fallback: most recent frame via frame-level classifier.
|
||||
let mut last_frame = [0.0f64; N_FEATURES];
|
||||
if window.len() >= N_FEATURES {
|
||||
let off = window.len() - N_FEATURES;
|
||||
last_frame.copy_from_slice(&window[off..off + N_FEATURES]);
|
||||
}
|
||||
self.classify(&last_frame)
|
||||
}
|
||||
|
||||
// Normalise features.
|
||||
/// Classify a raw feature vector. Returns (class_label, confidence).
|
||||
/// ADR-119: prefers MLP when trained; falls back to logistic regression
|
||||
/// otherwise. ADR-120: temporal-context API is `classify_window` —
|
||||
/// prefer it when callers have a recent feature buffer.
|
||||
pub fn classify(&self, raw_features: &[f64; N_FEATURES]) -> (String, f64) {
|
||||
// Normalise features once (shared by MLP and LogReg).
|
||||
let mut x = [0.0f64; N_FEATURES];
|
||||
for i in 0..N_FEATURES {
|
||||
x[i] = (raw_features[i] - self.global_mean[i]) / (self.global_std[i] + 1e-9);
|
||||
}
|
||||
|
||||
// Compute logits: w·x + b for each class.
|
||||
// ADR-119: MLP path (preferred when trained).
|
||||
if self.mlp.is_trained() {
|
||||
let probs = self.mlp.forward(&x);
|
||||
let (best_c, best_p) = probs.iter().enumerate()
|
||||
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
|
||||
.unwrap();
|
||||
let label = if best_c < self.class_names.len() {
|
||||
self.class_names[best_c].clone()
|
||||
} else {
|
||||
"present_still".to_string()
|
||||
};
|
||||
return (label, *best_p);
|
||||
}
|
||||
|
||||
// Legacy logistic regression fallback.
|
||||
let n_classes = self.weights.len();
|
||||
if n_classes == 0 || self.class_stats.is_empty() {
|
||||
return ("present_still".to_string(), 0.5);
|
||||
}
|
||||
let mut logits: Vec<f64> = vec![0.0; n_classes];
|
||||
for c in 0..n_classes {
|
||||
let w = &self.weights[c];
|
||||
let mut z = w[N_FEATURES]; // bias
|
||||
let mut z = w[N_FEATURES];
|
||||
for i in 0..N_FEATURES {
|
||||
z += w[i] * x[i];
|
||||
}
|
||||
logits[c] = z;
|
||||
}
|
||||
|
||||
// Softmax.
|
||||
let max_logit = logits.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
|
||||
let exp_sum: f64 = logits.iter().map(|z| (z - max_logit).exp()).sum();
|
||||
let mut probs: Vec<f64> = vec![0.0; n_classes];
|
||||
for c in 0..n_classes {
|
||||
probs[c] = ((logits[c] - max_logit).exp()) / exp_sum;
|
||||
}
|
||||
|
||||
// Pick argmax.
|
||||
let (best_c, best_p) = probs.iter().enumerate()
|
||||
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
|
||||
.unwrap();
|
||||
|
|
@ -226,6 +446,7 @@ impl AdaptiveModel {
|
|||
// ── Training ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A labeled training sample.
|
||||
#[derive(Clone)]
|
||||
struct Sample {
|
||||
features: [f64; N_FEATURES],
|
||||
class_idx: usize,
|
||||
|
|
@ -314,13 +535,18 @@ pub fn train_from_recordings(recordings_dir: &Path) -> Result<AdaptiveModel, Str
|
|||
}
|
||||
|
||||
// Second pass: load recordings with the discovered class indices.
|
||||
// ADR-120: keep recordings grouped so windowed-MLP training can slide
|
||||
// a temporal window WITHIN each recording (not across recording
|
||||
// boundaries — would mix classes).
|
||||
let mut samples: Vec<Sample> = Vec::new();
|
||||
let mut recording_groups: Vec<Vec<Sample>> = Vec::new();
|
||||
for (path, fname, class_name) in &file_classes {
|
||||
let class_idx = class_map[class_name];
|
||||
let loaded = load_recording(path, class_idx);
|
||||
eprintln!(" Loaded {}: {} frames → class '{}'",
|
||||
fname, loaded.len(), class_name);
|
||||
samples.extend(loaded);
|
||||
samples.extend(loaded.clone());
|
||||
recording_groups.push(loaded);
|
||||
}
|
||||
|
||||
if samples.is_empty() {
|
||||
|
|
@ -499,22 +725,428 @@ pub fn train_from_recordings(recordings_dir: &Path) -> Result<AdaptiveModel, Str
|
|||
}
|
||||
for c in 0..n_classes {
|
||||
let tot = class_total[c].max(1);
|
||||
eprintln!(" {}: {}/{} ({:.0}%)", class_names[c], class_correct[c], tot,
|
||||
eprintln!(" LogReg {}: {}/{} ({:.0}%)", class_names[c], class_correct[c], tot,
|
||||
class_correct[c] as f64 / tot as f64 * 100.0);
|
||||
}
|
||||
|
||||
// ── ADR-119: train MLP on the same normalised samples ──
|
||||
eprintln!("Training MLP (22 → {} → {}) ...", MLP_HIDDEN, n_classes);
|
||||
let mlp = train_mlp_classifier(&norm_samples, n_classes);
|
||||
let (mlp_acc, mlp_per_class) = eval_mlp(&mlp, &norm_samples, n_classes);
|
||||
eprintln!("MLP accuracy: {:.2}% (LogReg was {:.2}%)",
|
||||
mlp_acc * 100.0, accuracy * 100.0);
|
||||
for c in 0..n_classes {
|
||||
let tot = class_total[c].max(1);
|
||||
let corr = mlp_per_class[c];
|
||||
eprintln!(" MLP {}: {}/{} ({:.0}%)",
|
||||
class_names[c], corr, tot, corr as f64 / tot as f64 * 100.0);
|
||||
}
|
||||
|
||||
// ── ADR-120: Windowed MLP training ──
|
||||
// Build temporal-window samples within each recording (no cross-recording
|
||||
// mixing). Slide window of WINDOW_FRAMES with stride to balance class
|
||||
// count vs sample count.
|
||||
eprintln!("Building temporal windows ({} frames × {} features → {} dims)...",
|
||||
WINDOW_FRAMES, N_FEATURES, WINDOWED_INPUT);
|
||||
let window_stride = 5usize; // 4× overlap; ~28k windows total on 151k frames
|
||||
let mut win_samples: Vec<(Vec<f64>, usize)> = Vec::new();
|
||||
for group in &recording_groups {
|
||||
if group.len() < WINDOW_FRAMES { continue; }
|
||||
let class_idx = group[0].class_idx;
|
||||
let mut start = 0usize;
|
||||
while start + WINDOW_FRAMES <= group.len() {
|
||||
let mut flat: Vec<f64> = Vec::with_capacity(WINDOWED_INPUT);
|
||||
for f in 0..WINDOW_FRAMES {
|
||||
let frame = &group[start + f];
|
||||
for i in 0..N_FEATURES {
|
||||
let z = (frame.features[i] - global_mean[i]) / (global_std[i] + 1e-9);
|
||||
flat.push(z);
|
||||
}
|
||||
}
|
||||
win_samples.push((flat, class_idx));
|
||||
start += window_stride;
|
||||
}
|
||||
}
|
||||
eprintln!("Total windowed samples: {}", win_samples.len());
|
||||
|
||||
// Count per-class windowed samples.
|
||||
let mut win_class_total = vec![0usize; n_classes];
|
||||
for (_, c) in &win_samples { win_class_total[*c] += 1; }
|
||||
|
||||
eprintln!("Training Windowed MLP ({} → {} → {}) ...", WINDOWED_INPUT, WINDOWED_HIDDEN, n_classes);
|
||||
let windowed_mlp = train_windowed_mlp_classifier(&win_samples, n_classes);
|
||||
let (win_acc, win_per_class) = eval_windowed_mlp(&windowed_mlp, &win_samples, n_classes);
|
||||
eprintln!("Windowed MLP accuracy: {:.2}% (frame-level MLP was {:.2}%)",
|
||||
win_acc * 100.0, mlp_acc * 100.0);
|
||||
for c in 0..n_classes {
|
||||
let tot = win_class_total[c].max(1);
|
||||
let corr = win_per_class[c];
|
||||
eprintln!(" W-MLP {}: {}/{} ({:.0}%)",
|
||||
class_names[c], corr, tot, corr as f64 / tot as f64 * 100.0);
|
||||
}
|
||||
|
||||
// Pick the best classifier as final accuracy number.
|
||||
let final_accuracy = win_acc.max(mlp_acc).max(accuracy);
|
||||
|
||||
Ok(AdaptiveModel {
|
||||
class_stats,
|
||||
weights,
|
||||
mlp,
|
||||
windowed_mlp,
|
||||
global_mean,
|
||||
global_std,
|
||||
trained_frames: n,
|
||||
training_accuracy: accuracy,
|
||||
training_accuracy: final_accuracy,
|
||||
version: 1,
|
||||
class_names,
|
||||
})
|
||||
}
|
||||
|
||||
// ── ADR-119: MLP training (manual backprop, no external ML crate) ────────────
|
||||
|
||||
/// Train a single-hidden-layer MLP on already-z-score-normalised samples.
|
||||
/// Architecture: N_FEATURES → MLP_HIDDEN → n_classes (ReLU + softmax).
|
||||
/// Optimiser: SGD + momentum 0.9 + weight decay 1e-4 + cosine LR decay.
|
||||
fn train_mlp_classifier(samples: &[([f64; N_FEATURES], usize)], n_classes: usize) -> MlpModel {
|
||||
let n_w1 = N_FEATURES * MLP_HIDDEN;
|
||||
let n_w2 = MLP_HIDDEN * n_classes;
|
||||
|
||||
// He initialisation: w ~ N(0, sqrt(2/fan_in))
|
||||
let mut rng_state: u64 = 1337;
|
||||
let mut rng_u01 = move || -> f64 {
|
||||
rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
((rng_state >> 33) as f64) / ((u64::MAX >> 33) as f64)
|
||||
};
|
||||
let mut he_init = |n: usize, fan_in: usize| -> Vec<f64> {
|
||||
let s = (2.0 / fan_in as f64).sqrt();
|
||||
let mut v = Vec::with_capacity(n);
|
||||
let mut k = 0;
|
||||
while k < n {
|
||||
let u1 = rng_u01().max(1e-12);
|
||||
let u2 = rng_u01();
|
||||
let z0 = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() * s;
|
||||
let z1 = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).sin() * s;
|
||||
v.push(z0);
|
||||
k += 1;
|
||||
if k < n { v.push(z1); k += 1; }
|
||||
}
|
||||
v
|
||||
};
|
||||
|
||||
let mut w1 = he_init(n_w1, N_FEATURES);
|
||||
let mut b1 = vec![0.0f64; MLP_HIDDEN];
|
||||
let mut w2 = he_init(n_w2, MLP_HIDDEN);
|
||||
let mut b2 = vec![0.0f64; n_classes];
|
||||
|
||||
let mut mw1 = vec![0.0f64; n_w1];
|
||||
let mut mb1 = vec![0.0f64; MLP_HIDDEN];
|
||||
let mut mw2 = vec![0.0f64; n_w2];
|
||||
let mut mb2 = vec![0.0f64; n_classes];
|
||||
|
||||
let momentum = 0.9f64;
|
||||
let weight_decay = 1e-4f64;
|
||||
let base_lr = 0.05f64;
|
||||
let batch_size = 64usize;
|
||||
let epochs = 30usize;
|
||||
let n = samples.len();
|
||||
|
||||
// Shuffle index buffer (avoid cloning sample arrays).
|
||||
let mut idx: Vec<usize> = (0..n).collect();
|
||||
let mut shuf_state: u64 = 7;
|
||||
let mut shuf_next = move || -> u64 {
|
||||
shuf_state = shuf_state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
shuf_state >> 33
|
||||
};
|
||||
|
||||
for epoch in 0..epochs {
|
||||
for i in (1..idx.len()).rev() {
|
||||
let j = (shuf_next() as usize) % (i + 1);
|
||||
idx.swap(i, j);
|
||||
}
|
||||
|
||||
let lr = base_lr * 0.5 * (1.0 + (std::f64::consts::PI * epoch as f64 / epochs as f64).cos());
|
||||
let mut epoch_loss = 0.0f64;
|
||||
let mut h_pre = vec![0.0f64; MLP_HIDDEN];
|
||||
let mut h = vec![0.0f64; MLP_HIDDEN];
|
||||
let mut logits = vec![0.0f64; n_classes];
|
||||
|
||||
let mut k = 0usize;
|
||||
while k < n {
|
||||
let bend = (k + batch_size).min(n);
|
||||
let mut gw1 = vec![0.0f64; n_w1];
|
||||
let mut gb1 = vec![0.0f64; MLP_HIDDEN];
|
||||
let mut gw2 = vec![0.0f64; n_w2];
|
||||
let mut gb2 = vec![0.0f64; n_classes];
|
||||
let bs = (bend - k) as f64;
|
||||
|
||||
for &si in &idx[k..bend] {
|
||||
let (x, target) = &samples[si];
|
||||
|
||||
// Forward.
|
||||
for j in 0..MLP_HIDDEN {
|
||||
let mut s = b1[j];
|
||||
for i in 0..N_FEATURES { s += x[i] * w1[i * MLP_HIDDEN + j]; }
|
||||
h_pre[j] = s;
|
||||
h[j] = s.max(0.0);
|
||||
}
|
||||
for c in 0..n_classes {
|
||||
let mut s = b2[c];
|
||||
for j in 0..MLP_HIDDEN { s += h[j] * w2[j * n_classes + c]; }
|
||||
logits[c] = s;
|
||||
}
|
||||
let mx = logits.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
|
||||
let ex_sum: f64 = logits.iter().map(|z| (z - mx).exp()).sum();
|
||||
// d_logits = softmax - one_hot
|
||||
let mut d_logits = vec![0.0f64; n_classes];
|
||||
for c in 0..n_classes {
|
||||
let p = (logits[c] - mx).exp() / ex_sum;
|
||||
d_logits[c] = p - if c == *target { 1.0 } else { 0.0 };
|
||||
if c == *target { epoch_loss += -(p.max(1e-15)).ln(); }
|
||||
}
|
||||
|
||||
// Gradients.
|
||||
for c in 0..n_classes {
|
||||
gb2[c] += d_logits[c];
|
||||
for j in 0..MLP_HIDDEN {
|
||||
gw2[j * n_classes + c] += h[j] * d_logits[c];
|
||||
}
|
||||
}
|
||||
// Backprop through Layer-2 to hidden.
|
||||
let mut d_h = [0.0f64; MLP_HIDDEN];
|
||||
for j in 0..MLP_HIDDEN {
|
||||
if h_pre[j] <= 0.0 { continue; }
|
||||
let mut s = 0.0;
|
||||
for c in 0..n_classes { s += w2[j * n_classes + c] * d_logits[c]; }
|
||||
d_h[j] = s;
|
||||
}
|
||||
for j in 0..MLP_HIDDEN {
|
||||
gb1[j] += d_h[j];
|
||||
for i in 0..N_FEATURES { gw1[i * MLP_HIDDEN + j] += x[i] * d_h[j]; }
|
||||
}
|
||||
}
|
||||
|
||||
// SGD + momentum + weight decay.
|
||||
for q in 0..n_w1 {
|
||||
let g = gw1[q] / bs + weight_decay * w1[q];
|
||||
mw1[q] = momentum * mw1[q] + g;
|
||||
w1[q] -= lr * mw1[q];
|
||||
}
|
||||
for q in 0..MLP_HIDDEN {
|
||||
let g = gb1[q] / bs;
|
||||
mb1[q] = momentum * mb1[q] + g;
|
||||
b1[q] -= lr * mb1[q];
|
||||
}
|
||||
for q in 0..n_w2 {
|
||||
let g = gw2[q] / bs + weight_decay * w2[q];
|
||||
mw2[q] = momentum * mw2[q] + g;
|
||||
w2[q] -= lr * mw2[q];
|
||||
}
|
||||
for q in 0..n_classes {
|
||||
let g = gb2[q] / bs;
|
||||
mb2[q] = momentum * mb2[q] + g;
|
||||
b2[q] -= lr * mb2[q];
|
||||
}
|
||||
|
||||
k = bend;
|
||||
}
|
||||
if epoch % 5 == 0 || epoch == epochs - 1 {
|
||||
eprintln!(" MLP epoch {epoch:2}/{}: loss = {:.4}, lr = {:.4}",
|
||||
epochs, epoch_loss / n as f64, lr);
|
||||
}
|
||||
}
|
||||
|
||||
MlpModel { w1, b1, w2, b2, n_classes }
|
||||
}
|
||||
|
||||
/// Evaluate MLP accuracy and per-class correct counts on normalised samples.
|
||||
fn eval_mlp(mlp: &MlpModel, samples: &[([f64; N_FEATURES], usize)], n_classes: usize)
|
||||
-> (f64, Vec<usize>)
|
||||
{
|
||||
let mut correct = 0usize;
|
||||
let mut per_class = vec![0usize; n_classes];
|
||||
for (x, target) in samples {
|
||||
let probs = mlp.forward(x);
|
||||
let pred = probs.iter().enumerate()
|
||||
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
|
||||
.unwrap().0;
|
||||
if pred == *target { correct += 1; per_class[*target] += 1; }
|
||||
}
|
||||
(correct as f64 / samples.len() as f64, per_class)
|
||||
}
|
||||
|
||||
// ── ADR-120: Windowed MLP training ──────────────────────────────────────────
|
||||
|
||||
/// Train a windowed MLP on temporal-window samples.
|
||||
/// Each sample is a 440-d flat vector (20 frames × 22 features) labeled
|
||||
/// with a class index. Architecture: 440 → 64 ReLU → n_classes softmax.
|
||||
/// Same SGD + momentum + cosine-decay recipe as MLP, fewer epochs because
|
||||
/// each window is a richer training signal than a single frame.
|
||||
fn train_windowed_mlp_classifier(
|
||||
samples: &[(Vec<f64>, usize)],
|
||||
n_classes: usize,
|
||||
) -> WindowedMlpModel {
|
||||
let n_w1 = WINDOWED_INPUT * WINDOWED_HIDDEN;
|
||||
let n_w2 = WINDOWED_HIDDEN * n_classes;
|
||||
|
||||
let mut rng_state: u64 = 24601;
|
||||
let mut rng_u01 = move || -> f64 {
|
||||
rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
((rng_state >> 33) as f64) / ((u64::MAX >> 33) as f64)
|
||||
};
|
||||
let mut he_init = |n: usize, fan_in: usize| -> Vec<f64> {
|
||||
let s = (2.0 / fan_in as f64).sqrt();
|
||||
let mut v = Vec::with_capacity(n);
|
||||
let mut k = 0;
|
||||
while k < n {
|
||||
let u1 = rng_u01().max(1e-12);
|
||||
let u2 = rng_u01();
|
||||
let z0 = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() * s;
|
||||
let z1 = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).sin() * s;
|
||||
v.push(z0); k += 1;
|
||||
if k < n { v.push(z1); k += 1; }
|
||||
}
|
||||
v
|
||||
};
|
||||
|
||||
let mut w1 = he_init(n_w1, WINDOWED_INPUT);
|
||||
let mut b1 = vec![0.0f64; WINDOWED_HIDDEN];
|
||||
let mut w2 = he_init(n_w2, WINDOWED_HIDDEN);
|
||||
let mut b2 = vec![0.0f64; n_classes];
|
||||
|
||||
let mut mw1 = vec![0.0f64; n_w1];
|
||||
let mut mb1 = vec![0.0f64; WINDOWED_HIDDEN];
|
||||
let mut mw2 = vec![0.0f64; n_w2];
|
||||
let mut mb2 = vec![0.0f64; n_classes];
|
||||
|
||||
let momentum = 0.9f64;
|
||||
let weight_decay = 1e-4f64;
|
||||
let base_lr = 0.03f64; // smaller LR for larger network (vs MLP's 0.05)
|
||||
let batch_size = 32usize;
|
||||
let epochs = 25usize;
|
||||
let n = samples.len();
|
||||
|
||||
let mut idx: Vec<usize> = (0..n).collect();
|
||||
let mut shuf_state: u64 = 11;
|
||||
let mut shuf_next = move || -> u64 {
|
||||
shuf_state = shuf_state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
shuf_state >> 33
|
||||
};
|
||||
|
||||
let mut h_pre = vec![0.0f64; WINDOWED_HIDDEN];
|
||||
let mut h = vec![0.0f64; WINDOWED_HIDDEN];
|
||||
let mut logits = vec![0.0f64; n_classes];
|
||||
|
||||
for epoch in 0..epochs {
|
||||
for i in (1..idx.len()).rev() {
|
||||
let j = (shuf_next() as usize) % (i + 1);
|
||||
idx.swap(i, j);
|
||||
}
|
||||
let lr = base_lr * 0.5 * (1.0 + (std::f64::consts::PI * epoch as f64 / epochs as f64).cos());
|
||||
let mut epoch_loss = 0.0f64;
|
||||
|
||||
let mut k = 0usize;
|
||||
while k < n {
|
||||
let bend = (k + batch_size).min(n);
|
||||
let mut gw1 = vec![0.0f64; n_w1];
|
||||
let mut gb1 = vec![0.0f64; WINDOWED_HIDDEN];
|
||||
let mut gw2 = vec![0.0f64; n_w2];
|
||||
let mut gb2 = vec![0.0f64; n_classes];
|
||||
let bs = (bend - k) as f64;
|
||||
|
||||
for &si in &idx[k..bend] {
|
||||
let (x, target) = &samples[si];
|
||||
debug_assert_eq!(x.len(), WINDOWED_INPUT);
|
||||
|
||||
// Forward.
|
||||
for j in 0..WINDOWED_HIDDEN {
|
||||
let mut s = b1[j];
|
||||
for i in 0..WINDOWED_INPUT { s += x[i] * w1[i * WINDOWED_HIDDEN + j]; }
|
||||
h_pre[j] = s;
|
||||
h[j] = s.max(0.0);
|
||||
}
|
||||
for c in 0..n_classes {
|
||||
let mut s = b2[c];
|
||||
for j in 0..WINDOWED_HIDDEN { s += h[j] * w2[j * n_classes + c]; }
|
||||
logits[c] = s;
|
||||
}
|
||||
let mx = logits.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
|
||||
let ex_sum: f64 = logits.iter().map(|z| (z - mx).exp()).sum();
|
||||
let mut d_logits = vec![0.0f64; n_classes];
|
||||
for c in 0..n_classes {
|
||||
let p = (logits[c] - mx).exp() / ex_sum;
|
||||
d_logits[c] = p - if c == *target { 1.0 } else { 0.0 };
|
||||
if c == *target { epoch_loss += -(p.max(1e-15)).ln(); }
|
||||
}
|
||||
|
||||
for c in 0..n_classes {
|
||||
gb2[c] += d_logits[c];
|
||||
for j in 0..WINDOWED_HIDDEN {
|
||||
gw2[j * n_classes + c] += h[j] * d_logits[c];
|
||||
}
|
||||
}
|
||||
let mut d_h = vec![0.0f64; WINDOWED_HIDDEN];
|
||||
for j in 0..WINDOWED_HIDDEN {
|
||||
if h_pre[j] <= 0.0 { continue; }
|
||||
let mut s = 0.0;
|
||||
for c in 0..n_classes { s += w2[j * n_classes + c] * d_logits[c]; }
|
||||
d_h[j] = s;
|
||||
}
|
||||
for j in 0..WINDOWED_HIDDEN {
|
||||
gb1[j] += d_h[j];
|
||||
for i in 0..WINDOWED_INPUT { gw1[i * WINDOWED_HIDDEN + j] += x[i] * d_h[j]; }
|
||||
}
|
||||
}
|
||||
|
||||
for q in 0..n_w1 {
|
||||
let g = gw1[q] / bs + weight_decay * w1[q];
|
||||
mw1[q] = momentum * mw1[q] + g;
|
||||
w1[q] -= lr * mw1[q];
|
||||
}
|
||||
for q in 0..WINDOWED_HIDDEN {
|
||||
let g = gb1[q] / bs;
|
||||
mb1[q] = momentum * mb1[q] + g;
|
||||
b1[q] -= lr * mb1[q];
|
||||
}
|
||||
for q in 0..n_w2 {
|
||||
let g = gw2[q] / bs + weight_decay * w2[q];
|
||||
mw2[q] = momentum * mw2[q] + g;
|
||||
w2[q] -= lr * mw2[q];
|
||||
}
|
||||
for q in 0..n_classes {
|
||||
let g = gb2[q] / bs;
|
||||
mb2[q] = momentum * mb2[q] + g;
|
||||
b2[q] -= lr * mb2[q];
|
||||
}
|
||||
|
||||
k = bend;
|
||||
}
|
||||
if epoch % 3 == 0 || epoch == epochs - 1 {
|
||||
eprintln!(" W-MLP epoch {epoch:2}/{}: loss = {:.4}, lr = {:.4}",
|
||||
epochs, epoch_loss / n as f64, lr);
|
||||
}
|
||||
}
|
||||
|
||||
WindowedMlpModel { w1, b1, w2, b2, n_classes }
|
||||
}
|
||||
|
||||
/// Evaluate Windowed MLP accuracy + per-class correct counts.
|
||||
fn eval_windowed_mlp(
|
||||
mlp: &WindowedMlpModel,
|
||||
samples: &[(Vec<f64>, usize)],
|
||||
n_classes: usize,
|
||||
) -> (f64, Vec<usize>) {
|
||||
let mut correct = 0usize;
|
||||
let mut per_class = vec![0usize; n_classes];
|
||||
for (x, target) in samples {
|
||||
let probs = mlp.forward(x);
|
||||
let pred = probs.iter().enumerate()
|
||||
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
|
||||
.unwrap().0;
|
||||
if pred == *target { correct += 1; per_class[*target] += 1; }
|
||||
}
|
||||
(correct as f64 / samples.len() as f64, per_class)
|
||||
}
|
||||
|
||||
/// Default path for the saved adaptive model.
|
||||
pub fn model_path() -> PathBuf {
|
||||
PathBuf::from("data/adaptive_model.json")
|
||||
|
|
|
|||
|
|
@ -481,9 +481,16 @@ pub fn smooth_and_classify_node(ns: &mut NodeState, raw: &mut ClassificationInfo
|
|||
raw.confidence = (0.4 + sm * 0.6).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
/// ADR-118: legacy single-node override variant kept for API compatibility.
|
||||
/// New callers should query per-node amps from AMP_HIST and pass the full
|
||||
/// `&[(u8, &[f64])]` slice. This variant degrades to "node 1 only" which
|
||||
/// produces a feature vector with 5 zero-padded node slots — usable for
|
||||
/// emergency fallback but the trained model expects the full multi-node
|
||||
/// vector.
|
||||
pub fn adaptive_override(state: &AppStateInner, features: &FeatureInfo, classification: &mut ClassificationInfo) {
|
||||
if let Some(ref model) = state.adaptive_model {
|
||||
let amps = state.frame_history.back().map(|v| v.as_slice()).unwrap_or(&[]);
|
||||
let amps_owned: Vec<f64> = state.frame_history.back().cloned().unwrap_or_default();
|
||||
let per_node_refs: Vec<(u8, &[f64])> = vec![(1u8, amps_owned.as_slice())];
|
||||
let feat_arr = adaptive_classifier::features_from_runtime(
|
||||
&serde_json::json!({
|
||||
"variance": features.variance,
|
||||
|
|
@ -494,7 +501,7 @@ pub fn adaptive_override(state: &AppStateInner, features: &FeatureInfo, classifi
|
|||
"change_points": features.change_points,
|
||||
"mean_rssi": features.mean_rssi,
|
||||
}),
|
||||
amps,
|
||||
&per_node_refs,
|
||||
);
|
||||
let (label, conf) = model.classify(&feat_arr);
|
||||
classification.motion_level = label.to_string();
|
||||
|
|
|
|||
|
|
@ -1645,6 +1645,12 @@ struct AppStateInner {
|
|||
/// Each entry is the full subcarrier amplitude vector for one frame.
|
||||
/// Capacity: FRAME_HISTORY_CAPACITY frames.
|
||||
frame_history: VecDeque<Vec<f64>>,
|
||||
/// ADR-120: rolling buffer of the last WINDOW_FRAMES (=20) feature
|
||||
/// vectors from `features_from_runtime`. Used at classify time to
|
||||
/// feed the WindowedMlp inside the adaptive model. Pushed each tick
|
||||
/// before the broadcast emit. Cold start: classify_window falls back
|
||||
/// to frame-level until the buffer fills.
|
||||
feature_window: VecDeque<[f64; adaptive_classifier::N_FEATURES_PUB]>,
|
||||
tick: u64,
|
||||
source: String,
|
||||
/// Instant of the last ESP32 UDP frame received (for offline detection).
|
||||
|
|
@ -2645,14 +2651,32 @@ fn smooth_and_classify_node(ns: &mut NodeState, raw: &mut ClassificationInfo, ra
|
|||
raw.confidence = (0.4 + sm * 0.6).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
/// ADR-118: collect the latest amplitude vector per node from `AMP_HIST`.
|
||||
/// The adaptive classifier's new 22-feature pipeline reads 3 features per
|
||||
/// node × 6 nodes; calling code at the override sites no longer has access
|
||||
/// to a single global "amps" vector — it needs the per-node breakdown.
|
||||
fn current_per_node_amps() -> Vec<(u8, Vec<f64>)> {
|
||||
let map = amp_hist_init().lock().unwrap();
|
||||
map.iter()
|
||||
.filter_map(|(nid, st)| {
|
||||
st.nbvi_history.back().cloned().map(|amps| (*nid, amps))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// If an adaptive model is loaded, override the classification with the
|
||||
/// model's prediction. Uses the full 15-feature vector for higher accuracy.
|
||||
/// model's prediction. ADR-120: prefers temporal-window classifier when
|
||||
/// the rolling feature buffer is full (20 frames). Falls through to
|
||||
/// frame-level (ADR-119 MLP) at cold start.
|
||||
///
|
||||
/// Read-only over `state` — the per-tick push into `feature_window` happens
|
||||
/// at the tick site where `&mut AppStateInner` is already held (see the
|
||||
/// broadcast tick task in `run_*_pipeline`).
|
||||
fn adaptive_override(state: &AppStateInner, features: &FeatureInfo, classification: &mut ClassificationInfo) {
|
||||
if let Some(ref model) = state.adaptive_model {
|
||||
// Get current frame amplitudes from the latest history entry.
|
||||
let amps = state.frame_history.back()
|
||||
.map(|v| v.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
let per_node_owned = current_per_node_amps();
|
||||
let per_node_refs: Vec<(u8, &[f64])> = per_node_owned.iter()
|
||||
.map(|(n, a)| (*n, a.as_slice())).collect();
|
||||
let feat_arr = adaptive_classifier::features_from_runtime(
|
||||
&serde_json::json!({
|
||||
"variance": features.variance,
|
||||
|
|
@ -2663,9 +2687,37 @@ fn adaptive_override(state: &AppStateInner, features: &FeatureInfo, classificati
|
|||
"change_points": features.change_points,
|
||||
"mean_rssi": features.mean_rssi,
|
||||
}),
|
||||
amps,
|
||||
&per_node_refs,
|
||||
);
|
||||
let (label, conf) = model.classify(&feat_arr);
|
||||
|
||||
// ADR-120: if rolling window has at least the current frame + 19 prior,
|
||||
// use the temporal classifier. Otherwise fall back to frame-level.
|
||||
let (label, conf) = if state.feature_window.len() + 1 >= adaptive_classifier::WINDOW_FRAMES {
|
||||
// Flatten the last (WINDOW_FRAMES - 1) historic vectors + current
|
||||
// frame into a single 440-d row-major vector, oldest first.
|
||||
let wf = adaptive_classifier::WINDOW_FRAMES;
|
||||
let nf = adaptive_classifier::N_FEATURES_PUB;
|
||||
let mut flat = vec![0.0f64; wf * nf];
|
||||
// History fills the first (WINDOW_FRAMES - 1) frames.
|
||||
let hist_take = wf - 1;
|
||||
let skip = state.feature_window.len().saturating_sub(hist_take);
|
||||
for (frame_i, fv) in state.feature_window.iter().skip(skip).enumerate() {
|
||||
let base = frame_i * nf;
|
||||
for i in 0..nf { flat[base + i] = fv[i]; }
|
||||
}
|
||||
// Last slot = current frame.
|
||||
let last_base = (wf - 1) * nf;
|
||||
for i in 0..nf { flat[last_base + i] = feat_arr[i]; }
|
||||
model.classify_window(&flat)
|
||||
} else {
|
||||
model.classify(&feat_arr)
|
||||
};
|
||||
|
||||
// ADR-120 follow-up #2: emit raw model label here. Smoothing is
|
||||
// applied centrally at end-of-tick via finalize_motion_label so
|
||||
// it covers BOTH the adaptive path AND the rule-based override
|
||||
// paths (amp_presence_override / amp_classify_from_latest) which
|
||||
// previously wrote raw values directly to motion_level.
|
||||
classification.motion_level = label.to_string();
|
||||
classification.presence = label != "absent";
|
||||
// Blend model confidence with existing smoothed confidence.
|
||||
|
|
@ -2673,6 +2725,156 @@ fn adaptive_override(state: &AppStateInner, features: &FeatureInfo, classificati
|
|||
}
|
||||
}
|
||||
|
||||
/// ADR-120 follow-up: two-layer smoothing on the adaptive classifier
|
||||
/// output to stop UI flicker.
|
||||
///
|
||||
/// Layer 1 — majority-vote over the last `ADAPTIVE_SMOOTH_WIN` ticks
|
||||
/// (3 sec @ 10 Hz). Brief glitches lose to sustained signal.
|
||||
///
|
||||
/// Layer 2 — candidate confirmation: even when the layer-1 mode flips,
|
||||
/// the committed display label only updates after the new mode has
|
||||
/// persisted for `ADAPTIVE_CONFIRM_TICKS` consecutive ticks. Prevents
|
||||
/// rapid bouncing between two near-tied classes.
|
||||
///
|
||||
/// Combined effective dwell time: ≥3 sec before any visible label change.
|
||||
/// Live UX target: user can read the badge without it changing
|
||||
/// mid-read, while a genuine activity switch still propagates within
|
||||
/// ~3-4 seconds.
|
||||
const ADAPTIVE_SMOOTH_WIN: usize = 15;
|
||||
const ADAPTIVE_CONFIRM_TICKS: u32 = 2;
|
||||
|
||||
static ADAPTIVE_LABEL_HISTORY: OnceLock<Mutex<VecDeque<String>>> = OnceLock::new();
|
||||
/// (committed_label, candidate_label, candidate_consecutive_count)
|
||||
static ADAPTIVE_COMMITTED: OnceLock<Mutex<(String, String, u32)>> = OnceLock::new();
|
||||
|
||||
/// ADR-120 follow-up #3: keep the LAST 30 RAW labels pushed into the
|
||||
/// smoother. Exposed via `/api/v1/adaptive/debug` so the operator can
|
||||
/// see what the model thinks vs what the UI shows after smoothing —
|
||||
/// distinguishes "smoother is too sticky" from "model is overfit and
|
||||
/// keeps outputting this class".
|
||||
static ADAPTIVE_RAW_LOG: OnceLock<Mutex<VecDeque<String>>> = OnceLock::new();
|
||||
fn adaptive_raw_log_init() -> &'static Mutex<VecDeque<String>> {
|
||||
ADAPTIVE_RAW_LOG.get_or_init(|| Mutex::new(VecDeque::with_capacity(30)))
|
||||
}
|
||||
|
||||
fn adaptive_label_history_init() -> &'static Mutex<VecDeque<String>> {
|
||||
ADAPTIVE_LABEL_HISTORY.get_or_init(|| Mutex::new(VecDeque::with_capacity(ADAPTIVE_SMOOTH_WIN)))
|
||||
}
|
||||
|
||||
fn adaptive_committed_init() -> &'static Mutex<(String, String, u32)> {
|
||||
ADAPTIVE_COMMITTED.get_or_init(|| Mutex::new((String::new(), String::new(), 0)))
|
||||
}
|
||||
|
||||
/// ADR-120 follow-up #2: smooth WHATEVER label the cascade of overrides
|
||||
/// produced, regardless of source (adaptive model OR amp_presence_override
|
||||
/// OR amp_classify_from_latest). The earlier adaptive_label_smooth ONLY
|
||||
/// covered the adaptive output — anything else (the 4 baseline classes)
|
||||
/// passed through raw, so the live label kept flipping on every tick.
|
||||
/// This is the final chokepoint called from each tick handler after all
|
||||
/// overrides have run.
|
||||
pub fn finalize_motion_label(classification: &mut ClassificationInfo) {
|
||||
let smoothed = adaptive_label_smooth(&classification.motion_level);
|
||||
classification.presence = smoothed != "absent";
|
||||
classification.motion_level = smoothed;
|
||||
}
|
||||
|
||||
/// Push `raw_label` into Layer 1 (rolling history) and compute its mode.
|
||||
/// Then run Layer 2 (candidate confirmation): a label different from the
|
||||
/// committed one must persist for ADAPTIVE_CONFIRM_TICKS consecutive
|
||||
/// mode-results before becoming the new committed.
|
||||
fn adaptive_label_smooth(raw_label: &str) -> String {
|
||||
// ADR-120 follow-up #3: log raw input for /api/v1/adaptive/debug.
|
||||
{
|
||||
let mut raw = adaptive_raw_log_init().lock().unwrap();
|
||||
raw.push_back(raw_label.to_string());
|
||||
while raw.len() > 30 { raw.pop_front(); }
|
||||
}
|
||||
// Layer 1 — majority vote.
|
||||
let mode = {
|
||||
let mut buf = adaptive_label_history_init().lock().unwrap();
|
||||
buf.push_back(raw_label.to_string());
|
||||
while buf.len() > ADAPTIVE_SMOOTH_WIN { buf.pop_front(); }
|
||||
let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
|
||||
for v in buf.iter() {
|
||||
*counts.entry(v.as_str()).or_insert(0) += 1;
|
||||
}
|
||||
let mut best = (raw_label.to_string(), 0usize);
|
||||
for (k, v) in &counts {
|
||||
if *v > best.1 {
|
||||
best = ((*k).to_string(), *v);
|
||||
}
|
||||
}
|
||||
best.0
|
||||
};
|
||||
|
||||
// Layer 2 — candidate confirmation.
|
||||
let mut st = adaptive_committed_init().lock().unwrap();
|
||||
if st.0.is_empty() {
|
||||
// Cold start: commit immediately on first non-empty mode.
|
||||
st.0 = mode.clone();
|
||||
st.1 = mode.clone();
|
||||
st.2 = 0;
|
||||
return mode;
|
||||
}
|
||||
if mode == st.0 {
|
||||
// Mode agrees with the committed label — reset candidate.
|
||||
st.1 = mode;
|
||||
st.2 = 0;
|
||||
} else if mode == st.1 {
|
||||
// Same candidate as before — increment streak.
|
||||
st.2 += 1;
|
||||
if st.2 >= ADAPTIVE_CONFIRM_TICKS {
|
||||
// Confirmed; promote candidate.
|
||||
st.0 = st.1.clone();
|
||||
st.2 = 0;
|
||||
}
|
||||
} else {
|
||||
// New candidate.
|
||||
st.1 = mode;
|
||||
st.2 = 1;
|
||||
}
|
||||
st.0.clone()
|
||||
}
|
||||
|
||||
/// ADR-120: classes that ONLY the adaptive W-MLP model can produce.
|
||||
/// The rule-based amp_presence_override / amp_classify_from_latest paths
|
||||
/// know only {absent, present_still, present_moving, active}; if the
|
||||
/// adaptive model has just emitted `waving` or `transition`, we must NOT
|
||||
/// overwrite it with the rule-based output. Hybrid priority: rule-based
|
||||
/// wins for the 4 baseline classes (it's battle-tested at F1 > 96%);
|
||||
/// adaptive wins exclusively when emitting a class outside that set.
|
||||
const ADAPTIVE_EXCLUSIVE_CLASSES: &[&str] = &["waving", "transition"];
|
||||
|
||||
fn adaptive_owns_class(label: &str) -> bool {
|
||||
ADAPTIVE_EXCLUSIVE_CLASSES.iter().any(|&c| c == label)
|
||||
}
|
||||
|
||||
/// ADR-120: push the current frame's feature vector into the rolling
|
||||
/// window buffer, evicting the oldest entry when at capacity. Called
|
||||
/// once per tick from the broadcast tick task where `&mut AppStateInner`
|
||||
/// is already held.
|
||||
fn push_feature_window(state: &mut AppStateInner, features: &FeatureInfo) {
|
||||
let per_node_owned = current_per_node_amps();
|
||||
let per_node_refs: Vec<(u8, &[f64])> = per_node_owned.iter()
|
||||
.map(|(n, a)| (*n, a.as_slice())).collect();
|
||||
let feat_arr = adaptive_classifier::features_from_runtime(
|
||||
&serde_json::json!({
|
||||
"variance": features.variance,
|
||||
"motion_band_power": features.motion_band_power,
|
||||
"breathing_band_power": features.breathing_band_power,
|
||||
"spectral_power": features.spectral_power,
|
||||
"dominant_freq_hz": features.dominant_freq_hz,
|
||||
"change_points": features.change_points,
|
||||
"mean_rssi": features.mean_rssi,
|
||||
}),
|
||||
&per_node_refs,
|
||||
);
|
||||
state.feature_window.push_back(feat_arr);
|
||||
while state.feature_window.len() > adaptive_classifier::WINDOW_FRAMES {
|
||||
state.feature_window.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
/// Size of the median filter window for vital signs outlier rejection.
|
||||
const VITAL_MEDIAN_WINDOW: usize = 21;
|
||||
/// EMA alpha for vital signs (~5s time constant at 10 FPS).
|
||||
|
|
@ -2953,22 +3155,34 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
|
|||
let (features, mut classification, breathing_rate_hz, sub_variances, raw_motion) =
|
||||
extract_features_from_frame(&frame, &s_write_pre.frame_history, sample_rate_hz);
|
||||
smooth_and_classify(&mut s_write_pre, &mut classification, raw_motion);
|
||||
// ADR-120: push current frame's features before classify so the
|
||||
// windowed model has temporal context.
|
||||
push_feature_window(&mut s_write_pre, &features);
|
||||
adaptive_override(&s_write_pre, &features, &mut classification);
|
||||
// ADR-101: raw-amplitude presence/motion override. Supersedes the
|
||||
// RSSI MAD-Δ classifier from ADR-099 (left in the source for
|
||||
// reference, see #[allow(dead_code)]). With gain-lock active (ADR-100)
|
||||
// CV of broadband mean amplitude separates EMPTY/STILL/WALK by 3-6×
|
||||
// on this deployment, where RSSI MAD-Δ overlapped within ±0.03.
|
||||
if let Some((level, presence, conf)) =
|
||||
amp_presence_override(frame.node_id, &frame.amplitudes)
|
||||
{
|
||||
classification.motion_level = level;
|
||||
classification.presence = presence;
|
||||
classification.confidence = conf;
|
||||
// ADR-120: skip the rule-based override when the adaptive model
|
||||
// has emitted a class only it can produce (waving / transition).
|
||||
if !adaptive_owns_class(&classification.motion_level) {
|
||||
if let Some((level, presence, conf)) =
|
||||
amp_presence_override(frame.node_id, &frame.amplitudes)
|
||||
{
|
||||
classification.motion_level = level;
|
||||
classification.presence = presence;
|
||||
classification.confidence = conf;
|
||||
}
|
||||
}
|
||||
// ADR-104 phase-domain: update phase drift score for this node
|
||||
// alongside the amplitude classifier. No-op if no phase baseline.
|
||||
phase_drift_update(frame.node_id, &frame.phases);
|
||||
|
||||
// ADR-120 follow-up #2: final smoothing pass over the post-
|
||||
// override classification. Catches flicker from BOTH adaptive
|
||||
// and rule-based paths.
|
||||
finalize_motion_label(&mut classification);
|
||||
drop(s_write_pre);
|
||||
|
||||
// ── Step 5: Build enhanced fields from pipeline result ───────
|
||||
|
|
@ -3141,6 +3355,9 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
|
|||
let (features, mut classification, breathing_rate_hz, sub_variances, raw_motion) =
|
||||
extract_features_from_frame(&frame, &s.frame_history, sample_rate_hz);
|
||||
smooth_and_classify(&mut s, &mut classification, raw_motion);
|
||||
// ADR-120: push the current frame's feature vector before classifying,
|
||||
// so the windowed model can use up to WINDOW_FRAMES of history.
|
||||
push_feature_window(&mut s, &features);
|
||||
adaptive_override(&s, &features, &mut classification);
|
||||
|
||||
s.source = format!("wifi:{ssid}");
|
||||
|
|
@ -4824,6 +5041,34 @@ async fn adaptive_status(State(state): State<SharedState>) -> Json<serde_json::V
|
|||
}
|
||||
}
|
||||
|
||||
/// ADR-120 follow-up #3: GET /api/v1/adaptive/debug — return the raw
|
||||
/// model labels from the last 30 ticks alongside the smoothed/committed
|
||||
/// state. Lets the operator distinguish "smoother is sticky" from
|
||||
/// "model keeps outputting the same class".
|
||||
async fn adaptive_debug() -> Json<serde_json::Value> {
|
||||
let raw: Vec<String> = adaptive_raw_log_init().lock().unwrap().iter().cloned().collect();
|
||||
let smoothing_buf: Vec<String> = adaptive_label_history_init().lock().unwrap().iter().cloned().collect();
|
||||
let (committed, candidate, candidate_count) = {
|
||||
let st = adaptive_committed_init().lock().unwrap();
|
||||
(st.0.clone(), st.1.clone(), st.2)
|
||||
};
|
||||
// Count distribution in raw buffer.
|
||||
let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
for v in &raw { *counts.entry(v.clone()).or_insert(0) += 1; }
|
||||
let mut dist: Vec<(String, usize)> = counts.into_iter().collect();
|
||||
dist.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
Json(serde_json::json!({
|
||||
"smoothing_window_ticks": ADAPTIVE_SMOOTH_WIN,
|
||||
"confirm_ticks": ADAPTIVE_CONFIRM_TICKS,
|
||||
"raw_last_30": raw,
|
||||
"raw_distribution": dist.iter().map(|(k, v)| serde_json::json!({"label": k, "count": v})).collect::<Vec<_>>(),
|
||||
"smoothing_buffer": smoothing_buf,
|
||||
"committed_label": committed,
|
||||
"candidate_label": candidate,
|
||||
"candidate_count": candidate_count,
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/v1/adaptive/unload — unload the adaptive model (revert to thresholds).
|
||||
async fn adaptive_unload(State(state): State<SharedState>) -> Json<serde_json::Value> {
|
||||
let mut s = state.write().await;
|
||||
|
|
@ -5966,12 +6211,20 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
|||
|
||||
// ADR-101: inherit the raw-amplitude classifier from the
|
||||
// CSI path (this feature_state path doesn't carry amps).
|
||||
if let Some((level, presence, conf)) = amp_classify_from_latest() {
|
||||
classification.motion_level = level;
|
||||
classification.presence = presence;
|
||||
classification.confidence = conf;
|
||||
// ADR-120: skip when adaptive model produced a class only
|
||||
// it knows (waving / transition).
|
||||
if !adaptive_owns_class(&classification.motion_level) {
|
||||
if let Some((level, presence, conf)) = amp_classify_from_latest() {
|
||||
classification.motion_level = level;
|
||||
classification.presence = presence;
|
||||
classification.confidence = conf;
|
||||
}
|
||||
}
|
||||
|
||||
// ADR-120 follow-up #2: final smoothing pass — uniformly
|
||||
// damps flicker from both adaptive and rule-based outputs.
|
||||
finalize_motion_label(&mut classification);
|
||||
|
||||
// ADR-112: prefer multistatic-derived signal_field
|
||||
// when ≥ 2 ESP32 nodes are active; falls back to
|
||||
// ADR-105's zero grid on single-sensor / fusion-fail.
|
||||
|
|
@ -6179,10 +6432,12 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
|||
smooth_and_classify_node(ns, &mut classification, raw_motion);
|
||||
|
||||
// Adaptive override using cloned model (safe, no raw pointers).
|
||||
// ADR-118: full multi-node feature vector — pull all 6
|
||||
// nodes' latest amps from AMP_HIST, not just this node's.
|
||||
if let Some(ref model) = adaptive_model_clone {
|
||||
let amps = ns.frame_history.back()
|
||||
.map(|v| v.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
let per_node_owned = current_per_node_amps();
|
||||
let per_node_refs: Vec<(u8, &[f64])> = per_node_owned.iter()
|
||||
.map(|(n, a)| (*n, a.as_slice())).collect();
|
||||
let feat_arr = adaptive_classifier::features_from_runtime(
|
||||
&serde_json::json!({
|
||||
"variance": features.variance,
|
||||
|
|
@ -6193,7 +6448,7 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
|||
"change_points": features.change_points,
|
||||
"mean_rssi": features.mean_rssi,
|
||||
}),
|
||||
amps,
|
||||
&per_node_refs,
|
||||
);
|
||||
let (label, conf) = model.classify(&feat_arr);
|
||||
classification.motion_level = label.to_string();
|
||||
|
|
@ -6201,18 +6456,24 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
|||
classification.confidence = (conf * 0.7 + classification.confidence * 0.3).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
// ADR-101: amp classifier wins over the legacy adaptive model.
|
||||
// ADR-101: amp classifier wins over the legacy adaptive
|
||||
// model for absent/still/moving/active. ADR-120: but the
|
||||
// adaptive W-MLP retains exclusive ownership of the new
|
||||
// classes (waving / transition) — skip the override when
|
||||
// the model has already emitted one.
|
||||
let amps_now = ns.frame_history.back().cloned().unwrap_or_default();
|
||||
if !amps_now.is_empty() {
|
||||
if let Some((level, presence, conf)) = amp_presence_override(node_id, &s_now) {
|
||||
if !adaptive_owns_class(&classification.motion_level) {
|
||||
if !amps_now.is_empty() {
|
||||
if let Some((level, presence, conf)) = amp_presence_override(node_id, &s_now) {
|
||||
classification.motion_level = level;
|
||||
classification.presence = presence;
|
||||
classification.confidence = conf;
|
||||
}
|
||||
} else if let Some((level, presence, conf)) = amp_classify_from_latest() {
|
||||
classification.motion_level = level;
|
||||
classification.presence = presence;
|
||||
classification.confidence = conf;
|
||||
}
|
||||
} else if let Some((level, presence, conf)) = amp_classify_from_latest() {
|
||||
classification.motion_level = level;
|
||||
classification.presence = presence;
|
||||
classification.confidence = conf;
|
||||
}
|
||||
// ADR-104 phase-domain: update phase drift if a
|
||||
// phase baseline is loaded and the latest frame
|
||||
|
|
@ -6221,6 +6482,12 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
|||
phase_drift_update(node_id, ph);
|
||||
}
|
||||
|
||||
// ADR-120 follow-up #2: final smoothing pass on the
|
||||
// per-node loop's classification. Same shared smoother
|
||||
// state as the other two tick sites — single source
|
||||
// of truth for the displayed label.
|
||||
finalize_motion_label(&mut classification);
|
||||
|
||||
ns.rssi_history.push_back(features.mean_rssi);
|
||||
if ns.rssi_history.len() > 60 {
|
||||
ns.rssi_history.pop_front();
|
||||
|
|
@ -6424,6 +6691,8 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) {
|
|||
let (features, mut classification, breathing_rate_hz, sub_variances, raw_motion) =
|
||||
extract_features_from_frame(&frame, &s.frame_history, sample_rate_hz);
|
||||
smooth_and_classify(&mut s, &mut classification, raw_motion);
|
||||
// ADR-120: push current frame features into the rolling window first.
|
||||
push_feature_window(&mut s, &features);
|
||||
adaptive_override(&s, &features, &mut classification);
|
||||
|
||||
s.rssi_history.push_back(features.mean_rssi);
|
||||
|
|
@ -7138,6 +7407,7 @@ async fn main() {
|
|||
latest_update: None,
|
||||
rssi_history: VecDeque::new(),
|
||||
frame_history: VecDeque::new(),
|
||||
feature_window: VecDeque::with_capacity(adaptive_classifier::WINDOW_FRAMES),
|
||||
tick: 0,
|
||||
source: source.into(),
|
||||
last_esp32_frame: None,
|
||||
|
|
@ -7377,6 +7647,7 @@ async fn main() {
|
|||
// Adaptive classifier endpoints
|
||||
.route("/api/v1/adaptive/train", post(adaptive_train))
|
||||
.route("/api/v1/adaptive/status", get(adaptive_status))
|
||||
.route("/api/v1/adaptive/debug", get(adaptive_debug))
|
||||
.route("/api/v1/adaptive/unload", post(adaptive_unload))
|
||||
// Field model calibration (eigenvalue-based person counting)
|
||||
.route("/api/v1/calibration/start", post(calibration_start))
|
||||
|
|
|
|||
Loading…
Reference in New Issue