diff --git a/.gitignore b/.gitignore index b8440bf..6e65efa 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ __pycache__/ .pytest_cache/ .ruff_cache/ dist/ +# demo.ipynb: downloaded DOD-O records + cached embeddings (large; regenerated on run) +data/ # Seeded checkpoints / manifests (large binaries; fetched or seeded, not committed) checkpoints/ *.ckpt diff --git a/README.md b/README.md index fd42614..8b03094 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,10 @@ uv sync # or: pip install -e . ## Usage +> **Worked example:** [`demo.ipynb`](demo.ipynb) runs the full pipeline end-to-end — download the +> [Dreem Open Dataset (DOD-O)](https://arxiv.org/abs/1911.03221), generate Hypnos embeddings, and +> train a linear-probe sleep stager evaluated with subject-wise cross-validation. + Load an EDF, preprocess, and generate embeddings from the pre-trained Hypnos model: ```python diff --git a/demo.ipynb b/demo.ipynb new file mode 100644 index 0000000..8ff1627 --- /dev/null +++ b/demo.ipynb @@ -0,0 +1,477 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "c3ea4e78", + "metadata": {}, + "source": [ + "# Automatic sleep staging with **Hypnos** on the Dreem Open Datasets\n", + "\n", + "This notebook demonstrates using Hypnos embeddings for automated sleep staging:\n", + "\n", + "1. **Download** the [Dreem Open Dataset – DOD‑O](https://arxiv.org/abs/1911.03221) (patients with\n", + " obstructive sleep apnea, each scored by 5 sleep experts) — streaming one record at a time from\n", + " [Zenodo](https://zenodo.org/records/15900394), *idempotently*.\n", + "2. **Embed** each night with Hypnos and pool the 1 Hz vectors into one embedding per 30 s epoch.\n", + "3. **Sleep‑stage** with a **linear probe** (logistic regression on the *frozen* embeddings),\n", + " evaluated with **subject‑wise 5‑fold cross‑validation**." + ] + }, + { + "cell_type": "markdown", + "id": "ca80ebce", + "metadata": {}, + "source": [ + "## 1 · Install dependencies\n", + "\n", + "First, install a few extras for data streaming, ML and plotting. `remotezip` lets us pull individual `.h5` records out of the big Zenodo archive over HTTP range requests." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "90a31eb3", + "metadata": {}, + "outputs": [], + "source": [ + "%pip install -q h5py remotezip scikit-learn matplotlib tqdm\n", + "%pip install -q -e .\n", + "\n", + "import inspect\n", + "import hypnos\n", + "from hypnos.embedding import tokenize" + ] + }, + { + "cell_type": "markdown", + "id": "ffe1fcdc", + "metadata": {}, + "source": [ + "## 2 · Configuration\n", + "\n", + "DOD‑O lives in a single ~36 GB `dodo.zip` on Zenodo. `remotezip` reads the zip's central directory and fetches **only the members we ask for** via HTTP range requests. Each record is ~600 MB, so a fetch takes ~1–2 min; we therefore cache the tiny pooled\n", + "embeddings (`.npz`, a few MB/record) so re‑runs never touch the network or the model again.\n", + "\n", + "Set `MAX_RECORDS` small (e.g. `3`) for a quick pass, or `None` for all 55 DOD‑O records." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "37aede65", + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "import numpy as np\n", + "import torch\n", + "\n", + "REPO = Path.cwd()\n", + "DATA_DIR = REPO / \"data\" / \"dod-o\" # raw .h5 (only kept if KEEP_RAW_H5)\n", + "CACHE_DIR = REPO / \"data\" / \"cache\" # per-record pooled embeddings (.npz)\n", + "DATA_DIR.mkdir(parents=True, exist_ok=True)\n", + "CACHE_DIR.mkdir(parents=True, exist_ok=True)\n", + "\n", + "ZIP_URL = \"https://zenodo.org/api/records/15900394/files/dodo.zip/content\" # DOD-O archive\n", + "MAX_RECORDS = None # None = all 55; set e.g. 3 for a quick smoke run\n", + "KEEP_RAW_H5 = False # True keeps the ~600 MB/record .h5 on disk (else stream-only)\n", + "NOTCH_FREQ = 50.0 # DOD recorded in Europe -> 50 Hz powerline\n", + "MODEL_REPO = \"joncarter/hypnos\" # public pretrained Hypnos weights on the HF Hub\n", + "SEG_SECONDS = 1800 # tokenize each record in 30-min segments (bounds encoder memory on a full night). Lower to 900/600 if still tight.\n", + "EMBED_CHUNK = 1024 # transformer attention chunk; peak mem ~quadratic (~2 GB @1024, ~8 GB @2048).\n", + "\n", + "def pick_device() -> str:\n", + " if torch.cuda.is_available():\n", + " return \"cuda\"\n", + " if getattr(torch.backends, \"mps\", None) is not None and torch.backends.mps.is_available():\n", + " return \"mps\"\n", + " return \"cpu\"\n", + "\n", + "DEVICE = pick_device()\n", + "\n", + "def free_memory():\n", + " import gc\n", + " gc.collect()\n", + " if DEVICE == \"mps\":\n", + " torch.mps.empty_cache()\n", + " elif DEVICE == \"cuda\":\n", + " torch.cuda.empty_cache()\n", + "\n", + "print(\"device:\", DEVICE, \"| cache:\", CACHE_DIR)" + ] + }, + { + "cell_type": "markdown", + "id": "f9d3ab39", + "metadata": {}, + "source": [ + "## 3 · Open the remote archive\n", + "\n", + "We open the Zenodo zip once and list its record members. Nothing large is downloaded yet — only the\n", + "zip index. Individual records are streamed later, on demand." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0ba86625", + "metadata": {}, + "outputs": [], + "source": [ + "from remotezip import RemoteZip\n", + "\n", + "def is_record(name):\n", + " base = name.rsplit(\"/\", 1)[-1]\n", + " return name.endswith(\".h5\") and \"__MACOSX\" not in name and not base.startswith(\"._\")\n", + "\n", + "zf = RemoteZip(ZIP_URL) # central directory fetched lazily\n", + "members = sorted(n for n in zf.namelist() if is_record(n))\n", + "if MAX_RECORDS is not None:\n", + " members = members[:MAX_RECORDS]\n", + "print(f\"{len(members)} DOD-O record(s) selected (of {sum(is_record(n) for n in zf.namelist())} in archive)\")\n", + "print(\"example member:\", members[0])" + ] + }, + { + "cell_type": "markdown", + "id": "908e279c", + "metadata": {}, + "source": [ + "## 4 · Map DOD channels → Hypnos modalities\n", + "\n", + "Hypnos has 8 modalities; **DOD‑O** provides (all at 250 Hz):\n", + "\n", + "| Hypnos modality | DOD‑O dataset | present |\n", + "|-----------------|----------------------|---------|\n", + "| `eeg_c3` | `signals/eeg/C3_M2` | ✅ |\n", + "| `eeg_c4` | `signals/eeg/C4_M1` | ✅ |\n", + "| `eog_e1` | `signals/eog/EOG1` | ✅ |\n", + "| `eog_e2` | `signals/eog/EOG2` | ✅ |\n", + "| `emg_chin` | `signals/emg/EMG` | ✅ |\n", + "| `ecg` | `signals/emg/ECG` | ✅ |\n", + "| `resp_abd/thx` | — | ❌ (no respiratory belts) → skipped |\n", + "\n", + "DOD derivations are **already referenced** (e.g. `C3_M2`), so we skip Hypnos's EDF referencing and\n", + "feed the signals straight into the *same* preprocessing functions `preprocess_edf` uses —\n", + "`resample_signal` and `causal_preprocess_signal`. Each record's `description` attribute (JSON) gives\n", + "the per‑signal sampling rate; the expert labels are the top‑level `hypnogram` dataset (one code per\n", + "30 s epoch: `-1`=unscored, `0`=W, `1`=N1, `2`=N2, `3`=N3, `4`=REM)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "eaece6e0", + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "from hypnos.data.preprocessing import resample_signal, causal_preprocess_signal\n", + "\n", + "# Hypnos modality name -> DOD-O h5 dataset path.\n", + "CHANNEL_MAP = {\n", + " \"eeg_c3\": \"signals/eeg/C3_M2\",\n", + " \"eeg_c4\": \"signals/eeg/C4_M1\",\n", + " \"eog_e1\": \"signals/eog/EOG1\",\n", + " \"eog_e2\": \"signals/eog/EOG2\",\n", + " \"emg_chin\": \"signals/emg/EMG\",\n", + " \"ecg\": \"signals/emg/ECG\",\n", + "}\n", + "\n", + "def read_description(f) -> list:\n", + " '''Parse the JSON `description` attr -> list of {path, fs, ...} dicts.'''\n", + " d = f.attrs.get(\"description\")\n", + " if isinstance(d, bytes):\n", + " d = d.decode()\n", + " return json.loads(d) if d else []\n", + "\n", + "def h5_to_signals(f, meta, notch_freq=NOTCH_FREQ) -> dict:\n", + " '''Open DOD-O h5 file -> Hypnos `{modality_name: preprocessed 1-D float32}` dict.'''\n", + " specs = {m.name: m for m in meta.modalities}\n", + " fs_by_path = {e[\"path\"]: int(e[\"fs\"]) for e in read_description(f)}\n", + " signals = {}\n", + " for mod_name, path in CHANNEL_MAP.items():\n", + " if path not in f or mod_name not in specs or path not in fs_by_path:\n", + " continue # absent -> skipped modality\n", + " m = specs[mod_name]\n", + " raw = np.asarray(f[path][:], dtype=np.float64).reshape(-1)\n", + " sig = resample_signal(raw, fs_by_path[path], m.sample_rate)\n", + " proc, _, _ = causal_preprocess_signal(\n", + " sig, fs=m.sample_rate, modality=m.preprocess_modality, notch_freq=notch_freq,\n", + " )\n", + " signals[mod_name] = np.asarray(proc, dtype=np.float32)\n", + " return signals" + ] + }, + { + "cell_type": "markdown", + "id": "79c8de26", + "metadata": {}, + "source": [ + "## 5 · Load the Hypnos model" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ff50e7d3", + "metadata": {}, + "outputs": [], + "source": [ + "from hypnos.embedding import load_model, tokenize, embed\n", + "\n", + "model, tokenizers, meta = load_model(MODEL_REPO, device=DEVICE)\n", + "print(\"Supported modalities:\", [m.name for m in meta.modalities])" + ] + }, + { + "cell_type": "markdown", + "id": "c7f5efc8", + "metadata": {}, + "source": [ + "## 6 · Generate embeddings & pool to 30 s epochs (cached)\n", + "\n", + "For each record: stream the `.h5` (or reuse a local copy / cache) → build the `signals` dict →\n", + "`tokenize` → `embed` (per‑modality `[T, 768]` at 1 Hz) → **mean over modalities** → **mean‑pool each\n", + "30 s** → `[n_epochs, 768]`, aligned to the hypnogram (unscored `-1` epochs dropped). Each record's\n", + "`(X, y)` is cached to `.npz`, so the network + model only run once per record. First pass is\n", + "~1–2 min/record." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "47845014", + "metadata": {}, + "outputs": [], + "source": [ + "import io, time, h5py\n", + "from tqdm.auto import tqdm\n", + "from remotezip import RemoteZip\n", + "\n", + "STAGE_NAMES = [\"W\", \"N1\", \"N2\", \"N3\", \"REM\"] # hypnogram codes 0..4\n", + "HDF5_MAGIC = b\"\\x89HDF\\r\\n\\x1a\\n\"\n", + "\n", + "def fetch_member_bytes(member, retries=6):\n", + " '''Stream one record from Zenodo, retrying on dropped connections. Verifies the full\n", + " member arrived (size + HDF5 signature) and reopens a fresh RemoteZip between attempts,\n", + " so a transient IncompleteRead/ProtocolError doesn't abort the whole run.'''\n", + " global zf\n", + " expected = zf.getinfo(member).file_size\n", + " last = None\n", + " for attempt in range(retries):\n", + " try:\n", + " data = zf.read(member)\n", + " if len(data) == expected and data[:8] == HDF5_MAGIC:\n", + " return data\n", + " last = f\"incomplete ({len(data)}/{expected} bytes)\"\n", + " except Exception as e:\n", + " last = f\"{type(e).__name__}: {e}\"\n", + " wait = min(30, 2 ** attempt)\n", + " print(f\" fetch {member} failed [{last}] — retry {attempt + 1}/{retries} in {wait}s\")\n", + " time.sleep(wait)\n", + " zf = RemoteZip(ZIP_URL) # fresh connection for the next attempt\n", + " raise RuntimeError(f\"could not fetch {member} after {retries} attempts: {last}\")\n", + "\n", + "def record_features(member, verbose=False):\n", + " '''Return (X_epochs [n,768] float32, y [n] int) for one record, cached on disk.'''\n", + " rid = member.split(\"/\")[-1][:-3] # strip 'dodo/' and '.h5'\n", + " cache = CACHE_DIR / f\"{rid}.npz\"\n", + " if cache.exists():\n", + " d = np.load(cache)\n", + " return d[\"X\"], d[\"y\"]\n", + "\n", + " raw_path = DATA_DIR / f\"{rid}.h5\"\n", + " if raw_path.exists():\n", + " data = raw_path.read_bytes()\n", + " else:\n", + " data = fetch_member_bytes(member) # resilient ranged fetch (retries on drop)\n", + " if KEEP_RAW_H5:\n", + " raw_path.write_bytes(data)\n", + "\n", + " if data[:8] != HDF5_MAGIC: # guard: not a valid HDF5 file -> skip\n", + " print(\"skipped (not an HDF5 record):\", member)\n", + " return None\n", + "\n", + " with h5py.File(io.BytesIO(data), \"r\") as f:\n", + " if verbose:\n", + " print(\" signals:\", [e[\"path\"] for e in read_description(f)])\n", + " signals = h5_to_signals(f, meta)\n", + " hyp = np.asarray(f[\"hypnogram\"][:], dtype=int).reshape(-1)\n", + " del data # free the ~600 MB raw bytes\n", + " if verbose:\n", + " print(\" -> Hypnos modalities used:\", sorted(signals))\n", + " if not signals:\n", + " return None\n", + "\n", + " # tokenize() chunks internally (SEG_SECONDS windows, bit-exact); embed() chunks the transformer.\n", + " tokens, mask, ch_ids = tokenize(tokenizers, meta, signals, device=DEVICE, chunk_seconds=SEG_SECONDS)\n", + " emb = embed(model, tokens, mask, ch_ids, meta, device=DEVICE, chunk_tokens=EMBED_CHUNK) # {mod: [T, 768]}\n", + " fused = np.mean(list(emb.values()), axis=0).astype(np.float32) # [T, 768]\n", + "\n", + " n_ep = fused.shape[0] // 30\n", + " epochs = fused[: n_ep * 30].reshape(n_ep, 30, -1).mean(axis=1) # [n_ep, 768]\n", + " n = min(len(epochs), len(hyp))\n", + " epochs, hyp = epochs[:n], hyp[:n]\n", + " keep = hyp >= 0 # drop unscored epochs\n", + " X, y = epochs[keep], hyp[keep]\n", + " np.savez_compressed(cache, X=X, y=y)\n", + " return X, y\n", + "\n", + "X_list, y_list, groups = [], [], []\n", + "for i, member in enumerate(tqdm(members, desc=\"records\")):\n", + " out = record_features(member, verbose=(i == 0))\n", + " free_memory() # release cache between records\n", + " if out is None:\n", + " continue\n", + " Xr, yr = out\n", + " X_list.append(Xr); y_list.append(yr); groups += [i] * len(yr)\n", + "\n", + "X = np.concatenate(X_list); y = np.concatenate(y_list); groups = np.asarray(groups)\n", + "print(\"\\nX:\", X.shape, \"| y:\", y.shape, \"| records:\", len(set(groups)))\n", + "print(\"epochs per stage:\", {STAGE_NAMES[k]: int((y == k).sum()) for k in range(5)})" + ] + }, + { + "cell_type": "markdown", + "id": "de5e998c", + "metadata": {}, + "source": [ + "## 7 · Linear probe with subject‑wise cross‑validation\n", + "\n", + "A standardizer + multinomial logistic regression — a **linear probe** on the frozen embeddings — evaluated with `GroupKFold` so every fold's test subjects are unseen in training (avoids the optimistic leakage of splitting epochs within a night)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "40db0c25", + "metadata": {}, + "outputs": [], + "source": [ + "from sklearn.pipeline import make_pipeline\n", + "from sklearn.preprocessing import StandardScaler\n", + "from sklearn.linear_model import LogisticRegression\n", + "from sklearn.model_selection import GroupKFold\n", + "from sklearn.metrics import balanced_accuracy_score, f1_score, cohen_kappa_score, confusion_matrix\n", + "\n", + "n_splits = min(5, len(set(groups)))\n", + "gkf = GroupKFold(n_splits=n_splits)\n", + "y_pred = np.empty_like(y)\n", + "\n", + "print(f\"subject-wise {n_splits}-fold CV\\n\")\n", + "for fold, (tr, te) in enumerate(gkf.split(X, y, groups)):\n", + " clf = make_pipeline(\n", + " StandardScaler(),\n", + " LogisticRegression(max_iter=5000),\n", + " )\n", + " clf.fit(X[tr], y[tr])\n", + " y_pred[te] = clf.predict(X[te])\n", + " print(f\" fold {fold}: bal_acc={balanced_accuracy_score(y[te], y_pred[te]):.3f} \"\n", + " f\"macroF1={f1_score(y[te], y_pred[te], average='macro'):.3f} \"\n", + " f\"kappa={cohen_kappa_score(y[te], y_pred[te]):.3f}\")" + ] + }, + { + "cell_type": "markdown", + "id": "075c4e4d", + "metadata": {}, + "source": [ + "## 8 · Overall metrics" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "289b0d72", + "metadata": {}, + "outputs": [], + "source": [ + "print(\"=== overall (held-out predictions, pooled across folds) ===\")\n", + "print(f\"balanced accuracy : {balanced_accuracy_score(y, y_pred):.3f}\")\n", + "print(f\"macro F1 : {f1_score(y, y_pred, average='macro'):.3f}\")\n", + "print(f\"Cohen's kappa : {cohen_kappa_score(y, y_pred):.3f}\")" + ] + }, + { + "cell_type": "markdown", + "id": "420f6ccb", + "metadata": {}, + "source": [ + "## 9 · Confusion matrix & hypnogram" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c3824337", + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "\n", + "cm = confusion_matrix(y, y_pred, labels=range(5)).astype(float)\n", + "cmn = cm / cm.sum(axis=1, keepdims=True)\n", + "\n", + "fig, ax = plt.subplots(figsize=(5, 4.2))\n", + "im = ax.imshow(cmn, cmap=\"Blues\", vmin=0, vmax=1)\n", + "ax.set_xticks(range(5)); ax.set_xticklabels(STAGE_NAMES)\n", + "ax.set_yticks(range(5)); ax.set_yticklabels(STAGE_NAMES)\n", + "ax.set_xlabel(\"predicted\"); ax.set_ylabel(\"expert consensus\")\n", + "for i in range(5):\n", + " for j in range(5):\n", + " ax.text(j, i, f\"{cmn[i, j]:.2f}\", ha=\"center\", va=\"center\",\n", + " color=\"white\" if cmn[i, j] > 0.5 else \"black\", fontsize=8)\n", + "ax.set_title(\"Sleep-stage confusion (row-normalized)\")\n", + "fig.colorbar(im, fraction=0.046, pad=0.04)\n", + "plt.tight_layout(); plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c006e9f7", + "metadata": {}, + "outputs": [], + "source": [ + "# Predicted vs expert hypnogram for one held-out record.\n", + "rec = sorted(set(groups))[-1]\n", + "m_rec = groups == rec\n", + "order = [0, 4, 1, 2, 3] # W, REM, N1, N2, N3 (classic top->bottom)\n", + "pos = {s: i for i, s in enumerate(order)}\n", + "t = np.arange(m_rec.sum()) / 2 / 60 # 30 s epochs -> hours\n", + "\n", + "fig, ax = plt.subplots(figsize=(12, 3))\n", + "ax.step(t, [pos[s] for s in y[m_rec]], where=\"post\", lw=1.3, label=\"expert\")\n", + "ax.step(t, [pos[s] for s in y_pred[m_rec]], where=\"post\", lw=1.0, alpha=0.7, label=\"predicted\")\n", + "ax.set_yticks(range(5)); ax.set_yticklabels([STAGE_NAMES[s] for s in order])\n", + "ax.invert_yaxis() # W at top, N3 at bottom\n", + "ax.set_xlabel(\"time (hours)\")\n", + "ax.set_title(f\"Hypnogram — held-out record {members[rec].split('/')[-1][:8]}\")\n", + "ax.legend(loc=\"upper right\")\n", + "plt.tight_layout(); plt.show()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "hypnos", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pyproject.toml b/pyproject.toml index f8a60eb..d8e6b57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "hypnos" -version = "0.2.0" +version = "0.3.0" description = "Minimal inference library for Hypnos: load an EDF, preprocess, and generate sleep embeddings from pretrained checkpoints." readme = "README.md" requires-python = ">=3.11" @@ -55,3 +55,6 @@ packages = ["src/hypnos"] [tool.ruff] line-length = 120 +# Notebooks follow their own conventions (in-cell imports, `a; b` one-liners, +# trailing `plt.show()`); lint/format only the library and tests. +extend-exclude = ["*.ipynb"] diff --git a/src/hypnos/embedding/__init__.py b/src/hypnos/embedding/__init__.py index b63b432..6992c8d 100644 --- a/src/hypnos/embedding/__init__.py +++ b/src/hypnos/embedding/__init__.py @@ -82,6 +82,7 @@ def embed_edf( notch_freq: float = 50.0, causal: bool = True, chunk_tokens: int | None = None, + tokenize_chunk_seconds: int | None = 1800, autocast_dtype: torch.dtype | None = None, channel_aliases: Mapping[str, Sequence[str]] | None = None, ) -> dict[str, np.ndarray]: @@ -101,7 +102,9 @@ def embed_edf( """ model, tokenizers, meta = load_model(model_repo_or_path, device=device, dtype=dtype) signals = preprocess_edf(edf_path, meta, notch_freq=notch_freq, causal=causal, channel_aliases=channel_aliases) - tokens, modality_mask, channel_ids = tokenize(tokenizers, meta, signals, device=device) + tokens, modality_mask, channel_ids = tokenize( + tokenizers, meta, signals, device=device, chunk_seconds=tokenize_chunk_seconds + ) return embed( model, tokens, diff --git a/src/hypnos/embedding/pipeline.py b/src/hypnos/embedding/pipeline.py index 2777ced..ab91b87 100644 --- a/src/hypnos/embedding/pipeline.py +++ b/src/hypnos/embedding/pipeline.py @@ -93,12 +93,60 @@ def preprocess_edf( return signals +def _tokenize_signal( + tokenizer, + signal: np.ndarray, + samples_per_token: int, + chunk_tokens: int | None, + context_tokens: int, + device: str | torch.device, +) -> torch.Tensor: + """Tokenize one 1-D signal to ``(n_tokens, K)`` int64 on CPU. + + The SEANet encoder allocates activations proportional to the *whole* input length, so a + full-night recording tokenized in one pass costs tens of GB. To bound that, long signals + are tokenized in windows of ``chunk_tokens`` tokens. The tokenizer has a bounded receptive + field (a few conv layers plus the encoder transformer's causal window), reaching mostly + backwards but with a small forward leak from conv padding, so each window is padded with + ``context_tokens`` tokens of real signal on **both** sides whose outputs are then + discarded. As long as ``context_tokens`` exceeds the receptive field in each direction the + retained tokens are **identical** to a single-pass tokenization. Peak memory is set by + ``chunk_tokens + 2 * context_tokens``, not by the recording length. + + ``chunk_tokens=None`` (or a signal already shorter than one chunk) tokenizes in a single + pass, preserving the original behaviour for short recordings. + """ + n_tokens = len(signal) // samples_per_token + + def _run(sig: np.ndarray) -> torch.Tensor: + x = torch.from_numpy(np.ascontiguousarray(sig, dtype=np.float32)).view(1, 1, -1).to(device) + return tokenizer.tokenize(x)[0].to("cpu", torch.long) # (n, K) + + if chunk_tokens is None or n_tokens <= chunk_tokens: + return _run(signal) + + blocks: list[torch.Tensor] = [] + start = 0 + while start < n_tokens: + stop = min(start + chunk_tokens, n_tokens) + left = min(context_tokens, start) # real context available before this window + right = min(context_tokens, n_tokens - stop) # ... and after it + win = signal[(start - left) * samples_per_token : (stop + right) * samples_per_token] + toks = _run(win) # (left + (stop - start) + right, K) + blocks.append(toks[left : left + (stop - start)].clone()) + start = stop + return torch.cat(blocks, dim=0) # (n_tokens, K) + + @torch.inference_mode() def tokenize( tokenizers: dict, metadata: ModelMetadata, signals: dict[str, np.ndarray], device: str | torch.device = "cpu", + *, + chunk_seconds: int | None = 1800, + context_seconds: int = 128, ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: """Tokenize per-modality signals and assemble the token tensor. @@ -112,6 +160,14 @@ def tokenize( All present modalities are truncated to the common minimum token count (matches the train-time join). + + ``chunk_seconds`` bounds tokenizer memory on long recordings: each modality is tokenized + in ``chunk_seconds``-long windows, each padded with ``context_seconds`` of real signal on + both sides that is discarded after tokenizing. Because the tokenizer has a small receptive + field, the result is identical to a single pass as long as ``context_seconds`` exceeds it + (~96 s backwards, ~1 s forwards for the released tokenizers; 128 s default leaves margin). + Set ``chunk_seconds=None`` to tokenize the whole signal at once (higher peak memory; only + sensible for short recordings). """ # Tokenize present modalities to (n_m, K_m) int64. per_modality_tokens: dict[str, torch.Tensor] = {} @@ -119,9 +175,19 @@ def tokenize( sig = signals.get(m.name) if sig is None: continue - x = torch.from_numpy(np.asarray(sig, dtype=np.float32)).view(1, 1, -1).to(device) - tok = tokenizers[m.name].tokenize(x) # (1, n_m, K_m) - per_modality_tokens[m.name] = tok[0].to("cpu", torch.long) + samples_per_token = getattr( + tokenizers[m.name], "samples_per_token", int(round(m.sample_rate * m.token_duration_sec)) + ) + chunk_tokens = None if chunk_seconds is None else max(1, int(chunk_seconds / m.token_duration_sec)) + context_tokens = max(0, int(context_seconds / m.token_duration_sec)) + per_modality_tokens[m.name] = _tokenize_signal( + tokenizers[m.name], + np.asarray(sig, dtype=np.float32), + samples_per_token, + chunk_tokens, + context_tokens, + device, + ) if not per_modality_tokens: raise ValueError("No modalities present in the recording; cannot tokenize.") diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 3f7d55d..59cc547 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -270,7 +270,31 @@ def test_end_to_end_pipeline(): print("[B] absent-modality path OK; per-modality keys", sorted(per2)) +def test_tokenize_chunked_matches_single_pass(): + """Chunked (windowed + both-side context) tokenization is bit-identical to one pass.""" + from hypnos.embedding import load_model, preprocess_edf, tokenize + + with tempfile.TemporaryDirectory() as d: + bundle_path = Path(d) / "bundle.safetensors" + build_bundle(bundle_path) + edf = Path(d) / "rec.edf" + make_synthetic_edf(edf, duration_sec=200) # long enough for many chunks at chunk_seconds=20 + + _lm, toks, meta = load_model(bundle_path, device="cpu") + signals = preprocess_edf(str(edf), meta, notch_freq=60.0, causal=True) + + single, m_s, c_s = tokenize(toks, meta, signals, device="cpu", chunk_seconds=None) + chunked, m_c, c_c = tokenize(toks, meta, signals, device="cpu", chunk_seconds=20, context_seconds=16) + + assert single.shape[1] > 20, "signal must span multiple chunks to exercise the seams" + assert single.shape == chunked.shape, (single.shape, chunked.shape) + assert torch.equal(m_s, m_c) and torch.equal(c_s, c_c) + assert torch.equal(single, chunked), f"{(single != chunked).sum().item()} token(s) differ" + print("[F] chunked tokenize == single-pass OK", tuple(chunked.shape)) + + if __name__ == "__main__": test_temporal_only_matches_forward() test_end_to_end_pipeline() + test_tokenize_chunked_matches_single_pass() print("\nALL CHECKS PASSED")