islkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
islkit/__init__.py ADDED
@@ -0,0 +1,88 @@
1
+ """islkit — Indian Sign Language recognition from MediaPipe landmarks.
2
+
3
+ Landmark features, a dual-branch TCN, the INCLUDE loader, live inference and a
4
+ small HTTP/SSE recognition service.
5
+ """
6
+
7
+ import importlib
8
+
9
+ from islkit.baseline import pooled_stats, stratified_folds
10
+ from islkit.data import IncludeData, load_include
11
+ from islkit.features import DIM_FRAME, DIM_GEOM, RawFrame, encode_clip, encode_frame
12
+ from islkit.labels import LabelMap, build_dataset, decode_prediction
13
+ from islkit.metrics import accuracy, confusion_matrix, per_class_f1, top_confusions
14
+ from islkit.plotting import plot_confusion, plot_curves, plot_f1_distribution
15
+ from islkit.viz import animate_replay, reconstruct, still_replay
16
+
17
+ # device, seeding and model are the only modules that import torch, and they are
18
+ # loaded on first use rather than at import time. Not for speed: torch bundles its
19
+ # own libomp, XGBoost links Homebrew's, and macOS aborts the process when both land
20
+ # in it (OMP Error #15). The XGBoost baseline is pure numpy and must be able to import
21
+ # load_include without dragging torch in behind it. See islkit/baseline.py.
22
+ _LAZY = {
23
+ "describe_device": "islkit.device",
24
+ "pick_device": "islkit.device",
25
+ "set_seed": "islkit.seeding",
26
+ "Backbone": "islkit.model",
27
+ "SignClassifier": "islkit.model",
28
+ "build_model": "islkit.model",
29
+ "freeze_backbone": "islkit.model",
30
+ "replace_head": "islkit.model",
31
+ "ClipStore": "islkit.infer",
32
+ "ClipTooShort": "islkit.infer",
33
+ "HolisticExtractor": "islkit.infer",
34
+ "Prediction": "islkit.infer",
35
+ "SignRecogniser": "islkit.infer",
36
+ }
37
+
38
+
39
+ def __getattr__(name: str):
40
+ module = _LAZY.get(name)
41
+ if module is None:
42
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
43
+ value = getattr(importlib.import_module(module), name)
44
+ globals()[name] = value # bind so this runs once per name
45
+ return value
46
+
47
+
48
+ def __dir__() -> list[str]:
49
+ return sorted(__all__)
50
+
51
+
52
+ __all__ = [
53
+ "DIM_FRAME",
54
+ "DIM_GEOM",
55
+ "Backbone",
56
+ "ClipStore",
57
+ "ClipTooShort",
58
+ "HolisticExtractor",
59
+ "IncludeData",
60
+ "LabelMap",
61
+ "Prediction",
62
+ "RawFrame",
63
+ "SignClassifier",
64
+ "SignRecogniser",
65
+ "accuracy",
66
+ "animate_replay",
67
+ "build_dataset",
68
+ "build_model",
69
+ "confusion_matrix",
70
+ "decode_prediction",
71
+ "describe_device",
72
+ "encode_clip",
73
+ "encode_frame",
74
+ "freeze_backbone",
75
+ "load_include",
76
+ "per_class_f1",
77
+ "pick_device",
78
+ "plot_confusion",
79
+ "plot_curves",
80
+ "plot_f1_distribution",
81
+ "pooled_stats",
82
+ "reconstruct",
83
+ "replace_head",
84
+ "set_seed",
85
+ "still_replay",
86
+ "stratified_folds",
87
+ "top_confusions",
88
+ ]
islkit/adapters.py ADDED
@@ -0,0 +1,237 @@
1
+ """Inspect a downloaded landmark dataset, then feed it into the same encoder
2
+ your own recordings use.
3
+
4
+ Two jobs:
5
+
6
+ 1. inspect_dataset(root) — tells you what layout you actually got.
7
+ Do NOT skip this. Assuming a layout and being wrong produces a model that
8
+ trains fine and predicts nonsense.
9
+
10
+ 2. unflatten(vec) — turns one flattened MediaPipe Holistic vector
11
+ back into structured per-part arrays, so features.encode_frame() works on
12
+ public data unchanged.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from collections import Counter
19
+ from pathlib import Path
20
+
21
+ import numpy as np
22
+
23
+ from islkit.features import RawFrame
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Known flatten layouts
27
+ # ---------------------------------------------------------------------------
28
+ # The 1662 layout comes from a widely-copied tutorial and is by far the most
29
+ # common in public ISL landmark dumps. Order is always:
30
+ # pose(33x4) -> face(468x3) -> left_hand(21x3) -> right_hand(21x3)
31
+ # Missing parts are zero-filled, which is recoverable: an all-zero block means
32
+ # the tracker lost that part, NOT that it sat at the origin.
33
+
34
+ LAYOUTS = {
35
+ 1662: {
36
+ "pose": (0, 132, 33, 4),
37
+ "face": (132, 1536, 468, 3),
38
+ "lh": (1536, 1599, 21, 3),
39
+ "rh": (1599, 1662, 21, 3),
40
+ },
41
+ 1629: {
42
+ "pose": (0, 99, 33, 3),
43
+ "face": (99, 1503, 468, 3),
44
+ "lh": (1503, 1566, 21, 3),
45
+ "rh": (1566, 1629, 21, 3),
46
+ },
47
+ 258: {
48
+ "pose": (0, 132, 33, 4),
49
+ "face": None,
50
+ "lh": (132, 195, 21, 3),
51
+ "rh": (195, 258, 21, 3),
52
+ },
53
+ 225: {
54
+ "pose": (0, 99, 33, 3),
55
+ "face": None,
56
+ "lh": (99, 162, 21, 3),
57
+ "rh": (162, 225, 21, 3),
58
+ },
59
+ }
60
+
61
+
62
+ def unflatten(vec: np.ndarray, layout: int = 1662):
63
+ """One flattened frame -> dict of per-part arrays (None if absent).
64
+
65
+ Returns arrays shaped (33,4) / (468,3) / (21,3) / (21,3). A part is
66
+ reported as None when its whole block is exactly zero, which is how the
67
+ standard extractor encodes 'tracker lost this'.
68
+ """
69
+ spec = LAYOUTS[layout]
70
+ out = {}
71
+ for name in ("pose", "face", "lh", "rh"):
72
+ s = spec[name]
73
+ if s is None:
74
+ out[name] = None
75
+ continue
76
+ a, b, n, d = s
77
+ block = np.asarray(vec[a:b], np.float32)
78
+ out[name] = None if not np.any(block) else block.reshape(n, d)
79
+ # normalise pose to (33,4) so downstream code has one shape to expect
80
+ if out["pose"] is not None and out["pose"].shape[1] == 3:
81
+ vis = np.ones((33, 1), np.float32)
82
+ out["pose"] = np.concatenate([out["pose"], vis], axis=1)
83
+ return out
84
+
85
+
86
+ def to_raw_frames(seq: np.ndarray, layout: int = 1662) -> list[RawFrame]:
87
+ """(T, D) flattened sequence -> list of RawFrame for the encoder."""
88
+ frames = []
89
+ for row in seq:
90
+ p = unflatten(row, layout)
91
+ frames.append(
92
+ RawFrame(pose=p["pose"], face=p["face"], hand_left=p["lh"], hand_right=p["rh"])
93
+ )
94
+ return frames
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Inspector
99
+ # ---------------------------------------------------------------------------
100
+
101
+
102
+ def _load_any(path: Path):
103
+ """Best-effort load of one sample file. Returns (array, note)."""
104
+ sfx = path.suffix.lower()
105
+ try:
106
+ if sfx == ".npy":
107
+ return np.load(path, allow_pickle=True), "npy"
108
+ if sfx == ".npz":
109
+ z = np.load(path, allow_pickle=True)
110
+ keys = list(z.keys())
111
+ return z[keys[0]], f"npz keys={keys}"
112
+ if sfx == ".parquet":
113
+ import pandas as pd
114
+
115
+ df = pd.read_parquet(path)
116
+ return df, f"parquet cols={list(df.columns)[:8]}"
117
+ if sfx == ".csv":
118
+ import pandas as pd
119
+
120
+ df = pd.read_csv(path)
121
+ return df, f"csv cols={list(df.columns)[:8]}"
122
+ if sfx == ".json":
123
+ return json.loads(path.read_text()), "json"
124
+ except Exception as e:
125
+ return None, f"load failed: {e}"
126
+ return None, "unrecognised extension"
127
+
128
+
129
+ def inspect_dataset(root: str | Path, max_files: int = 400):
130
+ root = Path(root)
131
+ files = [
132
+ p
133
+ for p in root.rglob("*")
134
+ if p.is_file() and p.suffix.lower() in {".npy", ".npz", ".parquet", ".csv", ".json"}
135
+ ]
136
+
137
+ print(f"root : {root}")
138
+ print(f"data files found: {len(files)}")
139
+ if not files:
140
+ print("\nNothing recognised. List the directory manually — the archive")
141
+ print("may nest one level deeper, or store raw video instead.")
142
+ return
143
+
144
+ exts = Counter(p.suffix.lower() for p in files)
145
+ print(f"extensions : {dict(exts)}")
146
+
147
+ # Directory structure often carries the label and sometimes the signer.
148
+ depths = Counter(len(p.relative_to(root).parts) for p in files)
149
+ print(f"path depths : {dict(depths)}")
150
+ parents = sorted({p.parent.name for p in files})
151
+ print(f"distinct folders: {len(parents)}")
152
+ print(f" sample : {parents[:6]}")
153
+ print(f"sample filenames: {[p.name for p in files[:4]]}")
154
+
155
+ # Probe the most common extension, not files[0]. A dump that ships a
156
+ # label_map.json beside its clips would otherwise be inspected via its
157
+ # manifest and report a scalar array shape.
158
+ main_ext, _ = exts.most_common(1)[0]
159
+ probe = next(p for p in files if p.suffix.lower() == main_ext)
160
+ sample, note = _load_any(probe)
161
+ print(f"\nfirst file : {probe.relative_to(root)}")
162
+ print(f"loaded as : {note}")
163
+ if sample is None:
164
+ return
165
+
166
+ if hasattr(sample, "columns"):
167
+ print(f"dataframe shape : {sample.shape}")
168
+ print(sample.head(3).to_string())
169
+ print("\nTabular layout — likely one row per landmark per frame.")
170
+ print("Pivot on (frame, type, landmark_index) before using unflatten().")
171
+ return
172
+
173
+ arr = np.asarray(sample)
174
+ print(f"array shape : {arr.shape} dtype={arr.dtype}")
175
+
176
+ D = arr.shape[-1] if arr.ndim >= 2 else arr.shape[0]
177
+ layout = D if D in LAYOUTS else None
178
+ print(f"feature dim : {D} -> {'KNOWN layout' if layout else 'UNKNOWN layout'}")
179
+
180
+ if not layout:
181
+ print("\nUnknown width. Check the dataset description for the field order")
182
+ print("before assuming anything. Known widths:", sorted(LAYOUTS))
183
+ return
184
+
185
+ # Sanity: MediaPipe normalised coords mostly land in [0, 1] for x,y.
186
+ flat = arr.reshape(-1, D)
187
+ print(f"value range : [{flat.min():.3f}, {flat.max():.3f}]")
188
+ print(f"exact zeros : {(flat == 0).mean() * 100:.1f}% of all values")
189
+
190
+ spec = LAYOUTS[layout]
191
+ print("\nper-part presence across this file:")
192
+ for name in ("pose", "face", "lh", "rh"):
193
+ s = spec[name]
194
+ if s is None:
195
+ print(f" {name:5s}: not in this layout")
196
+ continue
197
+ a, b, _, _ = s
198
+ present = np.any(flat[:, a:b] != 0, axis=1).mean()
199
+ print(f" {name:5s}: present in {present * 100:5.1f}% of frames")
200
+
201
+ # Scan more files for class balance and tracking quality.
202
+ lost = 0
203
+ total = 0
204
+ labels = Counter()
205
+ for p in files[:max_files]:
206
+ labels[p.parent.name] += 1
207
+ a, _ = _load_any(p)
208
+ if a is None:
209
+ continue
210
+ a = np.asarray(a)
211
+ if a.ndim < 2 or a.shape[-1] != D:
212
+ continue
213
+ f = a.reshape(-1, D)
214
+ lh, rh = spec["lh"], spec["rh"]
215
+ both = np.any(f[:, lh[0] : lh[1]] != 0, axis=1) & np.any(f[:, rh[0] : rh[1]] != 0, axis=1)
216
+ lost += (~both).sum()
217
+ total += len(f)
218
+
219
+ print(f"\nscanned {min(len(files), max_files)} files")
220
+ if total:
221
+ print(f"frames missing a hand : {(1 - lost / total) * 100:.1f}% have BOTH hands")
222
+ print(" ISL is largely two-handed. A low number here is a warning:")
223
+ print(" either heavy occlusion or a single-handed vocabulary.")
224
+ print(f"distinct label folders: {len(labels)}")
225
+ common = labels.most_common(5)
226
+ print(f" most populated : {common}")
227
+ counts = np.array(list(labels.values()))
228
+ print(
229
+ f" clips per label : min={counts.min()} "
230
+ f"median={int(np.median(counts))} max={counts.max()}"
231
+ )
232
+
233
+ print("\n--- next steps ---")
234
+ print(f"use: to_raw_frames(seq, layout={layout}) -> features.encode_clip()")
235
+ print("BEFORE training, find the signer/session identifier. If filenames or")
236
+ print("folders encode it, group by it. If nothing does, you cannot build an")
237
+ print("honest split from this data alone — treat it as pretraining only.")
islkit/baseline.py ADDED
@@ -0,0 +1,287 @@
1
+ """The baseline the TCN has to beat: pooled statistics over time + XGBoost.
2
+
3
+ Baseline before model. If a gradient-boosted tree on six
4
+ summary statistics per feature dimension already does well, the features carry
5
+ the signal and the sequence model is only being asked for the rest. If it does
6
+ badly, the bug is in the features and no amount of TCN tuning will find it.
7
+
8
+ **On the split.** Own recordings are evaluated leave-one-session-out, because
9
+ consecutive frames of one recording are near-duplicates. INCLUDE has no grouping
10
+ at all — no signer, no session, in any file, folder or metadata of the dump — so
11
+ no group split is available and none is faked. `stratified_folds` is the
12
+ replacement: a class-stratified k-fold over whole clips, shared with the TCN.
13
+
14
+ It is **optimistic** and is named and reported as such everywhere it is used. It
15
+ does not leak *frames* — pooling collapses each clip to one row, so a clip is
16
+ wholly in train or wholly in test — but it does leak *signers*, and every fold's
17
+ training set contains the same seven people as its test set. Read the number it
18
+ produces as an upper bound on a features-sanity check, never as a generalisation
19
+ estimate.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import sys
25
+ import time
26
+ from dataclasses import dataclass, field
27
+
28
+ import numpy as np
29
+
30
+ # Named spans of the 352-dim frame vector, for attributing importance back to
31
+ # something physical. Deliberately duplicated here rather than added to
32
+ # features.py: the INCLUDE cache filename is a hash of that file's source bytes,
33
+ # so a one-line addition there would silently invalidate 291 MB and re-encode
34
+ # 4,284 clips. Kept in sync by test_baseline.py, which checks the spans against
35
+ # features.py's own DIM_GEOM / DIM_FRAME rather than against these numbers.
36
+ FEATURE_BLOCKS: dict[str, tuple[int, int]] = {
37
+ "hand_shape": (0, 126), # 2 x 21 x 3 wrist-local handshape
38
+ "wrist_pos": (126, 132), # 2 x 3 wrist position in the body frame
39
+ "pose": (132, 165), # 11 x 3 upper body
40
+ "non_manual": (165, 169), # brow raise/furrow, mouth open/wide
41
+ "mask": (169, 183), # 2 hands + 11 pose visibility + 1 face
42
+ "d_hand_shape": (183, 309),
43
+ "d_wrist_pos": (309, 315),
44
+ "d_pose": (315, 348),
45
+ "d_non_manual": (348, 352),
46
+ }
47
+
48
+ # Frozen once, on purpose. A baseline should take twenty minutes and set a floor to
49
+ # beat, not a tuned competitor: a baseline that has been searched over is no
50
+ # longer a baseline, because the model it gates was never given the same budget.
51
+ # Stock XGBoost defaults except for the two that only affect run time.
52
+ XGB_PARAMS: dict[str, object] = {
53
+ "objective": "multi:softprob",
54
+ "max_depth": 6,
55
+ "eta": 0.3,
56
+ "subsample": 0.9,
57
+ "colsample_bytree": 0.6, # 2,112 features, most of them near-duplicates
58
+ "tree_method": "hist",
59
+ "verbosity": 0,
60
+ }
61
+ N_ROUNDS = 60
62
+
63
+ # Order of the statistic blocks in the pooled vector. Feature j of statistic i
64
+ # lives at index i * D + j, which is what `pooled_feature_index` inverts.
65
+ STAT_NAMES: tuple[str, ...] = ("mean", "std", "min", "max", "first", "last")
66
+
67
+
68
+ def pooled_stats(X: np.ndarray) -> np.ndarray:
69
+ """(N, T, D) clips -> (N, 6D) rows of per-dimension summary statistics.
70
+
71
+ Six statistics per feature dimension, taken across time: mean, std, min,
72
+ max, first, last. Deliberately crude — this is the floor the sequence model
73
+ has to clear, so it must not be given anything resembling temporal
74
+ structure beyond the two endpoints.
75
+ """
76
+ X = np.asarray(X, dtype=np.float32)
77
+ if X.ndim != 3:
78
+ raise ValueError(f"expected (N, T, D), got shape {X.shape}")
79
+ if X.shape[1] == 0:
80
+ raise ValueError("clips have zero frames")
81
+
82
+ blocks = (
83
+ X.mean(axis=1),
84
+ X.std(axis=1),
85
+ X.min(axis=1),
86
+ X.max(axis=1),
87
+ X[:, 0, :],
88
+ X[:, -1, :],
89
+ )
90
+ return np.concatenate(blocks, axis=1).astype(np.float32, copy=False)
91
+
92
+
93
+ def pooled_feature_index(stat: str, dim: int, n_dims: int) -> int:
94
+ """Index into a pooled row for one (statistic, source dimension) pair."""
95
+ return STAT_NAMES.index(stat) * n_dims + dim
96
+
97
+
98
+ def stratified_folds(y: np.ndarray, n_folds: int = 5, seed: int = 0) -> list[np.ndarray]:
99
+ """Class-stratified k-fold over clips. Returns one test-index array per fold.
100
+
101
+ **Not a group split.** See the module docstring: no group identifier exists
102
+ for INCLUDE, and this function exists so a number can be reported with the
103
+ split it came from attached, not so a group split can be faked. Stratified
104
+ because 33 of the 262 classes have <= 8 clips and an unstratified fold can
105
+ miss one entirely, which scores that class 0.0 for reasons that have nothing
106
+ to do with the features.
107
+ """
108
+ y = np.asarray(y)
109
+ if n_folds < 2:
110
+ raise ValueError("n_folds must be at least 2")
111
+
112
+ rng = np.random.default_rng(seed)
113
+ fold_of = np.empty(len(y), dtype=np.int64)
114
+ for c in np.unique(y):
115
+ idx = np.flatnonzero(y == c)
116
+ rng.shuffle(idx)
117
+ # Rotate the starting fold per class so the remainder when a class does
118
+ # not divide evenly doesn't always pile into fold 0.
119
+ offset = int(rng.integers(n_folds))
120
+ fold_of[idx] = (np.arange(len(idx)) + offset) % n_folds
121
+ return [np.flatnonzero(fold_of == f) for f in range(n_folds)]
122
+
123
+
124
+ def _import_xgboost():
125
+ """Import xgboost, refusing to proceed if torch is already in the process.
126
+
127
+ torch ships its own libomp and the XGBoost wheel links Homebrew's. With both
128
+ loaded, macOS aborts with `OMP: Error #15` — an exit code and no traceback,
129
+ twenty minutes into a run. islkit.__init__ keeps torch lazy so this never
130
+ happens by accident; this turns the remaining case into a sentence.
131
+ """
132
+ if "torch" in sys.modules:
133
+ raise RuntimeError(
134
+ "torch is already imported; loading xgboost now will abort the process "
135
+ "(two OpenMP runtimes, macOS OMP Error #15). The pooled-stats baseline "
136
+ "needs no torch — import islkit.baseline before anything that pulls it in."
137
+ )
138
+ import xgboost # noqa: PLC0415 — deliberately deferred, see above
139
+
140
+ return xgboost
141
+
142
+
143
+ @dataclass
144
+ class XGBBaseline:
145
+ """XGBoost over pooled statistics. Thin wrapper on the native Booster API.
146
+
147
+ Not `XGBClassifier`: that path requires scikit-learn, and this package writes
148
+ its metrics out by hand (see metrics.py) rather than taking the dependency.
149
+ """
150
+
151
+ n_classes: int
152
+ n_rounds: int = N_ROUNDS
153
+ seed: int = 0
154
+ params: dict = field(default_factory=lambda: dict(XGB_PARAMS))
155
+ booster: object | None = None
156
+
157
+ def fit(self, X: np.ndarray, y: np.ndarray) -> XGBBaseline:
158
+ xgb = _import_xgboost()
159
+ params = {**self.params, "num_class": self.n_classes, "seed": self.seed}
160
+ self.booster = xgb.train(params, xgb.DMatrix(X, label=y), num_boost_round=self.n_rounds)
161
+ return self
162
+
163
+ def predict_proba(self, X: np.ndarray) -> np.ndarray:
164
+ xgb = _import_xgboost()
165
+ if self.booster is None:
166
+ raise RuntimeError("fit() first")
167
+ return np.asarray(self.booster.predict(xgb.DMatrix(X)))
168
+
169
+ def gain(self, n_features: int) -> np.ndarray:
170
+ """Total gain per feature, as a dense vector. Unsplit features are 0."""
171
+ if self.booster is None:
172
+ raise RuntimeError("fit() first")
173
+ out = np.zeros(n_features, dtype=np.float64)
174
+ for key, value in self.booster.get_score(importance_type="total_gain").items():
175
+ out[int(key[1:])] = value # keys are "f<index>"
176
+ return out
177
+
178
+
179
+ def cross_val_predict(
180
+ make_model,
181
+ X: np.ndarray,
182
+ y: np.ndarray,
183
+ folds: list[np.ndarray],
184
+ n_classes: int = 0,
185
+ verbose: bool = True,
186
+ ) -> tuple[np.ndarray, list, list[float]]:
187
+ """Out-of-fold class probabilities for every row, the models, the fold accuracies.
188
+
189
+ Every clip is predicted exactly once, by a model that never saw it. That
190
+ matters more than the mean of per-fold accuracies here: with 262 classes and
191
+ a median of 15 clips each, per-class F1 from a single held-out fold rests on
192
+ three examples. Pooling out-of-fold predictions puts all 4,284 behind it.
193
+
194
+ Full probabilities rather than argmax, because top-3 and the confidence
195
+ threshold for declining are both read off them afterwards.
196
+
197
+ `make_model` is a zero-argument factory so each fold gets a fresh estimator.
198
+ """
199
+ n_classes = n_classes or int(np.max(y)) + 1
200
+ proba = np.full((len(y), n_classes), np.nan, dtype=np.float32)
201
+ models, accs = [], []
202
+ all_idx = np.arange(len(y))
203
+
204
+ for k, test_idx in enumerate(folds):
205
+ train_idx = np.setdiff1d(all_idx, test_idx)
206
+ t0 = time.time()
207
+ model = make_model()
208
+ model.fit(X[train_idx], y[train_idx])
209
+ proba[test_idx] = model.predict_proba(X[test_idx])
210
+ models.append(model)
211
+ accs.append(float((proba[test_idx].argmax(axis=1) == y[test_idx]).mean()))
212
+ if verbose:
213
+ print(
214
+ f" fold {k + 1}/{len(folds)}: train={len(train_idx):5d} test={len(test_idx):5d} "
215
+ f" acc={accs[-1] * 100:5.2f}% ({time.time() - t0:.0f}s)",
216
+ flush=True,
217
+ )
218
+
219
+ if np.isnan(proba).any():
220
+ raise RuntimeError("folds did not cover every row")
221
+ if verbose:
222
+ print(f" fold spread: {min(accs) * 100:.2f}% – {max(accs) * 100:.2f}%")
223
+ return proba, models, accs
224
+
225
+
226
+ def nearest_neighbour_accuracy(X: np.ndarray, y: np.ndarray, folds: list[np.ndarray]) -> float:
227
+ """Out-of-fold 1-NN accuracy under cosine similarity on the raw features.
228
+
229
+ A leak check on the *split*, not a model. On this task, any number
230
+ above 95% is a leak until proven otherwise, and the first thing to rule out
231
+ is that held-out clips simply sit next to their training neighbours — which
232
+ is what a duplicated, near-duplicated, or otherwise degenerate split looks
233
+ like from the inside. If 1-NN is already near the model's score, the split
234
+ is giving the answer away and no model result from it means anything.
235
+
236
+ Deliberately parameter-free: it measures the geometry of the data under this
237
+ partition and nothing else.
238
+ """
239
+ X = np.asarray(X, dtype=np.float32).reshape(len(y), -1)
240
+ X = X / (np.linalg.norm(X, axis=1, keepdims=True) + 1e-9)
241
+ y = np.asarray(y)
242
+
243
+ pred = np.empty(len(y), dtype=y.dtype)
244
+ all_idx = np.arange(len(y))
245
+ for test_idx in folds:
246
+ train_idx = np.setdiff1d(all_idx, test_idx)
247
+ sim = X[test_idx] @ X[train_idx].T
248
+ pred[test_idx] = y[train_idx][sim.argmax(axis=1)]
249
+ return float((pred == y).mean())
250
+
251
+
252
+ def top_k_accuracy(proba: np.ndarray, y: np.ndarray, k: int = 3) -> float:
253
+ """Share of rows whose true class is among the k highest-scored."""
254
+ top = np.argpartition(-proba, kth=k - 1, axis=1)[:, :k]
255
+ return float((top == np.asarray(y)[:, None]).any(axis=1).mean())
256
+
257
+
258
+ def block_importance(
259
+ importance: np.ndarray, n_dims: int, blocks: dict[str, tuple[int, int]]
260
+ ) -> dict[str, float]:
261
+ """Sum a pooled-feature importance vector back onto named source blocks.
262
+
263
+ After body-frame normalisation, z is the highest-variance
264
+ channel in the pose block despite being MediaPipe's weakest estimate. Trees
265
+ split on whatever separates the training data, so where the gain actually
266
+ landed is the cheap way to see whether that spread is being leaned on.
267
+ """
268
+ importance = np.asarray(importance, dtype=np.float64)
269
+ if importance.size != len(STAT_NAMES) * n_dims:
270
+ raise ValueError(f"expected {len(STAT_NAMES) * n_dims} importances, got {importance.size}")
271
+
272
+ per_dim = importance.reshape(len(STAT_NAMES), n_dims).sum(axis=0)
273
+ total = per_dim.sum()
274
+ if total <= 0:
275
+ return {name: 0.0 for name in blocks}
276
+ return {name: float(per_dim[a:b].sum() / total) for name, (a, b) in blocks.items()}
277
+
278
+
279
+ def stat_importance(importance: np.ndarray, n_dims: int) -> dict[str, float]:
280
+ """Share of total importance carried by each of the six statistics."""
281
+ importance = np.asarray(importance, dtype=np.float64).reshape(len(STAT_NAMES), n_dims)
282
+ total = importance.sum()
283
+ if total <= 0:
284
+ return {name: 0.0 for name in STAT_NAMES}
285
+ return {
286
+ name: float(row.sum() / total) for name, row in zip(STAT_NAMES, importance, strict=True)
287
+ }