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 +88 -0
- islkit/adapters.py +237 -0
- islkit/baseline.py +287 -0
- islkit/data.py +320 -0
- islkit/device.py +38 -0
- islkit/domain.py +187 -0
- islkit/features.py +323 -0
- islkit/infer.py +911 -0
- islkit/labels.py +213 -0
- islkit/metrics.py +81 -0
- islkit/model.py +623 -0
- islkit/pipeline.py +717 -0
- islkit/plotting.py +131 -0
- islkit/seeding.py +19 -0
- islkit/server.py +246 -0
- islkit/view.py +287 -0
- islkit/viz.py +435 -0
- islkit-0.1.0.dist-info/METADATA +200 -0
- islkit-0.1.0.dist-info/RECORD +21 -0
- islkit-0.1.0.dist-info/WHEEL +4 -0
- islkit-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
}
|