- Medical JEPA β Joint Embedding Predictive Architectures for Physiological Signals
Medical JEPA β Joint Embedding Predictive Architectures for Physiological Signals
A collection of self-supervised encoders pretrained on large-scale ICU, clinical, and wearable-sensor data. Each modality ships its own frozen, downstream-ready encoder. The waveform encoders (respiratory, PPG, ABP, ECG, EEG) follow an I-JEPA-style recipe (multi-block masking, EMA target encoder, smooth-L1 / cosine loss on normalized predictor and target embeddings). The motion encoders (accelerometer, 6-axis IMU) are trained with raw-vs-spectrogram / RelCon-style contrastive learning.
Modalities
| Directory | Signal | Window | Encoder | Output | Pretraining source |
|---|---|---|---|---|---|
respiratory/ |
RESP + Pleth (2 ch) | 8 s @ 125 Hz | RespJEPA (d=256) |
(B, 40, 256) |
MC-MED + MIMIC-III (resp + pleth) |
ppg/ |
Pleth (1 ch) | 8 s @ 125 Hz | PPGJEPA (ViT-B, d=768, 12 layers) |
(B, 40, 768) |
MC-MED + MIMIC-III-Ext-PPG |
abp/ |
ABP (1 ch) | 8 s @ 125 Hz | PPGJEPA (ViT-B, d=768, 12 layers) |
(B, 40, 768) |
MIMIC-III ABP |
ecg/ |
12-lead ECG (lead-invariant) | 10 s @ 250 Hz | ECG-JEPA (d=768, 12 layers) | (B, 768) + per-lead |
MIMIC-IV-ECG (continued from upstream ECG-JEPA) |
eeg/ |
up to 32 ch (channel-tokenised) | 30 s @ 255 Hz | EEG-JEPA Small (d=384, 8 layers, factorised channelΓtime attn) | (B, 32, 150, 384) |
P2018 + NCH + HBN + I-CARE + TDBRAIN (3.5M windows) |
accelerator/ |
3-axis accelerometer | 3 s @ 50 Hz | ResNet1D morphology encoder (d=256) | (B, 256) |
Raw/spectrogram contrastive, multi-source accelerometry |
imu6/ |
6-axis IMU (accel + gyro) | 3 s @ 50 Hz | ResNet1D morphology encoder (d=256) | (B, 256) |
RelCon/SimCLR contrastive, 18 IMU datasets (2.9M windows) |
The respiratory, PPG, and ABP encoders share a common signal contract:
- 1000 samples per window @ 125 Hz (8 s)
- 25-sample patches (200 ms) β 40 patches per window
- Per-patch embeddings
(B, 40, D); pool (mean / CLS / attention) for downstream heads.
Repo layout
respiratory/encoder_best.pt # context encoder + patch embed (downstream-ready)
respiratory/best.pt # full training state (context + target + predictor + optim)
ppg/encoder_best.pt
ppg/best.pt
abp/encoder_best.pt
abp/best.pt
ecg/best_model.pth # continued-pretrained ECG-JEPA on MIMIC-IV-ECG (lead-invariant)
ecg/ecg_jepa.py # upstream ECG-JEPA model (MIT, Sehun Kim 2024)
ecg/pos_encoding.py # 2D sincos positional encoding
ecg/lead_regimes.py # 1/3/6/12-lead view definitions + helpers
ecg/extract_embeddings.py # batch extraction: HDF5 dataset β HDF5 embeddings
ecg/LICENSE_upstream_MIT # MIT license for the vendored ECG-JEPA files
eeg/final.pt # EEG-JEPA Small full training checkpoint
eeg/latest.pt # EEG-JEPA Small latest training checkpoint
eeg/modeling_eeg.py # EEG-JEPA context encoder architecture + loaders
eeg/montage.py # 217-entry channel-name vocabulary (10-20 / EGI / EOG)
eeg/train.log # pretraining log
load_eeg_encoder.py # repo-root loader wrapper for the EEG encoder
accelerator/encoder.pt # 3-axis accelerometer morphology encoder
accelerator/modeling_accelerator.py
accelerator/config.json
accelerator/preprocessor_config.json
accelerator/example_encode.py
imu6/encoder_best.pt # 6-axis IMU compact/default encoder
imu6/encoder_wide.pt # 6-axis IMU wide/transient alternate encoder
imu6/modeling_imu6.py # IMU6 ResNet1D architecture + loaders
example_imu6_encode.py # minimal IMU6 inference example
models/resp_jepa_model.py # RespJEPA architecture
models/ppg_jepa_model.py # PPGJEPA architecture (used by both PPG and ABP)
load_encoders.py # loaders for resp / ppg / abp
load_ecg_encoder.py # loader + 1/3/6/12-lead helpers for ECG
requirements.txt
For most downstream uses (linear probe, fine-tune, embedding extraction), use the
encoder_best.pt files β they contain only the context encoder and patch embedder.
The best.pt files are full training-state checkpoints intended for resuming
pretraining.
Installation
pip install -r requirements.txt
huggingface-cli download Siddharth63/medical_JEPA --local-dir ./medical_JEPA
cd medical_JEPA
# smoke-test the resp / ppg / abp encoders
python load_encoders.py --repo_dir . --device cpu
Waveform encoders (respiratory / PPG / ABP)
import torch
from load_encoders import load_resp_encoder, load_ppg_encoder, load_abp_encoder
device = "cuda" if torch.cuda.is_available() else "cpu"
resp = load_resp_encoder("respiratory/encoder_best.pt", device)
ppg = load_ppg_encoder("ppg/encoder_best.pt", device)
abp = load_abp_encoder("abp/encoder_best.pt", device)
# 8 seconds of signal at 125 Hz
x_resp = torch.randn(4, 2, 1000, device=device) # (B, [RESP, Pleth], 1000)
x_ppg = torch.randn(4, 1, 1000, device=device) # (B, Pleth, 1000)
x_abp = torch.randn(4, 1, 1000, device=device) # (B, ABP_normalized, 1000)
with torch.no_grad():
e_resp = resp.encode(x_resp) # (4, 40, 256)
e_ppg = ppg.encode(x_ppg) # (4, 40, 768)
e_abp = abp.encode(x_abp) # (4, 40, 768)
# Attention maps per encoder layer (interpretability)
attn = ppg.get_attention_maps(x_ppg) # list of (4, n_heads, 40, 40)
Pooling for downstream tasks
emb_window = e_ppg.mean(dim=1) # (B, D) β simple mean-pool
# or add a learned attention-pool / CLS token on top
Input preprocessing
Match the preprocessing used during pretraining. All three expect float32
input on the same device as the model.
- Respiratory: bandpass RESP (0.05β1 Hz) and Pleth (0.5β8 Hz); z-score per window, per channel.
- PPG: bandpass 0.5β8 Hz; z-score per window.
- ABP: clip to a plausible range (40β200 mmHg); z-score per window. Do not subtract a global mean β the encoder was trained on per-window standardised input.
ECG-JEPA (continued pretraining)
ecg/best_model.pth is a continued-pretrained checkpoint of
ECG-JEPA (Kim et al., 2024) on
MIMIC-IV-ECG, trained in mixed input mode so a single encoder serves both
standard 12-lead hospital ECGs and reduced-lead (Lead-I, 3-lead, 6-lead) wearable
devices. The mixed regime makes the encoder lead-count invariant β the same
checkpoint runs in any of the four views below.
Signal contract
- Sampling rate: 250 Hz
- Window: 2500 samples (10 s) per lead, 12 leads
- Input shape:
(B, 12, 2500)β leads outside the chosen view are zeroed - Output:
global(B, 768)andper_lead(B, 12, 768)
import torch
from load_ecg_encoder import load_ecg_encoder, apply_input_mode, normalize_per_lead
device = "cuda" if torch.cuda.is_available() else "cpu"
model = load_ecg_encoder("ecg/best_model.pth", device)
# raw 12-lead ECG window: (B, 12, 2500) float32
sig = torch.randn(2, 12, 2500, device=device)
sig = normalize_per_lead(sig) # always per-lead z-score first
# Pick a view: "1lead", "3lead", "6lead", or "12lead"
sig_1lead = apply_input_mode(sig, "1lead") # zeros all leads except I
sig_12 = apply_input_mode(sig, "12lead") # no-op
with torch.no_grad():
g_1lead, pl_1lead = model(sig_1lead) # smartwatch / single-lead device
g_12, pl_12 = model(sig_12) # standard 12-lead hospital ECG
Use global for whole-window classification heads and per_lead for lead-aware
tasks. For inference at scale over a large HDF5 dataset, use
ecg/extract_embeddings.py β it streams (B, 12, 2500) records, writes global
and per_lead arrays to HDF5, and supports the same four views via --input_mode.
ecg/ecg_jepa.pyandecg/pos_encoding.pyare vendored verbatim from the upstream MIT-licensed ECG-JEPA repo (seeecg/LICENSE_upstream_MIT). They depend ontimmforDropPathandtrunc_normal_.
EEG-JEPA (Small)
EEG-JEPA Small uses factorised channel Γ time attention (d=384, 8 layers, 6 heads). The downstream-ready context encoder is ~19M params; the full training model (context encoder + EMA target + predictor) is ~40M. It was trained from scratch on 3.5M 30-s windows across five datasets: adult sleep PSG (P2018), pediatric sleep PSG (NCH), pediatric task EEG (HBN, EGI 129-ch downsampled), ICU continuous EEG (I-CARE, post-cardiac-arrest), and adult resting-state psychiatric EEG (TDBRAIN). Trained on an NVIDIA DGX Spark (Blackwell GB10).
The encoder is I-JEPA-style: each channel is patch-embedded by a shared
Conv1d(1, 384, kernel=51, stride=51), a learned per-channel embedding (217-entry
montage vocab) is added as the only spatial position, and 8 factorised blocks
alternate channel attention β patch attention β MLP. Temporal position lives only in
the predictor during pretraining, so the context encoder carries no temporal
positional embedding.
Signal contract
- Sampling rate: ~255 Hz (256 Hz sources are accepted; the loader fits length)
- Window: 7650 samples (30 s) per channel β 150 patches Γ 51-sample patches
- Channels: up to 32, channel-tokenised. Fewer channels β zero-pad the channel
dim and pass
ch_pad=Truefor padded slots. - Input shape:
(B, C, 7650)float32, per-channel z-scored - Output:
(B, C, 150, 384)β per-(channel, patch) tokens
import torch, numpy as np
from load_eeg_encoder import load_eeg_encoder, prepare_eeg_window
device = "cuda" if torch.cuda.is_available() else "cpu"
eeg = load_eeg_encoder("eeg", device) # loads eeg/final.pt (or eeg/latest.pt)
# Raw 30-s EEG window @ ~255 Hz, e.g. 8 channels
sig = np.random.randn(8, 7650).astype(np.float32)
names = ["Fp1", "Fp2", "F3", "F4", "C3", "C4", "O1", "O2"]
# standardises channel names, robust z-scores, fits length, pads to 32 channels
x, ch_idx, ch_pad = prepare_eeg_window(sig, names)
x, ch_idx, ch_pad = x.to(device), ch_idx.to(device), ch_pad.to(device)
with torch.no_grad():
tokens = eeg.encode(x, ch_idx, ch_pad) # (1, 32, 150, 384)
pooled = eeg.encode_pooled(x, ch_idx, ch_pad) # (1, 384) β masked mean over real ch + patches
Pooling for downstream heads:
tokens = eeg.encode(x, ch_idx, ch_pad) # (B, C, 150, 384)
window_emb = eeg.encode_pooled(x, ch_idx, ch_pad) # (B, 384) whole-window
per_channel = tokens.mean(dim=2) # (B, C, 384) per-channel
Input preprocessing
prepare_eeg_window() handles channel-name standardisation, robust z-scoring,
length-fitting, and channel padding for you. To match pretraining, feed it windows
that are already:
- Bandpass 0.5β45 Hz (the full EDFβparquet pipeline in the training repo uses
0.3β45 Hz with mains notch β see
eeg/modeling_eeg.pydocstring for provenance) - Resampled to ~255β256 Hz; the loader center-crops/pads the time axis to 7650
- Named per any dataset convention β
eeg/montage.pymaps names to the 10-20 vocab; unknown names fall back toUNK_IDX(still encoded, without channel-specific info)
Robust per-channel z-score (median + std, clipped to Β±5 std) is applied by
prepare_eeg_window unless you pass do_zscore=False.
Accelerometer morphology encoder (3-axis)
accelerator/encoder.pt maps a 3-second, 50 Hz, 3-axis accelerometry window into a
256-dimensional movement-morphology embedding. Treat it as a frozen short-window
encoder.
input: [B, 3, 150] # axis order: x, y, z
output: [B, 256] # L2-normalized
Quick start
import torch
from accelerator.modeling_accelerator import load_accelerator_encoder, encode_windows
model = load_accelerator_encoder("accelerator")
x = torch.randn(4, 3, 150) # 4 windows, 3 axes, 3 s @ 50 Hz
z = encode_windows(model, x)
print(z.shape) # torch.Size([4, 256])
print(z[0].norm()) # ~1.0
Input format & architecture
- 3-axis acceleration, 50 Hz, 3-second windows β 150 samples, shape
[B, 3, 150], axis orderx, y, z. Resample to 50 Hz if needed; keep units consistent across a dataset/deployment. - Architecture: 1D ResNet, in-channels 3, widths
[48, 96, 192, 384], layers[3, 4, 6, 3], kernel size 7, embedding dim 256, checkpoint keyencoder.
Validation summary
Validated as a frozen feature extractor across fall, freezing-of-gait (FoG), Parkinsonian task, rehabilitation, gait, and long-stream fall-history datasets. In several cases the strongest model was a hybrid of frozen encoder embedding + DSP features.
| Dataset / task | Main result |
|---|---|
| SisFall fall-vs-ADL, window level | encoder AUROC 0.940, balanced acc 0.861 |
| SisFall fall-vs-ADL, hybrid encoder+DSP | AUROC 0.952, balanced acc 0.874 |
| SisFall file/event level | hybrid file AUROC 0.997 |
| Daphnet FoG, trunk/lower-back | encoder AUROC 0.847 vs random encoder 0.717 |
| Daphnet FoG, ankle event level | hybrid AUROC 0.868, event recall 0.966 |
| Kaggle Parkinson FoG, 200-file tdcsfog run | DSP AUROC 0.907, encoder 0.898, hybrid 0.905, random 0.836 |
| Kaggle Parkinson FoG, ~0.5 FA/min | DSP recall 0.594, encoder 0.455, hybrid 0.549 |
| LTMM full fall-history association, 71 subjects | hybrid AUROC 0.703, balanced acc 0.653 |
| PD-BioStampRC 7-way task/body morphology | encoder balanced acc 0.481 vs DSP 0.435 vs random 0.325 |
| PD-BioStampRC upper-limb bradykinesia vs tremor | hybrid balanced acc 0.627 vs DSP 0.593 vs random 0.575 |
| UCI Physical Therapy, 8-way exercise recognition | hybrid balanced acc 0.563, encoder 0.506, random 0.456 |
| UCI Physical Therapy, execution quality | hybrid balanced acc 0.436, encoder 0.397, random 0.360, chance 0.333 |
| Clinical gait abnormal-vs-healthy, lower-back | encoder AUROC 0.913, balanced acc 0.830 |
Takeaways
- FoG is DSP-competitive/DSP-led (strong spectral structure); prefer DSP or hybrid features over encoder-only features for FoG-style alerting. The frozen encoder still clearly beats random controls.
- LTMM shows modest fall-history-associated morphology (hybrid best). This is association with prior fall history, not detection of timestamped falls. LTMM has no timestamped event labels, so it should not be used as event-recall validation, and a naive per-person Mahalanobis baseline produced too many false alerts (2.7β10.4 block alerts/day) to be product-ready.
Intended use & limitations
Good uses (research): fall/instability detection, FoG-like movement-breakdown detection, task/body-morphology and exercise-type morphology, template-vs-current movement comparison. Most useful combined with simple DSP features.
Not validated as: a standalone disease-diagnosis model; a Parkinson's/dystonia/healthy-control diagnostic classifier; a medication ON/OFF detector from arbitrary windows; a clinical-deterioration detector; a production-ready alerting model; or a personal baseline-shift detector. The last is an important future use case but requires continuous data with real timestamped event/shift labels and fixed false-alarm-rate reporting.
This encoder is accelerometer-only (3 input channels). Some validation datasets
include gyroscope channels, but they were not used here β for accel+gyro use the
separate imu6/ encoder below. Do not treat the two checkpoints as interchangeable.
Recommended downstream protocol
Use frozen encoder embeddings + DSP features, with subject- or file-held-out evaluation, a random-encoder control, and a permutation-label control. For alerting tasks, report event recall, latency, false alarms/min (or /hour), and recall at fixed false-alarm rates β do not rely on AUROC alone.
6-axis IMU morphology encoder (accel + gyro)
The imu6/ encoders map a 3-second, 50 Hz, 6-axis IMU window into a 256-dimensional
movement-morphology embedding.
input: [B, 6, 150] # channel order: accel_x, accel_y, accel_z, gyro_x, gyro_y, gyro_z
output: [B, 256] # L2-normalized
Two variants:
| Checkpoint | Widths | Layers | Recommended for |
|---|---|---|---|
imu6/encoder_best.pt |
[48, 96, 192, 384] |
[3,4,6,3] |
Default β general movement morphology |
imu6/encoder_wide.pt |
[64, 128, 256, 512] |
[3,4,6,3] |
Transient/fall-sensitive alternate |
Quick start
import torch
from imu6.modeling_imu6 import load_imu6_encoder, encode_imu6_windows
device = "cuda" if torch.cuda.is_available() else "cpu"
model = load_imu6_encoder("imu6", device=device) # compact/default
wide = load_imu6_encoder("imu6", device=device, variant="wide") # wide alternate
x = torch.randn(4, 6, 150) # [B, accel_xyz + gyro_xyz, 3 s @ 50 Hz]
with torch.no_grad():
z = encode_imu6_windows(model, x)
print(z.shape) # torch.Size([4, 256])
print(z[0].norm()) # ~1.0
Input preprocessing
Resample to 50 Hz, use 3-second windows shaped [B, 6, 150], channel order
accel_x/y/z then gyro_x/y/z, consistent accel/gyro units within a
dataset/deployment, float32.
Pretraining
RelCon / SimCLR-style contrastive learning on 2,901,383 clean 3-second windows
from 18 datasets ([6, 150]), with dataset-temperature sampling (Ξ±=0.5).
| Dataset | Windows | Dataset | Windows |
|---|---|---|---|
| extrasensory_top1m | 1,000,000 | opportunity | 57,842 |
| realworld | 461,755 | payload_imu | 55,385 |
| ltmm | 358,587 | usc_had | 52,734 |
| hhar | 246,893 | mhealth | 48,582 |
| comprehensive_imu | 224,872 | simul | 32,855 |
| pamap2 | 115,085 | phys_ex_2024 | 32,355 |
| ronin | 89,156 | motionsense | 27,360 |
| forth_trace | 76,605 | uci_har | 10,299 |
| mobifall | 9,555 | ||
| wisdm | 1,463 |
Training runs: compact = imu6_relcon_v7_clean_temp05 (temperature 0.2, batch
1024); wide = imu6_relcon_v7_clean_wide_temp01 (temperature 0.1, batch 1024).
Diagnostics & compact-vs-wide comparison
Self-supervised diagnostics (compact / wide): pos_sim_mean 0.9987 / 0.9978,
neg_sim_mean 0.2834 / 0.3190, top1_aug_retrieval 0.9925 / 0.9941,
emb_dim_std_mean 0.0527 / 0.0513, emb_norm_mean 1.0 / 1.0.
Frozen downstream comparison:
| Task | Compact | Wide |
|---|---|---|
| MobiFall fall-vs-ADL (subject-grouped) | bal-acc 0.9534, F1 0.9354, AUROC 0.9849 | bal-acc 0.9563, F1 0.9427, AUROC 0.9859 |
| RealWorld 8-class activity (proband-grp) | bal-acc 0.7296, F1 0.7286, AUROC 0.9564 | bal-acc 0.7129, F1 0.7112, AUROC 0.9534 |
The compact encoder is the recommended default. The wide encoder is a useful alternate for transient/fall-sensitive applications but does not dominate on broad activity morphology, so it should not automatically replace compact.
Checkpoint contents
encoder_best.pt (resp / ppg / abp):
{
"context_encoder": OrderedDict[str, Tensor], # transformer block weights
"patch_embed": OrderedDict[str, Tensor], # patch linear + pos_embed + channel embed
"config": dict, # dataclass fields used at training time
}
best.pt (resp / ppg / abp):
{
"model": state_dict, # full PPGJEPA / RespJEPA (context + target + predictor)
"optimizer": ...,
"scaler": ...,
"scheduler": ...,
"config": dict,
"args": dict,
"metrics": dict,
"epoch": int,
}
ecg/best_model.pth:
{
"encoder": OrderedDict[str, Tensor], # context encoder weights
"target_encoder": OrderedDict[str, Tensor], # EMA target encoder
"predictor": OrderedDict[str, Tensor],
"optimizer": ...,
"epoch": int,
"args": dict,
}
load_ecg_encoder consumes only the encoder key (per-lead pos_embed is
regenerated deterministically via sincos), so the same checkpoint serves any lead
view.
eeg/final.pt / eeg/latest.pt: {"model": OrderedDict, "step": int}, where
model holds the full EEG-JEPA state dict (context_encoder.*, target_encoder.*
EMA, and predictor.*). load_eeg_encoder reads only the context_encoder.* keys
and rebuilds the config from their shapes.
accelerator/encoder.pt and imu6/encoder_best.pt / encoder_wide.pt:
{"encoder": OrderedDict[str, Tensor]} ResNet1D weights (see config.json /
modeling_*.py for the architecture config).
Training details
Pretraining recipes (data prep, masking, EMA schedule, optimiser) live in the source training repos:
- Resp / PPG / ABP:
resp_jepa_dataset_pipeline.py,ppg_jepa_pipeline.py,abp_jepa_dataset.pyin the upstream training repo. - ECG: continued from
Kim et al., ECG-JEPA, 2024, using
the
mixedregime (40% Lead-I, 25% 3/6-lead, 35% 12-lead).
License & data use
Pretraining data are derived from MIMIC-III, MIMIC-IV-ECG, MC-MED, and the public
EEG/IMU datasets listed above. Users are responsible for complying with the
respective PhysioNet and dataset data-use agreements when fine-tuning or
redistributing downstream models. The vendored ECG-JEPA files are MIT-licensed
(ecg/LICENSE_upstream_MIT).
Clinical disclaimer
These encoders are provided for research and development use only. They are not medical devices and must not be used for diagnosis, treatment decisions, emergency response, or clinical monitoring without additional validation, regulatory review, and appropriate human oversight.