A trained encoder that turns raw 6-axis IMU windows into embeddings where cosine similarity actually means something -- validated on real human-movement data from 8 subjects never pooled with training data, including an honest look at exactly which activities it does and doesn't separate well.
Requirements: pip install vaas-x torch numpy, and the dataset below. CPU only -- no GPU required.
https://archive.ics.uci.edu/static/public/256/daily+and+sports+activities.zip
(UCI Machine Learning Repository, dataset 256 -- Barshan & Altun, 2010.) 8 subjects, 5 Xsens IMU units each (torso, both arms, both legs), 19 activities, 60 five-second segments per activity per subject at 25Hz. This encoder is trained on the torso unit's 6 accelerometer/gyroscope channels only -- see Scope below for why, and what that leaves out.
import numpy as np
from pathlib import Path
def load_windows(root):
"""One row per 5-second segment file: [N, 125, 6] torso accel+gyro,
plus (activity, subject) metadata per window."""
windows, meta = [], []
for act_dir in sorted(Path(root).glob("a*")):
for subj_dir in sorted(act_dir.glob("p*")):
for seg in sorted(subj_dir.glob("s*.txt")):
row = np.loadtxt(seg, delimiter=",")
windows.append(row[:, 0:6]) # torso accel (0-2) + gyro (3-5)
meta.append((act_dir.name, subj_dir.name))
return np.stack(windows), np.array(meta, dtype=object)
X, meta = load_windows("daily_sports_activities")
subjects = np.array([m[1] for m in meta])
# Split by SUBJECT, not window -- the honest test is on people the
# model never saw, not just unseen segments of someone it did.
train_mask = ~np.isin(subjects, ["p7", "p8"])
X_train, X_held = X[train_mask], X[~train_mask]
meta_held = meta[~train_mask]
python -m vaasx.imu.oie_train_ssl --data X_train.npy --out oie_encoder.pt
SimCLR-style contrastive self-supervised training (NT-Xent loss) -- no activity labels used during training. 50 epochs took about 7 minutes on CPU for this dataset size; loss converges from ~2.7 to the 0.1-0.3 range.
import torch
from vaasx.imu import OIEConfig, OIEEncoder
ckpt = torch.load("oie_encoder.pt", map_location="cpu")
model = OIEEncoder(OIEConfig(out_dim=ckpt["config"]["out_dim"]))
model.load_state_dict(ckpt["model_state_dict"])
model.eval()
with torch.no_grad():
x = torch.from_numpy(np.transpose(X_held, (0, 2, 1)).astype(np.float32))
emb = model(x).numpy() # [N, 256], already L2-normalized
activities = np.array([m[0] for m in meta_held])
rng = np.random.default_rng(0)
same, diff = [], []
for i in range(len(emb)):
same_idx = np.where((activities == activities[i]) & (np.arange(len(emb)) != i))[0]
diff_idx = np.where(activities != activities[i])[0]
same.append(float(np.dot(emb[i], emb[rng.choice(same_idx)])))
diff.append(float(np.dot(emb[i], emb[rng.choice(diff_idx)])))
print("same-activity mean:", np.mean(same), " different-activity mean:", np.mean(diff))
Run this yourself against the real checkpoint and you should land close to same-activity mean ≈0.207 vs. different-activity mean ≈0.017 (separation 0.190, Welch's t=23.53) on the 2,280 windows from held-out subjects p7 and p8 -- a real, non-chance effect, not an artifact of training-set leakage.
python -m vaasx.bootstrap.imu_stream_adapter \
--bootstrap-url http://127.0.0.1:9050 \
--checkpoint oie_encoder.pt \
--input stream.csv --input-format csv \
--device-id your_device_id --window 125
Sliding window: once the window fills, every new sample re-embeds and posts. A CSV with N rows produces N - window + 1 requests -- e.g. 375 rows at --window 125 sends 251. We ran exactly this against a live production Bootstrap instance with a throwaway device: 251 successful posts, zero errors, device removed afterward.
Every one of the 19 activities separated from the others at better than chance -- but not by the same amount, and the pattern is physically explainable rather than random noise. Lying positions (gap 0.80 and 0.58), jumping (0.38), and running (0.37) separate strongly, because the torso itself moves distinctively in each. Vertical cycling (gap 0.01), flat-treadmill walking (0.03), the stepper (0.04), and standing (0.04) separate weakly, because a torso-only sensor can't see what's actually different about them -- the discriminating motion is in the legs. A model trained on torso data alone will be strong at posture and high-impact movement, and weak at anything where the limbs, not the torso, carry the signal. Reporting only the 0.190 average separation would have hidden this; the per-activity breakdown is what tells you where the encoder will and won't be useful for a given placement.
What this reproduces: a trained embedding that separates real human activities by cosine similarity on subjects never seen during training, with the real production streaming path into Bootstrap exercised end to end. What it does not show: results for any other sensor placement (the dataset has 4 more body units never trained on), the unused magnetometer axes, any sample rate or window length other than 25Hz/125 samples, any domain beyond lab-collected daily/sports activities, or anomaly detection built on top of the embedding -- that's a separate validation this encoder has not yet been through. Treat the embedding and the streaming path as what's proven here.
More guides: Reproduction Guides. Full reference: Developer Guide. Related: Wearable Activity Classification guide (the zero-config statistical classifier on the same dataset -- a different capability than this trained encoder).