Reproducing the OIE IMU Embedding Encoder

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.

Data

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.

Build windows, split by subject

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]

Train

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.

Validate on subjects the model never saw

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.

Stream it live into Bootstrap

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.

An honest limitation, not an average that hides it

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.

Scope

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).