Download inference.py from gundo0102/SleepMaMi: direct link, hf CLI and curl.
- Browser
- Download file 17.4 kB
-
https://huggingface.co/gundo0102/SleepMaMi/resolve/main/inference.py
- Command line
-
hf download hf://gundo0102/SleepMaMi/inference.py
-
curl -L -o inference.py https://huggingface.co/gundo0102/SleepMaMi/resolve/main/inference.py
17.4 kB
| """ | |
| inference.py — Inference utilities for SleepMaMi pretrained / finetuned checkpoints. | |
| This module provides a thin, dependency-light wrapper around the two | |
| classification backbones used in the paper and exposes three entry points: | |
| 1. extract_micro_features(...) | |
| -> ClassificationMultiConv1d (epoch-level Micro-Encoder). | |
| Returns the fused micro feature for each 30-s epoch. | |
| 2. extract_macro_micro_features(...) | |
| -> ClassificationMultiConv1dMamba (full-night model). | |
| Returns both the full-night Macro feature (sequence encoder output) | |
| and the per-epoch Micro feature. | |
| 3. predict_sleep_stages(...) | |
| -> ClassificationMultiConv1dMamba + classification head. | |
| Returns the predicted sleep stage for every valid epoch of the night. | |
| Inputs are read from per-modality `.npy` files holding the full night as a | |
| sequence of 30-s epochs, shape (N, sig_len) where sig_len = sig_len_target * | |
| TARGET_RESAMPLE (3000 for the default 30 s @ 100 Hz). | |
| Example | |
| ------- | |
| # --checkpoint defaults to checkpoints/<task>.safetensors | |
| python inference.py --task sleep_staging --npy_dir /path/to/night | |
| python inference.py --task micro --npy_dir /path/to/night --num_epochs 20 | |
| python inference.py --task macro_micro \ | |
| --checkpoint checkpoints/macro_micro.safetensors \ | |
| --npy_dir /path/to/night | |
| """ | |
| import argparse | |
| import contextlib | |
| import os | |
| import numpy as np | |
| import torch | |
| from safetensors.torch import load_file as load_safetensors | |
| import models | |
| from configs.global_cfg import TARGET_RESAMPLE | |
| from models.utils import flip_padded_sequences | |
| # Default 6-modality setup used throughout the paper. | |
| DEFAULT_MODALITIES = ["EEG", "EOG", "ECG", "RESP", "EMG", "SaO2"] | |
| # --------------------------------------------------------------------------- # | |
| # Checkpoint loading | |
| # --------------------------------------------------------------------------- # | |
| def _load_state_dict(ckpt_path, map_location="cpu"): | |
| """Return the raw parameter dict from a released `.safetensors` file | |
| (weights only, keys without the `model.` prefix) or a Lightning / plain | |
| `.ckpt` (e.g. one produced by `finetune.py`).""" | |
| if ckpt_path.endswith(".safetensors"): | |
| return load_safetensors(ckpt_path, device=str(map_location)) | |
| # Lightning checkpoints pickle non-tensor objects (hparams, numpy arrays), | |
| # so full unpickling is required — only load .ckpt files you trust. | |
| ckpt = torch.load(ckpt_path, map_location=map_location, weights_only=False) | |
| if isinstance(ckpt, dict) and "state_dict" in ckpt: | |
| return ckpt["state_dict"] | |
| return ckpt | |
| def load_weights(model, ckpt_path, device="cpu", verbose=True): | |
| """ | |
| Load weights into `model`, handling the `model.` prefix that PyTorch | |
| Lightning adds (the LightningModule stores the backbone as `self.model`). | |
| This mirrors `ConvMAE1d_Finetune.load_pretrained` in `finetune.py`: | |
| position embeddings are skipped (they are buffers regenerated at build | |
| time) and matching is attempted both with and without the `model.` prefix. | |
| """ | |
| src = _load_state_dict(ckpt_path, map_location=device) | |
| tgt_keys = model.state_dict().keys() | |
| new_state_dict = {} | |
| for k in tgt_keys: | |
| if "pos_embed" in k: | |
| continue | |
| if k in src: | |
| new_state_dict[k] = src[k] | |
| elif f"model.{k}" in src: | |
| new_state_dict[k] = src[f"model.{k}"] | |
| msg = model.load_state_dict(new_state_dict, strict=False) | |
| if verbose: | |
| missing = [k for k in msg.missing_keys if "pos_embed" not in k] | |
| print(f"[load_weights] Loaded {len(new_state_dict)} tensors from {ckpt_path}") | |
| if missing: | |
| print(f"[load_weights] Missing (left at init): {missing}") | |
| if msg.unexpected_keys: | |
| print(f"[load_weights] Unexpected (ignored): {msg.unexpected_keys}") | |
| return model | |
| # --------------------------------------------------------------------------- # | |
| # Model construction | |
| # --------------------------------------------------------------------------- # | |
| def build_micro_model(modalities=DEFAULT_MODALITIES, sig_len_target=30, | |
| patch_size=50, num_experts=4, num_experts_per=2, | |
| num_classes=5, use_revin=True): | |
| """ClassificationMultiConv1d — epoch-level Micro-Encoder (`classification_multi`).""" | |
| return models.classification_multi( | |
| modalities=modalities, | |
| sig_len=sig_len_target * TARGET_RESAMPLE, | |
| patch_size=patch_size, | |
| num_experts=num_experts, | |
| num_experts_per=num_experts_per, | |
| num_classes=num_classes, | |
| use_shared=True, | |
| use_moe=True, | |
| use_revin=use_revin, | |
| ft_task="sleep_staging", | |
| ) | |
| def build_full_night_model(modalities=DEFAULT_MODALITIES, sig_len_target=30, | |
| patch_size=50, num_experts=4, num_experts_per=2, | |
| num_classes=5, use_revin=True, | |
| use_micro=False, use_lstm=False, use_transformer=False, | |
| bidi=True): | |
| """ClassificationMultiConv1dMamba — full-night model (`classification_multi_full_night`).""" | |
| return models.classification_multi_full_night( | |
| modalities=modalities, | |
| sig_len=sig_len_target * TARGET_RESAMPLE, | |
| patch_size=patch_size, | |
| num_experts=num_experts, | |
| num_experts_per=num_experts_per, | |
| num_classes=num_classes, | |
| use_shared=True, | |
| use_moe=True, | |
| use_revin=use_revin, | |
| ft_task="sleep_staging", | |
| use_micro=use_micro, | |
| use_lstm=use_lstm, | |
| use_transformer=use_transformer, | |
| bidi=False if use_transformer else bidi, | |
| ) | |
| # --------------------------------------------------------------------------- # | |
| # Input preparation (reuses the training/eval data pipeline) | |
| # --------------------------------------------------------------------------- # | |
| def _to_device(inputs, device, dtype): | |
| return {m: v.to(device=device, dtype=dtype) for m, v in inputs.items()} | |
| def _autocast(model, dtype=torch.bfloat16): | |
| """Autocast context for the forward pass. | |
| FlashAttention (used in the encoder/fuser) only supports fp16/bf16, so a | |
| pure float32 forward raises an error. Running under CUDA autocast keeps the | |
| weights in their loaded dtype while computing attention in `dtype`, exactly | |
| reproducing the `bf16-mixed` precision the checkpoints were trained with. | |
| On CPU (no FlashAttention) autocast is a no-op. | |
| """ | |
| device = next(model.parameters()).device | |
| if device.type == "cuda": | |
| return torch.autocast(device_type="cuda", dtype=dtype) | |
| return contextlib.nullcontext() | |
| # --------------------------------------------------------------------------- # | |
| # Input preparation from .npy files | |
| # --------------------------------------------------------------------------- # | |
| # Each modality is stored as a single .npy array holding the full night as a | |
| # sequence of 30-s epochs, shape (N, sig_len) where sig_len = sig_len_target * | |
| # TARGET_RESAMPLE (3000 for the default 30 s @ 100 Hz). A flat (N*sig_len,) | |
| # array is also accepted and reshaped automatically. | |
| def _read_npy_arrays(npy_paths, modalities, sig_len): | |
| """Load and shape per-modality arrays to (N, sig_len). Validates epoch counts.""" | |
| arrays, n_epochs = {}, None | |
| for m in modalities: | |
| if m not in npy_paths: | |
| raise ValueError(f"No .npy path provided for modality '{m}'") | |
| arr = np.load(npy_paths[m]).astype(np.float32) | |
| if arr.ndim == 1 or arr.shape[-1] != sig_len: | |
| # if m == 'SaO2': | |
| # arr = arr.reshape(-1, 30) | |
| # else: | |
| # arr = arr.reshape(-1, sig_len) | |
| arr = arr.reshape(-1, sig_len) | |
| if n_epochs is None: | |
| n_epochs = arr.shape[0] | |
| elif arr.shape[0] != n_epochs: | |
| raise ValueError( | |
| f"Modality '{m}' has {arr.shape[0]} epochs, expected {n_epochs} " | |
| f"(all modalities must share the same number of epochs).") | |
| arrays[m] = arr | |
| return arrays, n_epochs | |
| def _normalize_epoch(arr, modality): | |
| """Per-modality normalization matching `data/data_loader.py`. | |
| - SaO2: scaled to [0, 1] via (clip(x, 0.7, 1.0) - 0.7) / 0.3. Percent-valued | |
| inputs (max > 1.5) are divided by 100 first. The model downsamples SaO2 | |
| internally, so it must be provided at the signal rate (sig_len samples). | |
| - others: per-epoch z-normalization. With `use_revin=True` (default) the | |
| model re-applies this internally, so the step is a harmless no-op there; | |
| it matters when RevIN is disabled. | |
| """ | |
| if modality == "SaO2": | |
| x = arr.copy() | |
| if np.nanmax(x) > 1.5: # percent -> fraction | |
| x = x / 100.0 | |
| return (np.clip(x, 0.7, 1.0) - 0.7) / 0.3 | |
| mu = arr.mean(axis=1, keepdims=True) | |
| std = arr.std(axis=1, keepdims=True) | |
| std = np.where(std < 1e-8, 1.0, std) | |
| return (arr - mu) / std | |
| def load_npy_epoch_inputs(npy_paths, modalities=DEFAULT_MODALITIES, sig_len_target=30, | |
| normalize=True, num_epochs=None, device="cpu", | |
| dtype=torch.float32): | |
| """Epoch-level inputs for the Micro-Encoder, built from .npy files. | |
| Returns dict[str, Tensor] each of shape (N, sig_len, 1). | |
| """ | |
| sig_len = sig_len_target * TARGET_RESAMPLE | |
| arrays, n = _read_npy_arrays(npy_paths, modalities, sig_len) | |
| if num_epochs is not None: | |
| n = min(num_epochs, n) | |
| inputs = {} | |
| for m in modalities: | |
| a = arrays[m][:n] | |
| if normalize: | |
| a = _normalize_epoch(a, m) | |
| inputs[m] = torch.from_numpy(np.ascontiguousarray(a)).unsqueeze(-1) | |
| return _to_device(inputs, device, dtype) | |
| def load_npy_full_night_inputs(npy_paths, modalities=DEFAULT_MODALITIES, sig_len_target=30, | |
| normalize=True, device="cpu", dtype=torch.float32): | |
| """Full-night inputs for the Mamba model, built from .npy files. | |
| Returns | |
| ------- | |
| inputs : dict[str, Tensor] each of shape (1, N*sig_len, 1) | |
| mask : BoolTensor (1, N) all True — a single .npy night has no padding. | |
| """ | |
| sig_len = sig_len_target * TARGET_RESAMPLE | |
| arrays, n = _read_npy_arrays(npy_paths, modalities, sig_len) | |
| inputs = {} | |
| for m in modalities: | |
| a = arrays[m] | |
| if normalize: | |
| a = _normalize_epoch(a, m) | |
| inputs[m] = torch.from_numpy(np.ascontiguousarray(a).reshape(1, n * sig_len, 1)) | |
| mask = torch.ones(1, n, dtype=torch.bool, device=device) | |
| return _to_device(inputs, device, dtype), mask | |
| # --------------------------------------------------------------------------- # | |
| # Inference entry points | |
| # --------------------------------------------------------------------------- # | |
| def extract_micro_features(model, inputs, amp_dtype=torch.bfloat16): | |
| """ | |
| (1) Micro feature from ClassificationMultiConv1d. | |
| Returns a tensor of shape (E, embed_dim[-1] * num_modalities) — one fused | |
| micro embedding per 30-s epoch. | |
| """ | |
| model.eval() | |
| with _autocast(model, amp_dtype): | |
| feats = model.forward_features(inputs) | |
| return feats | |
| def extract_macro_micro_features(model, inputs, mask, amp_dtype=torch.bfloat16): | |
| """ | |
| (2) Macro + Micro features from ClassificationMultiConv1dMamba. | |
| Returns | |
| ------- | |
| dict with | |
| 'macro' : (1, num_epochs, macro_dim) full-night sequence feature | |
| (for bidirectional models the forward and flipped-backward | |
| streams are concatenated, exactly as in finetuning). | |
| 'micro' : (1, num_epochs, micro_dim) per-epoch micro feature. | |
| """ | |
| model.eval() | |
| with _autocast(model, amp_dtype): | |
| macro, micro = model.forward_features(inputs, mask=mask) | |
| if not model.use_micro and getattr(model, "bidi", False): | |
| xf, xr = macro | |
| xr = flip_padded_sequences(xr, mask) | |
| macro = torch.cat([xf, xr], dim=-1) | |
| return {"macro": macro, "micro": micro} | |
| def predict_sleep_stages(model, inputs, mask, return_logits=False, amp_dtype=torch.bfloat16): | |
| """ | |
| (3) Sleep-staging inference (Macro + Micro features + classification head). | |
| Returns | |
| ------- | |
| pred : LongTensor (num_valid_epochs,) predicted stage per valid epoch. | |
| logits : (optional) FloatTensor (num_valid_epochs, num_classes). | |
| """ | |
| model.eval() | |
| with _autocast(model, amp_dtype): | |
| logits = model(inputs, mask=mask) # (1, num_epochs, num_classes) | |
| valid = mask[0].bool() | |
| logits = logits[0][valid] # (num_valid_epochs, num_classes) | |
| pred = logits.argmax(dim=-1) | |
| if return_logits: | |
| return pred, logits | |
| return pred | |
| # --------------------------------------------------------------------------- # | |
| # CLI demo | |
| # --------------------------------------------------------------------------- # | |
| def _parse_args(): | |
| p = argparse.ArgumentParser(description="SleepMaMi inference demo") | |
| p.add_argument("--task", required=True, | |
| choices=["micro", "macro_micro", "sleep_staging"], | |
| help="Which inference to run.") | |
| p.add_argument("--checkpoint", default=None, | |
| help="Path to a .safetensors (released weights) or Lightning .ckpt file. " | |
| "Default: checkpoints/<task>.safetensors") | |
| # Per-modality .npy files (full night, shape (N, sig_len)). | |
| p.add_argument("--npy_dir", default=None, | |
| help="Directory containing one '<MODALITY>.npy' per modality.") | |
| p.add_argument("--npy", nargs="+", default=None, metavar="MOD=PATH", | |
| help="Explicit per-modality .npy paths, e.g. EEG=eeg.npy EOG=eog.npy") | |
| p.add_argument("--no_normalize", dest="normalize", action="store_false", | |
| help="Skip built-in normalization (use if .npy is already preprocessed).") | |
| p.set_defaults(normalize=True) | |
| p.add_argument("--modalities", nargs="+", default=DEFAULT_MODALITIES) | |
| p.add_argument("--sig_len_target", type=int, default=30) | |
| p.add_argument("--patch_size", type=int, default=50) | |
| p.add_argument("--num_classes", type=int, default=5) | |
| p.add_argument("--num_experts", type=int, default=4) | |
| p.add_argument("--num_experts_per", type=int, default=2) | |
| p.add_argument("--no_revin", action="store_true", help="Disable RevIN normalization.") | |
| p.add_argument("--num_epochs", type=int, default=None, | |
| help="(micro only) number of epochs to embed; default = all.") | |
| p.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu") | |
| p.add_argument("--dtype", default="float32", choices=["float32", "bfloat16", "float16"]) | |
| return p.parse_args() | |
| def _resolve_npy_paths(args): | |
| """Build a {modality: path} dict from --npy / --npy_dir.""" | |
| if args.npy: | |
| paths = {} | |
| for item in args.npy: | |
| if "=" not in item: | |
| raise ValueError(f"--npy entries must be MOD=PATH, got '{item}'") | |
| k, v = item.split("=", 1) | |
| paths[k] = v | |
| return paths | |
| if args.npy_dir: | |
| return {m: os.path.join(args.npy_dir, f"{m}.npy") for m in args.modalities} | |
| raise SystemExit("Provide an input source: --npy_dir or --npy MOD=PATH ...") | |
| def main(): | |
| args = _parse_args() | |
| if args.checkpoint is None: | |
| args.checkpoint = os.path.join("checkpoints", f"{args.task}.safetensors") | |
| device = args.device | |
| dtype = {"float32": torch.float32, "bfloat16": torch.bfloat16, | |
| "float16": torch.float16}[args.dtype] | |
| npy_paths = _resolve_npy_paths(args) | |
| common = dict(modalities=args.modalities, sig_len_target=args.sig_len_target, | |
| patch_size=args.patch_size, num_experts=args.num_experts, | |
| num_experts_per=args.num_experts_per, num_classes=args.num_classes, | |
| use_revin=not args.no_revin) | |
| if args.task == "micro": | |
| model = build_micro_model(**common).to(device=device, dtype=dtype) | |
| load_weights(model, args.checkpoint, device=device) | |
| inputs = load_npy_epoch_inputs( | |
| npy_paths, modalities=args.modalities, sig_len_target=args.sig_len_target, | |
| normalize=args.normalize, num_epochs=args.num_epochs, | |
| device=device, dtype=dtype) | |
| feats = extract_micro_features(model, inputs) | |
| print(f"Micro features: {tuple(feats.shape)} (epochs x feature_dim)") | |
| print(feats.float().cpu().numpy()) | |
| else: # macro_micro or sleep_staging — both use the full-night model | |
| model = build_full_night_model(**common).to(device=device, dtype=dtype) | |
| load_weights(model, args.checkpoint, device=device) | |
| inputs, mask = load_npy_full_night_inputs( | |
| npy_paths, modalities=args.modalities, sig_len_target=args.sig_len_target, | |
| normalize=args.normalize, device=device, dtype=dtype) | |
| if args.task == "macro_micro": | |
| out = extract_macro_micro_features(model, inputs, mask) | |
| print(f"Macro feature: {tuple(out['macro'].shape)}") | |
| print(f"Micro feature: {tuple(out['micro'].shape)}") | |
| else: | |
| pred = predict_sleep_stages(model, inputs, mask).cpu().numpy() | |
| print(f"Predicted stages ({len(pred)} epochs): {pred}") | |
| if __name__ == "__main__": | |
| main() | |