lumamba-rs
LuMamba — EEG foundation-model inference in Rust, on the RLX runtime.
Pure-Rust port of PulpBio/LuMamba
(BioFoundation). LuMamba keeps
LUNA's topology-invariant front-end — variable-channel EEG is compressed into a
fixed set of learned queries by cross-attention — and replaces LUNA's rotary
Transformer encoder with a stack of bidirectional Mamba (FEMBA) blocks that
run in linear time over the patch sequence.
This crate is a sibling of luna-rs; the LUNA front-end and the
reconstruction head are shared, the temporal encoder is the part that differs.
Install
[]
= "0.0.1" # RLX on CPU by default
Use the library via LuMambaEncoder (see API). For a GPU/accelerator
backend, disable defaults and pick a backend feature, e.g.
lumamba = { version = "0.0.1", default-features = false, features = ["metal"] }.
To get the CLI tools (infer, eval, safetensors_info, download_weights)
instead:
Architecture
EEG (B, C, T)
├─ PatchEmbed (3× Conv2d + GroupNorm + GELU) ┐ host (CPU) prepare
└─ FreqEmbed (FFT magnitude/phase → MLP) ├─ (FFT, conv, MLP)
→ + NeRF channel-location embedding ┘
→ CrossAttention channel unification (C → Q queries) ┐
→ reshape [B, S, Q·E] │ RLX compute graph
→ N × { LayerNorm → BiMamba(fwd ⊕ flip∘rev) → +res } │
→ reconstruction head → (B, C, T) ┘
Each Mamba block is the stock mamba_ssm.Mamba recurrence, run in forward and
reversed streams and combined by addition:
in_proj → [x | z]
x → causal depthwise conv1d → SiLU
x → x_proj → (dt, B, C); delta = softplus(dt_proj(dt) + bias)
y = selective_scan(x, delta, A = -exp(A_log), B, C) # RLX Op::SelectiveScan
y = y + D ⊙ x
y = y * SiLU(z)
out = out_proj(y)
The temporal recurrence uses RLX's first-class selective_scan op, the
depthwise conv a grouped conv2d, and everything else the standard graph ops —
so the whole model compiles to one CompiledGraph per input shape and runs on
any RLX backend.
LuMamba-tiny (the released checkpoints)
| value | |
|---|---|
| params | 4.19 M |
| patch_size | 40 |
queries Q / embed E |
6 / 64 → d_model 384 |
| Mamba blocks | 2 (bidirectional, add) |
d_inner / d_state / d_conv / dt_rank |
768 / 16 / 4 / 24 |
| cross-attn heads | 2 |
Quick start
# default build = RLX on CPU
# fetch a checkpoint (needs the hf-download feature)
# reconstruction on a synthetic epoch
# emit the encoder latent (foundation embedding [S, Q·E]) instead
Inspect a checkpoint's tensors:
Backends
Latest RLX (0.2.10). Pick a backend feature:
| feature | backend |
|---|---|
rlx-cpu (default) |
multi-threaded CPU |
rlx-metal |
Apple Metal |
rlx-mlx |
Apple MLX |
rlx-cuda / rlx-rocm / rlx-gpu / rlx-tpu |
platform GPU/accelerator |
rlx-blas-accelerate, rlx-apple-silicon |
BLAS / aggregate |
EDF/FIF input loading is behind --features edf (pulls exg + exg-luna);
without it, infer runs on a synthetic epoch.
HuggingFace weights (--features hf-download)
With the hf-download feature, --weights / --config (on infer and
eval) accept an hf://<owner>/<repo>/<file> spec that is fetched from the
Hub and cached under ~/.cache/huggingface — anything else is a local path.
Without the feature, local paths still work and an hf:// spec errors with a
message pointing at the flag.
# auto-download + run in one step
# or just fetch (prints the cached path); --list shows the released checkpoints
Cargo features
| feature | default | enables |
|---|---|---|
rlx-cpu |
✅ | multi-threaded CPU backend |
rlx-metal / rlx-mlx |
Apple Metal / MLX backends | |
rlx-gpu |
portable GPU via wgpu | |
rlx-cuda / rlx-rocm / rlx-tpu |
NVIDIA / AMD / Google TPU backends | |
rlx-blas-accelerate / rlx-apple-silicon |
BLAS / Apple aggregate | |
cpu metal mlx gpu cuda apple-silicon |
short aliases for the above | |
edf |
EDF/FIF input loading (exg + exg-luna) |
|
hf-download |
hf:// weight download + download_weights bin |
|
validation |
per-stage encoder debug graphs + validate_encoder bin |
Performance
LuMamba-tiny is small (4.2M params), so for a single epoch the GPU cold-start (one-time graph compilation) dominates — which made GPUs look slower than CPU at first glance. The compiled graph is cached per input shape, so that cost is paid once and amortizes over a recording's epochs. Measured warm steady-state per epoch (Apple M-series, 128-patch epoch):
| backend | host-prepare | graph-run | end-to-end (warm) |
|---|---|---|---|
| Metal | ~20 ms | ~16 ms | ~36 ms |
| MLX | ~20 ms | ~20 ms | ~39 ms |
| CPU | ~20 ms | ~45 ms | ~64 ms |
| wgpu | ~20 ms | ~150 ms | ~170 ms |
So Metal and MLX beat the CPU once warm. Two things make this work:
- Parallelized host token-prep (
rayon): the patch-embed conv + FFT + channel-MLP that run on the host every epoch dropped from ~50 ms → ~20 ms, bit-identical to the serial version. This is backend-independent, so it lifts every backend and lets the fast GPU graph dominate. - Per-shape compiled-graph cache so warm epochs skip recompilation.
Tools to measure it yourself:
# cold (first) vs warm (cached-graph) per-epoch time
# split host-prepare vs graph-run
LUMAMBA_TIMING=1
Backend guidance: on Apple Silicon prefer metal or mlx; the
portable wgpu path works and is numerically correct but has higher
per-dispatch overhead for a model this small. For one-shot CLI use, the cold
compile is unavoidable; for serving many epochs it's a one-time cost.
API
use LuMambaEncoder;
use Path;
let = load?;
// reconstruction → [C, T]
let out = enc.run_epoch?;
// foundation embedding → [S, Q·E]
let = enc.encode?;
Validation
The bidirectional-Mamba encoder is numerically validated against an
independent PyTorch reference (scripts/lumamba_ref.py) that loads the raw
checkpoint and implements the mamba_ssm selective_scan_ref recurrence
directly — no code shared with the RLX path. Feeding a byte-identical latent
into both (validate_encoder, built with --features validation) and comparing:
| stage | max abs error |
|---|---|
inner Mamba (in_proj, conv, selective_scan, D-skip, gate, out_proj) |
~3e-6 |
bidirectional wrapper (fwd + flip∘rev∘flip) |
~1e-6 |
| full 2-block encoder, S=128 | 1.5e-5 (rel 1.2e-6) |
i.e. agreement at the f32 numerical-noise floor. Reproduce with:
Downstream evaluation harness
The eval binary scores a fine-tuned LuMamba classifier on a labeled EEG
eval set and compares against the paper's published numbers. (The released HF
checkpoints are pretrained encoders, num_classes: 0 — they have no
classifier head, so eval reports that and asks for a fine-tuned checkpoint.)
# 1. build an eval set from preprocessed signals + labels (see the helper)
# (real use: call scripts/make_eval_set.build(...) with your epochs/labels)
# 2. score a fine-tuned checkpoint
task : TUAB
scored units: 276 (recording-level (mean prob))
balanced_accuracy = 0.8104
expected = 0.8099 (published)
Δ vs paper = +0.0005
What it does: per-epoch classification → optional recording-level mean-prob
aggregation → metric. Supported metrics match the paper: balanced_accuracy
(TUAB), auroc (binary + macro-OvR), aupr (APAVA). Classifier heads are
auto-detected from the checkpoint keys: LUNA-style (learned-agg
cross-attention, run in the RLX graph) and linear (BasicLinearClassifier,
mean-pool → Linear → GELU, host-side) are supported; the Mamba classifier head
errors with a clear message.
Eval-set format (lumamba::eval): a JSON manifest (task,
num_classes, metric, positive_class, aggregate, labels,
recording_ids, expected) + a signals.safetensors (signals [N,C,T],
chan_pos [C,3], optional channel_idx [C]). Build it with
scripts/make_eval_set.py.
The metrics (src/metrics.rs) are unit-tested against scikit-learn reference
values (AUROC, average-precision/AUPR, balanced accuracy); the scoring +
aggregation path is unit-tested in src/eval.rs.
Reproducibility note. The paper's TUAB/APAVA/TDBrain/TUSL/TUAR numbers come from fine-tuning a head on each labeled dataset (credentialed clinical EEG) and scoring its test split. The fine-tuned checkpoints are not released and the datasets are access-controlled, so the numbers can't be reproduced from the public artifacts alone. This harness is the ready-to-run scorer for when you have a fine-tuned checkpoint + the matching test split. The one published quantity reproducible from the released weights — the 4.1M parameter count — matches exactly (encoder params = 4,100,473).
Project layout
src/
lib.rs crate root + re-exports
config.rs ModelConfig / DataConfig
channel_*.rs channel vocabulary + montage positions (montages/*.elc)
metrics.rs balanced-accuracy / AUROC / AUPR (sklearn-matched)
eval.rs eval-set loader, aggregation, scoring
hf.rs hf:// weight resolution
rlx/
graph.rs the LuMamba compute graph (front-end → Mamba → heads)
weights.rs safetensors → parameter map
prepare.rs host-side patch / FFT / channel embedding (rayon)
encoder.rs LuMambaEncoder: compile-cache + run / encode / classify
io.rs epoch I/O (EDF/FIF behind `edf`)
bin/ infer, eval, safetensors_info, download_weights, validate_encoder
scripts/ lumamba_ref.py (parity oracle), make_eval_set.py
tests/ smoke.rs
Parity notes
The architecture is mapped exactly against ground truth:
- Weights: the safetensors state-dict keys/shapes drive
weights.rs(mamba_blocks.{i}.{mamba_fwd,mamba_rev}.*,norm_layers.{i}, the LUNA front-end and decoder head).A = -exp(A_log)is materialised at load time; the depthwise conv weight is reshaped to grouped-conv2dNCHW. - SSM op: RLX
selective_scan(x, delta, A, B, C)takes[b,s,h]layout and applies neither thesoftplus, theDskip, thezgate, norAnegation internally — all four are done explicitly here, matchingmamba_ssm. - Bidirectional combine:
mamba_fwd(x) + flip(mamba_rev(flip(x)))(BioFoundationMambaWrapper,bidirectional_strategy="add"); the sequence flip uses RLXOp::Reversealong the patch axis (batch-general). - Front-end: ported verbatim from
luna-rs(Python parity ≈ 2e-6). The cross-attentiontemparatureparameter is a fixed, unused1.0in the reference and is correctly ignored. softplusis composed aslog(1+exp(x))— exact in f32 across Mamba'sdtrange (confirmed by thedeltastage matching to ~3e-7).
tests/smoke.rs checks graph compilation and finite, correctly-shaped outputs;
the numerical parity above is the authoritative check (see Validation).
License
GPL-3.0-only. This crate links the RLX runtime (GPL-3.0-only), so the
combined work is GPL-3.0. See LICENSE.
The LuMamba model weights are CC-BY-ND-4.0 from
PulpBio/LuMamba — see that repo for
their terms (no redistribution of modified weights). The bundled MNE-Python ASA
montage .elc files retain their original (BSD-3-Clause) license.
Citation