File size: 11,982 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
"""Frozen Cell2Sentence (C2S-Scale-Gemma-2-2B) spot embeddings for the RNA sets of rna_sets.py (spot = pseudo-cell).

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

Model     : HF vandijklab/C2S-Scale-Gemma-2-2B @ 5ddf28b8f1c81b7ab7a9be192924da82b6c5d512 (cc-by-4.0), a Gemma-2 2B
            decoder (26 layers, d=2304) instruction-tuned on cell sentences from >800 CellxGene/HCA datasets. Loaded as
            a plain transformers Gemma2Model (no remote code, strict: no missing / unexpected keys), bf16, eager
            attention (keeps Gemma-2's attention-logit soft-capping exactly as in training).
Tokenizer : the repo ships only the sentencepiece tokenizer.model; sentencepiece is not in the shared env, so it was
            converted once to ext/cell2sentence/tokenizer/fast/tokenizer.json with transformers' own GemmaConverter
            inside an isolated venv (ext/cell2sentence/venv) and checked token-identical to the sentencepiece
            GemmaTokenizer on sampled prompts of every set.
Cell sentence (exactly as cell2sentence src/cell2sentence/utils.py generate_sentences + prompt_formatter.py, repo
            vandijklab/cell2sentence @ 6599c9d, read-only clone in ext/cell2sentence/src):
            raw counts -> sc.pp.normalize_total (target = median total counts of the set) -> log1p base 10
            (tutorial 0; rank-preserving within a spot) -> gene symbols upper-cased (generate_vocabulary) ->
            nonzero genes of the spot, shuffled with np.random.seed(42) / sklearn.utils.shuffle and stable-sorted by
            descending expression (= their random tie-breaking) -> top K=200 symbols (default n_genes=200 of
            tasks.embed_cells / predict_cell_types_of_data and the tutorials), space-separated.
            Not applied: tutorial-0 QC filters (filter_cells min_genes=200, filter_genes min_cells=3), because they
            would drop spots / make a spot's sentence depend on the other spots of the set.
Prompt    : the cell-type-prediction template used by tasks.embed_cells (C2SPromptFormatter task
            "cell_type_prediction"); that formatter picks one of 15 paraphrases at random, we fix the first one, which
            is also the template on the C2S-Scale model card:
            "The following is a list of {K} gene names ordered by descending expression level in a Homo sapiens cell.
             Your task is to give the cell type which this cell belongs to based on its gene expression.\\n
             Cell sentence: {sentence}.\\nThe cell type corresponding to these genes is:"   (+ <bos>, no chat template)
            {K} is always 200, as in the formatter (spots with < 200 expressed genes keep "200").
Readout   : last hidden layer (post final RMSNorm = hidden_states[-1], as CSModel.embed_cells_batched).
            "c2s"      : mean over the cell-sentence tokens only (tokens whose char span overlaps the gene list;
                         official code averages over the whole padded prompt, which mixes in the constant template
                         and padding).
            "c2s-last" : hidden state of the last prompt token (":" after "is"), i.e. the state that would generate
                         the cell type.
Output    : cache/rnafm/c2s_<set>.npy and c2s-last_<set>.npy, (n_spot, 2304) float16, set order. Log logs/rnafm/c2s.log.
"""
import os
import sys
import time

import numpy as np
import pandas as pd
import scipy.sparse as sp
import torch
from sklearn.utils import shuffle

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

REPO = "vandijklab/C2S-Scale-Gemma-2-2B"
REV = "5ddf28b8f1c81b7ab7a9be192924da82b6c5d512"
TOK_DIR = os.path.join(ROOT, "ext", "cell2sentence", "tokenizer", "fast")
K = 200
ORGANISM = "Homo sapiens"
PREFIX = (f"The following is a list of {K} gene names ordered by descending expression level in a {ORGANISM} cell. "
          "Your task is to give the cell type which this cell belongs to based on its gene expression.\nCell sentence: ")
SUFFIX = ".\nThe cell type corresponding to these genes is:"
GPU_GB = 8.0
TOK_BUDGET = 8 * 800           # tokens per forward pass (halved on OOM; ~7 GB peak at L~750)
dev = "cuda"


def log(*a):
    print(*a, flush=True)


def cell_sentences(X, genes):
    """C2S normalisation + rank -> list of top-K space-separated (upper-case) gene symbols, plus #expressed genes."""
    X = X.tocsr().astype(np.float64)
    tot = np.asarray(X.sum(1)).ravel()
    target = np.median(tot[tot > 0])                       # sc.pp.normalize_total default target_sum=None
    Xn = sp.diags(target / np.maximum(tot, 1e-12)) @ X
    Xn = Xn.tocsr()
    Xn.data = np.log1p(Xn.data) / np.log(10)               # sc.pp.log1p(base=10)
    enc = np.char.upper(np.asarray(genes, dtype=str))
    np.random.seed(42)                                     # generate_sentences(random_state=42)
    sents, n_expr = [], np.zeros(Xn.shape[0], int)
    for i in range(Xn.shape[0]):
        cols = Xn.indices[Xn.indptr[i]:Xn.indptr[i + 1]]
        vals = Xn.data[Xn.indptr[i]:Xn.indptr[i + 1]]
        cols, vals = shuffle(cols, vals)
        order = cols[np.argsort(-vals, kind="stable")]
        n_expr[i] = len(order)
        sents.append(" ".join(enc[order[:K]]))
    return sents, n_expr


def tokenize(tok, sents):
    """Prompt ids + mask of cell-sentence tokens (by char offsets)."""
    prompts = [PREFIX + s + SUFFIX for s in sents]
    enc = tok(prompts, return_offsets_mapping=True, add_special_tokens=True)
    out = []
    for p, s, ids, off in zip(prompts, sents, enc["input_ids"], enc["offset_mapping"]):
        a, b = len(PREFIX), len(PREFIX) + len(s)
        off = np.asarray(off)
        m = (off[:, 0] < b) & (off[:, 1] > a) & (off[:, 1] > off[:, 0])
        assert ids[0] == tok.bos_token_id and m.sum() > 0
        out.append((np.asarray(ids), m))
    return prompts, out


@torch.no_grad()
def embed(model, seqs, pad_id):
    n, D = len(seqs), model.config.hidden_size
    E_mean, E_last = np.zeros((n, D), np.float32), np.zeros((n, D), np.float32)
    order = np.argsort([len(s[0]) for s in seqs])[::-1]   # longest first: OOM shows up immediately
    budget, k, t0, done = TOK_BUDGET, 0, time.time(), 0
    while k < n:
        Lm = len(seqs[order[k]][0])
        bs = max(1, budget // Lm)
        idx = order[k:k + bs]
        ids = torch.full((len(idx), Lm), pad_id, dtype=torch.long)
        msk = torch.zeros((len(idx), Lm), dtype=torch.bool)
        att = torch.zeros((len(idx), Lm), dtype=torch.long)
        for j, i in enumerate(idx):
            s, m = seqs[i]
            ids[j, :len(s)], msk[j, :len(s)], att[j, :len(s)] = torch.from_numpy(s), torch.from_numpy(m), 1
        try:
            h = model(input_ids=ids.to(dev), attention_mask=att.to(dev)).last_hidden_state.float()
        except torch.OutOfMemoryError:
            torch.cuda.empty_cache()
            if budget <= Lm:
                raise
            budget //= 2
            log(f"  OOM -> token budget {budget}")
            continue
        mk = msk.to(dev).float()
        E_mean[idx] = ((h * mk[..., None]).sum(1) / mk.sum(1, keepdim=True)).cpu().numpy()
        last = att.sum(1).to(dev) - 1
        E_last[idx] = h[torch.arange(len(idx), device=dev), last].cpu().numpy()
        k += len(idx)
        if k // 1000 > done:
            done = k // 1000
            log(f"  {k}/{n}  {time.time() - t0:.0f}s  bs={len(idx)} L={Lm}  peak {torch.cuda.max_memory_allocated() / 2**30:.2f} GB")
    return E_mean, E_last


def sanity(E, tag, organ=None):
    rng = np.random.default_rng(0)
    Z = E / np.linalg.norm(E, axis=1, keepdims=True)
    s = Z[rng.choice(len(Z), min(200, len(Z)), replace=False)]
    C = s @ s.T
    mc = C[~np.eye(len(s), dtype=bool)].mean()
    msg = f"[{tag}] finite={np.isfinite(E).all()}  mean pairwise cos(200)={mc:.4f}  std/dim={E.std(0).mean():.4f}"
    if organ is not None:
        Zc = Z - Z.mean(0)
        Zc /= np.linalg.norm(Zc, axis=1, keepdims=True)
        o = np.asarray(organ)
        same, raw = [], []
        for ZZ, acc in ((Zc, same), (Z, raw)):
            Zt = torch.from_numpy(ZZ).to(dev)
            for i in range(0, len(Zt), 2048):
                S = Zt[i:i + 2048] @ Zt.T
                S[torch.arange(S.shape[0]), torch.arange(i, i + S.shape[0])] = -2
                nn = S.topk(10, dim=1).indices.cpu().numpy()
                acc.append((o[nn] == o[i:i + S.shape[0], None]).mean(1))
        cnt = pd.Series(o).value_counts()
        base = ((cnt * (cnt - 1)).sum()) / (len(o) * (len(o) - 1))
        msg += (f"  10-NN same-organ: {np.concatenate(raw).mean():.3f} (cos), {np.concatenate(same).mean():.3f}"
                f" (centred cos)  base rate {base:.3f}")
    log(msg)


def main():
    from transformers import Gemma2Model, PreTrainedTokenizerFast
    from huggingface_hub import snapshot_download
    sets = sys.argv[1:] or SETS
    torch.cuda.set_per_process_memory_fraction(min(1.0, GPU_GB * 2**30 / torch.cuda.get_device_properties(0).total_memory))
    tok = PreTrainedTokenizerFast.from_pretrained(TOK_DIR)
    tok.padding_side = "right"
    mdir = snapshot_download(REPO, revision=REV)
    model, info = Gemma2Model.from_pretrained(mdir, dtype=torch.bfloat16, attn_implementation="eager",
                                              output_loading_info=True)
    log(f"loaded {REPO}@{REV}: missing={info['missing_keys']} unexpected={info['unexpected_keys']} "
        f"mismatched={info['mismatched_keys']}")
    assert not info["missing_keys"] and not info["mismatched_keys"] and not info["unexpected_keys"]
    model.eval().to(dev)
    log(f"weights {torch.cuda.memory_allocated() / 2**30:.2f} GB; prompt prefix tokens {len(tok(PREFIX)['input_ids'])}")
    meta = pd.read_csv(os.path.join(CACHE, "hest", "meta.csv"))
    tot_seq, tot_t = 0, 0.0
    for name in sets:
        t0 = time.time()
        X, genes = load(name)
        sents, n_expr = cell_sentences(X, genes)
        prompts, seqs = tokenize(tok, sents)
        ntok = np.array([len(s[0]) for s in seqs])
        nsent = np.array([m.sum() for _, m in seqs])
        upg = np.char.upper(genes.astype(str))
        nonhgnc = np.mean([("." in g) or g.startswith("ENSG") for g in upg])
        log(f"== {name}: {X.shape[0]} spots x {X.shape[1]} genes (all symbols usable as text; {nonhgnc:.1%} non-HGNC-"
            f"style ids e.g. ENSG / RP11-..), {len(set(upg))} unique upper-case symbols; expressed genes/spot median "
            f"{np.median(n_expr):.0f}, spots with < {K} expressed genes {np.mean(n_expr < K):.1%}; sentence genes/spot "
            f"median {np.median(np.minimum(n_expr, K)):.0f}; tokens/prompt median {np.median(ntok):.0f} "
            f"(sentence tokens {np.median(nsent):.0f}, {np.median(nsent) / K:.2f} tok/gene)")
        log("  example: " + prompts[0][:400].replace("\n", " | ") + " ...")
        with open(os.path.join(ROOT, "ext", "cell2sentence", f"prompts_sample_{name}.txt"), "w") as f:  # tokenizer cross-check
            for i in np.random.default_rng(1).choice(len(prompts), min(200, len(prompts)), replace=False):
                f.write(repr(prompts[i]) + "\n")
        t1 = time.time()
        E_mean, E_last = embed(model, seqs, tok.pad_token_id)
        dt = time.time() - t1
        tot_seq, tot_t = tot_seq + len(seqs), tot_t + dt
        log(f"  forward {dt:.0f}s = {len(seqs) / dt:.2f} seq/s, {ntok.sum() / dt:.0f} tok/s; total {time.time() - t0:.0f}s;"
            f" peak GPU {torch.cuda.max_memory_allocated() / 2**30:.2f} GB")
        save("c2s", name, E_mean)
        save("c2s-last", name, E_last)
        organ = meta.organ.values if name in ("hest", "hest_v") else None
        if organ is not None:
            assert len(organ) == len(E_mean)
        sanity(E_mean, f"c2s {name}", organ)
        sanity(E_last, f"c2s-last {name}", organ)
    log(f"overall throughput {tot_seq / max(tot_t, 1e-9):.2f} seq/s over {tot_seq} sequences ({tot_t / 60:.1f} min)")


if __name__ == "__main__":
    main()