research(phase3b): benchmark 1D CNN on RAW pulses — data-starved, benched

Trains a PyTorch 1D CNN on encoded RAW pulse arrays as the Phase 3B leg
of the designed ensemble, scored on the SAME group-disjoint held-out test
set as the heuristic and statistical models (GroupShuffleSplit by device
sub-folder, no near-duplicate leakage).

RESULT — CNN is data-starved and loses decisively:
  CNN        balanced 0.338, top-1 0.795 (garage-inflated)
  statistical balanced 0.625, top-1 0.868
  heuristic  top-1 0.300
Only 489 train samples with severe class imbalance (Garage 565, Security 2,
Weather 12). A blend sweep confirmed every non-zero CNN weight degrades the
statistical model (0.625 -> 0.613 at 15% CNN, worse beyond), so the CNN is
NOT wired into decode(). Production ensemble stays heuristic + statistical,
both already live.

Committing the trainer + shared pulse_encoder (trainer/inference parity) +
metrics.json to document the reproducible negative result. The benched
.pt/.onnx binaries are intentionally NOT committed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-07-19 13:47:53 -07:00
parent 7a1c7ef621
commit a46af03f90
3 changed files with 391 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""
Shared RAW-pulse encoder for the 1D-CNN (Phase 3B).
A single source of truth for turning a Flipper RAW pulse array into the fixed
normalized tensor the CNN consumes — imported by BOTH the trainer
(``scripts/train_cnn_classifier.py``) and the inference wrapper
(``src/matcher/cnn_classifier.py``) so the two can never drift.
Encoding:
* take the first ``length`` signed durations (µs; +high / -low),
* scale by the sequence's *median absolute* duration — a robust, TE-like
unit so a short pulse is ~±1 and a long pulse ~±2-3 (the ratios that carry
the protocol identity), while absolute gain / capture level is normalised
out. Using the median instead of the max is deliberate: a single huge
inter-packet gap (~10 000 µs) would otherwise squash every informative
short pulse toward zero and blind the CNN.
* clip to ±``CLIP`` and rescale to [-1, 1] so outsized gaps saturate the
ceiling instead of dominating the dynamic range,
* right-pad with zeros to ``length``.
Returns a float32 array of shape ``(length,)`` or ``None`` when the pulse train
is empty or degenerate (all-zero).
"""
from typing import List, Optional
import numpy as np
PULSE_LEN = 512
CLIP = 8.0 # in median-pulse units; gaps beyond this saturate to ±1
def encode_pulses(pulses: List[int], length: int = PULSE_LEN) -> Optional[np.ndarray]:
if pulses is None or len(pulses) == 0:
return None
arr = np.asarray(pulses[:length], dtype=np.float32)
nz = np.abs(arr[arr != 0])
if nz.size == 0:
return None
scale = float(np.median(nz))
if scale <= 0.0:
scale = float(np.max(np.abs(arr)))
if scale <= 0.0:
return None
arr = np.clip(arr / scale, -CLIP, CLIP) / CLIP
if arr.size < length:
arr = np.pad(arr, (0, length - arr.size))
return arr.astype(np.float32)