File size: 11,075 Bytes
4cfc415
 
 
 
96d4854
 
 
4cfc415
 
 
 
 
 
 
 
 
96d4854
 
4cfc415
 
993360d
96d4854
 
 
 
 
 
993360d
 
4cfc415
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96d4854
 
4cfc415
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# badminsens-analyzer

Counts and classifies badminton strokes in Garmin (Forerunner 255) `.fit` files with models
trained on the BADS smartwatch dataset (`BADS_CLL_OPENACCESS_V1`, BadminSense paper, `paper.pdf`).
The recordings come from a custom Garmin watch app that logs raw IMU data:
[github.com/omayib/badak](https://github.com/omayib/badak). Trained models and a Colab notebook are
on Hugging Face: [omayib/badak](https://huggingface.co/omayib/badak).

## Quick start

```bash
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt

.venv/bin/python -m badminsens.predict path/to/ACTIVITY.fit                # default model
.venv/bin/python -m badminsens.predict path/to/ACTIVITY.fit --model gru    # any shipped model
.venv/bin/python -m badminsens.predict path/to/ACTIVITY.fit --model all    # compare all models
.venv/bin/python -m badminsens.plot path/to/ACTIVITY.fit                   # 4 charts + desktop/mobile report pages
.venv/bin/python -m badminsens.plot path/to/ACTIVITY.fit --charts speed-simple  # just one
```

In Python or a notebook: `rows, summary = badminsens.predict.analyze("ACTIVITY.fit", model="svm")`,
then `badminsens.plot.plot_session("ACTIVITY.fit", rows)`. The charts: `hr` heart rate + strokes, `speed` average racket
speed (km/j) per 5 minutes, `speed-simple` average racket speed per stroke and game (km/j = km per hour,
estimated), times in the watch's local zone (WIB), `timeline` strokes per
2 minutes; `report-desktop` / `report-mobile` put all four on one page (a 2 x 2 grid, or one
narrow column for phones). A gap in the IMU data of at least `--min-rest` minutes (default 2, i.e. the watch app paused)
is drawn as rest and starts a new game. `badak_colab.ipynb` runs the same in
Google Colab, straight from the Hugging Face repo `omayib/badak`.

Output: counts and percentages per stroke in the terminal, `<ACTIVITY>_summary.json`, and
`<ACTIVITY>_strokes.csv` (one row per swing: time, stroke, confidence, gate decision, class
probabilities; with `--model all` every model's label).

Options: `--min-peak-dps` (swing detection, default 800 °/s), `--min-conf` (below this a stroke is
also flagged `Uncertain`, default 0.5), `--gate-quantile` (overhead-gate strictness, default 0.97),
`--no-gate` (force every swing into a class), `--balance` (class-balanced session statistics
iterations; default from the registry).

## What it can and cannot count

BADS contains only **4 overhead strokes**, all right-handed with the watch on the racket wrist:

| BADS label | stroke | side |
|---|---|---|
| `ForehandKill` | Smash | forehand |
| `ForehandHigh` | Clear | forehand |
| `ForehandLob` | Drop (吊球, "lob" is a mistranslation; it is **not** an underhand lift) | forehand |
| `BackhandTransition` | Backhand clear | backhand |

A match also contains lifts, net shots, drives, serves, defence and non-hitting swings. Earlier
versions forced all of them into one of the 4 classes, which inflated "backhand" (308 of 484
swings). Now an **overhead gate** first checks whether a swing looks like a dataset stroke at all
(forearm orientation before and after the swing, rotation axis at peak speed). The gate is
Mahalanobis distance to the stroke classes, accepting 97% of training strokes and 91% of strokes
of unseen players. Rejected swings are reported as `Other (not a dataset stroke)`.

Result for `24469883647_ACTIVITY.fit` (match, right wrist, right-handed), default model:

| | count | % of dataset strokes | % of all swings |
|---|---|---|---|
| Smash | 70 | 36.6 | 14.5 |
| Clear | 7 | 3.7 | 1.4 |
| Drop | 48 | 25.1 | 9.9 |
| Backhand clear | 66 | 34.6 | 13.6 |
| Other (not a dataset stroke) | 293 | | 60.5 |

Forehand 65% / backhand 35% of the dataset strokes. The other models give 28–41% smash, 4–18%
clear, 12–27% drop and 28–42% backhand clear (`--model all`). Treat that spread as the
uncertainty: there are no labelled Garmin strokes, so accuracy on your watch cannot be measured.
Clear is probably undercounted, because clear→smash is the main error on the dataset too (clear
recall 0.3–0.66).

## Pipeline

```bash
.venv/bin/python -m badminsens.dataset     # 1. BADS JSON -> data/bads_windows.npz, player split
.venv/bin/python -m badminsens.tune        # 2. configuration search loop (CV on 9 dev players)
.venv/bin/python -m badminsens.benchmark   # 3. evaluate once on 3 test players, ship to models/
.venv/bin/python -m badminsens.select      # 4. choose the default (leave-one-player-out, 12 players)
```

1. **Pre-processing** (`dataset.py`, `io.py`, `features.py`): 848 strokes as 2 s windows at 100 Hz
   (acc m/s², gyro rad/s), aligned so the gyro peak is at sample 94 (like windows cut from a .fit).
2. **Split by player**: train 7 players (490 strokes) + val 2 (141) = 9 *dev* players used for
   tuning; **test 3 players (217 strokes: Play6, Play9, Play12)** used once, after tuning.
3. **Features**: the paper's 23 time/frequency descriptors per axis (trim 200 ms, 20 Hz low-pass,
   stats of signal and derivative, FFT energy/centroid/entropy, Welch PSD) + our 378 shape and
   orientation features; sequence nets get the 12/20 Hz low-passed signal at 25 Hz (8 channels).
4. **Session normalization**: each player/session is centred on its own mean (removes individual
   style and device offsets). At inference the Garmin session mean is computed over the gate-accepted
   swings, re-weighted so each predicted class counts equally (`balance_iters`), because a real
   session does not have the dataset's balanced stroke mix. (Z-scaling per session scored slightly
   higher in CV but broke on Garmin data, so it is excluded.)
5. **Augmentation**: session-level wrist rotation (watch worn differently), per-stroke jitter,
   time shift, amplitude scaling, noise.

## The tuning loop

`badminsens.tune` searched 12 model families: 174 configurations in the final run, logged in
`experiments/tuning_log.jsonl` (plus 80 in an earlier run before peak alignment,
`tuning_log_v1_unaligned.jsonl`) (config + accuracy, balanced accuracy, macro P/R/F1, Cohen κ, MCC,
log-loss, ROC-AUC, per-player accuracy, confusion matrix, skewed-session accuracy). Per family it
tries the default, then random configurations, then mutations of the best one, and stops when
leave-one-player-out CV accuracy reaches the 0.90 target or the budget runs out. Searched settings:

- **SVM / LR / RF / ET / HGB**: feature set, normalization, C, γ, trees, max_features, leaf size,
  learning rate, leaf nodes, L2, number of augmented copies.
- **Neural nets**: epochs (30/60/100), batch (16/32/64), optimizer (AdamW/Adam/SGD+Nesterov),
  learning rate, weight decay, scheduler (cosine/one-cycle/none), label smoothing, dropout, dense
  head (none, 64, 128, 128-64, 256-128, …), conv channels and kernel, RNN hidden size, layers and
  direction, transformer width, heads and layers, input low-pass and rate, augmentation strength.

**No configuration reached 0.90 on unseen players.** The best was SVM at 0.892 CV. The biggest
gains came from the data rather than from hyperparameters: session normalization (+7 points) and
peak alignment (+1.5).

## Results

Full tables (all metrics, per-class P/R/F1, configurations, confusion matrices, tuning summary):
[`models/BENCHMARK.md`](models/BENCHMARK.md).

| model | CV acc (9 dev) | **test acc (3 unseen)** | macro F1 | κ | skewed session | 12-player LOPO |
|---|---|---|---|---|---|---|
| **svm + cnn + transformer** (default) | | | | | 0.845 / 0.856* | **0.875** |
| gru | 0.794 | **0.873 ± 0.021** | 0.870 | 0.829 | 0.861 | 0.821 |
| rf | 0.870 | 0.842 | 0.825 | 0.788 | 0.722 | 0.861 |
| transformer | 0.783 | 0.839 | 0.830 | 0.784 | 0.827 | 0.829 |
| et | 0.872 | 0.836 | 0.819 | 0.780 | 0.721 | 0.853 |
| svm | **0.892** | 0.814 | 0.783 | 0.751 | 0.716 | 0.871 |
| hgb | 0.878 | 0.810 | 0.788 | 0.745 | 0.728 | 0.861 |
| cnn | 0.792 | 0.810 | 0.811 | 0.745 | 0.805 | 0.807 |
| mlp | 0.881 | 0.794 | 0.755 | 0.724 | 0.743 | 0.856 |
| logreg | 0.853 | 0.779 | 0.738 | 0.703 | 0.684 | |
| lstm | 0.778 | 0.768 | 0.769 | 0.689 | 0.767 | |
| cnn_lstm | 0.759 | 0.754 | 0.743 | 0.672 | 0.757 | |
| tcn | 0.799 | 0.736 | 0.741 | 0.647 | 0.735 | |

*skewed session: test accuracy when the session statistics come from an unbalanced mix of the
player's strokes (without / with class balancing); for the default it is the 12-player value.*

How to read it:

- The **CV and test rankings disagree**: SVM is best on the 9 dev players and GRU on the 3 test
  players. Accuracy varies more between players than between models. Every model scores about 0.73
  on Play12 and 0.85–0.99 on Play9. With 3 test players, one standard error is about ±2.5 points.
- The **default was therefore chosen after the test evaluation**, by leave-one-player-out over all
  12 players (`badminsens.select`, 4× more unseen players). That makes the ensemble's number more
  reliable, but it is not an untouched test score.
- The paper reports 91.43% (SVM, one random leave-3-users-out split). Reproducing its pipeline
  gives 78.9% ± 5.4 over 20 random 3-player splits (best split 88.1%). Split luck explains most of
  the gap.
- Backhand clear is almost perfect, drop is good (F1 ≈ 0.9), and **clear vs smash** is the
  remaining error, as the paper also notes.

Best configurations (all settings in `models/registry.json`):

| model | key settings |
|---|---|
| svm | both feature sets, session-centred, RBF C=0.3, γ=3e-4, 4 augmented copies |
| cnn | conv 64-128-128, kernel 9, dense 128, dropout 0.3, 30 epochs, batch 32, AdamW lr 5e-4, wd 1e-4, cosine, label smoothing 0.2, 20 Hz → 25 Hz input, session-centred |
| transformer | d_model 32, 4 heads, 2 layers, no dense head, dropout 0.3, 30 epochs, batch 32, Adam lr 5e-4, wd 1e-3, cosine, session-centred |
| gru | 2-layer unidirectional GRU, 64 hidden, dense 128, dropout 0.5, 60 epochs, batch 32, SGD+Nesterov lr 0.01, cosine, no session normalization |

## Requirements for your recordings

- Raw accelerometer + gyroscope logging must be enabled (like `24469883647_ACTIVITY.fit`). The Garmin
  watch app at [github.com/omayib/badak](https://github.com/omayib/badak) records this kind of file.
- Watch on the **racket-hand wrist**, right-handed (as in BADS). The Garmin→BADS axis mapping was
  verified from the data: the gyro/accel physics check passed, and the identity rotation fits the
  dataset's stroke orientations far better than the other 23 rotations (32% vs ≤10% of swings in
  distribution).

## Garmin ↔ BADS sensor mapping

| | Garmin raw | Conversion |
|---|---|---|
| accel | 1 mg/LSB, ±8 g, **sign opposite to Android** | × −9.80665/1000 → m/s² |
| gyro | 16.384 LSB/(°/s), ±2000 °/s | ÷ 16.384 → °/s → rad/s |

## Notes

- Training uses the GPU when CUDA works and falls back to CPU otherwise. On this machine the
  driver was stuck (`CUDA unknown error`; fix with `sudo rmmod nvidia_uvm && sudo modprobe nvidia_uvm`),
  so everything was trained on CPU.
- `experiments/`: tuning logs (v1 = before peak alignment, kept for reference), selection results
  and run logs.