File size: 12,668 Bytes
9a5439c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Frozen Nicheformer spot embeddings of the 5 RNA sets (each Visium spot / virtual spot = one pseudo-cell).

    python 43_rnafm_nicheformer.py [set ...]          (default: all of rna_sets.SETS)

Model     : theislab/Nicheformer (HF, MIT), revision 0ba4ba162f9ad3d6c8b713761954c31be2fa69ed, file model.safetensors
            (12-layer post-norm nn.TransformerEncoder, d=512, 16 heads, FFN 1024, learnable PE, context 1500).
            Downloaded to ext/nicheformer/hf/; the HF remote code (modeling_/tokenization_nicheformer.py) was read but is
            NOT executed: the forward pass is re-implemented below (same nn.TransformerEncoderLayer stack) and loaded
            with a strict state-dict load (the unused template `encoder_layer.*`, the MLM head and the `pos` buffer
            are the only checkpoint keys skipped, and that set is asserted).
            Technology means: ext/nicheformer/repo/data/model_means/dissociated_mean_script.npy from the official
            GitHub repo theislab/nicheformer (commit 485cadbc5caa15119adfd54228f8a8af835fcabc); gene order =
            model.h5ad var (20,310 Ensembl ids, token = column index + 30, == vocab.json).
Inputs    : rna_sets.load(set) raw(-like) counts + HGNC symbols. Symbols -> Ensembl via cache/external/
            dlpfc_genes_full.csv (10x GRCh38 reference), fallback Geneformer gene_name_id_dict_gc104M.pkl; genes not in
            the Nicheformer vocabulary are dropped (several symbols hitting one Ensembl id are summed), i.e. the spot is
            aligned to model.h5ad like the official `ad.concat([model, adata])` step.
Tokenising: official NicheformerDataset / tokenization notebooks: counts -> size-factor normalise to 1e4 (over the
            aligned genes) -> divide by the technology mean (nan->0, rounded half-up, 0 -> 1, as in the notebooks) ->
            rank the non-zero genes by descending value (stable sort; official uses numba quicksort, so only ties may
            be ordered differently) -> token = gene index + 30, zero-padded to 1500. Context tokens prepended in the
            official order [specie, assay, modality] and the sequence truncated to context_length 1500 (=> at most
            1497 gene tokens). Pad 0 -> 1 and attention padding mask = (token == 1), as in models/_utils.complete_masking
            with p = 0.
Context   : Visium was not in the pretraining corpus (SpatialCorpus-110M = dissociated 10x/Smart-seq + MERFISH/CosMx/
            Xenium/ISS) and has no technology token (the "visium": 9 entry in the get_embeddings notebook is a typo
            for Xenium = 9 in every tokenisation notebook and the HF vocab). Chosen: specie = human (5),
            modality = spatial (4), assay = 10x 3' v3 (11) -- Visium v1/v2 is a whole-transcriptome poly(A) 3'
            sequencing assay, so the closest documented technology is 10x 3' v3, and its matching technology mean is
            the dissociated (10x) one (the image-based means only cover their 300-1000-gene panels). A sensitivity
            run with modality = dissociated (3) is logged for hest only (not saved).
Readout   : official Nicheformer.get_embeddings(layer=-1, with_context=False): last-layer output, drop the 3 context
            positions, mean over the remaining 1497 positions (this INCLUDES padding positions, exactly as the
            official code; only spots with < 1497 detected vocabulary genes are affected and the fraction is logged).
            D = 512, not L2-normalised. bf16 autocast inference.
Output    : rna_sets.save("nicheformer", set, E) -> cache/rnafm/nicheformer_<set>.npy (n_spot, 512) float16.
Sanity    : finite, mean pairwise cosine of 200 random spots, and (hest) organ agreement of cosine 10-NN vs base rate.
"""
import hashlib
import json
import os
import pickle
import sys
import time

import anndata as ad
import numpy as np
import pandas as pd
import scipy.sparse as sp
import torch
import torch.nn as nn
from safetensors.torch import load_file

from common import CACHE, ROOT
from rna_sets import SETS, load, save

MODEL = "nicheformer"
REPO, REV = "theislab/Nicheformer", "0ba4ba162f9ad3d6c8b713761954c31be2fa69ed"
GH_COMMIT = "485cadbc5caa15119adfd54228f8a8af835fcabc"
EXT = os.path.join(ROOT, "ext", "nicheformer")
HF_DIR, GH_DIR = os.path.join(EXT, "hf"), os.path.join(EXT, "repo")
PAD, AUX, CTX_LEN = 1, 30, 1500
SPECIE_HUMAN, ASSAY_10X3V3, MOD_SPATIAL, MOD_DISSOC = 5, 11, 4, 3
dev = "cuda"
torch.manual_seed(0)


def sha(p):
    return hashlib.sha256(open(p, "rb").read()).hexdigest()[:16]


def ensure_download():
    if not os.path.exists(os.path.join(HF_DIR, "model.safetensors")):
        from huggingface_hub import snapshot_download
        snapshot_download(REPO, revision=REV, local_dir=HF_DIR)
    assert os.path.exists(os.path.join(GH_DIR, "data", "model_means", "dissociated_mean_script.npy")), \
        f"git clone https://github.com/theislab/nicheformer {GH_DIR} (commit {GH_COMMIT})"


class Nicheformer(nn.Module):
    """Re-implementation of NicheformerModel (HF modeling_nicheformer.py / official models/_nicheformer.py)."""

    def __init__(self, cfg):
        super().__init__()
        self.embeddings = nn.Embedding(cfg["n_tokens"] + 5, cfg["dim_model"], padding_idx=1)
        self.positional_embedding = nn.Embedding(cfg["context_length"], cfg["dim_model"])
        layer = nn.TransformerEncoderLayer(cfg["dim_model"], cfg["nheads"], cfg["dim_feedforward"], dropout=0.0,
                                           batch_first=True, layer_norm_eps=1e-12)
        self.encoder = nn.TransformerEncoder(layer, cfg["nlayers"], enable_nested_tensor=False)

    def get_embeddings(self, ids):
        pad = ids.eq(PAD)
        x = self.embeddings(ids) + self.positional_embedding(torch.arange(ids.shape[1], device=ids.device))
        for blk in self.encoder.layers:
            x = blk(x, src_key_padding_mask=pad, is_causal=False)
        return x[:, 3:].float().mean(1)        # with_context=False, mean incl. pad positions (official)


def load_model():
    cfg = json.load(open(os.path.join(HF_DIR, "config.json")))
    assert cfg["learnable_pe"] and cfg["specie"] and cfg["assay"] and cfg["modality"]
    sd = load_file(os.path.join(HF_DIR, "model.safetensors"))
    keep = {k[len("nicheformer."):]: v for k, v in sd.items()
            if k.startswith("nicheformer.") and not k.startswith("nicheformer.encoder_layer.") and k != "nicheformer.pos"}
    skipped = sorted(set(sd) - {"nicheformer." + k for k in keep})
    assert all(k.startswith(("nicheformer.encoder_layer.", "classifier_head.")) or k == "nicheformer.pos"
               for k in skipped), skipped
    m = Nicheformer(cfg)
    m.load_state_dict(keep, strict=True)
    print(f"weights {sha(os.path.join(HF_DIR, 'model.safetensors'))}: loaded {len(keep)} tensors strictly; "
          f"skipped {len(skipped)} (template encoder_layer / MLM head / pos buffer)", flush=True)
    return m.eval().to(dev), cfg


def gene_reference():
    ref = ad.read_h5ad(os.path.join(HF_DIR, "model.h5ad"), backed=None)
    ens = np.asarray(ref.var_names, dtype=str)
    vocab = json.load(open(os.path.join(HF_DIR, "vocab.json")))
    assert all(vocab[g] == i + AUX for i, g in enumerate(ens))
    mean = np.load(os.path.join(GH_DIR, "data", "model_means", "dissociated_mean_script.npy"))
    assert mean.shape == ens.shape
    mean = np.nan_to_num(mean)
    r = np.where((mean % 1) >= 0.5, np.ceil(mean), np.floor(mean))
    mean = np.where(r == 0, 1, r)
    sym2ens = dict(pd.read_csv(os.path.join(CACHE, "external", "dlpfc_genes_full.csv"))[["gene", "ensembl_id"]].values)
    from huggingface_hub import hf_hub_download
    gf = pickle.load(open(hf_hub_download("ctheodoris/Geneformer", "geneformer/gene_name_id_dict_gc104M.pkl"), "rb"))
    for k, v in gf.items():
        sym2ens.setdefault(k, v)
    return ens, mean, sym2ens


def tokenize(X, genes, ens, mean, sym2ens, modality):
    e2i = {e: i for i, e in enumerate(ens)}
    src = [(j, e2i[sym2ens[g]]) for j, g in enumerate(genes) if g in sym2ens and sym2ens[g] in e2i]
    rows, cols = zip(*src)
    M = sp.csr_matrix((np.ones(len(src), np.float32), (rows, cols)), shape=(len(genes), len(ens)))
    A = (X @ M).tocsr()                                          # spot x model genes (aligned like concat with model)
    lib = np.asarray(A.sum(1)).ravel()
    cov = dict(n_in=len(genes), n_mapped=len(src), n_model_genes=int((np.asarray(M.sum(0)).ravel() > 0).sum()),
               umi_frac=float(np.median(lib / np.maximum(np.asarray(X.sum(1)).ravel(), 1))))
    lib = lib + (lib == 0)
    A = (sp.diags(1e4 / lib) @ A).tocsr()
    ids = np.full((A.shape[0], CTX_LEN), PAD, np.int64)
    ids[:, :3] = [SPECIE_HUMAN, ASSAY_10X3V3, modality]
    ngen = np.zeros(A.shape[0], int)
    for r in range(A.shape[0]):
        s, e = A.indptr[r], A.indptr[r + 1]
        nz, v = A.indices[s:e], A.data[s:e] / mean[A.indices[s:e]]
        keep = v > 0
        nz, v = nz[keep], v[keep]
        order = nz[np.argsort(-v, kind="stable")][:CTX_LEN - 3]
        ids[r, 3:3 + len(order)] = order + AUX
        ngen[r] = len(order)
    cov["expr_genes_median"] = float(np.median(np.diff(A.indptr)))
    cov["tokens_median"] = float(np.median(ngen))
    cov["frac_spots_padded"] = float((ngen < CTX_LEN - 3).mean())
    return ids, cov


@torch.no_grad()
def embed(model, ids, bs=32):
    out = np.zeros((len(ids), 512), np.float32)
    i = 0
    while i < len(ids):
        try:
            b = torch.from_numpy(ids[i:i + bs]).to(dev)
            with torch.autocast("cuda", dtype=torch.bfloat16):
                out[i:i + bs] = model.get_embeddings(b).cpu().numpy()
            i += bs
        except torch.OutOfMemoryError:
            torch.cuda.empty_cache()
            bs = max(1, bs // 2)
            print(f"  OOM -> batch {bs}", flush=True)
    return out


def sanity(E, name):
    rng = np.random.default_rng(0)
    S = E[rng.choice(len(E), min(200, len(E)), replace=False)]
    S = S / np.linalg.norm(S, axis=1, keepdims=True)
    C = S @ S.T
    mc = float(C[np.triu_indices(len(S), 1)].mean())
    msg = f"[{MODEL}] {name}: finite={np.isfinite(E).all()} std={E.std(0).mean():.4f} mean-pair-cos(200)={mc:.4f}"
    if name == "hest":
        org = pd.read_csv(os.path.join(CACHE, "hest", "meta.csv")).organ.values
        assert len(org) == len(E)
        Z = torch.from_numpy(E / np.linalg.norm(E, axis=1, keepdims=True)).to(dev)
        nn_idx = []
        for k in range(0, len(Z), 2048):
            s = Z[k:k + 2048] @ Z.T
            s[torch.arange(s.shape[0]), torch.arange(k, k + s.shape[0])] = -2
            nn_idx.append(s.topk(10, dim=1).indices.cpu().numpy())
        nn_idx = np.concatenate(nn_idx)
        knn = float((org[nn_idx] == org[:, None]).mean())
        p = pd.Series(org).value_counts(normalize=True).values
        msg += f"  10-NN same-organ={knn:.3f} (base rate {float((p ** 2).sum()):.3f})"
    print(msg, flush=True)
    return mc


def main():
    sets = sys.argv[1:] or SETS
    t0 = time.time()
    ensure_download()
    model, cfg = load_model()
    ens, mean, sym2ens = gene_reference()
    print(f"context tokens: specie=human({SPECIE_HUMAN}) assay=10x 3' v3({ASSAY_10X3V3}) modality=spatial({MOD_SPATIAL}); "
          f"technology mean = dissociated_mean_script.npy (GitHub {GH_COMMIT[:7]})", flush=True)
    for name in sets:
        t1 = time.time()
        X, genes = load(name)
        ids, cov = tokenize(X, genes, ens, mean, sym2ens, MOD_SPATIAL)
        print(f"[{MODEL}] {name}: {X.shape[0]} spots; genes in {cov['n_in']}, mapped to vocab {cov['n_mapped']} "
              f"({cov['n_mapped'] / cov['n_in']:.1%}) -> {cov['n_model_genes']} model genes; median UMI fraction kept "
              f"{cov['umi_frac']:.3f}; expressed vocab genes/spot median {cov['expr_genes_median']:.0f}; gene tokens "
              f"median {cov['tokens_median']:.0f}; spots with padding {cov['frac_spots_padded']:.1%}", flush=True)
        E = embed(model, ids)
        sanity(E, name)
        save(MODEL, name, E)
        if name == "hest":                                 # sensitivity: modality token = dissociated
            ids[:, 2] = MOD_DISSOC
            E2 = embed(model, ids)
            c = (E * E2).sum(1) / np.linalg.norm(E, axis=1) / np.linalg.norm(E2, axis=1)
            print(f"  sensitivity modality=dissociated(3): cosine to saved per spot median {np.median(c):.4f}", flush=True)
            sanity(E2, "hest")
        print(f"[{MODEL}] {name}: {time.time() - t1:.0f}s", flush=True)
    print(f"[{MODEL}] done {time.time() - t0:.0f}s; peak GPU {torch.cuda.max_memory_allocated() / 2**30:.2f} GB", flush=True)


if __name__ == "__main__":
    main()