ForeverBlue commited on
Commit
b058c1f
·
verified ·
1 Parent(s): f732646

Upload folder using huggingface_hub

Browse files
README.md ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ tags:
4
+ - emg
5
+ - semg
6
+ - electromyography
7
+ - biosignal
8
+ - self-supervised
9
+ - masked-autoencoder
10
+ - pretrained
11
+ library_name: pytorch
12
+ ---
13
+
14
+ # sEMG C1 multi-route pretrained encoder
15
+
16
+ Masked dual-reconstruction pretraining on surface EMG from wristbands, forearm arrays and high-density grids. One shared transformer serves three electrode layouts through route-specific wavelet frontends, and electrode identity enters as a learned embedding of the electrode NAME rather than of its index.
17
+
18
+ Trained for 45 epochs of 60 configured; validation was logged for the first 39, which is what `metrics_epoch.jsonl` holds
19
+ (the last logged validation at step 39000) on a mixture of public
20
+ sEMG corpora, 12.1 M parameters. The files
21
+ here are built around `pretrain_emg_c1_moe_epoch38.pth`, epoch 39.
22
+
23
+ ## Objective
24
+
25
+ ```
26
+ 0.5*L_spec + 0.5*L_raw + 0.001*L_foldKL
27
+ ```
28
+
29
+ Two heads at equal weight. One predicts folded wavelet patches, the other
30
+ predicts the preprocessed sEMG waveform of the same masked patches.
31
+ Masking is applied BEFORE the wavelet frontend (True),
32
+ at ratio 0.7, and the spec target is per-patch
33
+ normalised (True).
34
+
35
+ ## Validation, at the released checkpoint (epoch 39)
36
+
37
+ | metric | value | what 1.0 would mean |
38
+ |---|---|---|
39
+ | total loss | 0.2580 | — |
40
+ | spec MSE | 0.3207 | — |
41
+ | spec NMSE | 0.321 | predicting zero |
42
+ | spec Pearson r | 0.822 | perfect |
43
+ | raw SmoothL1 | 0.1953 | — |
44
+ | raw NMSE | 0.381 | predicting zero |
45
+ | raw Pearson r | 0.782 | perfect |
46
+ | route-macro total | 0.2315 | — |
47
+
48
+ NMSE is dimensionless: MSE(prediction, target) / MSE(0, target), so 1.0 is
49
+ what predicting zero scores on that head's own target. The two losses are NOT
50
+ comparable to each other -- one is an MSE on normalised wavelet coefficients,
51
+ the other a SmoothL1 on the window-normalised waveform. The columns that compare the
52
+ heads are the correlations and the NMSEs.
53
+
54
+ The route-macro row weights the three routes equally. The plain rows weight by
55
+ sample count, and validation is dominated by the largest corpora, so a model
56
+ specialising toward the smaller ones moves the two rows in opposite
57
+ directions.
58
+
59
+ ## Files
60
+
61
+ ### Which file do you want
62
+
63
+ | your situation | download |
64
+ |---|---|
65
+ | fine-tune on any montage | `emg_c1_encoder.pth` |
66
+ | your montage IS one of the three routes below | `pretrain_emg_c1_moe_epoch38.pth`, then cut the route out (one command, below) |
67
+ | continue pretraining, or take the model apart yourself | `pretrain_emg_c1_moe_epoch38.pth` |
68
+
69
+ **`pretrain_emg_c1_moe_epoch38.pth` is the complete model** -- all three wavelet frontends, the
70
+ patchers, both reconstruction decoders. Nothing is held back; only the
71
+ optimiser state was stripped, which no downstream use needs. Take it if you
72
+ want to keep or strip pieces yourself.
73
+
74
+ `emg_c1_encoder.pth` is the same weights minus everything route-bound and
75
+ pretraining-only. It exists because it cannot be loaded wrong.
76
+
77
+ | file | what it is |
78
+ |---|---|
79
+ | `emg_c1_encoder.pth` | encoder alone: channel embedding, gate, transformer |
80
+ | `pretrain_emg_c1_moe_epoch38.pth` | complete model, the reported checkpoint (epoch 39) |
81
+ | `pretrain_emg_c1_moe_final.pth` | complete model, last epoch (epoch 45) |
82
+ | `pretrain_emg_c1_moe_best.pth` | complete model, lowest validation total loss (epoch 7) |
83
+ | `pretrain_emg_c1_moe_best_macro.pth` | complete model, lowest route-macro total (epoch 25) |
84
+ | `metrics_epoch.jsonl` | the full training curve |
85
+ | `config_resolved.yaml` | every resolved hyperparameter |
86
+
87
+ ### Why several checkpoints
88
+
89
+ Each is the minimum of a different validation metric, and they are published
90
+ together so a downstream comparison can ask which criterion transfers instead
91
+ of assuming the one that names `best.pth`. Reconstruction loss cannot answer
92
+ that question; a downstream task can.
93
+
94
+ `best.pth` is selected on the TOTAL loss -- the loss that was trained (epoch
95
+ 6 counted from 0). It is not the best spec loss, and not
96
+ the best raw loss.
97
+
98
+ **Why `pretrain_emg_c1_moe_epoch38.pth` is the main file.** It has the lowest validation raw loss; best.pth wins the total loss at epoch 7, before the raw head had converged (raw r 0.740 against 0.782). The paper's sEMG downstream and fusion results use this file.
99
+
100
+ `pretrain_emg_c1_moe_best_spec.pth` would have been byte-identical to `pretrain_emg_c1_moe_best.pth` -- the same epoch won both bars -- so it is not published separately.
101
+ `pretrain_emg_c1_moe_best_raw.pth` would have been byte-identical to `pretrain_emg_c1_moe_epoch38.pth` -- the same epoch won both bars -- so it is not published separately.
102
+
103
+ ### What is in the complete model
104
+
105
+ | component | parameters | share | |
106
+ |---|---|---|---|
107
+ | shared transformer | 10.63 M | 88.2% | encoder, always transfers |
108
+ | channel-name embedding | 0.02 M | 0.1% | always transfers |
109
+ | channel projection | 0.02 M | 0.2% | always transfers |
110
+ | wavelet frontends (all three routes) | 0.79 M | 6.6% | route-bound; downstream usually builds its own |
111
+ | patchers (one rate) | 0.10 M | 0.8% | route-bound |
112
+ | spec decoder | 0.24 M | 2.0% | pretraining only |
113
+ | raw decoder | 0.24 M | 2.0% | pretraining only |
114
+ | **total** | **12.05 M** | 100% | |
115
+
116
+ The shared transformer is 88% of the model and the three frontends together are 7%, which is why there is no per-route encoder file here: three of them would duplicate that transformer three times to deliver frontends that are a small fraction of it. Cut the one you want out of the complete checkpoint instead.
117
+
118
+ ### What transfers, and what does not
119
+
120
+ `downstream.py` splits a checkpoint into two sets. The channel embedding, its
121
+ gate and the shared transformer **always** load. The wavelet frontend and the
122
+ patcher load **only when your montage is the route they were trained for** --
123
+ same channel count, same patch length -- and are skipped otherwise rather
124
+ than reshaped.
125
+
126
+ The usual sEMG benchmark montages -- armbands, forearm rings, NinaPro's electrodes -- are none of these routes, so a fresh frontend is the normal case. Their electrode names are outside the vocabulary too. Each unknown name gets an embedding row of its own, initialised from the pretrained `<unk>` row and trained as a montage adapter; `new_channel_rows=False` keeps the one shared `<unk>` vector instead, and channels are then told apart by the position encoding alone.
127
+
128
+ The three routes the shared transformer was pretrained across:
129
+
130
+ | route | channels | rate |
131
+ |---|---|---|
132
+ | `A24_2000` | 24 | 2000 Hz |
133
+ | `G64_2000` | 64 | 2000 Hz |
134
+ | `W16_2000` | 16 | 2000 Hz |
135
+
136
+ To fine-tune ON one of them exactly, take that route's frontend and patcher out
137
+ of the complete checkpoint:
138
+
139
+ ```bash
140
+ python scripts/export_eeg_pretrained_encoder.py \
141
+ --checkpoint pretrain_emg_c1_moe_epoch38.pth --route G64_2000 \
142
+ --output encoder_G64_2000.pth
143
+ ```
144
+
145
+ The exporter reads the modality from the checkpoint; its name is historical.
146
+ Omit `--route` and you get `emg_c1_encoder.pth` again. Both pretraining decoders are
147
+ dropped either way: fine-tuning against the pretext objective by accident is
148
+ the failure that prevents.
149
+
150
+ The last checkpoint (epoch 45) has no logged validation row, so it cannot be compared with the released one here. Which transfers better is a downstream question -- measure both.
151
+
152
+ ## The channel vocabulary travels with the weights
153
+
154
+ An embedding row means whatever channel held that id when the row was learned.
155
+ Every checkpoint here carries the vocabulary and its hash. Resolving
156
+ electrode names against a different vocabulary silently trains on relabelled
157
+ channels, which is why the hash is checked rather than assumed.
158
+
159
+ ## Architecture
160
+
161
+ ```
162
+ embed_dim 384 depth 6 heads 6
163
+ norm rmsnorm ffn swiglu qk_norm True
164
+ wavelet max_level 3, kernel 16, bank db2, db4, db7, sym4, sym5
165
+ channel id embedding, injected at the token site
166
+ mask frequency_guided, ratio 0.7
167
+ ```
168
+
169
+ ## Loading
170
+
171
+ `EMGC1Downstream` builds the frontend and patcher for your montage and loads this
172
+ file into everything else. Nothing here is route-bound, so the montage, the
173
+ window length and the patch length are yours to choose.
174
+
175
+ ```python
176
+ import torch
177
+ from physiowave.emg_c1.downstream import EMGC1Downstream
178
+
179
+ ENC = "emg_c1_encoder.pth"
180
+ ck = torch.load(ENC, map_location="cpu", weights_only=False)
181
+ mc = ck["model_config"] # the width the weights were trained at
182
+ CH = [f"ch{i}" for i in range(1, 9)] # an 8-electrode armband
183
+
184
+ model = EMGC1Downstream(
185
+ in_channels=len(CH), window_samples=2000, sampling_rate=2000.0,
186
+ patch_samples=250, num_classes=2, channel_names=CH,
187
+ embed_dim=mc["embed_dim"], depth=mc["depth"], num_heads=mc["num_heads"],
188
+ channel_embed_dim=mc.get("channel_embed_dim", 64),
189
+ channel_vocab_size=ck["channel_vocab_size"])
190
+
191
+ report = model.load_pretrained(ENC)
192
+ print(model.describe_transfer(report))
193
+ # loaded: transformer N, channel encoder N, gate yes, frontend 0, patcher 0
194
+
195
+ class Meta: # or your dataset's ChannelMeta
196
+ channel_names = CH
197
+ channel_mask = None
198
+
199
+ out = model(torch.randn(2, len(CH), 2000), Meta()) # {"logits": [2, 2]}
200
+ ```
201
+
202
+ `frontend 0, patcher 0` is the expected line, not a warning: those are the two
203
+ things your montage supplies.
204
+
205
+ **The frontend you build is not randomly initialised.** Its filters are seeded
206
+ from real wavelet families -- `db2, db4, db7, sym4, sym5` -- and learned from there during
207
+ fine-tuning. What a fresh frontend costs you is the adaptation those filters
208
+ underwent during pretraining, not the wavelet prior itself.
209
+
210
+ Names, not indices, are what this encoder and your data agree on. Pass real
211
+ electrode names and check `channel_vocab_sha256` -- `load_pretrained` refuses a
212
+ mismatch rather than silently training on relabelled channels.
213
+
214
+ Code: <https://github.com/ForeverBlue816/PhysioWave-T>
215
+
216
+ ---
217
+ Prepared by `scripts/prepare_hf_release.py` from `pretrain_emg_c1_moe`
218
+ on 2026-10-29.
config_resolved.yaml ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ data:
2
+ manifest_train: <path redacted for release>
3
+ manifest_val: <path redacted for release>
4
+ modality: emg
5
+ weights: temperature:0.5
6
+ model:
7
+ channel_embed_dim: 64
8
+ channel_encoding: id
9
+ channel_injection: token
10
+ channel_token_gate_init: 0.0
11
+ depth: 6
12
+ dropout: 0.0
13
+ embed_dim: 384
14
+ ffn: swiglu
15
+ fold_gamma: 0.1
16
+ fold_synthesis: 3
17
+ importance_ratio: 0.6
18
+ mask_ratio: 0.7
19
+ masking_strategy: frequency_guided
20
+ max_level: 3
21
+ mlp_ratio: 4.0
22
+ norm: rmsnorm
23
+ num_heads: 6
24
+ qk_norm: true
25
+ scale_fold: dynamic
26
+ use_separate_channel: true
27
+ wave_init_mode: pad
28
+ wave_kernel_size: 16
29
+ wavelet_names:
30
+ - db2
31
+ - db4
32
+ - db7
33
+ - sym4
34
+ - sym5
35
+ objective:
36
+ fold_kl: 0.001
37
+ mask_before_frontend: true
38
+ normalize_spec_target: true
39
+ raw_beta: 0.5
40
+ raw_weight: 0.5
41
+ spec_weight: 0.5
42
+ output_dir: <path redacted for release>
43
+ run_name: pretrain_emg_c1_moe
44
+ seed: 42
45
+ train:
46
+ batch_size_by_route:
47
+ A24_2000: 128
48
+ G64_2000: 48
49
+ W16_2000: 192
50
+ clip_grad_norm: 1.0
51
+ epochs: 60
52
+ grad_accumulation_steps: 1
53
+ lr: 0.0003
54
+ max_skipped_steps_per_epoch: 50
55
+ min_lr_ratio: 0.01
56
+ precision: bf16
57
+ skip_step_grad_norm_above: 100.0
58
+ steps_per_epoch: 1000
59
+ val_mask_seed: 1234
60
+ val_max_batches_per_dataset: 50
61
+ warmup_epochs: 2
62
+ weight_decay: 0.05
63
+ trainer: emg_c1_moe
emg_c1_encoder.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c51e7054cf3f7a964896d96f85b3b4fc9629becb404d522ca172083b471b4137
3
+ size 42713558
metrics_epoch.jsonl ADDED
The diff for this file is too large to render. See raw diff
 
pretrain_emg_c1_moe_best.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6de2fd4e1bbfd70702496c7ecd50c32fcbf2edd2a5bdfea709246f21c0a63b0b
3
+ size 48346302
pretrain_emg_c1_moe_best_macro.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:111cb1d804c9d4f0f4f4b848eb14a77026505a8e844d4f1d797ff0062df16e99
3
+ size 48348262
pretrain_emg_c1_moe_epoch38.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:cf635e671c9d8236f9e4934fb903fb0d68d7775e9cba03883402baa671a70e4d
3
+ size 48347250
pretrain_emg_c1_moe_final.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2065e8bee79f403426058e64b942da565834316e3f9ee6f1349ce2af8127b382
3
+ size 48346618