194 lines
7.7 KiB
Markdown
194 lines
7.7 KiB
Markdown
# 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)
|