File size: 10,888 Bytes
b058c1f 9af72f1 b058c1f 9af72f1 b058c1f 9af72f1 b058c1f fb25950 b058c1f | 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 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 | ---
license: apache-2.0
tags:
- physiowave
- physiowave-t
- foundation-model
- emg
- semg
- electromyography
- biosignal
- self-supervised
- masked-autoencoder
- pretrained
library_name: pytorch
---
# PhysioWave-T-sEMG: multi-route pretrained sEMG encoder
One of the three **PhysioWave-T** encoders ([EEG](https://huggingface.co/ForeverBlue/EEG), [ECG](https://huggingface.co/ForeverBlue/ECG), sEMG); the [RALF fusion model](https://huggingface.co/ForeverBlue/RALF-SeizeIT2) combines them. Code: <https://github.com/ForeverBlue816/PhysioWave>.
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.
Trained for 45 epochs of 60 configured; validation was logged for the first 39, which is what `metrics_epoch.jsonl` holds
(the last logged validation at step 39000) on a mixture of public
sEMG corpora, 12.1 M parameters. The files
here are built around `pretrain_emg_c1_moe_epoch38.pth`, epoch 39.
## Objective
```
0.5*L_spec + 0.5*L_raw + 0.001*L_foldKL
```
Two heads at equal weight. One predicts folded wavelet patches, the other
predicts the preprocessed sEMG waveform of the same masked patches.
Masking is applied BEFORE the wavelet frontend (True),
at ratio 0.7, and the spec target is per-patch
normalised (True).
## Training curves
Pretraining ran for 45 epochs on 16 GPUs over the six corpora, with validation logged for the first 39. The two reconstruction terms behave differently: the folded-wavelet loss is lowest early (epoch 7, which is what `best.pth` selects on the total), while the raw-waveform loss keeps falling to the end. The released main file is the epoch-38 checkpoint, the one with the lowest raw-waveform loss, and the one every downstream sEMG result of the paper was obtained with.

*Train and validation per epoch: the total loss, the two reconstruction terms (folded wavelet patch, raw waveform) and the masked spectral correlation.*
## Validation, at the released checkpoint (epoch 39)
| metric | value | what 1.0 would mean |
|---|---|---|
| total loss | 0.2580 | — |
| spec MSE | 0.3207 | — |
| spec NMSE | 0.321 | predicting zero |
| spec Pearson r | 0.822 | perfect |
| raw SmoothL1 | 0.1953 | — |
| raw NMSE | 0.381 | predicting zero |
| raw Pearson r | 0.782 | perfect |
| route-macro total | 0.2315 | — |
NMSE is dimensionless: MSE(prediction, target) / MSE(0, target), so 1.0 is
what predicting zero scores on that head's own target. The two losses are NOT
comparable to each other -- one is an MSE on normalised wavelet coefficients,
the other a SmoothL1 on the window-normalised waveform. The columns that compare the
heads are the correlations and the NMSEs.
The route-macro row weights the three routes equally. The plain rows weight by
sample count, and validation is dominated by the largest corpora, so a model
specialising toward the smaller ones moves the two rows in opposite
directions.
## Files
### Which file do you want
| your situation | download |
|---|---|
| fine-tune on any montage | `emg_c1_encoder.pth` |
| your montage IS one of the three routes below | `pretrain_emg_c1_moe_epoch38.pth`, then cut the route out (one command, below) |
| continue pretraining, or take the model apart yourself | `pretrain_emg_c1_moe_epoch38.pth` |
**`pretrain_emg_c1_moe_epoch38.pth` is the complete model** -- all three wavelet frontends, the
patchers, both reconstruction decoders. Nothing is held back; only the
optimiser state was stripped, which no downstream use needs. Take it if you
want to keep or strip pieces yourself.
`emg_c1_encoder.pth` is the same weights minus everything route-bound and
pretraining-only. It exists because it cannot be loaded wrong.
| file | what it is |
|---|---|
| `emg_c1_encoder.pth` | encoder alone: channel embedding, gate, transformer |
| `pretrain_emg_c1_moe_epoch38.pth` | complete model, the reported checkpoint (epoch 39) |
| `pretrain_emg_c1_moe_final.pth` | complete model, last epoch (epoch 45) |
| `pretrain_emg_c1_moe_best.pth` | complete model, lowest validation total loss (epoch 7) |
| `pretrain_emg_c1_moe_best_macro.pth` | complete model, lowest route-macro total (epoch 25) |
| `metrics_epoch.jsonl` | the full training curve |
| `config_resolved.yaml` | every resolved hyperparameter |
### Why several checkpoints
Each is the minimum of a different validation metric, and they are published
together so a downstream comparison can ask which criterion transfers instead
of assuming the one that names `best.pth`. Reconstruction loss cannot answer
that question; a downstream task can.
`best.pth` is selected on the TOTAL loss -- the loss that was trained (epoch
6 counted from 0). It is not the best spec loss, and not
the best raw loss.
**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.
`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.
`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.
### What is in the complete model
| component | parameters | share | |
|---|---|---|---|
| shared transformer | 10.63 M | 88.2% | encoder, always transfers |
| channel-name embedding | 0.02 M | 0.1% | always transfers |
| channel projection | 0.02 M | 0.2% | always transfers |
| wavelet frontends (all three routes) | 0.79 M | 6.6% | route-bound; downstream usually builds its own |
| patchers (one rate) | 0.10 M | 0.8% | route-bound |
| spec decoder | 0.24 M | 2.0% | pretraining only |
| raw decoder | 0.24 M | 2.0% | pretraining only |
| **total** | **12.05 M** | 100% | |
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.
### What transfers, and what does not
`downstream.py` splits a checkpoint into two sets. The channel embedding, its
gate and the shared transformer **always** load. The wavelet frontend and the
patcher load **only when your montage is the route they were trained for** --
same channel count, same patch length -- and are skipped otherwise rather
than reshaped.
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.
The three routes the shared transformer was pretrained across:
| route | channels | rate |
|---|---|---|
| `A24_2000` | 24 | 2000 Hz |
| `G64_2000` | 64 | 2000 Hz |
| `W16_2000` | 16 | 2000 Hz |
To fine-tune ON one of them exactly, take that route's frontend and patcher out
of the complete checkpoint:
```bash
python scripts/export_eeg_pretrained_encoder.py \
--checkpoint pretrain_emg_c1_moe_epoch38.pth --route G64_2000 \
--output encoder_G64_2000.pth
```
The exporter reads the modality from the checkpoint; its name is historical.
Omit `--route` and you get `emg_c1_encoder.pth` again. Both pretraining decoders are
dropped either way: fine-tuning against the pretext objective by accident is
the failure that prevents.
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.
## The channel vocabulary travels with the weights
An embedding row means whatever channel held that id when the row was learned.
Every checkpoint here carries the vocabulary and its hash. Resolving
electrode names against a different vocabulary silently trains on relabelled
channels, which is why the hash is checked rather than assumed.
## Architecture
```
embed_dim 384 depth 6 heads 6
norm rmsnorm ffn swiglu qk_norm True
wavelet max_level 3, kernel 16, bank db2, db4, db7, sym4, sym5
channel id embedding, injected at the token site
mask frequency_guided, ratio 0.7
```
## Loading
`EMGC1Downstream` builds the frontend and patcher for your montage and loads this
file into everything else. Nothing here is route-bound, so the montage, the
window length and the patch length are yours to choose.
```python
import torch
from physiowave.emg_c1.downstream import EMGC1Downstream
ENC = "emg_c1_encoder.pth"
ck = torch.load(ENC, map_location="cpu", weights_only=False)
mc = ck["model_config"] # the width the weights were trained at
CH = [f"ch{i}" for i in range(1, 9)] # an 8-electrode armband
model = EMGC1Downstream(
in_channels=len(CH), window_samples=2000, sampling_rate=2000.0,
patch_samples=250, num_classes=2, channel_names=CH,
embed_dim=mc["embed_dim"], depth=mc["depth"], num_heads=mc["num_heads"],
channel_embed_dim=mc.get("channel_embed_dim", 64),
channel_vocab_size=ck["channel_vocab_size"])
report = model.load_pretrained(ENC)
print(model.describe_transfer(report))
# loaded: transformer N, channel encoder N, gate yes, frontend 0, patcher 0
class Meta: # or your dataset's ChannelMeta
channel_names = CH
channel_mask = None
out = model(torch.randn(2, len(CH), 2000), Meta()) # {"logits": [2, 2]}
```
`frontend 0, patcher 0` is the expected line, not a warning: those are the two
things your montage supplies.
**The frontend you build is not randomly initialised.** Its filters are seeded
from real wavelet families -- `db2, db4, db7, sym4, sym5` -- and learned from there during
fine-tuning. What a fresh frontend costs you is the adaptation those filters
underwent during pretraining, not the wavelet prior itself.
Names, not indices, are what this encoder and your data agree on. Pass real
electrode names and check `channel_vocab_sha256` -- `load_pretrained` refuses a
mismatch rather than silently training on relabelled channels.
Code: <https://github.com/ForeverBlue816/PhysioWave>
---
Prepared by `scripts/prepare_hf_release.py` from `pretrain_emg_c1_moe`
on 2026-10-29.
|