Edit on GitHub

embedder

View embedder.py on GitHub →

embedder.py — swappable embedding backends for the feels vector search.

The Atlas cosine search + material aggregation + margin guard don't care where the vector comes from. This module puts one interface in front of the options:

FEELS_EMBEDDER=hand      10-D hand-engineered swirl features (default; current behavior)
FEELS_EMBEDDER=pca       10-D PCA on raw 6-channel torque-delta trajectories,
                         fit on swirl-only train recordings. Unsupervised —
                         no hand tuning, no scales to calibrate.
FEELS_EMBEDDER=chronos   2304-D zero-shot time-series foundation model
                         (amazon/chronos-t5-mini). Live-capable: Atlas picks
                         the vector index by embedding dim. Venue setup:
                           pip install torch chronos-forecasting
                           python -c "import atlas; atlas.ensure_vector_indexes('chronos')"
                           FEELS_EMBEDDER=chronos python teach_csv.py --force --require-swirl recordings/
                         then restart the dashboard with FEELS_EMBEDDER=chronos.
                         (First embed() call downloads the model from HuggingFace.)

Contract: embed(df) -> list[float], L2-normalized. The pca backend reuses the hand pipeline's preprocessing (INIT-median tare via _tare_with_qc, SWIRL window else fallback dynamic phases) so the only difference is the representation.

Switching embedders changes the vector SPACE, so the Atlas library must be rebuilt under the same embedder or queries hit the wrong space:

FEELS_EMBEDDER=pca python teach_csv.py --force --require-swirl recordings/

The live predict path refuses to query a library built with a different embedder (explicit 409, not silent garbage). Taught signatures are tagged with their embedder in properties.

Offline bake-off on the committed train/test split (swirl-only):

python embedder.py --compare     # hand vs pca, atlas-style ranking
python embedder.py --fit         # (re)fit the PCA cache from swirl train

2026-10-29 bake-off (swirl-only test, n=9, top-5 mean-cosine per material): hand 22%, pca 33%, chronos 56%. Absolute numbers are modest for all three — the 3.5 Hz signal is the bottleneck, not the features.

  1#!/usr/bin/env python3
  2"""
  3embedder.py — swappable embedding backends for the feels vector search.
  4
  5The Atlas cosine search + material aggregation + margin guard don't care where
  6the vector comes from. This module puts one interface in front of the options:
  7
  8    FEELS_EMBEDDER=hand      10-D hand-engineered swirl features (default; current behavior)
  9    FEELS_EMBEDDER=pca       10-D PCA on raw 6-channel torque-delta trajectories,
 10                             fit on swirl-only train recordings. Unsupervised —
 11                             no hand tuning, no scales to calibrate.
 12    FEELS_EMBEDDER=chronos   2304-D zero-shot time-series foundation model
 13                             (amazon/chronos-t5-mini). Live-capable: Atlas picks
 14                             the vector index by embedding dim. Venue setup:
 15                               pip install torch chronos-forecasting
 16                               python -c "import atlas; atlas.ensure_vector_indexes('chronos')"
 17                               FEELS_EMBEDDER=chronos python teach_csv.py --force --require-swirl recordings/
 18                             then restart the dashboard with FEELS_EMBEDDER=chronos.
 19                             (First embed() call downloads the model from HuggingFace.)
 20
 21Contract: embed(df) -> list[float], L2-normalized. The pca backend reuses the
 22hand pipeline's preprocessing (INIT-median tare via _tare_with_qc, SWIRL window
 23else fallback dynamic phases) so the only difference is the representation.
 24
 25Switching embedders changes the vector SPACE, so the Atlas library must be
 26rebuilt under the same embedder or queries hit the wrong space:
 27
 28    FEELS_EMBEDDER=pca python teach_csv.py --force --require-swirl recordings/
 29
 30The live predict path refuses to query a library built with a different
 31embedder (explicit 409, not silent garbage). Taught signatures are tagged
 32with their embedder in properties.
 33
 34Offline bake-off on the committed train/test split (swirl-only):
 35
 36    python embedder.py --compare     # hand vs pca, atlas-style ranking
 37    python embedder.py --fit         # (re)fit the PCA cache from swirl train
 38
 392026-10-29 bake-off (swirl-only test, n=9, top-5 mean-cosine per material):
 40hand 22%, pca 33%, chronos 56%. Absolute numbers are modest for all three —
 41the 3.5 Hz signal is the bottleneck, not the features.
 42"""
 43
 44import argparse
 45import csv
 46import os
 47import re
 48import sys
 49
 50import numpy as np
 51
 52_HERE = os.path.dirname(os.path.abspath(__file__))
 53if _HERE not in sys.path:
 54    sys.path.insert(0, _HERE)
 55
 56EMBEDDER = os.getenv("FEELS_EMBEDDER", "hand").strip().lower()
 57if EMBEDDER not in ("hand", "pca", "chronos"):
 58    raise ValueError(f"FEELS_EMBEDDER={EMBEDDER!r}: choose hand|pca|chronos")
 59
 60DIM = {"hand": 10, "pca": 10, "chronos": 2304}
 61CACHE = os.path.join(_HERE, "pca_swirl.npz")
 62TAU_COLS = [f"tau{i}_nm" for i in range(1, 7)]
 63
 64_MATERIAL_STEMS = {
 65    "water": "water", "soap": "soap", "dish_soap": "soap",
 66    "candy": "candy", "solid": "solid", "screws": "screws",
 67    "screw": "screws", "empty": "empty", "empty_cup": "empty",
 68}
 69_CAPACITY_SUFFIX = re.compile(r"_(\d+(pct|g|ml|oz)|half|full|mixed|empty)$")
 70
 71
 72def material_of(label: str) -> str:
 73    stem = _CAPACITY_SUFFIX.sub("", label.strip().lower())
 74    return _MATERIAL_STEMS.get(stem, stem)
 75
 76
 77def _l2(v):
 78    v = np.asarray(v, dtype=float)
 79    n = np.linalg.norm(v)
 80    return v / n if n > 1e-12 else v
 81
 82
 83def _window_deltas(df):
 84    """(T, 6) torque deltas over the dynamic window, hand-pipeline preprocessing."""
 85    from feature_extractor import _tare_with_qc, SWIRL_PHASE, FALLBACK_DYN_PHASES
 86    tares, _ = _tare_with_qc(df)
 87    idx = df.index[df["phase"] == SWIRL_PHASE].values
 88    if len(idx) < 4:
 89        idx = df.index[df["phase"].isin(FALLBACK_DYN_PHASES)].values
 90    if len(idx) < 4:
 91        raise ValueError("no dynamic window")
 92    return np.stack([df[c].values[idx] - tares[i + 1] for i, c in enumerate(TAU_COLS)], axis=1)
 93
 94
 95def _resample(mat, T):
 96    n, ch = mat.shape
 97    if n == T:
 98        return mat
 99    x_old = np.linspace(0, 1, n)
100    x_new = np.linspace(0, 1, T)
101    return np.stack([np.interp(x_new, x_old, mat[:, c]) for c in range(ch)], axis=1)
102
103
104def _swirl_train_files():
105    manifest = os.path.join(_HERE, "recordings", "split_manifest.csv")
106    if not os.path.isfile(manifest):
107        raise RuntimeError("recordings/split_manifest.csv not found — can't fit PCA")
108    files = []
109    with open(manifest) as f:
110        for r in csv.DictReader(f):
111            if r["split"].strip() == "train" and r["motion_path"].strip() == "swirl":
112                files.append(os.path.join(_HERE, "recordings", r["file"].strip()))
113    if not files:
114        raise RuntimeError("no swirl train recordings in manifest")
115    return files
116
117
118def fit_pca(cache_path=CACHE):
119    """Fit PCA-10 on swirl-only train recordings; persist components to .npz."""
120    import pandas as pd
121    from sklearn.decomposition import PCA
122    mats = []
123    for p in _swirl_train_files():
124        try:
125            df = pd.read_csv(p)
126            mats.append(_window_deltas(df))
127        except Exception as e:
128            print(f"  skip {os.path.basename(p)}: {e}")
129    T = int(np.median([m.shape[0] for m in mats]))
130    X = np.stack([_resample(m, T).ravel() for m in mats])
131    pca = PCA(n_components=10, random_state=0).fit(X)
132    np.savez(cache_path, components=pca.components_, mean=pca.mean_,
133             T=np.array(T), n_train=np.array(len(mats)))
134    print(f"fit PCA on {len(mats)} swirl train recordings "
135          f"(T={T}, explained var {pca.explained_variance_ratio_.sum():.3f}) -> {cache_path}")
136    return cache_path
137
138
139def _load_pca():
140    if not os.path.isfile(CACHE):
141        print("PCA cache missing — fitting from swirl train recordings...")
142        fit_pca()
143    z = np.load(CACHE)
144    return z["components"], z["mean"], int(z["T"])
145
146
147_chronos_pipe = None
148
149
150def _chronos_embed(deltas):
151    global _chronos_pipe
152    try:
153        import torch
154        from chronos import ChronosPipeline
155    except ImportError:
156        raise RuntimeError(
157            "FEELS_EMBEDDER=chronos needs: pip install torch chronos-forecasting "
158            "(plus a HuggingFace model download on first use)")
159    if _chronos_pipe is None:
160        print("loading amazon/chronos-t5-mini ...", flush=True)
161        _chronos_pipe = ChronosPipeline.from_pretrained(
162            "amazon/chronos-t5-mini", device_map="cpu", torch_dtype=torch.float32)
163    ctx = torch.tensor(deltas.T, dtype=torch.float32)  # (6, T)
164    with torch.no_grad():
165        emb, _ = _chronos_pipe.embed(ctx)  # (6, T', d)
166    pooled = emb.float().mean(dim=1).numpy()  # mean over time -> (6, d)
167    return _l2(pooled.ravel())
168
169
170def embed(df):
171    """10-D (hand/pca) or 2304-D (chronos) L2-normalized embedding for one recording."""
172    if EMBEDDER == "hand":
173        from feature_extractor import extract_features
174        return [float(x) for x in extract_features(df)["e"]]
175    if EMBEDDER == "pca":
176        comp, mean, T = _load_pca()
177        d = _window_deltas(df)
178        v = (_resample(d, T).ravel()[None, :] - mean) @ comp.T
179        return [float(x) for x in _l2(v[0])]
180    if EMBEDDER == "chronos":
181        return [float(x) for x in _chronos_embed(_window_deltas(df))]
182    raise AssertionError("unreachable")
183
184
185def _compare():
186    """Offline bake-off: hand vs pca on the swirl-only test split, atlas-style ranking."""
187    import pandas as pd
188    manifest = os.path.join(_HERE, "recordings", "split_manifest.csv")
189    rows = []
190    with open(manifest) as f:
191        for r in csv.DictReader(f):
192            if r["motion_path"].strip() == "swirl":
193                rows.append(r)
194    recs = {}
195    for r in rows:
196        try:
197            df = pd.read_csv(os.path.join(_HERE, "recordings", r["file"].strip()))
198            from feature_extractor import extract_features
199            e_hand = _l2(np.array(extract_features(df)["e"]))
200            comp, mean, T = _load_pca()
201            d = _window_deltas(df)
202            e_pca = _l2(((_resample(d, T).ravel()[None, :] - mean) @ comp.T)[0])
203            recs[r["file"]] = {"material": material_of(r["label"]),
204                               "split": r["split"].strip(),
205                               "hand": e_hand, "pca": e_pca}
206        except Exception as e:
207            print("  skip", r["file"].strip(), str(e)[:80])
208    train = [v for v in recs.values() if v["split"] == "train"]
209    test = [v for v in recs.values() if v["split"] == "test"]
210    for name, key in [("hand", "hand"), ("pca", "pca")]:
211        lib = np.stack([v[key] for v in train])
212        mats = [v["material"] for v in train]
213        correct = 0
214        for v in test:
215            sims = lib @ v[key]
216            order = np.argsort(-sims)[:5]
217            scores = {}
218            for j in order:
219                scores.setdefault(mats[j], []).append(float(sims[j]))
220            ranked = sorted(scores.items(), key=lambda kv: sum(kv[1]) / len(kv[1]), reverse=True)
221            correct += (ranked[0][0] == v["material"])
222        print(f"{name}: {correct}/{len(test)} = {correct / len(test):.3f}  (swirl-only test)")
223
224
225def main():
226    ap = argparse.ArgumentParser(description="Swappable embedding backends for feels vector search.")
227    ap.add_argument("--fit", action="store_true", help="(re)fit the PCA cache from swirl train recordings")
228    ap.add_argument("--compare", action="store_true", help="offline bake-off: hand vs pca on swirl-only test")
229    args = ap.parse_args()
230    if args.fit:
231        fit_pca()
232    elif args.compare:
233        _compare()
234    else:
235        print(f"FEELS_EMBEDDER={EMBEDDER}  dim={DIM[EMBEDDER]}")
236
237
238if __name__ == "__main__":
239    main()
EMBEDDER = 'hand'
DIM = {'hand': 10, 'pca': 10, 'chronos': 2304}
CACHE = '/home/hatch/workspace/feels/pca_swirl.npz'
TAU_COLS = ['tau1_nm', 'tau2_nm', 'tau3_nm', 'tau4_nm', 'tau5_nm', 'tau6_nm']
def material_of(label: str) -> str:
73def material_of(label: str) -> str:
74    stem = _CAPACITY_SUFFIX.sub("", label.strip().lower())
75    return _MATERIAL_STEMS.get(stem, stem)
def fit_pca(cache_path='/home/hatch/workspace/feels/pca_swirl.npz'):
119def fit_pca(cache_path=CACHE):
120    """Fit PCA-10 on swirl-only train recordings; persist components to .npz."""
121    import pandas as pd
122    from sklearn.decomposition import PCA
123    mats = []
124    for p in _swirl_train_files():
125        try:
126            df = pd.read_csv(p)
127            mats.append(_window_deltas(df))
128        except Exception as e:
129            print(f"  skip {os.path.basename(p)}: {e}")
130    T = int(np.median([m.shape[0] for m in mats]))
131    X = np.stack([_resample(m, T).ravel() for m in mats])
132    pca = PCA(n_components=10, random_state=0).fit(X)
133    np.savez(cache_path, components=pca.components_, mean=pca.mean_,
134             T=np.array(T), n_train=np.array(len(mats)))
135    print(f"fit PCA on {len(mats)} swirl train recordings "
136          f"(T={T}, explained var {pca.explained_variance_ratio_.sum():.3f}) -> {cache_path}")
137    return cache_path

Fit PCA-10 on swirl-only train recordings; persist components to .npz.

def embed(df):
171def embed(df):
172    """10-D (hand/pca) or 2304-D (chronos) L2-normalized embedding for one recording."""
173    if EMBEDDER == "hand":
174        from feature_extractor import extract_features
175        return [float(x) for x in extract_features(df)["e"]]
176    if EMBEDDER == "pca":
177        comp, mean, T = _load_pca()
178        d = _window_deltas(df)
179        v = (_resample(d, T).ravel()[None, :] - mean) @ comp.T
180        return [float(x) for x in _l2(v[0])]
181    if EMBEDDER == "chronos":
182        return [float(x) for x in _chronos_embed(_window_deltas(df))]
183    raise AssertionError("unreachable")

10-D (hand/pca) or 2304-D (chronos) L2-normalized embedding for one recording.

def main():
226def main():
227    ap = argparse.ArgumentParser(description="Swappable embedding backends for feels vector search.")
228    ap.add_argument("--fit", action="store_true", help="(re)fit the PCA cache from swirl train recordings")
229    ap.add_argument("--compare", action="store_true", help="offline bake-off: hand vs pca on swirl-only test")
230    args = ap.parse_args()
231    if args.fit:
232        fit_pca()
233    elif args.compare:
234        _compare()
235    else:
236        print(f"FEELS_EMBEDDER={EMBEDDER}  dim={DIM[EMBEDDER]}")