File size: 17,435 Bytes
a015518
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
737ad15
 
a015518
737ad15
a015518
 
737ad15
a015518
 
 
 
 
 
 
 
 
737ad15
a015518
 
 
 
 
 
 
 
 
 
 
 
 
 
737ad15
 
 
 
 
 
 
a015518
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
737ad15
 
 
a015518
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
737ad15
 
a015518
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
"""
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
# --------------------------------------------------------------------------- #
@torch.inference_mode()
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


@torch.inference_mode()
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}


@torch.inference_mode()
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()