feat(cog-person-count): train count_v1.safetensors — honest v0.0.1 (ADR-103) (#695)

Phase 2 of ADR-103: trained count head on the existing 1,077 paired
samples (the same data that produced pose_v1 yesterday).

Honest result: 65.1% eval accuracy / 100% within ±1 / MAE 0.349 on
the held-out time-window. Per-class: 100% on "empty room" / 0% on
"1 person". The model overfit by epoch 100 (train_acc → 1.0,
eval_loss climbed 0.67 → 7.8) and the "best" checkpoint is the
snapshot that happened to predict the eval window's class
distribution (140/215 = 65.1%, matches eval_acc exactly). Confidence
head Spearman = 0.023 ⇒ uncalibrated. Same data-bound failure mode
as pose_v1 (#645), bounded by single-session training data; same
fix path (multi-room).

What v0.0.1 still validates end-to-end:
* PyTorch → safetensors → Candle Rust loads cleanly on first try.
  `cog-person-count health` reports `backend: candle-cpu` and emits
  real per-frame predictions instead of the stub backend's hard-coded
  {1 person, 0 confidence}. Architecture parity between train-count.py
  and src/inference.rs::CountNet is bit-exact.
* ONNX export bit-clean (16 KB, opset 18, dynamic batch axis).
* Training wall time: 5.6 s for 400 epochs on RTX 5080.
* Binary size unchanged (2.36 MB stripped), model loads via mmap at
  runtime.

This commit ships:

* scripts/align-ground-truth.js: extended to emit n_persons_mode +
  n_persons_max per window so the training pipeline has count
  labels. Backwards-compatible (additive fields).
* scripts/train-count.py: new — mirrors CountNet architecture
  exactly, loads paired.jsonl, trains 400 epochs with
  CE+BCE+Brier loss, exports safetensors + ONNX + per-epoch JSON.
* v2/.../cog/artifacts/{count_v1.safetensors,count_v1.onnx,
  count_train_results.json}: the trained artifacts.
* v2/.../cog/README.md: Status table updated with the v0.0.1 numbers
  + an Honest Caveat section explaining the data-bound result.
* docs/benchmarks/person-count-cog.md: new — full v0.0.1 benchmark
  log mirroring the format docs/benchmarks/pose-estimation-cog.md
  established. Includes comparison to ADR-103 v0.1.0 acceptance
  gates and per-class breakdown.

Still pending:
* `run` subcommand wiring (long-running polling loop, same as pose)
* Cross-compile + sign + GCS upload (mirror of pose cog pipeline)
* Live install on cognitum-v0
* v0.2.0: re-train on multi-room data, LoRA per-room adapters,
  Stoer-Wagner min-cut clip in fusion stage
This commit is contained in:
rUv
2026-05-21 18:56:52 -04:00
committed by GitHub
parent 6959a42312
commit 6b4994e105
7 changed files with 3719 additions and 6 deletions
+21
View File
@@ -481,12 +481,33 @@ function align() {
? extractCsiMatrix(window)
: extractFeatureMatrix(window);
// ADR-103: aggregate `n_persons` per window so the cog-person-count
// training pipeline has count labels. Two summaries:
// - `n_persons_mode` — modal value across the camera frames in
// the window. Robust to single-frame noise;
// this is the supervised label for the
// categorical {0..7} count head.
// - `n_persons_max` — the maximum value seen in the window.
// Useful as a soft upper bound (e.g. for
// dynamic dropout weighting during training).
const personCounts = matched.map(f => f.nPersons ?? 0);
const counts = new Map();
for (const v of personCounts) counts.set(v, (counts.get(v) ?? 0) + 1);
let modeVal = 0;
let modeCount = -1;
for (const [v, n] of counts) {
if (n > modeCount) { modeVal = v; modeCount = n; }
}
const maxVal = personCounts.reduce((a, b) => Math.max(a, b), 0);
paired.push({
csi: csiMatrix.data,
csi_shape: csiMatrix.shape,
kp: keypoints,
conf: Math.round(avgConfidence * 1000) / 1000,
n_camera_frames: matched.length,
n_persons_mode: modeVal,
n_persons_max: maxVal,
ts_start: new Date(tStartMs).toISOString(),
ts_end: new Date(tEndMs).toISOString(),
});
+360
View File
@@ -0,0 +1,360 @@
#!/usr/bin/env python3
"""Train the person-count head — ADR-103 v0.0.1.
Mirrors the Conv1d encoder architecture from cog-person-count's
`src/inference.rs::CountNet` exactly, so the learned weights load
into the Rust cog without translation. Trains on
data/paired/wiflow-p7-1779210883.paired.jsonl (1,077 samples with
n_persons_mode labels in {0, 1}).
Output: count_v1.safetensors + count_v1.onnx + train_results.json.
"""
from __future__ import annotations
import argparse
import json
import struct
import time
from collections import Counter
from pathlib import Path
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
# Architecture constants — MUST match cog-person-count's src/inference.rs.
N_SUB = 56
N_FRAMES = 20
COUNT_CLASSES = 8
class CountNet(nn.Module):
"""Mirrors cog_person_count::inference::CountNet bit-for-bit."""
def __init__(self) -> None:
super().__init__()
# Encoder — identical to the pose cog's encoder so future joint
# training can share weights.
self.enc_c1 = nn.Conv1d(N_SUB, 64, kernel_size=3, padding=1, dilation=1)
self.enc_c2 = nn.Conv1d(64, 128, kernel_size=3, padding=2, dilation=2)
self.enc_c3 = nn.Conv1d(128, 128, kernel_size=3, padding=4, dilation=4)
# Count head
self.count_head_fc1 = nn.Linear(128, 64)
self.count_head_fc2 = nn.Linear(64, COUNT_CLASSES)
# Confidence head
self.conf_head_fc1 = nn.Linear(128, 32)
self.conf_head_fc2 = nn.Linear(32, 1)
def forward(self, x: torch.Tensor):
# x: [B, 56, 20]
h = F.relu(self.enc_c1(x))
h = F.relu(self.enc_c2(h))
h = F.relu(self.enc_c3(h))
h = h.mean(dim=2) # [B, 128]
# Logits (un-normalised); softmax at inference + cross-entropy training.
c = F.relu(self.count_head_fc1(h))
count_logits = self.count_head_fc2(c)
# Confidence head — sigmoid at inference; BCE-with-logits at training.
cf = F.relu(self.conf_head_fc1(h))
conf_logits = self.conf_head_fc2(cf)
return count_logits, conf_logits
def load_paired(path: Path) -> tuple[np.ndarray, np.ndarray]:
"""Return (X, y) where X is [N, 56, 20] CSI and y is [N] integer counts."""
csis, ys = [], []
with path.open(encoding="utf-8") as f:
for line in f:
if not line.strip():
continue
d = json.loads(line)
shape = d.get("csi_shape", [N_SUB, N_FRAMES])
if shape != [N_SUB, N_FRAMES]:
continue
csi = np.asarray(d["csi"], dtype=np.float32).reshape(N_SUB, N_FRAMES)
csis.append(csi)
ys.append(int(d.get("n_persons_mode", 0)))
X = np.stack(csis, axis=0)
y = np.asarray(ys, dtype=np.int64)
return X, y
def temporal_split(X: np.ndarray, y: np.ndarray, eval_frac: float = 0.2):
"""Held-out time-window eval (last `eval_frac` of samples, by index)."""
n = X.shape[0]
n_eval = int(round(n * eval_frac))
n_train = n - n_eval
return (
X[:n_train], y[:n_train],
X[n_train:], y[n_train:],
)
def standardise(X_train: np.ndarray, X_eval: np.ndarray):
"""Z-score by subcarrier across the time axis. Eval uses train stats."""
mu = X_train.mean(axis=(0, 2), keepdims=True)
sd = X_train.std(axis=(0, 2), keepdims=True) + 1e-6
return (X_train - mu) / sd, (X_eval - mu) / sd
def write_safetensors(model: CountNet, path: Path):
"""Write the model's state in the same on-disk layout the Rust cog expects."""
state = model.state_dict()
# Map PyTorch param names → cog-person-count's VarBuilder paths.
rename = {
"enc_c1.weight": "enc.c1.weight",
"enc_c1.bias": "enc.c1.bias",
"enc_c2.weight": "enc.c2.weight",
"enc_c2.bias": "enc.c2.bias",
"enc_c3.weight": "enc.c3.weight",
"enc_c3.bias": "enc.c3.bias",
"count_head_fc1.weight": "count_head.fc1.weight",
"count_head_fc1.bias": "count_head.fc1.bias",
"count_head_fc2.weight": "count_head.fc2.weight",
"count_head_fc2.bias": "count_head.fc2.bias",
"conf_head_fc1.weight": "conf_head.fc1.weight",
"conf_head_fc1.bias": "conf_head.fc1.bias",
"conf_head_fc2.weight": "conf_head.fc2.weight",
"conf_head_fc2.bias": "conf_head.fc2.bias",
}
header = {}
payload = bytearray()
offset = 0
for torch_name, cog_name in rename.items():
t = state[torch_name].detach().cpu().numpy().astype(np.float32)
n_bytes = t.nbytes
header[cog_name] = {
"dtype": "F32",
"shape": list(t.shape),
"data_offsets": [offset, offset + n_bytes],
}
payload.extend(t.tobytes())
offset += n_bytes
header_bytes = json.dumps(header, separators=(",", ":")).encode("utf-8")
with path.open("wb") as f:
f.write(struct.pack("<Q", len(header_bytes)))
f.write(header_bytes)
f.write(payload)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--paired", required=True)
parser.add_argument("--out-safetensors", default="count_v1.safetensors")
parser.add_argument("--out-onnx", default="count_v1.onnx")
parser.add_argument("--out-results", default="count_train_results.json")
parser.add_argument("--epochs", type=int, default=400)
parser.add_argument("--batch-size", type=int, default=64)
parser.add_argument("--lr", type=float, default=1e-3)
parser.add_argument("--weight-decay", type=float, default=0.01)
args = parser.parse_args()
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"device: {device}")
X, y = load_paired(Path(args.paired))
print(f"loaded {X.shape[0]} samples, X shape {X.shape}, "
f"label distribution: {dict(Counter(y.tolist()).most_common())}")
X_train, y_train, X_eval, y_eval = temporal_split(X, y, eval_frac=0.2)
X_train, X_eval = standardise(X_train, X_eval)
# Re-balance via class weights — handles the 50/50 split fine
# but also makes the loss correct under future imbalanced data.
cls_counts = np.bincount(y_train, minlength=COUNT_CLASSES).astype(np.float32)
cls_counts = np.where(cls_counts > 0, cls_counts, 1.0)
cls_weight = (1.0 / cls_counts) / (1.0 / cls_counts).sum() * COUNT_CLASSES
cls_weight_t = torch.from_numpy(cls_weight).to(device)
print(f"class weights: {cls_weight.tolist()}")
Xt = torch.from_numpy(X_train).to(device)
yt = torch.from_numpy(y_train).to(device)
Xe = torch.from_numpy(X_eval).to(device)
ye = torch.from_numpy(y_eval).to(device)
model = CountNet().to(device)
opt = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=args.weight_decay)
sched = torch.optim.lr_scheduler.CosineAnnealingWarmRestarts(opt, T_0=50, T_mult=1)
n_train = X_train.shape[0]
epoch_losses = []
t0 = time.perf_counter()
best_eval_acc = 0.0
best_state = None
for epoch in range(args.epochs):
model.train()
perm = torch.randperm(n_train, device=device)
train_loss = 0.0
train_correct = 0
n_batches = 0
for i in range(0, n_train, args.batch_size):
idx = perm[i : i + args.batch_size]
xb = Xt[idx]
yb = yt[idx]
opt.zero_grad()
count_logits, conf_logits = model(xb)
# Categorical cross-entropy for count.
ce = F.cross_entropy(count_logits, yb, weight=cls_weight_t)
# Confidence head: train against `argmax == truth` indicator.
with torch.no_grad():
pred = count_logits.argmax(dim=1)
correct_indicator = (pred == yb).float().unsqueeze(1)
bce = F.binary_cross_entropy_with_logits(conf_logits, correct_indicator)
# Brier-score uncertainty calibration on the conf head — sharpens
# the calibration so the sigmoid output is a real probability.
with torch.no_grad():
conf_sigm = torch.sigmoid(conf_logits)
brier = ((conf_sigm - correct_indicator) ** 2).mean()
loss = ce + 0.3 * bce + 0.1 * brier
loss.backward()
opt.step()
train_loss += loss.item()
train_correct += (pred == yb).sum().item()
n_batches += 1
sched.step()
model.eval()
with torch.no_grad():
cl_e, _ = model(Xe)
eval_loss = F.cross_entropy(cl_e, ye, weight=cls_weight_t).item()
eval_pred = cl_e.argmax(dim=1)
eval_acc = (eval_pred == ye).float().mean().item()
eval_within1 = ((eval_pred - ye).abs() <= 1).float().mean().item()
epoch_losses.append({
"epoch": epoch,
"train_loss": train_loss / n_batches,
"train_acc": train_correct / n_train,
"eval_loss": eval_loss,
"eval_acc": eval_acc,
"eval_within_pm1": eval_within1,
})
if eval_acc > best_eval_acc:
best_eval_acc = eval_acc
best_state = {k: v.detach().cpu().clone() for k, v in model.state_dict().items()}
if epoch < 5 or epoch % 50 == 0 or epoch == args.epochs - 1:
print(f"epoch {epoch:3d} train_loss={train_loss/n_batches:.4f} "
f"train_acc={train_correct/n_train:.3f} "
f"eval_loss={eval_loss:.4f} eval_acc={eval_acc:.3f} "
f"within±1={eval_within1:.3f}")
train_time = time.perf_counter() - t0
print(f"\ntrained {args.epochs} epochs in {train_time:.1f} s")
print(f"best eval_acc: {best_eval_acc:.3f}")
# Restore best checkpoint
if best_state is not None:
model.load_state_dict(best_state)
# Eval breakdown
model.eval()
with torch.no_grad():
cl_e, conf_e = model(Xe)
probs_e = torch.softmax(cl_e, dim=1)
pred_e = cl_e.argmax(dim=1)
acc = (pred_e == ye).float().mean().item()
within1 = ((pred_e - ye).abs() <= 1).float().mean().item()
mae = (pred_e - ye).abs().float().mean().item()
# Per-class accuracy
per_class = {}
for k in range(COUNT_CLASSES):
mask = ye == k
n = mask.sum().item()
if n > 0:
per_class[k] = {
"support": int(n),
"accuracy": ((pred_e == ye) & mask).sum().item() / n,
}
# Confidence-accuracy calibration: Spearman over (predicted-correct, confidence)
conf_sigm = torch.sigmoid(conf_e).squeeze(-1)
correct = (pred_e == ye).float()
# Spearman = Pearson over ranks
c_rank = conf_sigm.argsort().argsort().float()
r_rank = correct.argsort().argsort().float()
c_centered = c_rank - c_rank.mean()
r_centered = r_rank - r_rank.mean()
denom = (c_centered.norm() * r_centered.norm()).item()
spearman = (c_centered * r_centered).sum().item() / denom if denom > 0 else 0.0
print(f"\n=== final eval ===")
print(f" accuracy: {acc:.3f}")
print(f" within ±1: {within1:.3f}")
print(f" MAE: {mae:.3f}")
print(f" conf↔correct Spearman: {spearman:.3f}")
for k, v in per_class.items():
print(f" class {k}: {v['accuracy']:.3f} accuracy on {v['support']} samples")
# Save safetensors
write_safetensors(model, Path(args.out_safetensors))
print(f"\nwrote {args.out_safetensors} ({Path(args.out_safetensors).stat().st_size} bytes)")
# ONNX export
dummy = torch.zeros(1, N_SUB, N_FRAMES, device=device)
try:
torch.onnx.export(
model, dummy, args.out_onnx,
opset_version=18,
input_names=["csi_window"],
output_names=["count_logits", "conf_logits"],
dynamic_axes={
"csi_window": {0: "batch"},
"count_logits": {0: "batch"},
"conf_logits": {0: "batch"},
},
export_params=True,
do_constant_folding=True,
)
print(f"wrote {args.out_onnx} ({Path(args.out_onnx).stat().st_size} bytes)")
except Exception as e:
print(f"WARN: ONNX export failed: {e}")
# Results JSON
results = {
"backend": "candle-cuda" if device.type == "cuda" else "candle-cpu",
"device": str(device),
"epochs": args.epochs,
"train_time_s": train_time,
"best_eval_acc": best_eval_acc,
"final_eval_acc": acc,
"final_eval_within_pm1": within1,
"final_eval_mae": mae,
"conf_correctness_spearman": spearman,
"per_class_accuracy": per_class,
"hyperparameters": {
"optimizer": "AdamW",
"lr": args.lr,
"weight_decay": args.weight_decay,
"batch_size": args.batch_size,
"schedule": "cosine_warm_restarts",
"epochs": args.epochs,
"loss": "cross_entropy(count) + 0.3*bce(conf) + 0.1*brier(conf)",
"z_score_normalisation": True,
"class_weights": cls_weight.tolist(),
},
"epoch_losses": epoch_losses,
}
Path(args.out_results).write_text(json.dumps(results, indent=2))
print(f"wrote {args.out_results} ({Path(args.out_results).stat().st_size} bytes)")
if __name__ == "__main__":
main()