jlu-wsj commited on
Commit
94e9257
·
verified ·
1 Parent(s): b407e76

Add files using upload-large-folder tool

Browse files
.gitattributes CHANGED
@@ -1,35 +1 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
  *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  *.safetensors filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
.gitignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ .ipynb_checkpoints/
4
+ *.npy
LICENSE ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
README.md CHANGED
@@ -1,3 +1,101 @@
1
  ---
 
 
 
 
 
 
 
 
 
 
2
  license: apache-2.0
3
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ language:
3
+ - en
4
+ library_name: pytorch
5
+ pipeline_tag: feature-extraction
6
+ tags:
7
+ - single-cell
8
+ - transcriptomics
9
+ - scRNA-seq
10
+ - cell-embedding
11
+ - pytorch
12
  license: apache-2.0
13
  ---
14
+
15
+ # scRep
16
+
17
+ scRep is a PyTorch model for extracting L2-normalized cell embeddings from
18
+ single-cell RNA-seq AnnData (`.h5ad`) inputs. This repository is a standalone
19
+ Hugging Face release bundle: it includes checkpoint weights, the exact paired
20
+ gene vocabulary, the inference implementation, and runnable examples.
21
+
22
+ ## Checkpoints
23
+
24
+ | Checkpoint | Directory | Vocabulary size | Required paired tokenizer |
25
+ | --- | --- | ---: | --- |
26
+ | 2026-06-25 / 30M | `checkpoints/scRep_20260625_30M/` | 19,240 | `checkpoints/scRep_20260625_30M/tokenizer/` |
27
+ | 2026-07-17 / 3M | `checkpoints/scRep_20260717_3M/` | 19,239 | `checkpoints/scRep_20260717_3M/tokenizer/` |
28
+
29
+ `model.safetensors`, `config.json`, and `tokenizer/gene_vocab.json` from the
30
+ same checkpoint directory must always be used together. The checkpoint names
31
+ are retained as release identifiers; they do not by themselves specify a
32
+ parameter count or a benchmark claim.
33
+
34
+ ## Installation
35
+
36
+ Python 3.10--3.12 is supported. Install a PyTorch build appropriate for the
37
+ target CPU/CUDA platform first, then install the remaining requirements:
38
+
39
+ ```bash
40
+ pip install -r requirements.txt
41
+ ```
42
+
43
+ ## Upload to Hugging Face
44
+
45
+ Upload the contents of this directory as a model repository. The included
46
+ `.gitattributes` marks `*.safetensors` for Git LFS; run `git lfs install`
47
+ before committing the two weight files.
48
+
49
+ ## Quick start
50
+
51
+ From the root of this model repository:
52
+
53
+ ```bash
54
+ python examples/inference.py \
55
+ --input_h5ad /path/to/input.h5ad \
56
+ --output embeddings.npy \
57
+ --checkpoint checkpoints/scRep_20260625_30M
58
+ ```
59
+
60
+ The output is a float32 NumPy matrix with one 768-dimensional, L2-normalized
61
+ embedding per retained input cell. Input genes are matched by exact names to
62
+ the bundled vocabulary. Cells with no matched non-zero genes are skipped.
63
+ By default the script reads `adata.X`; add `--use_raw` to use `adata.raw.X`.
64
+
65
+ The same workflow is available interactively in
66
+ `examples/embedding_example.ipynb`.
67
+
68
+ ## Files
69
+
70
+ ```text
71
+ scRep_release/
72
+ ├── README.md
73
+ ├── LICENSE
74
+ ├── requirements.txt
75
+ ├── scRep_inference.py
76
+ ├── scRep_pretrain/ # Inference architecture implementation
77
+ ├── examples/
78
+ │ ├── inference.py
79
+ │ └── embedding_example.ipynb
80
+ └── checkpoints/
81
+ ├── scRep_20260625_30M/
82
+ │ ├── config.json
83
+ │ ├── model.safetensors
84
+ │ └── tokenizer/{gene_vocab.json,gene_names.txt}
85
+ └── scRep_20260717_3M/
86
+ ├── config.json
87
+ ├── model.safetensors
88
+ └── tokenizer/{gene_vocab.json,gene_names.txt}
89
+ ```
90
+
91
+ ## Limitations and responsible use
92
+
93
+ The embeddings are research outputs, not clinical measurements or diagnostic
94
+ predictions. Validate performance, preprocessing, gene naming conventions,
95
+ and potential batch/demographic bias for every downstream dataset before use.
96
+ Do not use this model as the sole basis for medical decisions.
97
+
98
+ ## License
99
+
100
+ The code and checkpoint weights in this release are distributed under the
101
+ Apache License 2.0. See `LICENSE` for the complete terms.
checkpoints/scRep_20260625_30M/config.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "d_model": 768, "n_heads": 12, "dropout": 0.0, "encoder_layers": 12,
3
+ "gradient_checkpointing": false, "gene_vocab_size": 19240, "bin_vocab_size": 53,
4
+ "gene_pad_id": 0, "bin_pad_id": 0, "bin_mask_id": 1, "bin_offset": 3,
5
+ "gene_cls_id": 1, "expr_embedding_mode": "bin", "expr_rank_mlp_hidden_dim": 256,
6
+ "dino_out_dim": 4096, "dino_hidden_dim": 2048, "dino_bottleneck_dim": 256,
7
+ "inference_use_teacher": true
8
+ }
checkpoints/scRep_20260625_30M/meta_vocab.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "meta_vocab_sizes": {},
3
+ "meta_maps": {}
4
+ }
checkpoints/scRep_20260625_30M/model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:abc7eb419f785b6f545d27d0ecf5770663a505cdee36b657b72ac8c0f7138ec4
3
+ size 915673940
checkpoints/scRep_20260625_30M/tokenizer/gene_names.txt ADDED
The diff for this file is too large to render. See raw diff
 
checkpoints/scRep_20260625_30M/tokenizer/gene_vocab.json ADDED
The diff for this file is too large to render. See raw diff
 
checkpoints/scRep_20260717_3M/config.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "d_model": 768, "n_heads": 12, "dropout": 0.0, "encoder_layers": 12,
3
+ "gradient_checkpointing": false, "gene_vocab_size": 19239, "bin_vocab_size": 53,
4
+ "gene_pad_id": 0, "bin_pad_id": 0, "bin_mask_id": 1, "bin_offset": 3,
5
+ "gene_cls_id": 1, "expr_embedding_mode": "bin", "expr_rank_mlp_hidden_dim": 256,
6
+ "dino_out_dim": 4096, "dino_hidden_dim": 2048, "dino_bottleneck_dim": 256,
7
+ "inference_use_teacher": true
8
+ }
checkpoints/scRep_20260717_3M/meta_vocab.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "meta_vocab_sizes": {},
3
+ "meta_maps": {}
4
+ }
checkpoints/scRep_20260717_3M/model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9204967d6e99165b69864b0b5b4c89cf14cf39407276d8e4e1312dadd807cf36
3
+ size 915667796
checkpoints/scRep_20260717_3M/tokenizer/gene_names.txt ADDED
The diff for this file is too large to render. See raw diff
 
checkpoints/scRep_20260717_3M/tokenizer/gene_vocab.json ADDED
The diff for this file is too large to render. See raw diff
 
examples/embedding_example.ipynb ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# scRep embedding example\n",
8
+ "\n",
9
+ "Run this notebook from the root of the downloaded scRep model repository."
10
+ ]
11
+ },
12
+ {
13
+ "cell_type": "code",
14
+ "execution_count": null,
15
+ "metadata": {},
16
+ "outputs": [],
17
+ "source": [
18
+ "!pip install -r requirements.txt"
19
+ ]
20
+ },
21
+ {
22
+ "cell_type": "code",
23
+ "execution_count": null,
24
+ "metadata": {},
25
+ "outputs": [],
26
+ "source": [
27
+ "from pathlib import Path\n",
28
+ "from types import SimpleNamespace\n",
29
+ "import numpy as np\n",
30
+ "from scRep_inference import encode_embeddings, load_examples, load_scRep_bundle\n",
31
+ "from scRep_pretrain.vocab import gene_vocab_to_map\n",
32
+ "\n",
33
+ "checkpoint = Path('checkpoints/scRep_20260625_30M')\n",
34
+ "input_h5ad = Path('/path/to/input.h5ad') # change this\n",
35
+ "args = SimpleNamespace(\n",
36
+ " model_dir=str(checkpoint), asset_dir='', device='cuda',\n",
37
+ " batch_size=128, n_bins=50, max_input_genes=2048, backbone='auto',\n",
38
+ ")\n",
39
+ "model, gene_vocab, *_ = load_scRep_bundle(checkpoint, args)\n",
40
+ "gene_name_to_id = gene_vocab_to_map(gene_vocab)\n",
41
+ "examples, label_key = load_examples(input_h5ad, gene_name_to_id)\n",
42
+ "embeddings = encode_embeddings(model, examples, args)\n",
43
+ "np.save('embeddings.npy', embeddings)\n",
44
+ "print(embeddings.shape, label_key)"
45
+ ]
46
+ }
47
+ ],
48
+ "metadata": {
49
+ "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
50
+ "language_info": {"name": "python", "version": "3.10"}
51
+ },
52
+ "nbformat": 4,
53
+ "nbformat_minor": 5
54
+ }
examples/inference.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Extract L2-normalized scRep cell embeddings from an .h5ad file."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+ from types import SimpleNamespace
9
+
10
+ import numpy as np
11
+
12
+ RELEASE_ROOT = Path(__file__).resolve().parents[1]
13
+ if str(RELEASE_ROOT) not in sys.path:
14
+ sys.path.insert(0, str(RELEASE_ROOT))
15
+ from scRep_inference import encode_embeddings, load_examples, load_scRep_bundle
16
+ from scRep_pretrain.vocab import gene_vocab_to_map
17
+
18
+
19
+ def parse_args() -> argparse.Namespace:
20
+ parser = argparse.ArgumentParser(description=__doc__)
21
+ parser.add_argument("--input_h5ad", required=True, help="Input AnnData (.h5ad) file.")
22
+ parser.add_argument("--output", default="embeddings.npy", help="Output .npy embedding matrix.")
23
+ parser.add_argument("--checkpoint", default="checkpoints/scRep_20260625_30M")
24
+ parser.add_argument("--device", default="cuda" if __import__("torch").cuda.is_available() else "cpu")
25
+ parser.add_argument("--batch_size", type=int, default=128)
26
+ parser.add_argument("--max_input_genes", type=int, default=2048)
27
+ parser.add_argument("--n_bins", type=int, default=50)
28
+ parser.add_argument("--max_cells", type=int, default=0, help="0 means all cells.")
29
+ parser.add_argument("--use_raw", action="store_true", help="Use adata.raw instead of adata.X.")
30
+ return parser.parse_args()
31
+
32
+
33
+ def main() -> None:
34
+ args = parse_args()
35
+ checkpoint = Path(args.checkpoint)
36
+ if not checkpoint.is_absolute():
37
+ checkpoint = RELEASE_ROOT / checkpoint
38
+ model, gene_vocab, *_ = load_scRep_bundle(checkpoint, SimpleNamespace(model_dir=str(checkpoint), asset_dir="", device=args.device))
39
+ gene_name_to_id = gene_vocab_to_map(gene_vocab)
40
+ examples, _ = load_examples(args.input_h5ad, gene_name_to_id, max_total_cells=args.max_cells, use_raw=args.use_raw)
41
+ if not examples:
42
+ raise ValueError("No input cells had genes present in the checkpoint vocabulary.")
43
+ embeddings = encode_embeddings(model, examples, args)
44
+ output = Path(args.output)
45
+ output.parent.mkdir(parents=True, exist_ok=True)
46
+ np.save(output, embeddings)
47
+ print(f"Saved {embeddings.shape[0]} x {embeddings.shape[1]} embeddings to {output}")
48
+
49
+
50
+ if __name__ == "__main__":
51
+ main()
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # Minimal dependencies for checkpoint inference examples (Python 3.10--3.12).
2
+ # Install the PyTorch build appropriate for your CPU/CUDA platform first:
3
+ # https://pytorch.org/get-started/locally/
4
+ torch>=2.2,<2.10
5
+ safetensors>=0.4,<1.0
6
+ anndata>=0.10,<0.12
7
+ numpy>=1.26,<2.0
8
+ scipy>=1.11,<1.16
scRep_inference.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Standalone inference helpers for the public scRep release.
2
+
3
+ This is the small, public equivalent of scDINO's experiment-side inference
4
+ adapter. It deliberately supports a checkpoint containing only weights by
5
+ inferring the architecture and taking vocab assets from ``assets/``.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from types import SimpleNamespace
13
+ from typing import Any, Sequence
14
+
15
+ import anndata as ad
16
+ import numpy as np
17
+ import torch
18
+
19
+ from scRep_pretrain.model import CellContrastivePredictModel, ContrastiveSplitCellCollator, FoundationConfig
20
+ from scRep_pretrain.vocab import VocabSpec, gene_vocab_to_map
21
+
22
+ ROOT = Path(__file__).resolve().parent
23
+ LABEL_CANDIDATES = ["cell_type", "author_cell_type", "cell_type_ontology_term_id", "time_label"]
24
+ BATCH_CANDIDATES = ["extended_batch_id", "batch_id", "batch", "sample_id", "sample", "donor_id"]
25
+
26
+
27
+ @dataclass
28
+ class EvalExample:
29
+ gene_ids: list[int]
30
+ expr: list[float]
31
+ label: str | None = None
32
+ source_file: str = ""
33
+ cell_idx: int = -1
34
+ batch_id: str | None = None
35
+
36
+
37
+ def safe_name(value: Any) -> str:
38
+ result = "".join(c if c.isalnum() or c in "._-" else "-" for c in str(value)).strip("-")
39
+ return result or "item"
40
+
41
+
42
+ def infer_model_cache_name(model_dir: str | Path) -> str:
43
+ path = Path(model_dir).expanduser().resolve()
44
+ return safe_name(f"{path.parent.name}_{path.name}" if path.name.startswith("checkpoint-") else path.name)
45
+
46
+
47
+ def choose_label_key(explicit_key: str, available: Sequence[str]) -> str | None:
48
+ if explicit_key:
49
+ return explicit_key if explicit_key in set(available) else None
50
+ return next((key for key in LABEL_CANDIDATES if key in set(available)), None)
51
+
52
+
53
+ def choose_batch_key(available: Sequence[str]) -> str | None:
54
+ return next((key for key in BATCH_CANDIDATES if key in set(available)), None)
55
+
56
+
57
+ def list_data_files(data_dir: str | Path, data_files: Sequence[str | Path] = ()) -> list[Path]:
58
+ root = Path(data_dir).expanduser().resolve()
59
+ if data_files:
60
+ paths = [Path(p).expanduser() for p in data_files]
61
+ return [(p if p.is_absolute() else root / p).resolve() for p in paths]
62
+ paths = sorted(root.glob("*.h5ad")) if root.is_dir() else [root]
63
+ if not paths or not all(p.is_file() for p in paths):
64
+ raise FileNotFoundError(f"No .h5ad files found in: {root}")
65
+ return paths
66
+
67
+
68
+ def _extract_row(matrix: Any, row: int) -> tuple[np.ndarray, np.ndarray]:
69
+ value = matrix[row]
70
+ if hasattr(value, "indices"):
71
+ return np.asarray(value.indices), np.asarray(value.data, dtype=np.float32)
72
+ dense = np.asarray(value).reshape(-1)
73
+ idx = np.flatnonzero(dense)
74
+ return idx, dense[idx].astype(np.float32)
75
+
76
+
77
+ def _process_expr(values: np.ndarray, use_raw: bool, cp_target_sum: float) -> np.ndarray:
78
+ values = np.asarray(values, dtype=np.float32)
79
+ if not use_raw or not values.size:
80
+ return values
81
+ total = float(values.sum())
82
+ return np.log1p(values * (float(cp_target_sum) / total)) if total > 0 else np.empty(0, dtype=np.float32)
83
+
84
+
85
+ def load_examples(data_dir: str | Path, gene_name_to_id: dict[str, int], *, max_cells_per_file: int = 0,
86
+ max_total_cells: int = 0, seed: int = 42, backed: str = "r", use_raw: bool = False,
87
+ cp_target_sum: float = 1e4, label_key: str = "") -> tuple[list[EvalExample], str | None]:
88
+ rng, result, chosen_label = np.random.default_rng(seed), [], None
89
+ for path in list_data_files(data_dir):
90
+ adata = ad.read_h5ad(path, backed=backed)
91
+ try:
92
+ matrix, names = (adata.raw.X, adata.raw.var_names) if use_raw else (adata.X, adata.var_names)
93
+ if matrix is None:
94
+ raise ValueError(f"Missing expression matrix: {path}")
95
+ if use_raw and adata.raw is None:
96
+ raise ValueError(f"--use_raw requested but raw is missing: {path}")
97
+ if chosen_label is None:
98
+ chosen_label = choose_label_key(label_key, list(adata.obs.columns))
99
+ local_to_global = np.fromiter((gene_name_to_id.get(str(g), 0) for g in names), dtype=np.int64, count=len(names))
100
+ indices = np.arange(adata.n_obs); rng.shuffle(indices)
101
+ if max_cells_per_file > 0:
102
+ indices = indices[:max_cells_per_file]
103
+ for row in indices:
104
+ idx, values = _extract_row(matrix, int(row)); values = _process_expr(values, use_raw, cp_target_sum)
105
+ gids = local_to_global[idx]; valid = gids > 0
106
+ if not np.any(valid) or not values.size:
107
+ continue
108
+ label = str(adata.obs.iloc[int(row)][chosen_label]) if chosen_label else None
109
+ result.append(EvalExample(gids[valid].astype(int).tolist(), values[valid].astype(float).tolist(), label,
110
+ path.name, int(row), None))
111
+ if max_total_cells > 0 and len(result) >= max_total_cells:
112
+ return result, chosen_label
113
+ finally:
114
+ if getattr(adata, "file", None) is not None:
115
+ adata.file.close()
116
+ return result, chosen_label
117
+
118
+
119
+ def _config_from_state(state: dict[str, torch.Tensor], vocab: VocabSpec) -> FoundationConfig:
120
+ emb = state["student_backbone.tokenizer.gene_emb.weight"]
121
+ layers = len({key.split(".")[2] for key in state if key.startswith("student_backbone.encoder.") and key.endswith("norm1.scale")})
122
+ return FoundationConfig(d_model=int(emb.shape[1]), n_heads=12, encoder_layers=layers,
123
+ gene_vocab_size=int(emb.shape[0]), bin_vocab_size=int(state["student_backbone.tokenizer.bin_emb.weight"].shape[0]),
124
+ gene_pad_id=vocab.gene_pad_id, bin_pad_id=vocab.bin_pad_id, gene_cls_id=vocab.gene_cls_id,
125
+ dino_out_dim=int(state["student_head.prototypes.weight"].shape[0]),
126
+ dino_hidden_dim=int(state["student_head.mlp.0.weight"].shape[0]),
127
+ dino_bottleneck_dim=int(state["student_head.mlp.4.weight"].shape[0]))
128
+
129
+
130
+ def load_scRep_bundle(model_dir_or_args: str | Path | Any, args: Any | None = None):
131
+ if args is None:
132
+ args, model_dir = model_dir_or_args, getattr(model_dir_or_args, "model_dir")
133
+ else:
134
+ model_dir = model_dir_or_args
135
+ weights_dir = Path(model_dir).expanduser().resolve()
136
+ from safetensors.torch import load_file
137
+ state = load_file(str(weights_dir / "model.safetensors"), device="cpu")
138
+ asset_arg = Path(str(getattr(args, "asset_dir", "") or ROOT / "assets")).expanduser()
139
+ candidates = [weights_dir, weights_dir / "tokenizer", weights_dir.parent, asset_arg, ROOT / "assets"]
140
+ def asset(name: str) -> Path:
141
+ found = next((p / name for p in candidates if (p / name).is_file()), None)
142
+ if found is None: raise FileNotFoundError(f"Missing {name}; pass --asset_dir")
143
+ return found
144
+ gene_vocab = json.loads(asset("gene_vocab.json").read_text())
145
+ meta_path, manifest_path = next((p / "meta_vocab.json" for p in candidates if (p / "meta_vocab.json").is_file()), None), next((p / "manifest.json" for p in candidates if (p / "manifest.json").is_file()), None)
146
+ vocab = VocabSpec()
147
+ config_path = next((candidate for p in candidates for candidate in (p / "config.json", p / "model_config.json") if candidate.is_file()), None)
148
+ cfg = FoundationConfig(**json.loads(config_path.read_text())) if config_path else _config_from_state(state, vocab)
149
+ model = CellContrastivePredictModel(cfg); model.load_state_dict(state, strict=True)
150
+ device = str(getattr(args, "device", "cpu")); model.to(device).eval()
151
+ return model, gene_vocab, json.loads(meta_path.read_text()) if meta_path else None, json.loads(manifest_path.read_text()) if manifest_path else None, {"model_family": "model", "weights_dir": str(weights_dir), "config_path": str(config_path or "")}
152
+
153
+
154
+ def _select_input_genes(gene_ids: np.ndarray, expr: np.ndarray, max_input_genes: int):
155
+ if max_input_genes <= 0 or len(gene_ids) <= max_input_genes: return gene_ids, expr
156
+ order = np.argsort(-expr, kind="stable")[:max_input_genes]
157
+ return gene_ids[order], expr[order]
158
+
159
+
160
+ def _make_eval_collator(vocab: VocabSpec, gene_vocab_size: int, n_bins: int, max_input_genes: int, model_family: str = "model", expr_embedding_mode: str = "bin"):
161
+ limit = max_input_genes if max_input_genes > 0 else gene_vocab_size
162
+ return ContrastiveSplitCellCollator(vocab=vocab, gene_vocab_size=gene_vocab_size, n_bins=n_bins, max_input_genes=max_input_genes,
163
+ shuffle_genes=False, teacher_top_genes=limit, teacher_num_views=1, teacher_global_ratio=1.0,
164
+ student_global_ratio_min=1.0, student_global_ratio_max=1.0, student_global_min_genes=limit,
165
+ student_local_min_genes=limit, student_local_max_genes=limit, student_num_local_views=0,
166
+ student_global_dropout_prob=0.0, student_local_dropout_prob=0.0, student_global_bin_mask_prob=0.0,
167
+ expr_embedding_mode=expr_embedding_mode)
168
+
169
+
170
+ def build_eval_batch(examples: Sequence[EvalExample], *, vocab: VocabSpec, collator: Any, max_input_genes: int, use_bin: bool):
171
+ rows = []; width = 1
172
+ for ex in examples:
173
+ gids, expr = _select_input_genes(np.asarray(ex.gene_ids, dtype=np.int64), np.asarray(ex.expr, dtype=np.float32), max_input_genes)
174
+ order = np.argsort(-expr, kind="stable") if use_bin else np.argsort(gids, kind="stable")
175
+ gids, expr = gids[order], expr[order]
176
+ bins = collator._encode_expression_ids(expr) if use_bin else np.full(len(gids), vocab.bin_pad_id, dtype=np.int64)
177
+ rows.append((gids, bins)); width = max(width, len(gids) + 1)
178
+ gene = torch.full((len(rows), width), vocab.gene_pad_id, dtype=torch.long); bins = torch.full_like(gene, vocab.bin_pad_id); mask = torch.zeros_like(gene, dtype=torch.bool)
179
+ gene[:, 0] = vocab.gene_cls_id; mask[:, 0] = True
180
+ for i, (gids, vals) in enumerate(rows):
181
+ gene[i, 1:len(gids)+1] = torch.from_numpy(gids); bins[i, 1:len(gids)+1] = torch.from_numpy(vals); mask[i, 1:len(gids)+1] = True
182
+ return {"gene_ids": gene, "bin_ids": bins, "attention_mask": mask}
183
+
184
+
185
+ def encode_embeddings(model: Any, examples: Sequence[EvalExample], args: Any, *, use_bin: bool = True, model_family: str = "model") -> np.ndarray:
186
+ vocab = VocabSpec(); collator = _make_eval_collator(vocab, model.cfg.gene_vocab_size, int(args.n_bins), int(args.max_input_genes), model_family, model.cfg.expr_embedding_mode)
187
+ backbone = None if getattr(args, "backbone", "auto") == "auto" else args.backbone; pieces = []
188
+ with torch.inference_mode():
189
+ for start in range(0, len(examples), int(args.batch_size)):
190
+ batch = build_eval_batch(examples[start:start+int(args.batch_size)], vocab=vocab, collator=collator, max_input_genes=int(args.max_input_genes), use_bin=use_bin)
191
+ states = model._encode_tokens(batch["gene_ids"].to(args.device), batch["bin_ids"].to(args.device), batch["attention_mask"].to(args.device), use_bin_embeddings=use_bin, backbone=backbone)
192
+ pieces.append(torch.nn.functional.normalize(states[:, 0], dim=-1).cpu())
193
+ return torch.cat(pieces).numpy() if pieces else np.empty((0, model.cfg.d_model), dtype=np.float32)
scRep_pretrain/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .manifest import build_manifest_from_dir, save_manifest_json
2
+ from .vocab import VocabSpec, build_gene_vocab_from_manifest, build_meta_vocab_from_manifest, gene_vocab_to_map
3
+ from .dataset import MultiH5ADCellStream
4
+ from . import model
5
+
6
+ __all__ = [
7
+ "build_manifest_from_dir",
8
+ "save_manifest_json",
9
+ "VocabSpec",
10
+ "build_gene_vocab_from_manifest",
11
+ "build_meta_vocab_from_manifest",
12
+ "gene_vocab_to_map",
13
+ "MultiH5ADCellStream",
14
+ "model",
15
+ ]
scRep_pretrain/bin_cache.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any, Dict, Optional
7
+
8
+ import numpy as np
9
+
10
+
11
+ def cache_key_for_h5ad(path: str) -> str:
12
+ resolved = str(Path(path).expanduser().resolve())
13
+ digest = hashlib.sha1(resolved.encode("utf-8")).hexdigest()[:12]
14
+ return f"{Path(path).stem}.{digest}"
15
+
16
+
17
+ def value_bin_cache_paths(cache_dir: str, h5ad_path: str) -> Dict[str, Path]:
18
+ root = Path(cache_dir).expanduser().resolve()
19
+ key = cache_key_for_h5ad(h5ad_path)
20
+ return {
21
+ "indptr": root / f"{key}.indptr.npy",
22
+ "bin_ids": root / f"{key}.bin_ids.npy",
23
+ "meta": root / f"{key}.meta.json",
24
+ }
25
+
26
+
27
+ def load_value_bin_cache(
28
+ cache_dir: str,
29
+ h5ad_path: str,
30
+ *,
31
+ expected_n_cells: Optional[int] = None,
32
+ mmap_mode: str = "r",
33
+ ) -> Optional[Dict[str, Any]]:
34
+ if not cache_dir:
35
+ return None
36
+ paths = value_bin_cache_paths(cache_dir, h5ad_path)
37
+ if not paths["indptr"].exists() or not paths["bin_ids"].exists():
38
+ return None
39
+
40
+ indptr = np.load(paths["indptr"], mmap_mode=mmap_mode)
41
+ bin_ids = np.load(paths["bin_ids"], mmap_mode=mmap_mode)
42
+ if expected_n_cells is not None and int(indptr.shape[0]) != int(expected_n_cells) + 1:
43
+ raise ValueError(
44
+ f"bin cache n_cells mismatch for {h5ad_path}: "
45
+ f"indptr_len={indptr.shape[0]} expected={int(expected_n_cells) + 1}"
46
+ )
47
+
48
+ meta: Dict[str, Any] = {}
49
+ if paths["meta"].exists():
50
+ with open(paths["meta"], "r", encoding="utf-8") as f:
51
+ meta = json.load(f)
52
+ return {"indptr": indptr, "bin_ids": bin_ids, "meta": meta, "paths": paths}
scRep_pretrain/dataset.py ADDED
@@ -0,0 +1,725 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Dict, List, Optional, Tuple
5
+
6
+ from collections import OrderedDict
7
+ import os
8
+
9
+ import torch
10
+ import anndata as ad
11
+ import numpy as np
12
+ import scipy.sparse as sp
13
+ from torch.utils.data import IterableDataset, get_worker_info
14
+
15
+ from .bin_cache import load_value_bin_cache
16
+
17
+
18
+ # =============================================================================
19
+ # Debug / Logging
20
+ # =============================================================================
21
+ DEBUG_PRINT: bool = False
22
+
23
+
24
+ def _log(msg: str) -> None:
25
+ if DEBUG_PRINT:
26
+ print(f"[sc-token] {msg}")
27
+
28
+
29
+ def _safe_close_anndata(adata_obj: Any) -> None:
30
+ f = getattr(adata_obj, "file", None)
31
+ if f is not None:
32
+ try:
33
+ f.close()
34
+ except Exception:
35
+ pass
36
+
37
+
38
+ # =============================================================================
39
+ # Per-file materialized reader
40
+ # =============================================================================
41
+ class H5ADFileReader:
42
+ """
43
+ Per-file reader.
44
+
45
+ Matrix semantics:
46
+ - use_raw=True : adata.raw.X counts -> log1p(CP10K)
47
+ - use_raw=False : adata.X is assumed to already be log1p(CP10K)
48
+
49
+ Design notes:
50
+ - This reader always returns ALL observed nonzero genes for a cell.
51
+ - No top-k truncation happens here anymore.
52
+ - Top-k is now applied later inside the collator.
53
+ - Gene order is preserved here; any shuffling happens in the collator.
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ path: str,
59
+ dataset_id: int,
60
+ *,
61
+ gene_name_to_id: Dict[str, int],
62
+ meta_maps: Dict[str, Dict[str, int]],
63
+ meta_keys: List[str],
64
+ use_raw: bool,
65
+ cp_target_sum: float = 1e4,
66
+ backed: Optional[str] = "r",
67
+ prefer_csr: bool = True,
68
+ strict_gene: bool = True,
69
+ filter_missing_genes: bool = False,
70
+ value_bin_cache_dir: str = "",
71
+ require_value_bin_cache: bool = False,
72
+ ) -> None:
73
+ self.path = path
74
+ self.dataset_id = int(dataset_id)
75
+
76
+ self.gene_name_to_id = gene_name_to_id
77
+ self.meta_maps = meta_maps
78
+ self.meta_keys = list(meta_keys)
79
+
80
+ self.use_raw = bool(use_raw)
81
+ self.cp_target_sum = float(cp_target_sum)
82
+ self.prefer_csr = bool(prefer_csr)
83
+ self.filter_missing_genes = bool(filter_missing_genes)
84
+ self.value_bin_cache_dir = str(value_bin_cache_dir or "")
85
+ self.require_value_bin_cache = bool(require_value_bin_cache)
86
+ self._bin_cache_indptr = None
87
+ self._bin_cache_ids = None
88
+
89
+ _log(f"open: {path} (use_raw={self.use_raw}, backed={backed})")
90
+ self.adata = ad.read_h5ad(path, backed=backed)
91
+ self.n_cells = int(self.adata.n_obs)
92
+
93
+ if self.use_raw:
94
+ if (self.adata.raw is None) or (self.adata.raw.X is None):
95
+ raise ValueError(f"[{path}] use_raw=True but adata.raw.X missing.")
96
+ self.X = self.adata.raw.X
97
+ self.var_names = self.adata.raw.var_names
98
+ else:
99
+ if self.adata.X is None:
100
+ raise ValueError(f"[{path}] use_raw=False but adata.X missing.")
101
+ self.X = self.adata.X
102
+ self.var_names = self.adata.var_names
103
+
104
+ self.local_to_global = self._build_local_to_global(strict=strict_gene)
105
+ self.meta_int_cols = self._build_meta_int_cols()
106
+ self._load_value_bin_cache()
107
+
108
+ self._is_csr = sp.isspmatrix_csr(self.X)
109
+ if self._is_csr and self.prefer_csr:
110
+ self._csr_indptr = self.X.indptr
111
+ self._csr_indices = self.X.indices
112
+ self._csr_data = self.X.data
113
+
114
+ _log(f"[{path}] n_cells={self.n_cells}")
115
+
116
+ def close(self) -> None:
117
+ _safe_close_anndata(self.adata)
118
+
119
+ def _load_value_bin_cache(self) -> None:
120
+ if not self.value_bin_cache_dir:
121
+ return
122
+ cache = load_value_bin_cache(
123
+ self.value_bin_cache_dir,
124
+ self.path,
125
+ expected_n_cells=self.n_cells,
126
+ mmap_mode="r",
127
+ )
128
+ if cache is None:
129
+ if self.require_value_bin_cache:
130
+ raise FileNotFoundError(
131
+ f"Missing value bin cache for {self.path} under {self.value_bin_cache_dir}"
132
+ )
133
+ return
134
+ self._bin_cache_indptr = cache["indptr"]
135
+ self._bin_cache_ids = cache["bin_ids"]
136
+
137
+ def has_value_bin_cache(self) -> bool:
138
+ return self._bin_cache_indptr is not None and self._bin_cache_ids is not None
139
+
140
+ def get_cached_bin_ids(self, cell_idx: int) -> Optional[np.ndarray]:
141
+ if not self.has_value_bin_cache():
142
+ return None
143
+ i = int(cell_idx)
144
+ start = int(self._bin_cache_indptr[i])
145
+ end = int(self._bin_cache_indptr[i + 1])
146
+ return np.asarray(self._bin_cache_ids[start:end], dtype=np.int64)
147
+
148
+ def _build_local_to_global(self, *, strict: bool) -> np.ndarray:
149
+ ids = np.fromiter(
150
+ (self.gene_name_to_id.get(str(g), 0) for g in list(self.var_names)),
151
+ dtype=np.int64,
152
+ count=len(self.var_names),
153
+ )
154
+ if strict and int(np.sum(ids == 0)) > 0:
155
+ missing = int(np.sum(ids == 0))
156
+ raise ValueError(
157
+ f"[{self.path}] {missing} genes missing in global gene vocab. "
158
+ "Rebuild vocab with full manifest or check gene naming consistency."
159
+ )
160
+ return ids
161
+
162
+ def _build_meta_int_cols(self) -> Dict[str, np.ndarray]:
163
+ out: Dict[str, np.ndarray] = {}
164
+ for k in self.meta_keys:
165
+ if k not in self.adata.obs.columns:
166
+ raise ValueError(f"[{self.path}] obs has no column '{k}'")
167
+ if k not in self.meta_maps:
168
+ raise ValueError(f"meta_maps missing key='{k}'. Build meta vocabs first.")
169
+
170
+ gmap = self.meta_maps[k]
171
+ col = self.adata.obs[k].astype("category")
172
+ cats = col.cat.categories.astype(str).tolist()
173
+ codes = col.cat.codes.to_numpy()
174
+
175
+ cat_ids = np.array([gmap.get(c, 0) for c in cats], dtype=np.int32)
176
+ ids = np.zeros((self.n_cells,), dtype=np.int32)
177
+ valid = codes >= 0
178
+ ids[valid] = cat_ids[codes[valid]]
179
+ out[k] = ids
180
+ return out
181
+
182
+ def _csr_row_indices_data(self, cell_idx: int) -> Tuple[np.ndarray, np.ndarray]:
183
+ i = int(cell_idx)
184
+ start = int(self._csr_indptr[i])
185
+ end = int(self._csr_indptr[i + 1])
186
+ return self._csr_indices[start:end], self._csr_data[start:end]
187
+
188
+ def _row_indices_data(self, cell_idx: int) -> Tuple[np.ndarray, np.ndarray]:
189
+ if self._is_csr and self.prefer_csr:
190
+ idx, val = self._csr_row_indices_data(cell_idx)
191
+ else:
192
+ row = self.X[int(cell_idx)]
193
+ if sp.issparse(row):
194
+ if not sp.isspmatrix_csr(row):
195
+ row = row.tocsr()
196
+ idx, val = row.indices, row.data
197
+ else:
198
+ dense = np.asarray(row).ravel()
199
+ idx = np.flatnonzero(dense)
200
+ val = dense[idx]
201
+
202
+ if val.size > 0:
203
+ keep = val != 0
204
+ if not np.all(keep):
205
+ idx = idx[keep]
206
+ val = val[keep]
207
+
208
+ return idx, val
209
+
210
+ def get_cell_tokens(self, cell_idx: int) -> Tuple[List[int], List[float]]:
211
+ idx, val = self._row_indices_data(cell_idx)
212
+
213
+ if idx.size == 0:
214
+ return [], []
215
+
216
+ row_sum = float(val.sum())
217
+ if self.use_raw:
218
+ if row_sum <= 0:
219
+ return [], []
220
+ expr = np.log1p(val.astype(np.float32) * (self.cp_target_sum / row_sum))
221
+ else:
222
+ expr = val.astype(np.float32)
223
+
224
+ gene_ids = self.local_to_global[idx.astype(np.int64)].astype(np.int64)
225
+ if self.filter_missing_genes:
226
+ keep = gene_ids > 0
227
+ if not np.all(keep):
228
+ gene_ids = gene_ids[keep]
229
+ expr = expr[keep]
230
+ if gene_ids.size == 0:
231
+ return [], []
232
+ return gene_ids.tolist(), expr.tolist()
233
+
234
+ def get_cell_observation_stats(self, cell_idx: int) -> Tuple[float, int]:
235
+ idx, val = self._row_indices_data(cell_idx)
236
+ detected_genes = int(idx.size)
237
+ if self.use_raw:
238
+ return float(val.sum()), detected_genes
239
+
240
+ obs = self.adata.obs
241
+ row = obs.iloc[int(cell_idx)]
242
+
243
+ total_counts = None
244
+ for key in ("total_counts", "n_counts"):
245
+ if key not in obs.columns:
246
+ continue
247
+ candidate = float(row[key])
248
+ if np.isfinite(candidate):
249
+ total_counts = candidate
250
+ break
251
+ if total_counts is None:
252
+ total_counts = float(detected_genes)
253
+
254
+ for key in ("n_genes_by_counts", "n_genes"):
255
+ if key not in obs.columns:
256
+ continue
257
+ candidate = float(row[key])
258
+ if np.isfinite(candidate):
259
+ detected_genes = int(candidate)
260
+ break
261
+ return total_counts, detected_genes
262
+
263
+ def get_meta_batch(self, cell_ids: np.ndarray, *, include_dataset_id: bool = False) -> Dict[str, List[int]]:
264
+ ids = cell_ids.astype(np.int64)
265
+ out: Dict[str, List[int]] = {}
266
+
267
+ if include_dataset_id:
268
+ out["dataset_id"] = [self.dataset_id + 1] * int(ids.size)
269
+
270
+ for k in self.meta_keys:
271
+ out[k] = self.meta_int_cols[k][ids].astype(np.int64).tolist()
272
+
273
+ return out
274
+
275
+
276
+ # =============================================================================
277
+ # Global pseudo-batch style index metadata
278
+ # =============================================================================
279
+ @dataclass
280
+ class _PseudoBatchGroupIndex:
281
+ """
282
+ One synthetic group inside one file.
283
+
284
+ For scRep we keep one group containing all cells in the file.
285
+ """
286
+
287
+ raw_cell_ids: np.ndarray # int32
288
+ n_batches: int
289
+ usable_cells: int
290
+
291
+
292
+ @dataclass
293
+ class _FileIndex:
294
+ path: str
295
+ dataset_id: int
296
+ groups: List[_PseudoBatchGroupIndex]
297
+
298
+
299
+ # =============================================================================
300
+ # Small LRU reader cache
301
+ # =============================================================================
302
+ class _ReaderCache:
303
+ """
304
+ Small LRU cache of open H5ADFileReader objects.
305
+
306
+ Each worker / rank gets its own cache inside __iter__.
307
+ """
308
+
309
+ def __init__(
310
+ self,
311
+ *,
312
+ max_open_files: int,
313
+ gene_name_to_id: Dict[str, int],
314
+ meta_maps: Dict[str, Dict[str, int]],
315
+ meta_keys: List[str],
316
+ use_raw: bool,
317
+ cp_target_sum: float,
318
+ backed: Optional[str],
319
+ prefer_csr: bool,
320
+ strict_gene: bool,
321
+ filter_missing_genes: bool,
322
+ value_bin_cache_dir: str,
323
+ require_value_bin_cache: bool,
324
+ ) -> None:
325
+ self.max_open_files = int(max_open_files)
326
+ self.gene_name_to_id = gene_name_to_id
327
+ self.meta_maps = meta_maps
328
+ self.meta_keys = meta_keys
329
+ self.use_raw = bool(use_raw)
330
+ self.cp_target_sum = float(cp_target_sum)
331
+ self.backed = backed
332
+ self.prefer_csr = bool(prefer_csr)
333
+ self.strict_gene = bool(strict_gene)
334
+ self.filter_missing_genes = bool(filter_missing_genes)
335
+ self.value_bin_cache_dir = str(value_bin_cache_dir or "")
336
+ self.require_value_bin_cache = bool(require_value_bin_cache)
337
+
338
+ self._cache: "OrderedDict[int, H5ADFileReader]" = OrderedDict()
339
+
340
+ def get(self, file_id: int, file_meta: _FileIndex) -> H5ADFileReader:
341
+ if file_id in self._cache:
342
+ reader = self._cache.pop(file_id)
343
+ self._cache[file_id] = reader
344
+ return reader
345
+
346
+ reader = H5ADFileReader(
347
+ path=file_meta.path,
348
+ dataset_id=file_meta.dataset_id,
349
+ gene_name_to_id=self.gene_name_to_id,
350
+ meta_maps=self.meta_maps,
351
+ meta_keys=self.meta_keys,
352
+ use_raw=self.use_raw,
353
+ cp_target_sum=self.cp_target_sum,
354
+ backed=self.backed,
355
+ prefer_csr=self.prefer_csr,
356
+ strict_gene=self.strict_gene,
357
+ filter_missing_genes=self.filter_missing_genes,
358
+ value_bin_cache_dir=self.value_bin_cache_dir,
359
+ require_value_bin_cache=self.require_value_bin_cache,
360
+ )
361
+
362
+ self._cache[file_id] = reader
363
+
364
+ while len(self._cache) > self.max_open_files:
365
+ _, old_reader = self._cache.popitem(last=False)
366
+ try:
367
+ old_reader.close()
368
+ except Exception:
369
+ pass
370
+
371
+ return reader
372
+
373
+ def close_all(self) -> None:
374
+ for _, reader in self._cache.items():
375
+ try:
376
+ reader.close()
377
+ except Exception:
378
+ pass
379
+ self._cache.clear()
380
+
381
+
382
+ # =============================================================================
383
+ # Multi-file single-cell stream
384
+ # =============================================================================
385
+ class MultiH5ADCellStream(IterableDataset):
386
+ """
387
+ Globally shuffled single-cell stream.
388
+
389
+ Changes from the old implementation:
390
+ - Build a static global cell-slot index once.
391
+ - Sample slots by a global permutation, so cells are drawn with weight
392
+ proportional to file size (number of cells), not by equal-file coin flips.
393
+ - Still materializes one cell per yielded example.
394
+ """
395
+
396
+ def __init__(
397
+ self,
398
+ manifest: List[Dict[str, Any]],
399
+ *,
400
+ gene_name_to_id: Dict[str, int],
401
+ meta_maps: Dict[str, Dict[str, int]],
402
+ meta_keys: Optional[List[str]] = None,
403
+ buffer_size: int = 16,
404
+ seed: int = 42,
405
+ use_raw: bool = True,
406
+ cp_target_sum: float = 1e4,
407
+ backed: Optional[str] = "r",
408
+ prefer_csr: bool = True,
409
+ sort_cell_ids_for_io: bool = False,
410
+ include_dataset_id: bool = False,
411
+ skip_bad_files: bool = False,
412
+ filter_missing_genes: bool = False,
413
+ value_bin_cache_dir: str = "",
414
+ require_value_bin_cache: bool = False,
415
+ ) -> None:
416
+ self.manifest = list(manifest)
417
+ self.gene_name_to_id = gene_name_to_id
418
+ self.meta_maps = meta_maps
419
+
420
+ self.meta_keys = list(meta_keys or [])
421
+ self.full_meta_keys = list(self.meta_keys)
422
+
423
+ # Kept for compatibility with existing call sites / configs.
424
+ self.buffer_size = int(buffer_size)
425
+ self.seed = int(seed)
426
+
427
+ # Equivalent to sampling one-cell pseudo-batches.
428
+ self.N = 1
429
+
430
+ self.use_raw = bool(use_raw)
431
+ self.cp_target_sum = float(cp_target_sum)
432
+ self.backed = backed
433
+ self.prefer_csr = bool(prefer_csr)
434
+ self.filter_missing_genes = bool(filter_missing_genes)
435
+ self.strict_gene = not self.filter_missing_genes
436
+
437
+ self.sort_cell_ids_for_io = bool(sort_cell_ids_for_io)
438
+ self.include_dataset_id = bool(include_dataset_id)
439
+ self.skip_bad_files = bool(skip_bad_files)
440
+ self.value_bin_cache_dir = str(value_bin_cache_dir or "")
441
+ self.require_value_bin_cache = bool(require_value_bin_cache)
442
+
443
+ # reader cache
444
+ self.max_open_files = 1024
445
+
446
+ self.cycle = True
447
+ self._iter_id = 0
448
+
449
+ if len(self.manifest) == 0:
450
+ raise RuntimeError("Empty manifest.")
451
+
452
+ # Built once
453
+ self.file_index: List[_FileIndex] = []
454
+ # shape: (num_global_pseudo_batches, 3), columns = [file_id, group_id, batch_id]
455
+ self.batch_refs: np.ndarray = np.empty((0, 3), dtype=np.int32)
456
+
457
+ self._build_global_index()
458
+
459
+ # -------------------------------------------------------------------------
460
+ # Global index construction
461
+ # -------------------------------------------------------------------------
462
+ def _scan_one_file(self, item: Dict[str, Any], file_id: int) -> Optional[_FileIndex]:
463
+ path = item["path"]
464
+ dataset_id = int(item.get("dataset_id", 0))
465
+
466
+ adata = None
467
+ try:
468
+ adata = ad.read_h5ad(path, backed=self.backed)
469
+ n_cells = int(adata.n_obs)
470
+ if n_cells < self.N:
471
+ _log(f"skip file (n_cells < N): {path}")
472
+ return None
473
+
474
+ # Keep failure mode aligned with old stream: invalid/missing columns fail at scan
475
+ # rather than mid-iteration.
476
+ for k in self.full_meta_keys:
477
+ if k not in adata.obs.columns:
478
+ raise ValueError(f"[{path}] obs has no required column '{k}'.")
479
+
480
+ raw_cells = np.arange(n_cells, dtype=np.int32)
481
+ n_batches = int(raw_cells.size // self.N)
482
+ if n_batches <= 0:
483
+ _log(f"skip file (no full pseudo-batches): {path}")
484
+ return None
485
+
486
+ usable_cells = int(n_batches * self.N)
487
+ group = _PseudoBatchGroupIndex(
488
+ raw_cell_ids=raw_cells,
489
+ n_batches=n_batches,
490
+ usable_cells=usable_cells,
491
+ )
492
+ return _FileIndex(path=path, dataset_id=dataset_id, groups=[group])
493
+ finally:
494
+ if adata is not None:
495
+ _safe_close_anndata(adata)
496
+
497
+ def _build_global_index(self) -> None:
498
+ file_index: List[_FileIndex] = []
499
+ batch_refs: List[Tuple[int, int, int]] = []
500
+
501
+ for src_file_id, item in enumerate(self.manifest):
502
+ try:
503
+ fidx = self._scan_one_file(item, src_file_id)
504
+ except Exception as e:
505
+ if self.skip_bad_files:
506
+ _log(f"skip bad file: {item.get('path')} | {e}")
507
+ continue
508
+ raise
509
+
510
+ if fidx is None:
511
+ continue
512
+
513
+ new_file_id = len(file_index)
514
+ file_index.append(fidx)
515
+
516
+ for group_id, g in enumerate(fidx.groups):
517
+ for batch_id in range(int(g.n_batches)):
518
+ batch_refs.append((new_file_id, group_id, batch_id))
519
+
520
+ if len(file_index) == 0 or len(batch_refs) == 0:
521
+ raise RuntimeError(
522
+ "No eligible cells found. "
523
+ "Check manifest, requested meta keys, and skip_bad_files setting."
524
+ )
525
+
526
+ self.file_index = file_index
527
+ self.batch_refs = np.asarray(batch_refs, dtype=np.int32)
528
+
529
+ total_files = len(self.file_index)
530
+ total_groups = int(sum(len(f.groups) for f in self.file_index))
531
+ total_batches = int(self.batch_refs.shape[0])
532
+ total_cells_usable = int(sum(g.usable_cells for f in self.file_index for g in f.groups))
533
+
534
+ _log(
535
+ f"global index built: files={total_files}, groups={total_groups}, "
536
+ f"slots={total_batches}, usable_cells={total_cells_usable}"
537
+ )
538
+
539
+ # -------------------------------------------------------------------------
540
+ # Introspection helpers
541
+ # -------------------------------------------------------------------------
542
+ @property
543
+ def num_global_pseudo_batches(self) -> int:
544
+ return int(self.batch_refs.shape[0])
545
+
546
+ def estimate_batches_per_shard(self, num_shards: int) -> int:
547
+ if num_shards <= 0:
548
+ return self.num_global_pseudo_batches
549
+ return (self.num_global_pseudo_batches + num_shards - 1) // num_shards
550
+
551
+ # -------------------------------------------------------------------------
552
+ # Per-epoch group reshuffle
553
+ # -------------------------------------------------------------------------
554
+ def _build_epoch_group_batches(self, epoch_seed: int) -> List[List[np.ndarray]]:
555
+ """
556
+ For the current epoch/pass, rebuild each file/group by:
557
+ - reshuffling cell ids
558
+ - dropping tail
559
+ - reshaping to (n_batches, N)
560
+
561
+ Returns:
562
+ epoch_group_batches[file_id][group_id] = np.ndarray of shape (n_batches, N)
563
+ """
564
+ epoch_group_batches: List[List[np.ndarray]] = []
565
+
566
+ for file_id, fidx in enumerate(self.file_index):
567
+ file_batches: List[np.ndarray] = []
568
+
569
+ for group_id, gidx in enumerate(fidx.groups):
570
+ group_seed = (
571
+ int(epoch_seed)
572
+ + 1000003 * int(file_id + 1)
573
+ + 9176 * int(group_id + 1)
574
+ ) % (2**63 - 1)
575
+
576
+ rng = np.random.default_rng(group_seed)
577
+
578
+ raw = gidx.raw_cell_ids
579
+ perm = rng.permutation(raw.size)
580
+ shuffled = raw[perm]
581
+
582
+ usable = shuffled[: gidx.usable_cells]
583
+ batch_cells = usable.reshape(gidx.n_batches, self.N).astype(np.int32, copy=False)
584
+ file_batches.append(batch_cells)
585
+
586
+ epoch_group_batches.append(file_batches)
587
+
588
+ return epoch_group_batches
589
+
590
+ # -------------------------------------------------------------------------
591
+ # Materialization
592
+ # -------------------------------------------------------------------------
593
+ def _materialize_one_batch(
594
+ self,
595
+ *,
596
+ file_id: int,
597
+ group_id: int,
598
+ batch_id: int,
599
+ epoch_group_batches: List[List[np.ndarray]],
600
+ cache: _ReaderCache,
601
+ ) -> Dict[str, Any]:
602
+ fmeta = self.file_index[file_id]
603
+ cell_ids = epoch_group_batches[file_id][group_id][batch_id]
604
+
605
+ if self.sort_cell_ids_for_io:
606
+ cell_ids = np.sort(cell_ids)
607
+
608
+ reader = cache.get(file_id, fmeta)
609
+
610
+ meta_acc = reader.get_meta_batch(
611
+ cell_ids,
612
+ include_dataset_id=self.include_dataset_id,
613
+ )
614
+
615
+ cid = int(cell_ids[0])
616
+ gene_ids, expr = reader.get_cell_tokens(cid)
617
+ total_counts, n_genes_by_counts = reader.get_cell_observation_stats(cid)
618
+ out = {
619
+ "gene_ids": gene_ids,
620
+ "expr": expr,
621
+ "meta": meta_acc,
622
+ "total_counts": float(total_counts),
623
+ "n_genes_by_counts": int(n_genes_by_counts),
624
+ }
625
+ cached_bin_ids = reader.get_cached_bin_ids(cid)
626
+ if cached_bin_ids is not None:
627
+ if int(cached_bin_ids.shape[0]) != len(gene_ids):
628
+ raise ValueError(
629
+ f"Cached bin length mismatch for {reader.path} cell={cid}: "
630
+ f"bin_ids={cached_bin_ids.shape[0]} genes={len(gene_ids)}"
631
+ )
632
+ out["bin_ids"] = cached_bin_ids.astype(np.int64, copy=False).tolist()
633
+ return out
634
+
635
+ # -------------------------------------------------------------------------
636
+ # Iteration
637
+ # -------------------------------------------------------------------------
638
+ def __iter__(self):
639
+ # -------------------------
640
+ # rank / world
641
+ # -------------------------
642
+ if torch.distributed.is_available() and torch.distributed.is_initialized():
643
+ rank = torch.distributed.get_rank()
644
+ world_size = torch.distributed.get_world_size()
645
+ else:
646
+ rank = int(os.environ.get("RANK", "0"))
647
+ world_size = int(os.environ.get("WORLD_SIZE", "1"))
648
+
649
+ # -------------------------
650
+ # worker info
651
+ # -------------------------
652
+ info = get_worker_info()
653
+ if info is None:
654
+ worker_id, num_workers = 0, 1
655
+ else:
656
+ worker_id, num_workers = info.id, info.num_workers
657
+
658
+ shard_id = rank * num_workers + worker_id
659
+ num_shards = world_size * num_workers
660
+
661
+ iter_id = getattr(self, "_iter_id", 0)
662
+ self._iter_id = iter_id + 1
663
+
664
+ base_seed = int(self.seed)
665
+ mix = (base_seed + 10000019 * iter_id) % (2**63 - 1)
666
+ rng = np.random.default_rng(mix)
667
+
668
+ if DEBUG_PRINT:
669
+ _log(
670
+ f"rank={rank}/{world_size} worker={worker_id}/{num_workers} "
671
+ f"iter_id={iter_id} seed={mix} shard={shard_id}/{num_shards} "
672
+ f"global_pseudo_batches={self.num_global_pseudo_batches}"
673
+ )
674
+
675
+ cache = _ReaderCache(
676
+ max_open_files=self.max_open_files,
677
+ gene_name_to_id=self.gene_name_to_id,
678
+ meta_maps=self.meta_maps,
679
+ meta_keys=self.full_meta_keys,
680
+ use_raw=self.use_raw,
681
+ cp_target_sum=self.cp_target_sum,
682
+ backed=self.backed,
683
+ prefer_csr=self.prefer_csr,
684
+ strict_gene=self.strict_gene,
685
+ filter_missing_genes=self.filter_missing_genes,
686
+ value_bin_cache_dir=self.value_bin_cache_dir,
687
+ require_value_bin_cache=self.require_value_bin_cache,
688
+ )
689
+
690
+ try:
691
+ epoch_idx = 0
692
+ while True:
693
+ epoch_seed = (mix + 1000003 * epoch_idx) % (2**63 - 1)
694
+
695
+ # 1) Re-shuffle every group for this epoch
696
+ epoch_group_batches = self._build_epoch_group_batches(epoch_seed)
697
+
698
+ # 2) Global shuffle over static slot table
699
+ global_order = rng.permutation(self.num_global_pseudo_batches)
700
+
701
+ # 3) Shard by rank * worker
702
+ local_order = global_order[shard_id::num_shards]
703
+
704
+ if DEBUG_PRINT:
705
+ _log(
706
+ f"new epoch/pass: epoch_idx={epoch_idx}, total={self.num_global_pseudo_batches}, "
707
+ f"local={len(local_order)}, shard={shard_id}/{num_shards}"
708
+ )
709
+
710
+ for ref_idx in local_order.tolist():
711
+ file_id, group_id, batch_id = self.batch_refs[int(ref_idx)].tolist()
712
+ yield self._materialize_one_batch(
713
+ file_id=int(file_id),
714
+ group_id=int(group_id),
715
+ batch_id=int(batch_id),
716
+ epoch_group_batches=epoch_group_batches,
717
+ cache=cache,
718
+ )
719
+
720
+ epoch_idx += 1
721
+ if not self.cycle:
722
+ break
723
+
724
+ finally:
725
+ cache.close_all()
scRep_pretrain/manifest.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import time
3
+ import logging
4
+ import anndata as ad
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List, Optional, Tuple
7
+
8
+
9
+ # =============================================================================
10
+ # Debug / Logging
11
+ # =============================================================================
12
+ DEBUG_PRINT: bool = False
13
+ logger = logging.getLogger("scRep_train")
14
+
15
+
16
+ def _log(msg: str) -> None:
17
+ if DEBUG_PRINT:
18
+ print(f"[manifest] {msg}")
19
+
20
+
21
+ # =============================================================================
22
+ # Utils
23
+ # =============================================================================
24
+ def _safe_close_anndata(adata_obj: Any) -> None:
25
+ """Close backed AnnData file handle if present."""
26
+ f = getattr(adata_obj, "file", None)
27
+ if f is not None:
28
+ try:
29
+ f.close()
30
+ except Exception:
31
+ pass
32
+
33
+
34
+ def save_manifest_json(manifest: List[Dict[str, Any]], out_path: str) -> None:
35
+ """Save manifest to json."""
36
+ _log(f"Save -> {out_path}")
37
+ with open(out_path, "w", encoding="utf-8") as f:
38
+ json.dump(manifest, f, ensure_ascii=False, indent=2)
39
+ _log(f"Saved items: {len(manifest)}")
40
+
41
+
42
+ def _obs_nunique_and_topk(
43
+ adata_obj: Any,
44
+ col_name: str,
45
+ *,
46
+ topk: int = 0,
47
+ ) -> Tuple[int, Optional[List[Tuple[str, int]]]]:
48
+ """
49
+ Compute nunique (dropna) for one obs column, optionally return top-k value counts.
50
+ """
51
+ if col_name not in adata_obj.obs.columns:
52
+ return 0, None
53
+
54
+ s = adata_obj.obs[col_name]
55
+
56
+ try:
57
+ n_uniq = int(s.nunique(dropna=True))
58
+ except Exception:
59
+ s_cat = s.astype("category")
60
+ n_uniq = int(len(s_cat.cat.categories))
61
+
62
+ top_items: Optional[List[Tuple[str, int]]] = None
63
+ if topk and topk > 0:
64
+ try:
65
+ vc = s.value_counts(dropna=True)
66
+ top_items = [(str(idx), int(cnt)) for idx, cnt in vc.head(int(topk)).items()]
67
+ except Exception:
68
+ top_items = None
69
+
70
+ return n_uniq, top_items
71
+
72
+
73
+ # =============================================================================
74
+ # Manifest builder
75
+ # =============================================================================
76
+ def build_manifest_from_dir(
77
+ dir_path: str,
78
+ *,
79
+ pattern: str = "*.h5ad",
80
+ backed: Optional[str] = "r",
81
+ use_raw: bool = True,
82
+ required_obs_cols: Optional[List[str]] = None,
83
+ obs_count_cols: Optional[List[str]] = None,
84
+ include_topk: int = 0,
85
+ min_cells: int = 1,
86
+ max_files: Optional[int] = None,
87
+ print_every: int = 50,
88
+ ) -> List[Dict[str, Any]]:
89
+ """
90
+ Scan a directory and build a stable manifest.
91
+
92
+ Stored per file:
93
+ - path, name, dataset_id, n_cells
94
+ - obs_nunique: Dict[col, nunique]
95
+ - obs_topk: Dict[col, List[(value, count)]] # optional
96
+ """
97
+ required_obs_cols = list(required_obs_cols or [])
98
+ obs_count_cols = list(obs_count_cols or [])
99
+
100
+ root = Path(dir_path)
101
+ files = sorted(root.glob(pattern))
102
+ if max_files is not None:
103
+ files = files[: int(max_files)]
104
+
105
+ _log(f"Scan dir: {dir_path}")
106
+ _log(f"Found files: {len(files)} (pattern={pattern})")
107
+ _log(f"Checks: use_raw={use_raw}, min_cells={min_cells}")
108
+ _log(f"Required obs cols: {required_obs_cols}")
109
+ _log(f"Count obs cols: {obs_count_cols} (topk={include_topk})")
110
+ logger.info(
111
+ "manifest scan start: dir=%s files=%s use_raw=%s min_cells=%s required_obs_cols=%s",
112
+ dir_path, len(files), use_raw, min_cells, required_obs_cols,
113
+ )
114
+
115
+ manifest: List[Dict[str, Any]] = []
116
+ n_skipped = 0
117
+ n_failed = 0
118
+ start_time = time.time()
119
+
120
+ for i, p in enumerate(files):
121
+ path = str(p)
122
+
123
+ if (i + 1) % max(1, int(print_every)) == 0:
124
+ elapsed = time.time() - start_time
125
+ _log(
126
+ f"Progress: {i + 1}/{len(files)} | "
127
+ f"kept={len(manifest)} skipped={n_skipped} failed={n_failed}"
128
+ )
129
+ logger.info(
130
+ "manifest scan progress: %s/%s kept=%s skipped=%s failed=%s elapsed=%.1fs",
131
+ i + 1, len(files), len(manifest), n_skipped, n_failed, elapsed,
132
+ )
133
+
134
+ adata_obj = None
135
+ try:
136
+ adata_obj = ad.read_h5ad(path, backed=backed)
137
+
138
+ n_obs = int(adata_obj.n_obs)
139
+ if n_obs < int(min_cells):
140
+ n_skipped += 1
141
+ continue
142
+
143
+ if use_raw:
144
+ has_raw = (adata_obj.raw is not None) and (getattr(adata_obj.raw, "X", None) is not None)
145
+ if not has_raw:
146
+ n_skipped += 1
147
+ continue
148
+ else:
149
+ has_x = getattr(adata_obj, "X", None) is not None
150
+ if not has_x:
151
+ n_skipped += 1
152
+ continue
153
+
154
+ obs_cols = set(adata_obj.obs.columns.tolist())
155
+ if any(c not in obs_cols for c in required_obs_cols):
156
+ n_skipped += 1
157
+ continue
158
+
159
+ obs_nunique: Dict[str, int] = {}
160
+ obs_topk: Dict[str, List[Tuple[str, int]]] = {}
161
+
162
+ for col in obs_count_cols:
163
+ n_uniq, top_items = _obs_nunique_and_topk(adata_obj, col, topk=include_topk)
164
+ obs_nunique[col] = int(n_uniq)
165
+ if include_topk and top_items is not None:
166
+ obs_topk[col] = top_items
167
+
168
+ dataset_id = len(manifest)
169
+
170
+ item: Dict[str, Any] = {
171
+ "path": path,
172
+ "name": p.name,
173
+ "dataset_id": int(dataset_id),
174
+ "n_cells": int(n_obs),
175
+ "obs_nunique": obs_nunique,
176
+ }
177
+ if include_topk and len(obs_topk) > 0:
178
+ item["obs_topk"] = obs_topk
179
+
180
+ manifest.append(item)
181
+
182
+ except Exception as e:
183
+ n_failed += 1
184
+ _log(f"Failed: {path} | {type(e).__name__}: {e}")
185
+ finally:
186
+ if adata_obj is not None:
187
+ _safe_close_anndata(adata_obj)
188
+
189
+ elapsed = time.time() - start_time
190
+ _log(f"Done. Kept={len(manifest)}, skipped={n_skipped}, failed={n_failed}")
191
+ logger.info(
192
+ "manifest scan done: kept=%s skipped=%s failed=%s elapsed=%.1fs",
193
+ len(manifest), n_skipped, n_failed, elapsed,
194
+ )
195
+ if len(manifest) > 0:
196
+ _log(f"Example item:\n{json.dumps(manifest[0], ensure_ascii=False, indent=2)[:2000]}")
197
+ return manifest
scRep_pretrain/model/__init__.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ from .foundation_model import CellContrastivePredictModel, FoundationConfig
2
+ from .contrastive_collator import ContrastiveSplitCellCollator
3
+ from .contrastive_trainer import ContrastiveCellTrainer
4
+
5
+ __all__ = [
6
+ "CellContrastivePredictModel",
7
+ "FoundationConfig",
8
+ "ContrastiveSplitCellCollator",
9
+ "ContrastiveCellTrainer",
10
+ ]
scRep_pretrain/model/contrastive_collator.py ADDED
@@ -0,0 +1,981 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ import torch
5
+ from torch.utils.data import get_worker_info
6
+ from typing import Any, Dict, List, Optional, Tuple
7
+
8
+
9
+ def _distributed_rank_world() -> Tuple[int, int]:
10
+ if torch.distributed.is_available() and torch.distributed.is_initialized():
11
+ return int(torch.distributed.get_rank()), int(torch.distributed.get_world_size())
12
+ try:
13
+ rank = int(__import__("os").environ.get("RANK", "0"))
14
+ world = int(__import__("os").environ.get("WORLD_SIZE", "1"))
15
+ except Exception:
16
+ return 0, 1
17
+ return max(0, rank), max(1, world)
18
+
19
+
20
+ PlanStep = tuple[str, int, int, int]
21
+
22
+
23
+ def _split_target_count(count: int, target: float, bins_left: int, reserve: int, split_factor: float) -> int:
24
+ max_split = min(int(count), int(bins_left) - int(reserve))
25
+ if max_split < 2 or float(count) < float(split_factor) * float(target):
26
+ return 1
27
+ denom = max(float(target), 1.0)
28
+ return max(2, min(int(round(float(count) / denom)), int(max_split)))
29
+
30
+
31
+ def _plan_value_aware_lookahead_bins(
32
+ counts: np.ndarray,
33
+ n_bins: int,
34
+ split_factor: float = 1.2,
35
+ ) -> list[PlanStep]:
36
+ counts_i = np.asarray(counts, dtype=np.int64)
37
+ num_values = int(counts_i.shape[0])
38
+ total = int(counts_i.sum())
39
+ if num_values == 0 or total <= 0:
40
+ return []
41
+
42
+ k_eff = max(1, min(int(n_bins), total))
43
+ prefix = np.zeros(num_values + 1, dtype=np.int64)
44
+ prefix[1:] = np.cumsum(counts_i, dtype=np.int64)
45
+
46
+ plan: list[PlanStep] = []
47
+ i = 0
48
+ bins_left = int(k_eff)
49
+
50
+ while i < num_values and bins_left > 0:
51
+ remaining_values = int(num_values - i)
52
+ remaining_total = int(prefix[num_values] - prefix[i])
53
+ if remaining_total <= 0:
54
+ break
55
+
56
+ if bins_left == 1:
57
+ plan.append(("merge", int(i), int(num_values - 1), 1))
58
+ break
59
+
60
+ if remaining_values == 1:
61
+ count = int(counts_i[i])
62
+ bins_used = min(int(bins_left), count)
63
+ if bins_used >= 2:
64
+ plan.append(("split", int(i), int(i), int(bins_used)))
65
+ else:
66
+ plan.append(("merge", int(i), int(i), 1))
67
+ break
68
+
69
+ if bins_left > remaining_values:
70
+ count = int(counts_i[i])
71
+ needed_extra = int(bins_left - remaining_values)
72
+ bins_used = min(count, needed_extra + 1)
73
+ if bins_used >= 2:
74
+ plan.append(("split", int(i), int(i), int(bins_used)))
75
+ bins_left -= int(bins_used)
76
+ i += 1
77
+ continue
78
+
79
+ target = float(remaining_total) / float(bins_left)
80
+ reserve = 1 if (i + 1) < num_values else 0
81
+ split_bins = _split_target_count(int(counts_i[i]), target, bins_left, reserve, float(split_factor))
82
+ if split_bins >= 2:
83
+ plan.append(("split", int(i), int(i), int(split_bins)))
84
+ bins_left -= int(split_bins)
85
+ i += 1
86
+ continue
87
+
88
+ max_end = int(num_values - max(1, bins_left - 1) - 1)
89
+ max_end = max(int(i), max_end)
90
+ best_end = int(i)
91
+ best_cost = (float("inf"), float("inf"), float("inf"))
92
+ current_size = 0
93
+ worse_after_target = 0
94
+
95
+ for end in range(int(i), max_end + 1):
96
+ current_size += int(counts_i[end])
97
+ rest = int(prefix[num_values] - prefix[end + 1])
98
+ rest_bins = max(1, int(bins_left) - 1)
99
+ rest_target = float(rest) / float(rest_bins)
100
+ current_dev = abs(float(current_size) - target)
101
+ rest_dev = abs(rest_target - target)
102
+ cost = (max(current_dev, rest_dev), current_dev, rest_dev)
103
+ if cost < best_cost:
104
+ best_cost = cost
105
+ best_end = int(end)
106
+ worse_after_target = 0
107
+ elif current_size >= target:
108
+ worse_after_target += 1
109
+ if worse_after_target >= 2:
110
+ break
111
+
112
+ plan.append(("merge", int(i), int(best_end), 1))
113
+ bins_left -= 1
114
+ i = int(best_end) + 1
115
+
116
+ return plan
117
+
118
+
119
+ class ContrastiveSplitCellCollator:
120
+ """
121
+ DINO-like scRNA batch builder.
122
+
123
+ Per cell:
124
+ - teacher views:
125
+ 1) global gene+equal-bin view sampled at teacher_global_ratio from top teacher_top_genes genes
126
+ 2) another global gene+equal-bin view sampled at teacher_global_ratio from top teacher_top_genes genes
127
+ - student views:
128
+ 1) global gene+equal-bin subset with masked bin ids
129
+ 2) global gene+equal-bin subset
130
+ 3) local gene+equal-bin subset
131
+ 4) local gene+equal-bin subset
132
+ """
133
+
134
+ def __init__(
135
+ self,
136
+ *,
137
+ vocab: Any,
138
+ gene_vocab_size: int,
139
+ n_bins: int = 50,
140
+ max_input_genes: int = 5120,
141
+ shuffle_genes: bool = True,
142
+ seed: int = 42,
143
+ teacher_top_genes: int = 2048,
144
+ teacher_num_views: int = 2,
145
+ teacher_global_ratio: float = 0.95,
146
+ student_global_ratio_min: float = 0.60,
147
+ student_global_ratio_max: float = 0.85,
148
+ student_global_min_genes: int = 1000,
149
+ student_local_min_genes: int = 512,
150
+ student_local_max_genes: int = 768,
151
+ student_num_local_views: int = 2,
152
+ student_global_dropout_prob: float = 0.10,
153
+ student_local_dropout_prob: float = 0.20,
154
+ student_global_bin_mask_prob: float = 0.35,
155
+ student_global_bin_mask_before_binning: bool = False,
156
+ student_high_expr_global_view: str = "none",
157
+ student_high_expr_local_view: str = "none",
158
+ student_gene_only_local_view: str = "none",
159
+ student_global_mask_mode: str = "current",
160
+ student_global_model_masking: bool = False,
161
+ student_global_mask_sample_probability: float = 0.5,
162
+ student_global_mask_ratio_min: float = 0.1,
163
+ student_global_mask_ratio_max: float = 0.5,
164
+ include_teacher_cls_decoder_targets: bool = False,
165
+ teacher_cls_decoder_target_mode: str = "top_other_zero",
166
+ teacher_cls_decoder_other_expr_genes: int = 512,
167
+ teacher_cls_decoder_zero_genes: int = 512,
168
+ plan_mode: str = "lookahead_split_1.2",
169
+ expr_embedding_mode: str = "bin",
170
+ ) -> None:
171
+ self.vocab = vocab
172
+ self.gene_vocab_size = int(gene_vocab_size)
173
+ self.n_bins = int(n_bins)
174
+ self.shuffle_genes = bool(shuffle_genes)
175
+ self.base_seed = int(seed)
176
+ self.num_cls_tokens = 1
177
+ self.expr_embedding_mode = str(expr_embedding_mode).strip().lower()
178
+
179
+ if self.gene_vocab_size <= int(getattr(self.vocab, "gene_offset", 0)):
180
+ raise ValueError("gene_vocab_size must be greater than vocab.gene_offset")
181
+ if self.n_bins <= 0:
182
+ raise ValueError("n_bins must be > 0")
183
+ if self.expr_embedding_mode not in {"bin", "rank"}:
184
+ raise ValueError("expr_embedding_mode must be one of {'bin', 'rank'}")
185
+
186
+ teacher_top_genes = int(teacher_top_genes)
187
+ if teacher_top_genes <= 0:
188
+ raise ValueError("teacher_top_genes must be > 0")
189
+ if int(max_input_genes) > 0:
190
+ teacher_top_genes = min(teacher_top_genes, int(max_input_genes))
191
+ self.teacher_top_genes = int(teacher_top_genes)
192
+ teacher_num_views = int(teacher_num_views)
193
+ if teacher_num_views <= 0:
194
+ raise ValueError("teacher_num_views must be > 0")
195
+ self.teacher_num_views = int(teacher_num_views)
196
+ if not (0.0 < float(teacher_global_ratio) <= 1.0):
197
+ raise ValueError("teacher_global_ratio must be in (0, 1]")
198
+ self.teacher_global_ratio = float(teacher_global_ratio)
199
+
200
+ if not (0.0 < float(student_global_ratio_min) <= 1.0):
201
+ raise ValueError("student_global_ratio_min must be in (0, 1]")
202
+ if not (0.0 < float(student_global_ratio_max) <= 1.0):
203
+ raise ValueError("student_global_ratio_max must be in (0, 1]")
204
+ if float(student_global_ratio_min) > float(student_global_ratio_max):
205
+ raise ValueError("student_global_ratio_min must be <= student_global_ratio_max")
206
+ self.student_global_ratio_min = float(student_global_ratio_min)
207
+ self.student_global_ratio_max = float(student_global_ratio_max)
208
+ self.student_global_min_genes = int(student_global_min_genes)
209
+ self.student_local_min_genes = int(student_local_min_genes)
210
+ self.student_local_max_genes = int(student_local_max_genes)
211
+ self.student_num_local_views = int(student_num_local_views)
212
+ self.student_global_dropout_prob = float(student_global_dropout_prob)
213
+ self.student_local_dropout_prob = float(student_local_dropout_prob)
214
+ self.student_global_bin_mask_prob = float(student_global_bin_mask_prob)
215
+ self.student_global_bin_mask_before_binning = bool(student_global_bin_mask_before_binning)
216
+ high_expr_global_view = str(student_high_expr_global_view or "none").strip().lower()
217
+ if high_expr_global_view not in {"none", "global1", "global2"}:
218
+ raise ValueError("student_high_expr_global_view must be one of {'none', 'global1', 'global2'}")
219
+ self.student_high_expr_global_view = high_expr_global_view
220
+ high_expr_local_view = str(student_high_expr_local_view or "none").strip().lower()
221
+ if high_expr_local_view != "none":
222
+ if not (high_expr_local_view.startswith("local") and high_expr_local_view[5:].isdigit()):
223
+ raise ValueError("student_high_expr_local_view must be 'none' or localN, e.g. local1")
224
+ self.student_high_expr_local_view = high_expr_local_view
225
+ gene_only_local_view = str(student_gene_only_local_view or "none").strip().lower()
226
+ if gene_only_local_view != "none":
227
+ if not (gene_only_local_view.startswith("local") and gene_only_local_view[5:].isdigit()):
228
+ raise ValueError("student_gene_only_local_view must be 'none' or localN, e.g. local1")
229
+ self.student_gene_only_local_view = gene_only_local_view
230
+ global_mask_mode = str(student_global_mask_mode or "current").strip().lower().replace("-", "_")
231
+ if global_mask_mode not in {"current", "global1", "global2", "both"}:
232
+ raise ValueError("student_global_mask_mode must be one of {'current', 'global1', 'global2', 'both'}")
233
+ self.student_global_mask_mode = "global1" if global_mask_mode == "current" else global_mask_mode
234
+ self.student_global_model_masking = bool(student_global_model_masking)
235
+ self.student_global_mask_sample_probability = float(student_global_mask_sample_probability)
236
+ self.student_global_mask_ratio_min = float(student_global_mask_ratio_min)
237
+ self.student_global_mask_ratio_max = float(student_global_mask_ratio_max)
238
+ self.include_teacher_cls_decoder_targets = bool(include_teacher_cls_decoder_targets)
239
+ target_mode = str(teacher_cls_decoder_target_mode or "top_other_zero").strip().lower().replace("-", "_")
240
+ if target_mode not in {"teacher_view", "top_other_zero"}:
241
+ raise ValueError("teacher_cls_decoder_target_mode must be one of {'teacher_view', 'top_other_zero'}")
242
+ self.teacher_cls_decoder_target_mode = target_mode
243
+ self.teacher_cls_decoder_other_expr_genes = int(teacher_cls_decoder_other_expr_genes)
244
+ self.teacher_cls_decoder_zero_genes = int(teacher_cls_decoder_zero_genes)
245
+ if self.teacher_cls_decoder_other_expr_genes < 0:
246
+ raise ValueError("teacher_cls_decoder_other_expr_genes must be >= 0")
247
+ if self.teacher_cls_decoder_zero_genes < 0:
248
+ raise ValueError("teacher_cls_decoder_zero_genes must be >= 0")
249
+ if self.student_global_min_genes <= 0:
250
+ raise ValueError("student_global_min_genes must be > 0")
251
+ if self.student_local_min_genes <= 0:
252
+ raise ValueError("student_local_min_genes must be > 0")
253
+ if self.student_local_max_genes < self.student_local_min_genes:
254
+ raise ValueError("student_local_max_genes must be >= student_local_min_genes")
255
+ if self.student_num_local_views < 0:
256
+ raise ValueError("student_num_local_views must be >= 0")
257
+ if self.student_high_expr_local_view != "none":
258
+ high_expr_local_idx = int(self.student_high_expr_local_view[5:])
259
+ if high_expr_local_idx < 1 or high_expr_local_idx > self.student_num_local_views:
260
+ raise ValueError("student_high_expr_local_view must refer to an existing local view")
261
+ if self.student_gene_only_local_view != "none":
262
+ gene_only_local_idx = int(self.student_gene_only_local_view[5:])
263
+ if gene_only_local_idx < 1 or gene_only_local_idx > self.student_num_local_views:
264
+ raise ValueError("student_gene_only_local_view must refer to an existing local view")
265
+ if not (0.0 <= self.student_global_dropout_prob < 1.0):
266
+ raise ValueError("student_global_dropout_prob must be in [0, 1)")
267
+ if not (0.0 <= self.student_local_dropout_prob < 1.0):
268
+ raise ValueError("student_local_dropout_prob must be in [0, 1)")
269
+ if not (0.0 <= self.student_global_bin_mask_prob < 1.0):
270
+ raise ValueError("student_global_bin_mask_prob must be in [0, 1)")
271
+ if not (0.0 <= self.student_global_mask_sample_probability <= 1.0):
272
+ raise ValueError("student_global_mask_sample_probability must be in [0, 1]")
273
+ if not (0.0 <= self.student_global_mask_ratio_min < 1.0):
274
+ raise ValueError("student_global_mask_ratio_min must be in [0, 1)")
275
+ if not (0.0 <= self.student_global_mask_ratio_max < 1.0):
276
+ raise ValueError("student_global_mask_ratio_max must be in [0, 1)")
277
+ if self.student_global_mask_ratio_min > self.student_global_mask_ratio_max:
278
+ raise ValueError("student_global_mask_ratio_min must be <= student_global_mask_ratio_max")
279
+
280
+ mode = str(plan_mode or "lookahead_split_1.2").strip().lower().replace("-", "_")
281
+ if mode not in {"lookahead_split_1.2", "lookahead_split_1p2", "lookahead", "greedy_lookahead"}:
282
+ raise ValueError("plan_mode must resolve to lookahead_split_1.2")
283
+ self.plan_mode = "lookahead_split_1.2"
284
+ self._rng: Optional[np.random.Generator] = None
285
+ self._global_mask_plan_calls = 0
286
+
287
+ self.teacher_view_use_bin = torch.ones((self.teacher_num_views,), dtype=torch.bool)
288
+ self.teacher_view_is_global = torch.ones((self.teacher_num_views,), dtype=torch.bool)
289
+ self.student_view_use_bin = torch.ones((2 + self.student_num_local_views,), dtype=torch.bool)
290
+ if self.student_gene_only_local_view != "none":
291
+ self.student_view_use_bin[2 + int(self.student_gene_only_local_view[5:]) - 1] = False
292
+ self.student_view_is_global = torch.tensor(
293
+ [True, True] + [False] * self.student_num_local_views,
294
+ dtype=torch.bool,
295
+ )
296
+
297
+ def _get_rng(self) -> np.random.Generator:
298
+ if self._rng is None:
299
+ info = get_worker_info()
300
+ seed = self.base_seed if info is None else (self.base_seed ^ int(info.seed))
301
+ self._rng = np.random.default_rng(seed % (2**63 - 1))
302
+ return self._rng
303
+
304
+ def _top_teacher_genes(
305
+ self,
306
+ gene_ids: np.ndarray,
307
+ expr: np.ndarray,
308
+ ) -> Tuple[np.ndarray, np.ndarray]:
309
+ nnz = int(gene_ids.shape[0])
310
+ if nnz == 0:
311
+ return gene_ids, expr
312
+ if nnz <= self.teacher_top_genes:
313
+ return gene_ids, expr
314
+ top_idx = np.argpartition(expr, -self.teacher_top_genes)[-self.teacher_top_genes :]
315
+ return gene_ids[top_idx], expr[top_idx]
316
+
317
+ def _maybe_shuffle(
318
+ self,
319
+ gene_ids: np.ndarray,
320
+ expr: np.ndarray,
321
+ rng: np.random.Generator,
322
+ ) -> Tuple[np.ndarray, np.ndarray]:
323
+ if not self.shuffle_genes or int(gene_ids.shape[0]) <= 1:
324
+ return gene_ids, expr
325
+ perm = rng.permutation(int(gene_ids.shape[0]))
326
+ return gene_ids[perm], expr[perm]
327
+
328
+ def _sample_subset(
329
+ self,
330
+ gene_ids: np.ndarray,
331
+ expr: np.ndarray,
332
+ sample_size: int,
333
+ rng: np.random.Generator,
334
+ ) -> Tuple[np.ndarray, np.ndarray]:
335
+ n = int(gene_ids.shape[0])
336
+ if n == 0:
337
+ return gene_ids, expr
338
+ k = min(max(int(sample_size), 0), n)
339
+ if k == n:
340
+ return self._maybe_shuffle(gene_ids, expr, rng)
341
+ idx = rng.choice(n, size=k, replace=False)
342
+ return self._maybe_shuffle(gene_ids[idx], expr[idx], rng)
343
+
344
+ def _select_high_expr_subset(
345
+ self,
346
+ gene_ids: np.ndarray,
347
+ expr: np.ndarray,
348
+ sample_size: int,
349
+ rng: np.random.Generator,
350
+ ) -> Tuple[np.ndarray, np.ndarray]:
351
+ n = int(gene_ids.shape[0])
352
+ if n == 0:
353
+ return gene_ids, expr
354
+ k = min(max(int(sample_size), 0), n)
355
+ if k == n:
356
+ return self._maybe_shuffle(gene_ids, expr, rng)
357
+ top_idx = np.argpartition(expr, -k)[-k:]
358
+ return self._maybe_shuffle(gene_ids[top_idx], expr[top_idx], rng)
359
+
360
+ def _sample_gene_ids_only(
361
+ self,
362
+ gene_ids: np.ndarray,
363
+ sample_size: int,
364
+ rng: np.random.Generator,
365
+ *,
366
+ replace_if_needed: bool = False,
367
+ ) -> np.ndarray:
368
+ gene_ids = np.asarray(gene_ids, dtype=np.int64).reshape(-1)
369
+ n = int(gene_ids.shape[0])
370
+ k = int(sample_size)
371
+ if n == 0 or k <= 0:
372
+ return np.empty((0,), dtype=np.int64)
373
+ replace = bool(replace_if_needed and n < k)
374
+ size = k if replace else min(k, n)
375
+ idx = rng.choice(n, size=size, replace=replace)
376
+ return np.asarray(gene_ids[idx], dtype=np.int64).reshape(-1)
377
+
378
+ def _build_top_other_zero_decoder_targets(
379
+ self,
380
+ *,
381
+ all_gene_ids: np.ndarray,
382
+ all_expr: np.ndarray,
383
+ top_gene_ids: np.ndarray,
384
+ top_expr: np.ndarray,
385
+ rng: np.random.Generator,
386
+ ) -> Tuple[np.ndarray, np.ndarray]:
387
+ top_gene_ids = np.asarray(top_gene_ids, dtype=np.int64).reshape(-1)
388
+ top_expr = np.asarray(top_expr, dtype=np.float32).reshape(-1)
389
+ all_gene_ids = np.asarray(all_gene_ids, dtype=np.int64).reshape(-1)
390
+ all_expr = np.asarray(all_expr, dtype=np.float32).reshape(-1)
391
+
392
+ if int(top_gene_ids.shape[0]) > 0:
393
+ top_set = set(int(x) for x in top_gene_ids.tolist())
394
+ other_mask = np.asarray([int(g) not in top_set for g in all_gene_ids], dtype=np.bool_)
395
+ else:
396
+ other_mask = np.ones((int(all_gene_ids.shape[0]),), dtype=np.bool_)
397
+ other_gene_pool = all_gene_ids[other_mask]
398
+ other_expr_pool = all_expr[other_mask]
399
+ if int(other_gene_pool.shape[0]) > 0 and self.teacher_cls_decoder_other_expr_genes > 0:
400
+ other_gene_ids, other_expr = self._sample_subset(
401
+ other_gene_pool,
402
+ other_expr_pool,
403
+ self.teacher_cls_decoder_other_expr_genes,
404
+ rng=rng,
405
+ )
406
+ else:
407
+ other_gene_ids = np.empty((0,), dtype=np.int64)
408
+ other_expr = np.empty((0,), dtype=np.float32)
409
+
410
+ zero_gene_ids = np.empty((0,), dtype=np.int64)
411
+ zero_expr = np.empty((0,), dtype=np.float32)
412
+ if self.teacher_cls_decoder_zero_genes > 0:
413
+ valid_gene_ids = np.arange(
414
+ int(getattr(self.vocab, "gene_offset", 0)),
415
+ int(self.gene_vocab_size),
416
+ dtype=np.int64,
417
+ )
418
+ if int(valid_gene_ids.shape[0]) > 0:
419
+ observed = np.unique(all_gene_ids.astype(np.int64, copy=False))
420
+ zero_pool = np.setdiff1d(valid_gene_ids, observed, assume_unique=False)
421
+ zero_gene_ids = self._sample_gene_ids_only(
422
+ zero_pool,
423
+ self.teacher_cls_decoder_zero_genes,
424
+ rng,
425
+ replace_if_needed=False,
426
+ )
427
+ zero_expr = np.zeros((int(zero_gene_ids.shape[0]),), dtype=np.float32)
428
+
429
+ query_gene_ids = np.concatenate([top_gene_ids, other_gene_ids, zero_gene_ids]).astype(np.int64, copy=False)
430
+ query_expr = np.concatenate([top_expr, other_expr.astype(np.float32, copy=False), zero_expr]).astype(
431
+ np.float32, copy=False
432
+ )
433
+ if self.shuffle_genes and int(query_gene_ids.shape[0]) > 1:
434
+ perm = rng.permutation(int(query_gene_ids.shape[0]))
435
+ query_gene_ids = query_gene_ids[perm]
436
+ query_expr = query_expr[perm]
437
+ return query_gene_ids, query_expr
438
+
439
+ def _choose_student_subset(
440
+ self,
441
+ gene_ids: np.ndarray,
442
+ expr: np.ndarray,
443
+ sample_size: int,
444
+ *,
445
+ high_expr_priority: bool,
446
+ rng: np.random.Generator,
447
+ ) -> Tuple[np.ndarray, np.ndarray]:
448
+ if high_expr_priority:
449
+ return self._select_high_expr_subset(gene_ids, expr, sample_size, rng)
450
+ return self._sample_subset(gene_ids, expr, sample_size, rng)
451
+
452
+ def _apply_gene_dropout(
453
+ self,
454
+ gene_ids: np.ndarray,
455
+ expr: np.ndarray,
456
+ *,
457
+ dropout_prob: float,
458
+ min_keep: int,
459
+ rng: np.random.Generator,
460
+ ) -> Tuple[np.ndarray, np.ndarray]:
461
+ n = int(gene_ids.shape[0])
462
+ if n == 0 or dropout_prob <= 0.0:
463
+ return gene_ids, expr
464
+
465
+ min_keep = max(1, min(int(min_keep), n))
466
+ if n <= min_keep:
467
+ return gene_ids, expr
468
+
469
+ keep_mask = rng.random(n) >= float(dropout_prob)
470
+ keep_count = int(keep_mask.sum())
471
+ if keep_count < min_keep:
472
+ restore_count = min_keep - keep_count
473
+ dropped_idx = np.flatnonzero(~keep_mask)
474
+ if restore_count > 0 and dropped_idx.size > 0:
475
+ restore_idx = rng.choice(dropped_idx, size=restore_count, replace=False)
476
+ keep_mask[restore_idx] = True
477
+ if bool(keep_mask.all()):
478
+ return gene_ids, expr
479
+ return gene_ids[keep_mask], expr[keep_mask]
480
+
481
+ def _global_sample_size(self, available: int, rng: np.random.Generator) -> int:
482
+ if available <= 0:
483
+ return 0
484
+ if available <= self.student_global_min_genes:
485
+ return int(available)
486
+ ratio = float(rng.uniform(self.student_global_ratio_min, self.student_global_ratio_max))
487
+ target = int(round(ratio * float(available)))
488
+ target = max(int(self.student_global_min_genes), target)
489
+ return min(int(available), target)
490
+
491
+ def _local_sample_size(self, available: int, rng: np.random.Generator) -> int:
492
+ if available <= 0:
493
+ return 0
494
+ if available <= self.student_local_min_genes:
495
+ return int(available)
496
+ low = min(int(available), int(self.student_local_min_genes))
497
+ high = min(int(available), int(self.student_local_max_genes))
498
+ if high <= low:
499
+ return int(high)
500
+ return int(rng.integers(low, high + 1))
501
+
502
+ def _bucketize_observed_expr(self, expr: np.ndarray) -> np.ndarray:
503
+ expr_f = np.asarray(expr, dtype=np.float32)
504
+ n = int(expr_f.shape[0])
505
+ if n == 0:
506
+ return np.empty((0,), dtype=np.int64)
507
+
508
+ _, inverse, counts = np.unique(expr_f, return_inverse=True, return_counts=True)
509
+ value_order = np.argsort(inverse, kind="stable")
510
+ value_offsets = np.zeros(int(counts.shape[0]) + 1, dtype=np.int64)
511
+ value_offsets[1:] = np.cumsum(counts, dtype=np.int64)
512
+ plan = _plan_value_aware_lookahead_bins(counts, int(self.n_bins), split_factor=1.2)
513
+
514
+ bin_ids = np.empty(n, dtype=np.int64)
515
+ next_bin = 0
516
+ for action, start, end, bins_used in plan:
517
+ if action == "merge":
518
+ positions = value_order[int(value_offsets[start]) : int(value_offsets[end + 1])]
519
+ bin_ids[positions] = next_bin + int(self.vocab.bin_offset)
520
+ next_bin += 1
521
+ continue
522
+ if action == "split":
523
+ positions = value_order[int(value_offsets[start]) : int(value_offsets[start + 1])]
524
+ for local_bin, group_idx in enumerate(np.array_split(positions, int(bins_used))):
525
+ if group_idx.size == 0:
526
+ continue
527
+ bin_ids[group_idx] = next_bin + local_bin + int(self.vocab.bin_offset)
528
+ next_bin += int(bins_used)
529
+ continue
530
+ raise RuntimeError(f"Unknown bin action: {action}")
531
+ return bin_ids
532
+
533
+ def _rank_observed_expr(self, expr: np.ndarray) -> np.ndarray:
534
+ expr_f = np.asarray(expr, dtype=np.float32)
535
+ n = int(expr_f.shape[0])
536
+ if n == 0:
537
+ return np.empty((0,), dtype=np.int64)
538
+ order = np.argsort(-expr_f, kind="stable")
539
+ rank_ids = np.empty((n,), dtype=np.int64)
540
+ rank_ids[order] = np.arange(int(getattr(self.vocab, "bin_offset", 3)), int(getattr(self.vocab, "bin_offset", 3)) + n, dtype=np.int64)
541
+ return rank_ids
542
+
543
+ def _encode_expression_ids(self, expr: np.ndarray) -> np.ndarray:
544
+ if self.expr_embedding_mode == "rank":
545
+ return self._rank_observed_expr(expr)
546
+ return self._bucketize_observed_expr(expr)
547
+
548
+ def _build_view(
549
+ self,
550
+ gene_ids: np.ndarray,
551
+ expr: np.ndarray,
552
+ *,
553
+ use_bin: bool,
554
+ rng: np.random.Generator,
555
+ ) -> Tuple[np.ndarray, np.ndarray]:
556
+ gene_ids, expr = self._maybe_shuffle(gene_ids, expr, rng)
557
+ if not use_bin:
558
+ pad_id = int(self.vocab.bin_pad_id)
559
+ return gene_ids, np.full((int(gene_ids.shape[0]),), pad_id, dtype=np.int64)
560
+ if int(expr.shape[0]) == 0:
561
+ return gene_ids, np.empty((0,), dtype=np.int64)
562
+ return gene_ids, self._encode_expression_ids(expr)
563
+
564
+ def _mask_bin_ids(
565
+ self,
566
+ bin_ids: np.ndarray,
567
+ *,
568
+ mask_prob: float,
569
+ rng: np.random.Generator,
570
+ ) -> Tuple[np.ndarray, np.ndarray]:
571
+ n = int(bin_ids.shape[0])
572
+ mask = self._sample_bin_mask(n=n, mask_prob=mask_prob, rng=rng)
573
+ if n == 0 or not bool(mask.any()):
574
+ return bin_ids, mask
575
+
576
+ masked_bin_ids = np.array(bin_ids, copy=True)
577
+ masked_bin_ids[mask] = int(self.vocab.bin_mask_id)
578
+ return masked_bin_ids, mask
579
+
580
+ def _sample_bin_mask(
581
+ self,
582
+ *,
583
+ n: int,
584
+ mask_prob: float,
585
+ rng: np.random.Generator,
586
+ ) -> np.ndarray:
587
+ n = int(n)
588
+ if n <= 0 or mask_prob <= 0.0:
589
+ return np.zeros((max(n, 0),), dtype=np.bool_)
590
+
591
+ mask = rng.random(n) < float(mask_prob)
592
+ if not bool(mask.any()):
593
+ mask[int(rng.integers(0, n))] = True
594
+ return mask
595
+
596
+ def _build_masked_bin_view(
597
+ self,
598
+ gene_ids: np.ndarray,
599
+ expr: np.ndarray,
600
+ *,
601
+ mask_prob: float,
602
+ rng: np.random.Generator,
603
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
604
+ gene_ids, expr = self._maybe_shuffle(gene_ids, expr, rng)
605
+ n = int(gene_ids.shape[0])
606
+ if n == 0:
607
+ empty = np.empty((0,), dtype=np.int64)
608
+ return gene_ids, empty, np.zeros((0,), dtype=np.bool_)
609
+
610
+ mask = self._sample_bin_mask(n=n, mask_prob=mask_prob, rng=rng)
611
+ if not self.student_global_bin_mask_before_binning:
612
+ bin_ids = self._encode_expression_ids(expr)
613
+ masked_bin_ids = np.array(bin_ids, copy=True)
614
+ masked_bin_ids[mask] = int(self.vocab.bin_mask_id)
615
+ return gene_ids, masked_bin_ids, mask
616
+
617
+ masked_bin_ids = np.full((n,), int(self.vocab.bin_mask_id), dtype=np.int64)
618
+ keep_mask = ~mask
619
+ if bool(keep_mask.any()):
620
+ masked_bin_ids[keep_mask] = self._encode_expression_ids(expr[keep_mask])
621
+ return gene_ids, masked_bin_ids, mask
622
+
623
+ def _build_model_global_mask_plan(
624
+ self,
625
+ *,
626
+ batch_size: int,
627
+ rng: np.random.Generator,
628
+ ) -> List[Tuple[bool, float]]:
629
+ del rng
630
+ local_global_views = int(batch_size) * 2
631
+ rank, world = _distributed_rank_world()
632
+ total_global_views = int(local_global_views) * int(world)
633
+ n_masked = int(total_global_views * self.student_global_mask_sample_probability)
634
+ n_masked = max(0, min(n_masked, total_global_views))
635
+ if local_global_views <= 0 or n_masked == 0:
636
+ return [(False, 0.0) for _ in range(local_global_views)]
637
+
638
+ plan_seed = int(self.base_seed) + int(self._global_mask_plan_calls)
639
+ self._global_mask_plan_calls += 1
640
+ plan_rng = np.random.default_rng(plan_seed % (2**63 - 1))
641
+ masked_global_indices = set(int(i) for i in plan_rng.permutation(total_global_views)[:n_masked].tolist())
642
+ if self.student_global_mask_ratio_min == self.student_global_mask_ratio_max:
643
+ global_ratios = np.full((total_global_views,), self.student_global_mask_ratio_min, dtype=np.float64)
644
+ else:
645
+ global_ratios = plan_rng.uniform(
646
+ self.student_global_mask_ratio_min,
647
+ self.student_global_mask_ratio_max,
648
+ size=total_global_views,
649
+ )
650
+
651
+ start = int(rank) * int(local_global_views)
652
+ local_plan: List[Tuple[bool, float]] = []
653
+ for local_idx in range(local_global_views):
654
+ global_idx = start + int(local_idx)
655
+ is_masked = global_idx in masked_global_indices
656
+ local_plan.append((bool(is_masked), float(global_ratios[global_idx]) if is_masked else 0.0))
657
+ return local_plan
658
+
659
+ def _pack_views(
660
+ self,
661
+ view_gene_ids: List[List[np.ndarray]],
662
+ view_bin_ids: List[List[np.ndarray]],
663
+ view_use_bin: torch.Tensor,
664
+ view_token_masks: Optional[List[List[np.ndarray]]] = None,
665
+ ) -> Dict[str, torch.Tensor]:
666
+ def _as_1d_int_array(arr: Any) -> np.ndarray:
667
+ np_arr = np.asarray(arr, dtype=np.int64)
668
+ if np_arr.ndim == 0:
669
+ np_arr = np_arr.reshape(-1)
670
+ elif np_arr.ndim != 1:
671
+ np_arr = np_arr.reshape(-1)
672
+ return np_arr
673
+
674
+ batch_size = len(view_gene_ids)
675
+ num_views = len(view_gene_ids[0]) if batch_size > 0 else int(view_use_bin.numel())
676
+ max_tokens = self.num_cls_tokens
677
+ for sample_views in view_gene_ids:
678
+ for gene_ids in sample_views:
679
+ gene_ids_1d = _as_1d_int_array(gene_ids)
680
+ max_tokens = max(max_tokens, self.num_cls_tokens + int(gene_ids_1d.shape[0]))
681
+
682
+ device = torch.device("cpu")
683
+ gene_pad_id = int(self.vocab.gene_pad_id)
684
+ bin_pad_id = int(self.vocab.bin_pad_id)
685
+ gene_cls_id = int(self.vocab.gene_cls_id)
686
+
687
+ gene_tensor = torch.full((batch_size, num_views, max_tokens), gene_pad_id, dtype=torch.long, device=device)
688
+ bin_tensor = torch.full((batch_size, num_views, max_tokens), bin_pad_id, dtype=torch.long, device=device)
689
+ attn_tensor = torch.zeros((batch_size, num_views, max_tokens), dtype=torch.bool, device=device)
690
+ token_mask_tensor = None
691
+ if view_token_masks is not None:
692
+ token_mask_tensor = torch.zeros((batch_size, num_views, max_tokens), dtype=torch.bool, device=device)
693
+
694
+ for m in range(batch_size):
695
+ for v in range(num_views):
696
+ gene_tensor[m, v, 0] = gene_cls_id
697
+ bin_tensor[m, v, 0] = bin_pad_id
698
+ attn_tensor[m, v, 0] = True
699
+
700
+ gene_ids = view_gene_ids[m][v]
701
+ bin_ids = view_bin_ids[m][v]
702
+ gene_ids = _as_1d_int_array(gene_ids)
703
+ bin_ids = _as_1d_int_array(bin_ids)
704
+ if int(gene_ids.shape[0]) == 0:
705
+ continue
706
+ start = self.num_cls_tokens
707
+ end = start + int(gene_ids.shape[0])
708
+ gene_tensor[m, v, start:end] = torch.from_numpy(gene_ids).to(dtype=torch.long)
709
+ bin_tensor[m, v, start:end] = torch.from_numpy(bin_ids).to(dtype=torch.long)
710
+ attn_tensor[m, v, start:end] = True
711
+ if token_mask_tensor is not None:
712
+ token_mask_tensor[m, v, start:end] = torch.from_numpy(view_token_masks[m][v]).to(dtype=torch.bool)
713
+
714
+ packed = {
715
+ "gene_ids": gene_tensor,
716
+ "bin_ids": bin_tensor,
717
+ "attention_mask": attn_tensor,
718
+ }
719
+ if token_mask_tensor is not None:
720
+ packed["token_mask"] = token_mask_tensor
721
+ return packed
722
+
723
+ def _pack_decoder_targets(
724
+ self,
725
+ target_gene_ids: List[np.ndarray],
726
+ target_expr: List[np.ndarray],
727
+ ) -> Dict[str, torch.Tensor]:
728
+ batch_size = len(target_gene_ids)
729
+ max_targets = 0
730
+ for gene_ids in target_gene_ids:
731
+ max_targets = max(max_targets, int(np.asarray(gene_ids, dtype=np.int64).reshape(-1).shape[0]))
732
+
733
+ device = torch.device("cpu")
734
+ gene_pad_id = int(self.vocab.gene_pad_id)
735
+ gene_tensor = torch.full((batch_size, max_targets), gene_pad_id, dtype=torch.long, device=device)
736
+ expr_tensor = torch.zeros((batch_size, max_targets), dtype=torch.float32, device=device)
737
+ mask_tensor = torch.zeros((batch_size, max_targets), dtype=torch.bool, device=device)
738
+
739
+ for idx, (gene_ids, expr) in enumerate(zip(target_gene_ids, target_expr)):
740
+ gene_arr = np.asarray(gene_ids, dtype=np.int64).reshape(-1)
741
+ expr_arr = np.asarray(expr, dtype=np.float32).reshape(-1)
742
+ if int(gene_arr.shape[0]) == 0:
743
+ continue
744
+ end = int(gene_arr.shape[0])
745
+ gene_tensor[idx, :end] = torch.from_numpy(gene_arr).to(dtype=torch.long)
746
+ expr_tensor[idx, :end] = torch.from_numpy(expr_arr).to(dtype=torch.float32)
747
+ mask_tensor[idx, :end] = True
748
+
749
+ return {
750
+ "gene_ids": gene_tensor,
751
+ "expr": expr_tensor,
752
+ "mask": mask_tensor,
753
+ }
754
+
755
+ def _pack_decoder_obs_features(self, obs_features: List[np.ndarray]) -> torch.Tensor:
756
+ batch_size = len(obs_features)
757
+ device = torch.device("cpu")
758
+ obs_tensor = torch.zeros((batch_size, 2), dtype=torch.float32, device=device)
759
+ for idx, features in enumerate(obs_features):
760
+ feat_arr = np.asarray(features, dtype=np.float32).reshape(-1)
761
+ if int(feat_arr.shape[0]) != 2:
762
+ raise ValueError(f"Expected 2 decoder observation features, got shape={feat_arr.shape!r}")
763
+ obs_tensor[idx] = torch.from_numpy(feat_arr).to(dtype=torch.float32)
764
+ return obs_tensor
765
+
766
+ def __call__(self, examples: List[Dict[str, Any]]) -> Dict[str, torch.Tensor]:
767
+ batch_size = len(examples)
768
+ if batch_size == 0:
769
+ raise ValueError("Empty batch passed to collator.")
770
+
771
+ rng = self._get_rng()
772
+ teacher_gene_views: List[List[np.ndarray]] = []
773
+ teacher_bin_views: List[List[np.ndarray]] = []
774
+ student_gene_views: List[List[np.ndarray]] = []
775
+ student_bin_views: List[List[np.ndarray]] = []
776
+ student_token_masks: List[List[np.ndarray]] = []
777
+ decoder_target_gene_ids: List[np.ndarray] = []
778
+ decoder_target_expr: List[np.ndarray] = []
779
+ decoder_target_obs_features: List[np.ndarray] = []
780
+ model_mask_plan = (
781
+ self._build_model_global_mask_plan(batch_size=batch_size, rng=rng)
782
+ if self.student_global_model_masking
783
+ else None
784
+ )
785
+ model_mask_plan_idx = 0
786
+
787
+ for ex in examples:
788
+ gene_ids = np.asarray(ex["gene_ids"], dtype=np.int64)
789
+ expr = np.asarray(ex["expr"], dtype=np.float32)
790
+
791
+ teacher_gene_ids, teacher_expr = self._top_teacher_genes(gene_ids, expr)
792
+ teacher_gene_ids, teacher_expr = self._maybe_shuffle(teacher_gene_ids, teacher_expr, rng)
793
+
794
+ available = int(teacher_gene_ids.shape[0])
795
+ teacher_sample_size = int(round(self.teacher_global_ratio * float(available)))
796
+ teacher_sample_size = max(1, teacher_sample_size) if available > 0 else 0
797
+
798
+ teacher_views: List[Tuple[np.ndarray, np.ndarray]] = []
799
+ first_teacher_target_gene_ids: Optional[np.ndarray] = None
800
+ first_teacher_target_expr: Optional[np.ndarray] = None
801
+ for view_idx in range(self.teacher_num_views):
802
+ sampled_teacher_gene_ids, sampled_teacher_expr = self._sample_subset(
803
+ teacher_gene_ids,
804
+ teacher_expr,
805
+ teacher_sample_size,
806
+ rng=rng,
807
+ )
808
+ if view_idx == 0 and self.include_teacher_cls_decoder_targets:
809
+ first_teacher_target_gene_ids, first_teacher_target_expr = self._build_top_other_zero_decoder_targets(
810
+ all_gene_ids=gene_ids,
811
+ all_expr=expr,
812
+ top_gene_ids=teacher_gene_ids,
813
+ top_expr=teacher_expr,
814
+ rng=rng,
815
+ )
816
+ t_bin_gene, t_bin_bin = self._build_view(
817
+ sampled_teacher_gene_ids,
818
+ sampled_teacher_expr,
819
+ use_bin=True,
820
+ rng=rng,
821
+ )
822
+ teacher_views.append((t_bin_gene, t_bin_bin))
823
+ global_bin_size = self._global_sample_size(available, rng)
824
+ global_gene_size = self._global_sample_size(available, rng)
825
+ local_sizes = [self._local_sample_size(available, rng) for _ in range(self.student_num_local_views)]
826
+
827
+ sgb_gene_ids, sgb_expr = self._choose_student_subset(
828
+ teacher_gene_ids,
829
+ teacher_expr,
830
+ global_bin_size,
831
+ high_expr_priority=self.student_high_expr_global_view == "global1",
832
+ rng=rng,
833
+ )
834
+ sgg_gene_ids, sgg_expr = self._choose_student_subset(
835
+ teacher_gene_ids,
836
+ teacher_expr,
837
+ global_gene_size,
838
+ high_expr_priority=self.student_high_expr_global_view == "global2",
839
+ rng=rng,
840
+ )
841
+ local_subsets = [
842
+ self._choose_student_subset(
843
+ teacher_gene_ids,
844
+ teacher_expr,
845
+ local_size,
846
+ high_expr_priority=self.student_high_expr_local_view == f"local{local_idx + 1}",
847
+ rng=rng,
848
+ )
849
+ for local_idx, local_size in enumerate(local_sizes)
850
+ ]
851
+
852
+ sgb_gene_ids, sgb_expr = self._apply_gene_dropout(
853
+ sgb_gene_ids,
854
+ sgb_expr,
855
+ dropout_prob=self.student_global_dropout_prob,
856
+ min_keep=self.student_global_min_genes,
857
+ rng=rng,
858
+ )
859
+ sgg_gene_ids, sgg_expr = self._apply_gene_dropout(
860
+ sgg_gene_ids,
861
+ sgg_expr,
862
+ dropout_prob=self.student_global_dropout_prob,
863
+ min_keep=self.student_global_min_genes,
864
+ rng=rng,
865
+ )
866
+ local_subsets = [
867
+ self._apply_gene_dropout(
868
+ local_gene_ids,
869
+ local_expr,
870
+ dropout_prob=self.student_local_dropout_prob,
871
+ min_keep=self.student_local_min_genes,
872
+ rng=rng,
873
+ )
874
+ for local_gene_ids, local_expr in local_subsets
875
+ ]
876
+
877
+ if model_mask_plan is not None:
878
+ mask_global1, global1_mask_prob = model_mask_plan[model_mask_plan_idx]
879
+ mask_global2, global2_mask_prob = model_mask_plan[model_mask_plan_idx + 1]
880
+ model_mask_plan_idx += 2
881
+ else:
882
+ mask_global1 = self.student_global_mask_mode in {"global1", "both"}
883
+ mask_global2 = self.student_global_mask_mode in {"global2", "both"}
884
+ global1_mask_prob = self.student_global_bin_mask_prob
885
+ global2_mask_prob = self.student_global_bin_mask_prob
886
+ if mask_global1:
887
+ sgb_gene, sgb_bin, sgb_mask = self._build_masked_bin_view(
888
+ sgb_gene_ids,
889
+ sgb_expr,
890
+ mask_prob=global1_mask_prob,
891
+ rng=rng,
892
+ )
893
+ else:
894
+ sgb_gene, sgb_bin = self._build_view(sgb_gene_ids, sgb_expr, use_bin=True, rng=rng)
895
+ sgb_mask_value = False if model_mask_plan is not None else True
896
+ sgb_mask = np.full((int(sgb_gene.shape[0]),), sgb_mask_value, dtype=np.bool_)
897
+ if mask_global2:
898
+ sgg_gene, sgg_bin, sgg_mask = self._build_masked_bin_view(
899
+ sgg_gene_ids,
900
+ sgg_expr,
901
+ mask_prob=global2_mask_prob,
902
+ rng=rng,
903
+ )
904
+ else:
905
+ sgg_gene, sgg_bin = self._build_view(sgg_gene_ids, sgg_expr, use_bin=True, rng=rng)
906
+ sgg_mask_value = False if model_mask_plan is not None else True
907
+ sgg_mask = np.full((int(sgg_gene.shape[0]),), sgg_mask_value, dtype=np.bool_)
908
+ local_views = [
909
+ self._build_view(
910
+ local_gene_ids,
911
+ local_expr,
912
+ use_bin=self.student_gene_only_local_view != f"local{local_idx + 1}",
913
+ rng=rng,
914
+ )
915
+ for local_idx, (local_gene_ids, local_expr) in enumerate(local_subsets)
916
+ ]
917
+ local_genes = [view_gene for view_gene, _ in local_views]
918
+ local_bins = [view_bin for _, view_bin in local_views]
919
+ local_masks = [np.zeros((int(view_gene.shape[0]),), dtype=np.bool_) for view_gene in local_genes]
920
+
921
+ teacher_gene_views.append([view_gene for view_gene, _ in teacher_views])
922
+ teacher_bin_views.append([view_bin for _, view_bin in teacher_views])
923
+ student_gene_views.append([sgb_gene, sgg_gene] + local_genes)
924
+ student_bin_views.append([sgb_bin, sgg_bin] + local_bins)
925
+ student_token_masks.append([sgb_mask, sgg_mask] + local_masks)
926
+ if self.include_teacher_cls_decoder_targets:
927
+ decoder_target_gene_ids.append(
928
+ np.empty((0,), dtype=np.int64)
929
+ if first_teacher_target_gene_ids is None
930
+ else first_teacher_target_gene_ids
931
+ )
932
+ decoder_target_expr.append(
933
+ np.empty((0,), dtype=np.float32)
934
+ if first_teacher_target_expr is None
935
+ else first_teacher_target_expr
936
+ )
937
+ total_counts = float(ex.get("total_counts", gene_ids.shape[0]))
938
+ n_genes_by_counts = float(ex.get("n_genes_by_counts", gene_ids.shape[0]))
939
+ decoder_target_obs_features.append(
940
+ np.asarray(
941
+ [
942
+ np.log1p(max(total_counts, 0.0)),
943
+ np.log1p(max(n_genes_by_counts, 0.0)),
944
+ ],
945
+ dtype=np.float32,
946
+ )
947
+ )
948
+
949
+ teacher_packed = self._pack_views(teacher_gene_views, teacher_bin_views, self.teacher_view_use_bin)
950
+ student_packed = self._pack_views(
951
+ student_gene_views,
952
+ student_bin_views,
953
+ self.student_view_use_bin,
954
+ view_token_masks=student_token_masks,
955
+ )
956
+
957
+ batch = {
958
+ "teacher_gene_ids": teacher_packed["gene_ids"],
959
+ "teacher_bin_ids": teacher_packed["bin_ids"],
960
+ "teacher_attention_mask": teacher_packed["attention_mask"],
961
+ "teacher_view_use_bin": self.teacher_view_use_bin.clone(),
962
+ "teacher_view_is_global": self.teacher_view_is_global.clone(),
963
+ "student_gene_ids": student_packed["gene_ids"],
964
+ "student_bin_ids": student_packed["bin_ids"],
965
+ "student_attention_mask": student_packed["attention_mask"],
966
+ "student_ibot_mask": student_packed["token_mask"],
967
+ "student_view_use_bin": self.student_view_use_bin.clone(),
968
+ "student_view_is_global": self.student_view_is_global.clone(),
969
+ }
970
+ if self.include_teacher_cls_decoder_targets:
971
+ decoder_targets = self._pack_decoder_targets(decoder_target_gene_ids, decoder_target_expr)
972
+ batch["teacher_cls_decoder_target_gene_ids"] = decoder_targets["gene_ids"]
973
+ batch["teacher_cls_decoder_target_expr"] = decoder_targets["expr"]
974
+ batch["teacher_cls_decoder_target_mask"] = decoder_targets["mask"]
975
+ batch["teacher_cls_decoder_obs_features"] = self._pack_decoder_obs_features(
976
+ decoder_target_obs_features
977
+ )
978
+ return batch
979
+
980
+
981
+ __all__ = ["ContrastiveSplitCellCollator"]
scRep_pretrain/model/contrastive_trainer.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ from transformers import Trainer, TrainerCallback
6
+
7
+
8
+ def _unwrap_model(model):
9
+ current = model
10
+ while hasattr(current, "module"):
11
+ current = current.module
12
+ return current
13
+
14
+
15
+ class _TeacherMomentumCallback(TrainerCallback):
16
+ def on_optimizer_step(self, args, state, control, model=None, **kwargs):
17
+ base_model = _unwrap_model(model)
18
+ if hasattr(base_model, "update_teacher"):
19
+ current_step = int(state.global_step) + 1
20
+ max_steps = int(getattr(args, "max_steps", 0) or 0)
21
+ base_model.update_teacher(global_step=current_step, max_steps=max_steps)
22
+ return control
23
+
24
+
25
+ class ContrastiveCellTrainer(Trainer):
26
+ """
27
+ Trainer for DINO-like student/teacher models.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ *args,
33
+ loss_scale: float = 1.0,
34
+ steps_per_epoch: Optional[int] = None,
35
+ planned_num_epochs: Optional[float] = None,
36
+ **kwargs,
37
+ ):
38
+ super().__init__(*args, **kwargs)
39
+ self.loss_scale = float(loss_scale)
40
+ self.steps_per_epoch = None if steps_per_epoch is None else int(steps_per_epoch)
41
+ self.planned_num_epochs = None if planned_num_epochs is None else float(planned_num_epochs)
42
+ self._latest_train_metrics = {}
43
+ self.add_callback(_TeacherMomentumCallback())
44
+
45
+ def _current_data_epoch(self) -> Optional[float]:
46
+ if self.steps_per_epoch is None or self.steps_per_epoch <= 0:
47
+ return None
48
+ return float(self.state.global_step + 1) / float(self.steps_per_epoch)
49
+
50
+ def _current_train_epoch(self) -> Optional[float]:
51
+ max_steps = int(getattr(self.args, "max_steps", 0) or 0)
52
+ if max_steps <= 0:
53
+ return None
54
+ planned_num_epochs = self.planned_num_epochs
55
+ if planned_num_epochs is None or planned_num_epochs <= 0.0:
56
+ planned_num_epochs = 1.0
57
+ return float(self.state.global_step + 1) / float(max_steps) * float(planned_num_epochs)
58
+
59
+ def _current_train_progress(self) -> Optional[float]:
60
+ max_steps = int(getattr(self.args, "max_steps", 0) or 0)
61
+ if max_steps <= 0:
62
+ return None
63
+ return float(self.state.global_step + 1) / float(max_steps)
64
+
65
+ def compute_loss(self, model, inputs, return_outputs: bool = False, **kwargs):
66
+ batch = inputs["batch"]
67
+ outputs = model(batch)
68
+ loss_unscaled = outputs["loss"]
69
+ loss = loss_unscaled * self.loss_scale
70
+ if return_outputs:
71
+ return loss, outputs
72
+ return loss
73
+
74
+ def training_step(self, model, inputs, num_items_in_batch=None):
75
+ model.train()
76
+ inputs = self._prepare_inputs(inputs)
77
+
78
+ with self.compute_loss_context_manager():
79
+ loss, outputs = self.compute_loss(model, inputs, return_outputs=True)
80
+
81
+ if self.args.n_gpu > 1:
82
+ loss = loss.mean()
83
+
84
+ self.accelerator.backward(loss)
85
+
86
+ data_epoch = self._current_data_epoch()
87
+ train_epoch = self._current_train_epoch()
88
+ train_progress = self._current_train_progress()
89
+
90
+ log_dict = {}
91
+ for key in (
92
+ "loss_total",
93
+ "loss_dino",
94
+ "loss_dino_same_mode",
95
+ "loss_dino_cross_mode",
96
+ "loss_token",
97
+ "loss_token_same_mode",
98
+ "loss_token_cross_mode",
99
+ "loss_decoder",
100
+ "teacher_momentum",
101
+ ):
102
+ if key in outputs:
103
+ value = outputs[key]
104
+ if hasattr(value, "detach"):
105
+ value = value.detach().float().item()
106
+ log_dict[key] = float(value)
107
+ if data_epoch is not None:
108
+ log_dict["data_epoch"] = float(data_epoch)
109
+ if train_epoch is not None:
110
+ log_dict["train_epoch"] = float(train_epoch)
111
+ if train_progress is not None:
112
+ log_dict["train_progress"] = float(train_progress)
113
+ if log_dict:
114
+ self._latest_train_metrics = log_dict
115
+
116
+ return loss.detach()
117
+
118
+ def log(self, logs: dict, start_time=None) -> None:
119
+ if self._latest_train_metrics:
120
+ for key, value in self._latest_train_metrics.items():
121
+ logs.setdefault(key, value)
122
+ return super().log(logs, start_time=start_time)
123
+
124
+
125
+ __all__ = ["ContrastiveCellTrainer"]
scRep_pretrain/model/foundation_model.py ADDED
@@ -0,0 +1,1411 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, fields as dataclass_fields
4
+ from contextlib import ExitStack
5
+ from typing import Any, Dict, List, Optional, Tuple
6
+
7
+ import json
8
+ import math
9
+ import os
10
+
11
+ import torch
12
+ import torch.nn as nn
13
+ import torch.nn.functional as F
14
+ from torch.utils.checkpoint import checkpoint
15
+
16
+ try:
17
+ from torch.distributed.fsdp import FullyShardedDataParallel as FSDP
18
+ except Exception: # pragma: no cover - FSDP availability depends on runtime
19
+ FSDP = None
20
+
21
+
22
+ def _dist_is_initialized() -> bool:
23
+ return torch.distributed.is_available() and torch.distributed.is_initialized()
24
+
25
+
26
+ def _leaf_fsdp_modules(module: nn.Module) -> List[nn.Module]:
27
+ if FSDP is None:
28
+ return []
29
+
30
+ fsdp_modules: List[nn.Module] = []
31
+ for child in module.modules():
32
+ if not isinstance(child, FSDP):
33
+ continue
34
+ has_fsdp_descendant = any(isinstance(grandchild, FSDP) for grandchild in child.children())
35
+ if not has_fsdp_descendant:
36
+ fsdp_modules.append(child)
37
+ return fsdp_modules
38
+
39
+
40
+ def _json_dump(obj: Any, path: str) -> None:
41
+ os.makedirs(os.path.dirname(path), exist_ok=True)
42
+ with open(path, "w", encoding="utf-8") as f:
43
+ json.dump(obj, f, ensure_ascii=False, indent=2)
44
+
45
+
46
+ class RmsNorm(nn.Module):
47
+ def __init__(self, dim: int, eps: float = 1e-6):
48
+ super().__init__()
49
+ self.eps = float(eps)
50
+ self.scale = nn.Parameter(torch.ones(dim))
51
+
52
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
53
+ inv = x.pow(2).mean(dim=-1, keepdim=True).add(self.eps).rsqrt()
54
+ return x * inv * self.scale
55
+
56
+
57
+ class SdpaSelfAttention(nn.Module):
58
+ def __init__(self, d_model: int, n_heads: int, dropout: float):
59
+ super().__init__()
60
+ if d_model % n_heads != 0:
61
+ raise ValueError("d_model must be divisible by n_heads")
62
+ self.d_model = int(d_model)
63
+ self.n_heads = int(n_heads)
64
+ self.head_dim = self.d_model // self.n_heads
65
+ self.dropout = float(dropout)
66
+
67
+ self.q_proj = nn.Linear(self.d_model, self.d_model, bias=False)
68
+ self.k_proj = nn.Linear(self.d_model, self.d_model, bias=False)
69
+ self.v_proj = nn.Linear(self.d_model, self.d_model, bias=False)
70
+ self.o_proj = nn.Linear(self.d_model, self.d_model, bias=False)
71
+
72
+ def forward(self, x: torch.Tensor, *, attn_mask_valid: torch.Tensor) -> torch.Tensor:
73
+ batch_size, seq_len, _ = x.shape
74
+ q = self.q_proj(x).view(batch_size, seq_len, self.n_heads, self.head_dim).transpose(1, 2)
75
+ k = self.k_proj(x).view(batch_size, seq_len, self.n_heads, self.head_dim).transpose(1, 2)
76
+ v = self.v_proj(x).view(batch_size, seq_len, self.n_heads, self.head_dim).transpose(1, 2)
77
+
78
+ if attn_mask_valid.shape != (batch_size, seq_len):
79
+ raise ValueError(
80
+ "attn_mask_valid must have shape [batch_size, seq_len], "
81
+ f"got {tuple(attn_mask_valid.shape)} for x={tuple(x.shape)}"
82
+ )
83
+ # Expression-masked genes remain valid tokens. This mask only excludes
84
+ # right-padding positions from the attention key/value softmax.
85
+ key_mask = attn_mask_valid.to(device=x.device, dtype=torch.bool)[:, None, None, :]
86
+ drop_p = self.dropout if self.training else 0.0
87
+ out = F.scaled_dot_product_attention(
88
+ q,
89
+ k,
90
+ v,
91
+ attn_mask=key_mask,
92
+ dropout_p=drop_p,
93
+ is_causal=False,
94
+ )
95
+ out = out.transpose(1, 2).contiguous().view(batch_size, seq_len, self.d_model)
96
+ return self.o_proj(out)
97
+
98
+
99
+ class EncoderBlock(nn.Module):
100
+ def __init__(self, d_model: int, n_heads: int, dropout: float):
101
+ super().__init__()
102
+ self.norm1 = RmsNorm(d_model)
103
+ self.attn = SdpaSelfAttention(d_model, n_heads, dropout)
104
+ self.drop = nn.Dropout(dropout)
105
+ self.norm2 = RmsNorm(d_model)
106
+ self.ffn = nn.Sequential(
107
+ nn.Linear(d_model, 4 * d_model, bias=False),
108
+ nn.GELU(),
109
+ nn.Dropout(dropout),
110
+ nn.Linear(4 * d_model, d_model, bias=False),
111
+ )
112
+
113
+ def forward(self, x: torch.Tensor, *, attn_mask_valid: torch.Tensor) -> torch.Tensor:
114
+ x = x * attn_mask_valid.unsqueeze(-1).to(dtype=x.dtype)
115
+ h = self.norm1(x)
116
+ x = x + self.drop(self.attn(h, attn_mask_valid=attn_mask_valid))
117
+ x = x + self.drop(self.ffn(self.norm2(x)))
118
+ return x * attn_mask_valid.unsqueeze(-1).to(dtype=x.dtype)
119
+
120
+
121
+ @dataclass
122
+ class FoundationConfig:
123
+ d_model: int = 768
124
+ n_heads: int = 12
125
+ dropout: float = 0.0
126
+ encoder_layers: int = 12
127
+ gradient_checkpointing: bool = False
128
+
129
+ gene_vocab_size: int = 0
130
+ bin_vocab_size: int = 0
131
+ gene_pad_id: int = 0
132
+ bin_pad_id: int = 0
133
+ bin_mask_id: int = 1
134
+ bin_offset: int = 3
135
+ gene_cls_id: int = 1
136
+ expr_embedding_mode: str = "bin"
137
+ expr_rank_mlp_hidden_dim: int = 256
138
+
139
+ dino_out_dim: int = 4096
140
+ dino_hidden_dim: int = 2048
141
+ dino_bottleneck_dim: int = 256
142
+ student_temperature: float = 0.10
143
+ teacher_temperature: float = 0.04
144
+ teacher_momentum: float = 0.996
145
+ teacher_momentum_end: float = 1.0
146
+ center_momentum: float = 0.9
147
+ token_center_momentum: float = 0.9
148
+ cross_modal_dino_weight: float = 0.25
149
+ cross_modal_pairing_mode: str = "disabled"
150
+ token_loss_weight: float = 1.0
151
+ token_unmasked_loss_weight: float = 0.0
152
+ inference_use_teacher: bool = True
153
+ teacher_cls_decoder_loss_weight: float = 0.0
154
+ teacher_cls_decoder_hidden_dim: int = 1024
155
+ teacher_cls_decoder_condition_on_obs: bool = False
156
+ teacher_cls_decoder_rank_loss_alpha: float = 0.05
157
+ teacher_cls_decoder_rank_num_pairs: int = 4096
158
+ teacher_student_decoder_distill_weight: float = 0.0
159
+ teacher_student_decoder_loss_mode: str = "distribution"
160
+ teacher_student_decoder_rank_alpha: float = 0.05
161
+ teacher_student_decoder_raw_mmd_weight: float = 0.1
162
+ teacher_student_decoder_raw_rank_weight: float = 0.01
163
+ teacher_student_decoder_raw_mse_weight: float = 1.0
164
+
165
+
166
+ class GeneBinTokenizer(nn.Module):
167
+ def __init__(self, cfg: FoundationConfig):
168
+ super().__init__()
169
+ self.gene_emb = nn.Embedding(cfg.gene_vocab_size, cfg.d_model, padding_idx=cfg.gene_pad_id)
170
+ self.bin_emb = nn.Embedding(cfg.bin_vocab_size, cfg.d_model, padding_idx=cfg.bin_pad_id)
171
+ self.expr_embedding_mode = str(getattr(cfg, "expr_embedding_mode", "bin")).strip().lower()
172
+ if self.expr_embedding_mode not in {"bin", "rank"}:
173
+ raise ValueError(f"Unsupported expr_embedding_mode={self.expr_embedding_mode!r}")
174
+ self.bin_mask_id = int(getattr(cfg, "bin_mask_id", 1))
175
+ self.bin_offset = int(getattr(cfg, "bin_offset", 3))
176
+ rank_hidden_dim = int(getattr(cfg, "expr_rank_mlp_hidden_dim", 256))
177
+ if rank_hidden_dim <= 0:
178
+ raise ValueError("expr_rank_mlp_hidden_dim must be > 0")
179
+ self.rank_mlp: Optional[nn.Sequential]
180
+ self.rank_mask_emb: Optional[nn.Parameter]
181
+ if self.expr_embedding_mode == "rank":
182
+ self.rank_mlp = nn.Sequential(
183
+ nn.Linear(1, rank_hidden_dim, bias=True),
184
+ nn.SiLU(),
185
+ nn.Linear(rank_hidden_dim, cfg.d_model, bias=False),
186
+ )
187
+ self.rank_mask_emb = nn.Parameter(torch.empty(cfg.d_model))
188
+ nn.init.normal_(self.rank_mask_emb, mean=0.0, std=0.02)
189
+ else:
190
+ self.rank_mlp = None
191
+ self.rank_mask_emb = None
192
+
193
+ def _rank_expression_emb(self, expr_ids: torch.Tensor) -> torch.Tensor:
194
+ if self.rank_mlp is None:
195
+ raise RuntimeError("rank_mlp is not initialized for non-rank expr_embedding_mode")
196
+ out = self.gene_emb.weight.new_zeros((*expr_ids.shape, self.gene_emb.embedding_dim))
197
+ positive_mask = expr_ids >= int(self.bin_offset)
198
+ if bool(positive_mask.any().item()):
199
+ rank_values = (expr_ids[positive_mask] - int(self.bin_offset) + 1).to(dtype=out.dtype).unsqueeze(-1)
200
+ rank_emb = self.rank_mlp(rank_values)
201
+ if rank_emb.dtype != out.dtype:
202
+ rank_emb = rank_emb.to(dtype=out.dtype)
203
+ out[positive_mask] = rank_emb
204
+ mask_mask = expr_ids == int(self.bin_mask_id)
205
+ if bool(mask_mask.any().item()):
206
+ if self.rank_mask_emb is None:
207
+ raise RuntimeError("rank_mask_emb is not initialized for rank expr_embedding_mode")
208
+ out[mask_mask] = self.rank_mask_emb.to(dtype=out.dtype)
209
+ return out
210
+
211
+ def forward(
212
+ self,
213
+ gene_ids: torch.Tensor,
214
+ bin_ids: torch.Tensor,
215
+ attn_mask_valid: torch.Tensor,
216
+ *,
217
+ use_bin_embeddings: bool,
218
+ ) -> torch.Tensor:
219
+ x = self.gene_emb(gene_ids)
220
+ if bool(use_bin_embeddings):
221
+ if self.expr_embedding_mode == "rank":
222
+ x = x + self._rank_expression_emb(bin_ids)
223
+ else:
224
+ x = x + self.bin_emb(bin_ids)
225
+ return x * attn_mask_valid.unsqueeze(-1).to(dtype=x.dtype)
226
+
227
+
228
+ class EncoderBackbone(nn.Module):
229
+ def __init__(self, cfg: FoundationConfig):
230
+ super().__init__()
231
+ self.gradient_checkpointing = bool(getattr(cfg, "gradient_checkpointing", False))
232
+ self.tokenizer = GeneBinTokenizer(cfg)
233
+ self.encoder = nn.ModuleList(
234
+ [EncoderBlock(cfg.d_model, cfg.n_heads, cfg.dropout) for _ in range(cfg.encoder_layers)]
235
+ )
236
+ self.encoder_norm = RmsNorm(cfg.d_model)
237
+
238
+ def forward(
239
+ self,
240
+ gene_ids: torch.Tensor,
241
+ bin_ids: torch.Tensor,
242
+ attention_mask: torch.Tensor,
243
+ *,
244
+ use_bin_embeddings: bool,
245
+ ) -> torch.Tensor:
246
+ x = self.tokenizer(
247
+ gene_ids,
248
+ bin_ids,
249
+ attention_mask,
250
+ use_bin_embeddings=use_bin_embeddings,
251
+ )
252
+ for blk in self.encoder:
253
+ if self.gradient_checkpointing and self.training and torch.is_grad_enabled():
254
+ def _block_forward(hidden: torch.Tensor, *, block: EncoderBlock = blk) -> torch.Tensor:
255
+ return block(hidden, attn_mask_valid=attention_mask)
256
+
257
+ x = checkpoint(_block_forward, x, use_reentrant=False)
258
+ else:
259
+ x = blk(x, attn_mask_valid=attention_mask)
260
+ x = self.encoder_norm(x)
261
+ return x * attention_mask.unsqueeze(-1).to(dtype=x.dtype)
262
+
263
+
264
+ class DinoHead(nn.Module):
265
+ def __init__(self, cfg: FoundationConfig):
266
+ super().__init__()
267
+ hidden_dim = int(cfg.dino_hidden_dim)
268
+ bottleneck_dim = int(cfg.dino_bottleneck_dim)
269
+ out_dim = int(cfg.dino_out_dim)
270
+ if hidden_dim <= 0 or bottleneck_dim <= 0 or out_dim <= 0:
271
+ raise ValueError("DINO head dimensions must all be > 0")
272
+ self.mlp = nn.Sequential(
273
+ nn.Linear(cfg.d_model, hidden_dim, bias=False),
274
+ nn.GELU(),
275
+ nn.Linear(hidden_dim, hidden_dim, bias=False),
276
+ nn.GELU(),
277
+ nn.Linear(hidden_dim, bottleneck_dim, bias=False),
278
+ )
279
+ self.norm = nn.LayerNorm(bottleneck_dim)
280
+ self.prototypes = nn.Linear(bottleneck_dim, out_dim, bias=False)
281
+
282
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
283
+ x = self.mlp(x)
284
+ x = self.norm(x)
285
+ x = F.normalize(x, dim=-1)
286
+ return self.prototypes(x)
287
+
288
+
289
+ class TeacherCLSExpressionDecoder(nn.Module):
290
+ def __init__(self, cfg: FoundationConfig):
291
+ super().__init__()
292
+ hidden_dim = int(getattr(cfg, "teacher_cls_decoder_hidden_dim", 1024))
293
+ if hidden_dim <= 0:
294
+ raise ValueError("teacher_cls_decoder_hidden_dim must be > 0")
295
+ self.condition_on_obs = bool(getattr(cfg, "teacher_cls_decoder_condition_on_obs", False))
296
+ self.cls_proj = nn.Sequential(
297
+ nn.Linear(cfg.d_model, hidden_dim, bias=False),
298
+ nn.LayerNorm(hidden_dim),
299
+ nn.SiLU(),
300
+ )
301
+ self.obs_proj: Optional[nn.Sequential]
302
+ if self.condition_on_obs:
303
+ self.obs_proj = nn.Sequential(
304
+ nn.Linear(2, hidden_dim, bias=False),
305
+ nn.LayerNorm(hidden_dim),
306
+ nn.SiLU(),
307
+ )
308
+ else:
309
+ self.obs_proj = None
310
+ self.gene_proj = nn.Sequential(
311
+ nn.Linear(cfg.d_model, hidden_dim, bias=False),
312
+ nn.LayerNorm(hidden_dim),
313
+ nn.SiLU(),
314
+ )
315
+ self.out_proj = nn.Sequential(
316
+ nn.Linear(2 * hidden_dim, hidden_dim, bias=False),
317
+ nn.SiLU(),
318
+ nn.Linear(hidden_dim, 1, bias=True),
319
+ )
320
+
321
+ def forward(
322
+ self,
323
+ cls_state: torch.Tensor,
324
+ gene_query_states: torch.Tensor,
325
+ obs_features: Optional[torch.Tensor] = None,
326
+ ) -> torch.Tensor:
327
+ cls_hidden = self.cls_proj(cls_state)
328
+ if self.condition_on_obs:
329
+ if obs_features is None:
330
+ raise ValueError("teacher_cls_decoder_condition_on_obs=True requires obs_features")
331
+ cls_hidden = cls_hidden + self.obs_proj(
332
+ obs_features.to(device=cls_hidden.device, dtype=cls_hidden.dtype)
333
+ )
334
+ cls_hidden = cls_hidden.unsqueeze(1).expand(-1, gene_query_states.shape[1], -1)
335
+ gene_hidden = self.gene_proj(gene_query_states)
336
+ return self.out_proj(torch.cat([cls_hidden, gene_hidden], dim=-1)).squeeze(-1)
337
+
338
+
339
+ class CellContrastivePredictModel(nn.Module):
340
+ def __init__(self, cfg: FoundationConfig):
341
+ super().__init__()
342
+ self.cfg = cfg
343
+ if cfg.encoder_layers < 1:
344
+ raise ValueError("encoder_layers must be >= 1")
345
+ if cfg.n_heads < 1:
346
+ raise ValueError("n_heads must be >= 1")
347
+ if cfg.d_model <= 0:
348
+ raise ValueError("d_model must be > 0")
349
+ if cfg.gene_vocab_size <= cfg.gene_cls_id:
350
+ raise ValueError("gene_vocab_size too small for gene_cls_id")
351
+ if cfg.bin_vocab_size <= 0:
352
+ raise ValueError("bin_vocab_size must be > 0")
353
+ if not (0.0 < float(cfg.student_temperature)):
354
+ raise ValueError("student_temperature must be > 0")
355
+ if not (0.0 < float(cfg.teacher_temperature)):
356
+ raise ValueError("teacher_temperature must be > 0")
357
+ if not (0.0 <= float(cfg.token_loss_weight)):
358
+ raise ValueError("token_loss_weight must be >= 0")
359
+ if float(getattr(cfg, "token_unmasked_loss_weight", 0.0)) < 0.0:
360
+ raise ValueError("token_unmasked_loss_weight must be >= 0")
361
+ if not (0.0 <= float(getattr(cfg, "teacher_cls_decoder_loss_weight", 0.0))):
362
+ raise ValueError("teacher_cls_decoder_loss_weight must be >= 0")
363
+ if int(getattr(cfg, "teacher_cls_decoder_hidden_dim", 1024)) <= 0:
364
+ raise ValueError("teacher_cls_decoder_hidden_dim must be > 0")
365
+ if not isinstance(getattr(cfg, "teacher_cls_decoder_condition_on_obs", False), bool):
366
+ raise ValueError("teacher_cls_decoder_condition_on_obs must be a bool")
367
+ if float(getattr(cfg, "teacher_cls_decoder_rank_loss_alpha", 0.05)) < 0.0:
368
+ raise ValueError("teacher_cls_decoder_rank_loss_alpha must be >= 0")
369
+ if int(getattr(cfg, "teacher_cls_decoder_rank_num_pairs", 4096)) < 0:
370
+ raise ValueError("teacher_cls_decoder_rank_num_pairs must be >= 0")
371
+ if float(getattr(cfg, "teacher_student_decoder_distill_weight", 0.0)) < 0.0:
372
+ raise ValueError("teacher_student_decoder_distill_weight must be >= 0")
373
+ loss_mode = str(getattr(cfg, "teacher_student_decoder_loss_mode", "distribution")).strip().lower()
374
+ if loss_mode not in {"distribution", "mse"}:
375
+ raise ValueError("teacher_student_decoder_loss_mode must be one of {'distribution', 'mse'}")
376
+ cfg.teacher_student_decoder_loss_mode = loss_mode
377
+ if float(getattr(cfg, "teacher_student_decoder_rank_alpha", 0.05)) < 0.0:
378
+ raise ValueError("teacher_student_decoder_rank_alpha must be >= 0")
379
+ if float(getattr(cfg, "teacher_student_decoder_raw_mmd_weight", 0.1)) < 0.0:
380
+ raise ValueError("teacher_student_decoder_raw_mmd_weight must be >= 0")
381
+ if float(getattr(cfg, "teacher_student_decoder_raw_rank_weight", 0.01)) < 0.0:
382
+ raise ValueError("teacher_student_decoder_raw_rank_weight must be >= 0")
383
+ if float(getattr(cfg, "teacher_student_decoder_raw_mse_weight", 1.0)) < 0.0:
384
+ raise ValueError("teacher_student_decoder_raw_mse_weight must be >= 0")
385
+ if str(getattr(cfg, "expr_embedding_mode", "bin")).strip().lower() not in {"bin", "rank"}:
386
+ raise ValueError("expr_embedding_mode must be one of {'bin', 'rank'}")
387
+ if int(getattr(cfg, "expr_rank_mlp_hidden_dim", 256)) <= 0:
388
+ raise ValueError("expr_rank_mlp_hidden_dim must be > 0")
389
+ cross_modal_pairing_mode = str(getattr(cfg, "cross_modal_pairing_mode", "disabled")).strip().lower()
390
+ if cross_modal_pairing_mode not in {"disabled", "symmetric", "bin_to_gene_only"}:
391
+ raise ValueError("cross_modal_pairing_mode must be one of {'disabled', 'symmetric', 'bin_to_gene_only'}")
392
+ cfg.cross_modal_pairing_mode = cross_modal_pairing_mode
393
+
394
+ self.student_backbone = EncoderBackbone(cfg)
395
+ self.teacher_backbone = EncoderBackbone(cfg)
396
+ self.student_head = DinoHead(cfg)
397
+ self.teacher_head = DinoHead(cfg)
398
+ self.student_ibot_head = DinoHead(cfg)
399
+ self.teacher_ibot_head = DinoHead(cfg)
400
+ self.student_cls_expression_decoder: Optional[TeacherCLSExpressionDecoder] = None
401
+ self.teacher_cls_expression_decoder: Optional[TeacherCLSExpressionDecoder] = None
402
+ self._configure_teacher_cls_expression_decoder(
403
+ loss_weight=float(getattr(cfg, "teacher_cls_decoder_loss_weight", 0.0)),
404
+ hidden_dim=int(getattr(cfg, "teacher_cls_decoder_hidden_dim", 1024)),
405
+ )
406
+
407
+ self.register_buffer("center", torch.zeros(1, int(cfg.dino_out_dim)))
408
+ self.register_buffer("token_center", torch.zeros(1, int(cfg.dino_out_dim)))
409
+ self.register_buffer("last_teacher_momentum", torch.tensor(float(cfg.teacher_momentum), dtype=torch.float32))
410
+
411
+ self._sync_teacher_from_student()
412
+ self._freeze_teacher()
413
+
414
+ def _configure_teacher_cls_expression_decoder(
415
+ self,
416
+ *,
417
+ loss_weight: float,
418
+ hidden_dim: int,
419
+ condition_on_obs: Optional[bool] = None,
420
+ ) -> None:
421
+ current_hidden = int(getattr(self.cfg, "teacher_cls_decoder_hidden_dim", hidden_dim))
422
+ current_condition = bool(getattr(self.cfg, "teacher_cls_decoder_condition_on_obs", False))
423
+ next_condition = current_condition if condition_on_obs is None else bool(condition_on_obs)
424
+ distill_enabled = float(getattr(self.cfg, "teacher_student_decoder_distill_weight", 0.0)) > 0.0
425
+ self.cfg.teacher_cls_decoder_loss_weight = float(loss_weight)
426
+ self.cfg.teacher_cls_decoder_hidden_dim = int(hidden_dim)
427
+ self.cfg.teacher_cls_decoder_condition_on_obs = bool(next_condition)
428
+ if float(loss_weight) <= 0.0:
429
+ self.student_cls_expression_decoder = None
430
+ self.teacher_cls_expression_decoder = None
431
+ return
432
+ if distill_enabled:
433
+ if (
434
+ self.student_cls_expression_decoder is None
435
+ or int(current_hidden) != int(hidden_dim)
436
+ or current_condition != bool(next_condition)
437
+ ):
438
+ self.student_cls_expression_decoder = TeacherCLSExpressionDecoder(self.cfg)
439
+ if (
440
+ self.teacher_cls_expression_decoder is None
441
+ or int(current_hidden) != int(hidden_dim)
442
+ or current_condition != bool(next_condition)
443
+ ):
444
+ self.teacher_cls_expression_decoder = TeacherCLSExpressionDecoder(self.cfg)
445
+ self.teacher_cls_expression_decoder.load_state_dict(self.student_cls_expression_decoder.state_dict())
446
+ for param in self.teacher_cls_expression_decoder.parameters():
447
+ param.requires_grad = False
448
+ return
449
+ self.student_cls_expression_decoder = None
450
+ if (
451
+ self.teacher_cls_expression_decoder is None
452
+ or int(current_hidden) != int(hidden_dim)
453
+ or current_condition != bool(next_condition)
454
+ ):
455
+ self.teacher_cls_expression_decoder = TeacherCLSExpressionDecoder(self.cfg)
456
+
457
+ def configure_teacher_cls_expression_decoder(
458
+ self,
459
+ *,
460
+ loss_weight: float,
461
+ hidden_dim: int,
462
+ condition_on_obs: Optional[bool] = None,
463
+ ) -> None:
464
+ self._configure_teacher_cls_expression_decoder(
465
+ loss_weight=float(loss_weight),
466
+ hidden_dim=int(hidden_dim),
467
+ condition_on_obs=condition_on_obs,
468
+ )
469
+
470
+ def _freeze_teacher(self) -> None:
471
+ for param in self.teacher_backbone.parameters():
472
+ param.requires_grad = False
473
+ for param in self.teacher_head.parameters():
474
+ param.requires_grad = False
475
+ for param in self.teacher_ibot_head.parameters():
476
+ param.requires_grad = False
477
+ if (
478
+ float(getattr(self.cfg, "teacher_student_decoder_distill_weight", 0.0)) > 0.0
479
+ and self.teacher_cls_expression_decoder is not None
480
+ ):
481
+ for param in self.teacher_cls_expression_decoder.parameters():
482
+ param.requires_grad = False
483
+
484
+ def _sync_teacher_from_student(self) -> None:
485
+ self.teacher_backbone.load_state_dict(self.student_backbone.state_dict())
486
+ self.teacher_head.load_state_dict(self.student_head.state_dict())
487
+ self.teacher_ibot_head.load_state_dict(self.student_ibot_head.state_dict())
488
+ if self.student_cls_expression_decoder is not None and self.teacher_cls_expression_decoder is not None:
489
+ self.teacher_cls_expression_decoder.load_state_dict(self.student_cls_expression_decoder.state_dict())
490
+
491
+ def _pool_cell_repr(self, token_states: torch.Tensor) -> torch.Tensor:
492
+ return token_states[:, 0, :]
493
+
494
+ def _pool_instance_repr(self, token_states: torch.Tensor) -> torch.Tensor:
495
+ return self._pool_cell_repr(token_states)
496
+
497
+ def _select_backbone(self, backbone: Optional[str] = None) -> EncoderBackbone:
498
+ chosen = backbone
499
+ if chosen is None:
500
+ chosen = "teacher" if bool(getattr(self.cfg, "inference_use_teacher", True)) else "student"
501
+ if chosen == "teacher":
502
+ return self.teacher_backbone
503
+ if chosen == "student":
504
+ return self.student_backbone
505
+ raise ValueError(f"Unknown backbone={backbone!r}")
506
+
507
+ def _encode_tokens(
508
+ self,
509
+ gene_ids: torch.Tensor,
510
+ bin_ids: torch.Tensor,
511
+ attention_mask: torch.Tensor,
512
+ *,
513
+ use_bin_embeddings: bool = True,
514
+ backbone: Optional[str] = None,
515
+ ) -> torch.Tensor:
516
+ module = self._select_backbone(backbone)
517
+ return module(
518
+ gene_ids,
519
+ bin_ids,
520
+ attention_mask,
521
+ use_bin_embeddings=use_bin_embeddings,
522
+ )
523
+
524
+ def _encode(
525
+ self,
526
+ gene_ids: torch.Tensor,
527
+ bin_ids: torch.Tensor,
528
+ attention_mask: torch.Tensor,
529
+ *,
530
+ use_bin_embeddings: bool = True,
531
+ backbone: Optional[str] = None,
532
+ ) -> torch.Tensor:
533
+ return self._pool_cell_repr(
534
+ self._encode_tokens(
535
+ gene_ids,
536
+ bin_ids,
537
+ attention_mask,
538
+ use_bin_embeddings=use_bin_embeddings,
539
+ backbone=backbone,
540
+ )
541
+ )
542
+
543
+ def _encode_view_stack(
544
+ self,
545
+ gene_ids: torch.Tensor,
546
+ bin_ids: torch.Tensor,
547
+ attention_mask: torch.Tensor,
548
+ view_use_bin: torch.Tensor,
549
+ *,
550
+ backbone_name: str,
551
+ head: nn.Module,
552
+ capture_token_views: Optional[Tuple[int, ...]] = None,
553
+ ) -> Tuple[torch.Tensor, torch.Tensor, Dict[int, torch.Tensor]]:
554
+ num_views = int(gene_ids.shape[1])
555
+ pooled_views = []
556
+ logits_views = []
557
+ token_view_set = set(capture_token_views or ())
558
+ captured_token_states: Dict[int, torch.Tensor] = {}
559
+ for view_idx in range(num_views):
560
+ use_bin = bool(view_use_bin[view_idx].item())
561
+ view_attention_mask = attention_mask[:, view_idx, :]
562
+ valid_positions = torch.nonzero(view_attention_mask, as_tuple=False)
563
+ if valid_positions.numel() == 0:
564
+ raise ValueError(f"view {view_idx} contains no valid tokens")
565
+ max_valid_len = int(valid_positions[:, -1].max().item()) + 1
566
+ tokens = self._encode_tokens(
567
+ gene_ids[:, view_idx, :max_valid_len],
568
+ bin_ids[:, view_idx, :max_valid_len],
569
+ view_attention_mask[:, :max_valid_len],
570
+ use_bin_embeddings=use_bin,
571
+ backbone=backbone_name,
572
+ )
573
+ pooled = self._pool_cell_repr(tokens)
574
+ pooled_views.append(pooled)
575
+ logits_views.append(head(pooled))
576
+ if view_idx in token_view_set:
577
+ captured_token_states[view_idx] = tokens
578
+ return torch.stack(pooled_views, dim=1), torch.stack(logits_views, dim=1), captured_token_states
579
+
580
+ def _distributed_batch_mean(self, x: torch.Tensor) -> torch.Tensor:
581
+ mean = x.mean(dim=0, keepdim=True)
582
+ if _dist_is_initialized():
583
+ mean = mean.clone()
584
+ torch.distributed.all_reduce(mean, op=torch.distributed.ReduceOp.SUM)
585
+ mean /= float(torch.distributed.get_world_size())
586
+ return mean
587
+
588
+ def _distributed_optional_rank_mean(
589
+ self,
590
+ x: Optional[torch.Tensor],
591
+ *,
592
+ output_dim: int,
593
+ device: torch.device,
594
+ ) -> Optional[torch.Tensor]:
595
+ if x is None or int(x.numel()) == 0 or int(x.shape[0]) == 0:
596
+ mean = torch.zeros((1, int(output_dim)), device=device, dtype=torch.float32)
597
+ active = torch.zeros([], device=device, dtype=torch.float32)
598
+ else:
599
+ mean = x.reshape(-1, x.shape[-1]).detach().float().mean(dim=0, keepdim=True)
600
+ active = torch.ones([], device=mean.device, dtype=torch.float32)
601
+
602
+ if _dist_is_initialized():
603
+ torch.distributed.all_reduce(mean, op=torch.distributed.ReduceOp.SUM)
604
+ torch.distributed.all_reduce(active, op=torch.distributed.ReduceOp.SUM)
605
+
606
+ if float(active.item()) <= 0.0:
607
+ return None
608
+ return mean / active.clamp_min(1.0)
609
+
610
+ def _update_center(self, teacher_logits: torch.Tensor) -> None:
611
+ batch_center = self._distributed_batch_mean(teacher_logits.reshape(-1, teacher_logits.shape[-1]).detach())
612
+ batch_center = batch_center.to(device=self.center.device, dtype=self.center.dtype)
613
+ momentum = float(getattr(self.cfg, "center_momentum", 0.9))
614
+ self.center.mul_(momentum).add_(batch_center, alpha=1.0 - momentum)
615
+
616
+ def _update_token_center(self, teacher_logits: Optional[torch.Tensor]) -> None:
617
+ batch_center = self._distributed_optional_rank_mean(
618
+ teacher_logits,
619
+ output_dim=int(self.token_center.shape[-1]),
620
+ device=self.token_center.device,
621
+ )
622
+ if batch_center is None:
623
+ return
624
+ batch_center = batch_center.to(device=self.token_center.device, dtype=self.token_center.dtype)
625
+ momentum = float(getattr(self.cfg, "token_center_momentum", getattr(self.cfg, "center_momentum", 0.9)))
626
+ self.token_center.mul_(momentum).add_(batch_center, alpha=1.0 - momentum)
627
+
628
+ def _teacher_probs(self, logits: torch.Tensor) -> torch.Tensor:
629
+ temperature = float(getattr(self.cfg, "teacher_temperature", 0.04))
630
+ logits_f = logits.float()
631
+ center_f = self.center.to(device=logits_f.device, dtype=logits_f.dtype)
632
+ return F.softmax((logits_f - center_f) / temperature, dim=-1)
633
+
634
+ def _teacher_token_probs(self, logits: torch.Tensor) -> torch.Tensor:
635
+ temperature = float(getattr(self.cfg, "teacher_temperature", 0.04))
636
+ logits_f = logits.float()
637
+ center_f = self.token_center.to(device=logits_f.device, dtype=logits_f.dtype)
638
+ return F.softmax((logits_f - center_f) / temperature, dim=-1)
639
+
640
+ def _student_log_probs(self, logits: torch.Tensor) -> torch.Tensor:
641
+ temperature = float(getattr(self.cfg, "student_temperature", 0.10))
642
+ return F.log_softmax(logits.float() / temperature, dim=-1)
643
+
644
+ def _cross_entropy_target(self, student_logits: torch.Tensor, teacher_probs: torch.Tensor) -> torch.Tensor:
645
+ student_log_probs = self._student_log_probs(student_logits)
646
+ return (-teacher_probs * student_log_probs).sum(dim=-1).mean()
647
+
648
+ @staticmethod
649
+ def _mean_pairwise_abs_1d(values: torch.Tensor) -> torch.Tensor:
650
+ n = int(values.numel())
651
+ if n <= 1:
652
+ return values.new_zeros(())
653
+ sorted_values = values.sort().values
654
+ coeff = torch.arange(n, device=values.device, dtype=values.dtype) * 2 - (n - 1)
655
+ return (2.0 * (coeff * sorted_values).sum()) / float(n * n)
656
+
657
+ @staticmethod
658
+ def _mean_cross_abs_1d(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
659
+ nx = int(x.numel())
660
+ ny = int(y.numel())
661
+ if nx == 0 or ny == 0:
662
+ return x.new_zeros(())
663
+ y_sorted = y.sort().values
664
+ prefix = torch.cat([y_sorted.new_zeros((1,)), y_sorted.cumsum(dim=0)])
665
+ right = torch.searchsorted(y_sorted, x, right=False)
666
+ left_sum = prefix[right]
667
+ total_sum = prefix[-1]
668
+ right_f = right.to(dtype=x.dtype)
669
+ ny_f = float(ny)
670
+ abs_sum = x * right_f - left_sum + (total_sum - left_sum) - x * (ny_f - right_f)
671
+ return abs_sum.mean() / ny_f
672
+
673
+ def _gene_mmd_loss(self, pred_expr: torch.Tensor, target_expr: torch.Tensor, target_mask: torch.Tensor) -> torch.Tensor:
674
+ mask = target_mask.to(dtype=torch.bool)
675
+ losses = []
676
+ for batch_idx in range(int(pred_expr.shape[0])):
677
+ valid = mask[batch_idx]
678
+ if not bool(valid.any().item()):
679
+ continue
680
+ pred = pred_expr[batch_idx, valid].float()
681
+ target = target_expr[batch_idx, valid].to(device=pred.device, dtype=pred.dtype)
682
+ if int(pred.numel()) <= 1:
683
+ losses.append(F.smooth_l1_loss(pred, target, reduction="mean"))
684
+ continue
685
+ cross = self._mean_cross_abs_1d(pred, target)
686
+ pred_pair = self._mean_pairwise_abs_1d(pred)
687
+ target_pair = self._mean_pairwise_abs_1d(target)
688
+ losses.append((2.0 * cross - pred_pair - target_pair).clamp_min(0.0))
689
+ if not losses:
690
+ return pred_expr.new_zeros(())
691
+ return torch.stack(losses).mean()
692
+
693
+ def _rank_loss(self, pred_expr: torch.Tensor, target_expr: torch.Tensor, target_mask: torch.Tensor) -> torch.Tensor:
694
+ num_pairs = int(getattr(self.cfg, "teacher_cls_decoder_rank_num_pairs", 4096))
695
+ if num_pairs <= 0:
696
+ return pred_expr.new_zeros(())
697
+ mask = target_mask.to(dtype=torch.bool)
698
+ losses = []
699
+ for batch_idx in range(int(pred_expr.shape[0])):
700
+ valid = mask[batch_idx]
701
+ if int(valid.sum().item()) <= 1:
702
+ continue
703
+ pred = pred_expr[batch_idx, valid].float()
704
+ target = target_expr[batch_idx, valid].to(device=pred.device, dtype=pred.dtype)
705
+ n = int(pred.numel())
706
+ device = pred.device
707
+ expressed = torch.nonzero(target > 0, as_tuple=False).flatten()
708
+ zero = torch.nonzero(target <= 0, as_tuple=False).flatten()
709
+ pair_i = []
710
+ pair_j = []
711
+ ez_pairs = num_pairs // 2 if int(expressed.numel()) > 0 and int(zero.numel()) > 0 else 0
712
+ if ez_pairs > 0:
713
+ pair_i.append(expressed[torch.randint(int(expressed.numel()), (ez_pairs,), device=device)])
714
+ pair_j.append(zero[torch.randint(int(zero.numel()), (ez_pairs,), device=device)])
715
+ random_pairs = num_pairs - ez_pairs
716
+ if random_pairs > 0:
717
+ pair_i.append(torch.randint(n, (random_pairs,), device=device))
718
+ pair_j.append(torch.randint(n, (random_pairs,), device=device))
719
+ i = torch.cat(pair_i, dim=0)
720
+ j = torch.cat(pair_j, dim=0)
721
+ true_diff = target[i] - target[j]
722
+ true_sign = torch.sign(true_diff)
723
+ valid_pair = true_sign != 0
724
+ if not bool(valid_pair.any().item()):
725
+ continue
726
+ pred_diff = pred[i] - pred[j]
727
+ losses.append(F.softplus(-true_sign[valid_pair] * pred_diff[valid_pair]).mean())
728
+ if not losses:
729
+ return pred_expr.new_zeros(())
730
+ return torch.stack(losses).mean()
731
+
732
+ def _rank_loss_random_pairs(
733
+ self,
734
+ pred_expr: torch.Tensor,
735
+ target_expr: torch.Tensor,
736
+ target_mask: torch.Tensor,
737
+ ) -> torch.Tensor:
738
+ num_pairs = int(getattr(self.cfg, "teacher_cls_decoder_rank_num_pairs", 4096))
739
+ if num_pairs <= 0:
740
+ return pred_expr.new_zeros(())
741
+ mask = target_mask.to(dtype=torch.bool)
742
+ losses = []
743
+ for batch_idx in range(int(pred_expr.shape[0])):
744
+ valid = mask[batch_idx]
745
+ if int(valid.sum().item()) <= 1:
746
+ continue
747
+ pred = pred_expr[batch_idx, valid].float()
748
+ target = target_expr[batch_idx, valid].to(device=pred.device, dtype=pred.dtype)
749
+ n = int(pred.numel())
750
+ device = pred.device
751
+ i = torch.randint(n, (num_pairs,), device=device)
752
+ j = torch.randint(n, (num_pairs,), device=device)
753
+ true_sign = torch.sign(target[i] - target[j])
754
+ valid_pair = true_sign != 0
755
+ if not bool(valid_pair.any().item()):
756
+ continue
757
+ pred_diff = pred[i] - pred[j]
758
+ losses.append(F.softplus(-true_sign[valid_pair] * pred_diff[valid_pair]).mean())
759
+ if not losses:
760
+ return pred_expr.new_zeros(())
761
+ return torch.stack(losses).mean()
762
+
763
+ def _masked_mse_loss(
764
+ self,
765
+ pred_expr: torch.Tensor,
766
+ target_expr: torch.Tensor,
767
+ target_mask: torch.Tensor,
768
+ ) -> torch.Tensor:
769
+ mask = target_mask.to(device=pred_expr.device, dtype=torch.bool)
770
+ if not bool(mask.any().item()):
771
+ return pred_expr.new_zeros(())
772
+ target = target_expr.to(device=pred_expr.device, dtype=pred_expr.dtype)
773
+ return F.mse_loss(pred_expr[mask].float(), target[mask].float(), reduction="mean")
774
+
775
+ def _teacher_cls_decoder_loss(
776
+ self,
777
+ *,
778
+ teacher_gene_ids: torch.Tensor,
779
+ teacher_bin_ids: torch.Tensor,
780
+ teacher_attention_mask: torch.Tensor,
781
+ teacher_view_use_bin: torch.Tensor,
782
+ target_gene_ids: torch.Tensor,
783
+ target_expr: torch.Tensor,
784
+ target_mask: torch.Tensor,
785
+ obs_features: Optional[torch.Tensor] = None,
786
+ ) -> torch.Tensor:
787
+ if self.teacher_cls_expression_decoder is None:
788
+ return torch.zeros([], device=teacher_gene_ids.device, dtype=torch.float32)
789
+ if target_gene_ids.numel() == 0 or not bool(target_mask.any().item()):
790
+ return torch.zeros([], device=teacher_gene_ids.device, dtype=torch.float32)
791
+
792
+ decoder_tokens = self._encode_tokens(
793
+ teacher_gene_ids[:, 0, :],
794
+ teacher_bin_ids[:, 0, :],
795
+ teacher_attention_mask[:, 0, :],
796
+ use_bin_embeddings=bool(teacher_view_use_bin[0].item()),
797
+ backbone="student",
798
+ )
799
+ decoder_cls = self._pool_cell_repr(decoder_tokens)
800
+ gene_queries = self.student_backbone.tokenizer.gene_emb(target_gene_ids)
801
+ pred_expr = self.teacher_cls_expression_decoder(decoder_cls, gene_queries, obs_features=obs_features)
802
+
803
+ gene_mmd = self._gene_mmd_loss(pred_expr, target_expr, target_mask)
804
+ rank_alpha = float(getattr(self.cfg, "teacher_cls_decoder_rank_loss_alpha", 0.05))
805
+ if rank_alpha <= 0.0:
806
+ return gene_mmd
807
+ return gene_mmd + rank_alpha * self._rank_loss(pred_expr, target_expr, target_mask)
808
+
809
+ def _teacher_student_cls_decoder_loss(
810
+ self,
811
+ *,
812
+ teacher_gene_ids: torch.Tensor,
813
+ teacher_bin_ids: torch.Tensor,
814
+ teacher_attention_mask: torch.Tensor,
815
+ teacher_view_use_bin: torch.Tensor,
816
+ student_gene_ids: torch.Tensor,
817
+ student_bin_ids: torch.Tensor,
818
+ student_attention_mask: torch.Tensor,
819
+ student_view_use_bin: torch.Tensor,
820
+ target_gene_ids: torch.Tensor,
821
+ target_expr: torch.Tensor,
822
+ target_mask: torch.Tensor,
823
+ obs_features: Optional[torch.Tensor] = None,
824
+ ) -> torch.Tensor:
825
+ if self.student_cls_expression_decoder is None or self.teacher_cls_expression_decoder is None:
826
+ return torch.zeros([], device=teacher_gene_ids.device, dtype=torch.float32)
827
+ if target_gene_ids.numel() == 0 or not bool(target_mask.any().item()):
828
+ return torch.zeros([], device=teacher_gene_ids.device, dtype=torch.float32)
829
+
830
+ student_tokens = self._encode_tokens(
831
+ student_gene_ids[:, 0, :],
832
+ student_bin_ids[:, 0, :],
833
+ student_attention_mask[:, 0, :],
834
+ use_bin_embeddings=bool(student_view_use_bin[0].item()),
835
+ backbone="student",
836
+ )
837
+ student_cls = self._pool_cell_repr(student_tokens)
838
+ student_gene_queries = self.student_backbone.tokenizer.gene_emb(target_gene_ids)
839
+ student_scores = self.student_cls_expression_decoder(
840
+ student_cls,
841
+ student_gene_queries,
842
+ obs_features=obs_features,
843
+ )
844
+
845
+ with torch.no_grad():
846
+ teacher_tokens = self._encode_tokens(
847
+ teacher_gene_ids[:, 0, :],
848
+ teacher_bin_ids[:, 0, :],
849
+ teacher_attention_mask[:, 0, :],
850
+ use_bin_embeddings=bool(teacher_view_use_bin[0].item()),
851
+ backbone="teacher",
852
+ )
853
+ teacher_cls = self._pool_cell_repr(teacher_tokens)
854
+ teacher_gene_queries = self.teacher_backbone.tokenizer.gene_emb(target_gene_ids)
855
+ teacher_scores = self.teacher_cls_expression_decoder(
856
+ teacher_cls,
857
+ teacher_gene_queries,
858
+ obs_features=obs_features,
859
+ )
860
+
861
+ distill_weight = float(getattr(self.cfg, "teacher_student_decoder_distill_weight", 0.0))
862
+ loss_mode = str(getattr(self.cfg, "teacher_student_decoder_loss_mode", "distribution")).strip().lower()
863
+ if loss_mode == "mse":
864
+ raw_mse_weight = float(getattr(self.cfg, "teacher_student_decoder_raw_mse_weight", 1.0))
865
+ loss = student_scores.new_zeros(())
866
+ if distill_weight > 0.0:
867
+ loss = loss + distill_weight * self._masked_mse_loss(student_scores, teacher_scores, target_mask)
868
+ if raw_mse_weight > 0.0:
869
+ loss = loss + raw_mse_weight * self._masked_mse_loss(student_scores, target_expr, target_mask)
870
+ return loss
871
+ if loss_mode != "distribution":
872
+ raise ValueError(f"Unsupported teacher_student_decoder_loss_mode={loss_mode!r}")
873
+
874
+ rank_alpha = float(getattr(self.cfg, "teacher_student_decoder_rank_alpha", 0.05))
875
+ raw_mmd_weight = float(getattr(self.cfg, "teacher_student_decoder_raw_mmd_weight", 0.1))
876
+ raw_rank_weight = float(getattr(self.cfg, "teacher_student_decoder_raw_rank_weight", 0.01))
877
+
878
+ loss = student_scores.new_zeros(())
879
+ if distill_weight > 0.0:
880
+ loss = loss + distill_weight * self._gene_mmd_loss(student_scores, teacher_scores, target_mask)
881
+ if rank_alpha > 0.0:
882
+ loss = loss + distill_weight * rank_alpha * self._rank_loss_random_pairs(student_scores, teacher_scores, target_mask)
883
+ if raw_mmd_weight > 0.0:
884
+ loss = loss + raw_mmd_weight * self._gene_mmd_loss(student_scores, target_expr, target_mask)
885
+ if raw_rank_weight > 0.0:
886
+ loss = loss + raw_rank_weight * self._rank_loss(student_scores, target_expr, target_mask)
887
+ return loss
888
+
889
+ def _allow_cross_mode_pair(self, *, teacher_is_bin: bool, student_is_bin: bool) -> bool:
890
+ mode = str(getattr(self.cfg, "cross_modal_pairing_mode", "bin_to_gene_only"))
891
+ if mode == "disabled":
892
+ return False
893
+ if mode == "symmetric":
894
+ return teacher_is_bin != student_is_bin
895
+ if mode == "bin_to_gene_only":
896
+ return bool(teacher_is_bin) and (not bool(student_is_bin))
897
+ raise ValueError(f"Unknown cross_modal_pairing_mode={mode!r}")
898
+
899
+ def _pairwise_dino_loss(
900
+ self,
901
+ student_logits: torch.Tensor,
902
+ student_view_use_bin: torch.Tensor,
903
+ teacher_logits: torch.Tensor,
904
+ teacher_view_use_bin: torch.Tensor,
905
+ ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
906
+ same_losses = []
907
+ cross_losses = []
908
+ cross_weight = float(getattr(self.cfg, "cross_modal_dino_weight", 0.0))
909
+
910
+ num_teacher_views = int(teacher_logits.shape[1])
911
+ num_student_views = int(student_logits.shape[1])
912
+
913
+ for teacher_idx in range(num_teacher_views):
914
+ teacher_probs = self._teacher_probs(teacher_logits[:, teacher_idx, :]).detach()
915
+ teacher_is_bin = bool(teacher_view_use_bin[teacher_idx].item())
916
+ for student_idx in range(num_student_views):
917
+ student_is_bin = bool(student_view_use_bin[student_idx].item())
918
+ pair_loss = self._cross_entropy_target(
919
+ student_logits[:, student_idx, :],
920
+ teacher_probs,
921
+ )
922
+ if student_is_bin == teacher_is_bin:
923
+ same_losses.append(pair_loss)
924
+ elif cross_weight > 0.0 and self._allow_cross_mode_pair(
925
+ teacher_is_bin=teacher_is_bin,
926
+ student_is_bin=student_is_bin,
927
+ ):
928
+ cross_losses.append(pair_loss)
929
+
930
+ device = student_logits.device
931
+ same_loss = (
932
+ torch.stack(same_losses).mean()
933
+ if same_losses
934
+ else torch.zeros([], device=device, dtype=torch.float32)
935
+ )
936
+ cross_loss = (
937
+ torch.stack(cross_losses).mean()
938
+ if cross_losses
939
+ else torch.zeros([], device=device, dtype=torch.float32)
940
+ )
941
+ total = same_loss + float(cross_weight) * cross_loss
942
+ return total, same_loss, cross_loss
943
+
944
+ def _matched_token_states_for_pair(
945
+ self,
946
+ *,
947
+ teacher_tokens: torch.Tensor,
948
+ teacher_gene_ids: torch.Tensor,
949
+ teacher_attention_mask: torch.Tensor,
950
+ student_tokens: torch.Tensor,
951
+ student_gene_ids: torch.Tensor,
952
+ student_attention_mask: torch.Tensor,
953
+ student_ibot_mask: torch.Tensor,
954
+ use_masked_tokens: bool = True,
955
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
956
+ teacher_states: List[torch.Tensor] = []
957
+ student_states: List[torch.Tensor] = []
958
+
959
+ batch_size = int(teacher_tokens.shape[0])
960
+ hidden_dim = int(teacher_tokens.shape[-1])
961
+ for batch_idx in range(batch_size):
962
+ teacher_valid = teacher_attention_mask[batch_idx].bool().clone()
963
+ student_attention_valid = student_attention_mask[batch_idx].bool()
964
+ student_mask = student_ibot_mask[batch_idx].bool()
965
+ if use_masked_tokens:
966
+ student_valid = (student_attention_valid & student_mask).clone()
967
+ else:
968
+ student_valid = (student_attention_valid & (~student_mask)).clone()
969
+
970
+ if teacher_valid.numel() <= 1 or student_valid.numel() <= 1:
971
+ continue
972
+
973
+ teacher_valid[0] = False
974
+ student_valid[0] = False
975
+ if not teacher_valid.any().item() or not student_valid.any().item():
976
+ continue
977
+
978
+ teacher_positions = torch.nonzero(teacher_valid, as_tuple=False).squeeze(-1)
979
+ student_positions = torch.nonzero(student_valid, as_tuple=False).squeeze(-1)
980
+ teacher_genes = teacher_gene_ids[batch_idx, teacher_positions]
981
+ student_genes = student_gene_ids[batch_idx, student_positions]
982
+
983
+ matches = student_genes.unsqueeze(1) == teacher_genes.unsqueeze(0)
984
+ matched_student = matches.any(dim=1)
985
+ if not matched_student.any().item():
986
+ continue
987
+
988
+ matched_student_positions = student_positions[matched_student]
989
+ matched_teacher_rel_pos = matches[matched_student].to(dtype=torch.int64).argmax(dim=1)
990
+ matched_teacher_positions = teacher_positions[matched_teacher_rel_pos]
991
+
992
+ teacher_states.append(teacher_tokens[batch_idx, matched_teacher_positions, :])
993
+ student_states.append(student_tokens[batch_idx, matched_student_positions, :])
994
+
995
+ if teacher_states:
996
+ return torch.cat(teacher_states, dim=0), torch.cat(student_states, dim=0)
997
+
998
+ empty = teacher_tokens.new_empty((0, hidden_dim))
999
+ return empty, empty
1000
+
1001
+ def _pairwise_token_loss(
1002
+ self,
1003
+ *,
1004
+ student_token_views: Dict[int, torch.Tensor],
1005
+ student_gene_ids: torch.Tensor,
1006
+ student_attention_mask: torch.Tensor,
1007
+ student_ibot_mask: torch.Tensor,
1008
+ student_view_use_bin: torch.Tensor,
1009
+ student_view_is_global: torch.Tensor,
1010
+ teacher_token_views: Dict[int, torch.Tensor],
1011
+ teacher_gene_ids: torch.Tensor,
1012
+ teacher_attention_mask: torch.Tensor,
1013
+ teacher_view_use_bin: torch.Tensor,
1014
+ ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, Optional[torch.Tensor]]:
1015
+ same_losses = []
1016
+ cross_losses = []
1017
+ unmasked_same_losses = []
1018
+ unmasked_cross_losses = []
1019
+ teacher_logits_for_center: List[torch.Tensor] = []
1020
+ cross_weight = float(getattr(self.cfg, "cross_modal_dino_weight", 0.0))
1021
+ unmasked_weight = float(getattr(self.cfg, "token_unmasked_loss_weight", 0.0))
1022
+
1023
+ for teacher_idx, teacher_tokens in teacher_token_views.items():
1024
+ teacher_is_bin = bool(teacher_view_use_bin[teacher_idx].item())
1025
+ for student_idx, student_tokens in student_token_views.items():
1026
+ if not bool(student_view_is_global[student_idx].item()):
1027
+ continue
1028
+ if not student_ibot_mask[:, student_idx, :].any().item():
1029
+ continue
1030
+
1031
+ student_is_bin = bool(student_view_use_bin[student_idx].item())
1032
+ if student_is_bin == teacher_is_bin:
1033
+ allow_pair = True
1034
+ elif cross_weight > 0.0:
1035
+ allow_pair = self._allow_cross_mode_pair(
1036
+ teacher_is_bin=teacher_is_bin,
1037
+ student_is_bin=student_is_bin,
1038
+ )
1039
+ else:
1040
+ allow_pair = False
1041
+ if not allow_pair:
1042
+ continue
1043
+
1044
+ teacher_states, student_states = self._matched_token_states_for_pair(
1045
+ teacher_tokens=teacher_tokens,
1046
+ teacher_gene_ids=teacher_gene_ids[:, teacher_idx, :],
1047
+ teacher_attention_mask=teacher_attention_mask[:, teacher_idx, :],
1048
+ student_tokens=student_tokens,
1049
+ student_gene_ids=student_gene_ids[:, student_idx, :],
1050
+ student_attention_mask=student_attention_mask[:, student_idx, :],
1051
+ student_ibot_mask=student_ibot_mask[:, student_idx, :],
1052
+ use_masked_tokens=True,
1053
+ )
1054
+ if teacher_states.shape[0] > 0:
1055
+ teacher_logits = self.teacher_ibot_head(teacher_states)
1056
+ teacher_probs = self._teacher_token_probs(teacher_logits).detach()
1057
+ student_logits = self.student_ibot_head(student_states)
1058
+ pair_loss = self._cross_entropy_target(student_logits, teacher_probs)
1059
+ teacher_logits_for_center.append(teacher_logits.detach())
1060
+
1061
+ if student_is_bin == teacher_is_bin:
1062
+ same_losses.append(pair_loss)
1063
+ else:
1064
+ cross_losses.append(pair_loss)
1065
+
1066
+ if unmasked_weight > 0.0:
1067
+ unmasked_teacher_states, unmasked_student_states = self._matched_token_states_for_pair(
1068
+ teacher_tokens=teacher_tokens,
1069
+ teacher_gene_ids=teacher_gene_ids[:, teacher_idx, :],
1070
+ teacher_attention_mask=teacher_attention_mask[:, teacher_idx, :],
1071
+ student_tokens=student_tokens,
1072
+ student_gene_ids=student_gene_ids[:, student_idx, :],
1073
+ student_attention_mask=student_attention_mask[:, student_idx, :],
1074
+ student_ibot_mask=student_ibot_mask[:, student_idx, :],
1075
+ use_masked_tokens=False,
1076
+ )
1077
+ if unmasked_teacher_states.shape[0] > 0:
1078
+ unmasked_teacher_logits = self.teacher_ibot_head(unmasked_teacher_states)
1079
+ unmasked_teacher_probs = self._teacher_token_probs(unmasked_teacher_logits).detach()
1080
+ unmasked_student_logits = self.student_ibot_head(unmasked_student_states)
1081
+ unmasked_pair_loss = self._cross_entropy_target(
1082
+ unmasked_student_logits,
1083
+ unmasked_teacher_probs,
1084
+ )
1085
+ if student_is_bin == teacher_is_bin:
1086
+ unmasked_same_losses.append(unmasked_pair_loss)
1087
+ else:
1088
+ unmasked_cross_losses.append(unmasked_pair_loss)
1089
+
1090
+ device = student_gene_ids.device
1091
+ same_loss = (
1092
+ torch.stack(same_losses).mean()
1093
+ if same_losses
1094
+ else torch.zeros([], device=device, dtype=torch.float32)
1095
+ )
1096
+ cross_loss = (
1097
+ torch.stack(cross_losses).mean()
1098
+ if cross_losses
1099
+ else torch.zeros([], device=device, dtype=torch.float32)
1100
+ )
1101
+ unmasked_same_loss = (
1102
+ torch.stack(unmasked_same_losses).mean()
1103
+ if unmasked_same_losses
1104
+ else torch.zeros([], device=device, dtype=torch.float32)
1105
+ )
1106
+ unmasked_cross_loss = (
1107
+ torch.stack(unmasked_cross_losses).mean()
1108
+ if unmasked_cross_losses
1109
+ else torch.zeros([], device=device, dtype=torch.float32)
1110
+ )
1111
+ masked_total = same_loss + float(cross_weight) * cross_loss
1112
+ unmasked_total = unmasked_same_loss + float(cross_weight) * unmasked_cross_loss
1113
+ total = masked_total + unmasked_weight * unmasked_total
1114
+ teacher_logits_center = torch.cat(teacher_logits_for_center, dim=0) if teacher_logits_for_center else None
1115
+ self._last_token_unmasked_loss = unmasked_total.detach()
1116
+ self._last_token_unmasked_same_mode_loss = unmasked_same_loss.detach()
1117
+ self._last_token_unmasked_cross_mode_loss = unmasked_cross_loss.detach()
1118
+ return total, same_loss, cross_loss, teacher_logits_center
1119
+
1120
+ def _teacher_momentum_value(self, global_step: int, max_steps: int) -> float:
1121
+ start = float(getattr(self.cfg, "teacher_momentum", 0.996))
1122
+ end = float(getattr(self.cfg, "teacher_momentum_end", 1.0))
1123
+ if max_steps <= 1:
1124
+ return start
1125
+ progress = min(max(float(global_step), 0.0), float(max_steps - 1)) / float(max_steps - 1)
1126
+ cosine = 0.5 * (1.0 + math.cos(math.pi * progress))
1127
+ return end - (end - start) * cosine
1128
+
1129
+ def _ema_update_module(self, student_module: nn.Module, teacher_module: nn.Module, momentum: float) -> None:
1130
+ with ExitStack() as stack:
1131
+ if FSDP is not None:
1132
+ for wrapped in _leaf_fsdp_modules(student_module):
1133
+ stack.enter_context(FSDP.summon_full_params(wrapped, recurse=False, writeback=False))
1134
+ for wrapped in _leaf_fsdp_modules(teacher_module):
1135
+ stack.enter_context(FSDP.summon_full_params(wrapped, recurse=False, writeback=True))
1136
+
1137
+ student_params = dict(student_module.named_parameters())
1138
+ teacher_params = dict(teacher_module.named_parameters())
1139
+ if student_params.keys() != teacher_params.keys():
1140
+ missing_student = sorted(set(teacher_params.keys()) - set(student_params.keys()))
1141
+ missing_teacher = sorted(set(student_params.keys()) - set(teacher_params.keys()))
1142
+ raise RuntimeError(
1143
+ "Student/teacher parameter names do not match for EMA update: "
1144
+ f"missing_student={missing_student[:5]}, missing_teacher={missing_teacher[:5]}"
1145
+ )
1146
+
1147
+ for name, teacher_param in teacher_params.items():
1148
+ student_param = student_params[name]
1149
+ if teacher_param.data.shape != student_param.data.shape:
1150
+ raise RuntimeError(
1151
+ f"EMA shape mismatch for {name}: teacher={tuple(teacher_param.shape)} "
1152
+ f"student={tuple(student_param.shape)}"
1153
+ )
1154
+ teacher_param.data.mul_(momentum).add_(student_param.data, alpha=1.0 - momentum)
1155
+
1156
+ @torch.no_grad()
1157
+ def update_teacher(self, *, global_step: int, max_steps: int) -> float:
1158
+ momentum = self._teacher_momentum_value(global_step=global_step, max_steps=max_steps)
1159
+ self.last_teacher_momentum.fill_(float(momentum))
1160
+
1161
+ self._ema_update_module(self.student_backbone, self.teacher_backbone, momentum)
1162
+ self._ema_update_module(self.student_head, self.teacher_head, momentum)
1163
+ self._ema_update_module(self.student_ibot_head, self.teacher_ibot_head, momentum)
1164
+ if self.student_cls_expression_decoder is not None and self.teacher_cls_expression_decoder is not None:
1165
+ self._ema_update_module(
1166
+ self.student_cls_expression_decoder,
1167
+ self.teacher_cls_expression_decoder,
1168
+ momentum,
1169
+ )
1170
+ return momentum
1171
+
1172
+ def forward(self, batch: Dict[str, torch.Tensor]) -> Dict[str, torch.Tensor]:
1173
+ student_gene_ids = batch["student_gene_ids"]
1174
+ student_bin_ids = batch["student_bin_ids"]
1175
+ student_attention_mask = batch["student_attention_mask"]
1176
+ student_ibot_mask = batch["student_ibot_mask"]
1177
+ student_view_use_bin = batch["student_view_use_bin"]
1178
+ student_view_is_global = batch["student_view_is_global"]
1179
+
1180
+ teacher_gene_ids = batch["teacher_gene_ids"]
1181
+ teacher_bin_ids = batch["teacher_bin_ids"]
1182
+ teacher_attention_mask = batch["teacher_attention_mask"]
1183
+ teacher_view_use_bin = batch["teacher_view_use_bin"]
1184
+
1185
+ student_capture_views = tuple(
1186
+ idx for idx in range(int(student_view_is_global.numel())) if bool(student_view_is_global[idx].item())
1187
+ )
1188
+ teacher_capture_views = tuple(range(int(teacher_view_use_bin.numel())))
1189
+
1190
+ _, student_logits, student_token_views = self._encode_view_stack(
1191
+ student_gene_ids,
1192
+ student_bin_ids,
1193
+ student_attention_mask,
1194
+ student_view_use_bin,
1195
+ backbone_name="student",
1196
+ head=self.student_head,
1197
+ capture_token_views=student_capture_views,
1198
+ )
1199
+
1200
+ with torch.no_grad():
1201
+ _, teacher_logits, teacher_token_views = self._encode_view_stack(
1202
+ teacher_gene_ids,
1203
+ teacher_bin_ids,
1204
+ teacher_attention_mask,
1205
+ teacher_view_use_bin,
1206
+ backbone_name="teacher",
1207
+ head=self.teacher_head,
1208
+ capture_token_views=teacher_capture_views,
1209
+ )
1210
+
1211
+ loss_dino, loss_same_mode, loss_cross_mode = self._pairwise_dino_loss(
1212
+ student_logits,
1213
+ student_view_use_bin,
1214
+ teacher_logits,
1215
+ teacher_view_use_bin,
1216
+ )
1217
+
1218
+ token_loss_weight = float(getattr(self.cfg, "token_loss_weight", 1.0))
1219
+ if token_loss_weight > 0.0:
1220
+ loss_token, loss_token_same_mode, loss_token_cross_mode, teacher_token_center_logits = self._pairwise_token_loss(
1221
+ student_token_views=student_token_views,
1222
+ student_gene_ids=student_gene_ids,
1223
+ student_attention_mask=student_attention_mask,
1224
+ student_ibot_mask=student_ibot_mask,
1225
+ student_view_use_bin=student_view_use_bin,
1226
+ student_view_is_global=student_view_is_global,
1227
+ teacher_token_views=teacher_token_views,
1228
+ teacher_gene_ids=teacher_gene_ids,
1229
+ teacher_attention_mask=teacher_attention_mask,
1230
+ teacher_view_use_bin=teacher_view_use_bin,
1231
+ )
1232
+ else:
1233
+ zero = torch.zeros([], device=student_logits.device, dtype=torch.float32)
1234
+ loss_token = zero
1235
+ loss_token_same_mode = zero
1236
+ loss_token_cross_mode = zero
1237
+ self._last_token_unmasked_loss = zero.detach()
1238
+ self._last_token_unmasked_same_mode_loss = zero.detach()
1239
+ self._last_token_unmasked_cross_mode_loss = zero.detach()
1240
+ teacher_token_center_logits = None
1241
+
1242
+ decoder_loss_weight = float(getattr(self.cfg, "teacher_cls_decoder_loss_weight", 0.0))
1243
+ if decoder_loss_weight > 0.0:
1244
+ if float(getattr(self.cfg, "teacher_student_decoder_distill_weight", 0.0)) > 0.0:
1245
+ loss_decoder = self._teacher_student_cls_decoder_loss(
1246
+ teacher_gene_ids=teacher_gene_ids,
1247
+ teacher_bin_ids=teacher_bin_ids,
1248
+ teacher_attention_mask=teacher_attention_mask,
1249
+ teacher_view_use_bin=teacher_view_use_bin,
1250
+ student_gene_ids=student_gene_ids,
1251
+ student_bin_ids=student_bin_ids,
1252
+ student_attention_mask=student_attention_mask,
1253
+ student_view_use_bin=student_view_use_bin,
1254
+ target_gene_ids=batch["teacher_cls_decoder_target_gene_ids"],
1255
+ target_expr=batch["teacher_cls_decoder_target_expr"],
1256
+ target_mask=batch["teacher_cls_decoder_target_mask"],
1257
+ obs_features=batch.get("teacher_cls_decoder_obs_features"),
1258
+ )
1259
+ else:
1260
+ loss_decoder = self._teacher_cls_decoder_loss(
1261
+ teacher_gene_ids=teacher_gene_ids,
1262
+ teacher_bin_ids=teacher_bin_ids,
1263
+ teacher_attention_mask=teacher_attention_mask,
1264
+ teacher_view_use_bin=teacher_view_use_bin,
1265
+ target_gene_ids=batch["teacher_cls_decoder_target_gene_ids"],
1266
+ target_expr=batch["teacher_cls_decoder_target_expr"],
1267
+ target_mask=batch["teacher_cls_decoder_target_mask"],
1268
+ obs_features=batch.get("teacher_cls_decoder_obs_features"),
1269
+ )
1270
+ else:
1271
+ loss_decoder = torch.zeros([], device=student_logits.device, dtype=torch.float32)
1272
+
1273
+ total_loss = loss_dino + token_loss_weight * loss_token + decoder_loss_weight * loss_decoder
1274
+
1275
+ with torch.no_grad():
1276
+ self._update_center(teacher_logits)
1277
+ self._update_token_center(teacher_token_center_logits)
1278
+
1279
+ return {
1280
+ "loss": total_loss,
1281
+ "loss_dino": loss_dino.detach(),
1282
+ "loss_dino_same_mode": loss_same_mode.detach(),
1283
+ "loss_dino_cross_mode": loss_cross_mode.detach(),
1284
+ "loss_token": loss_token.detach(),
1285
+ "loss_token_same_mode": loss_token_same_mode.detach(),
1286
+ "loss_token_cross_mode": loss_token_cross_mode.detach(),
1287
+ "loss_token_unmasked": getattr(self, "_last_token_unmasked_loss", loss_token.new_zeros(())).detach(),
1288
+ "loss_token_unmasked_same_mode": getattr(
1289
+ self, "_last_token_unmasked_same_mode_loss", loss_token.new_zeros(())
1290
+ ).detach(),
1291
+ "loss_token_unmasked_cross_mode": getattr(
1292
+ self, "_last_token_unmasked_cross_mode_loss", loss_token.new_zeros(())
1293
+ ).detach(),
1294
+ "loss_decoder": loss_decoder.detach(),
1295
+ "loss_total": total_loss.detach(),
1296
+ "loss_contrastive": total_loss.detach(),
1297
+ "teacher_momentum": self.last_teacher_momentum.detach(),
1298
+ }
1299
+
1300
+ def save_pretrained(
1301
+ self,
1302
+ save_directory: str,
1303
+ *,
1304
+ save_safetensors: bool = True,
1305
+ state_dict: Optional[Dict[str, torch.Tensor]] = None,
1306
+ gene_vocab: Optional[Dict[str, Any]] = None,
1307
+ meta_vocab: Optional[Dict[str, Any]] = None,
1308
+ manifest: Optional[Any] = None,
1309
+ extra_files: Optional[Dict[str, Any]] = None,
1310
+ ) -> None:
1311
+ os.makedirs(save_directory, exist_ok=True)
1312
+
1313
+ cfg_path = os.path.join(save_directory, "model_config.json")
1314
+ _json_dump(self.cfg.__dict__, cfg_path)
1315
+
1316
+ if state_dict is None:
1317
+ state_dict = self.state_dict()
1318
+ cpu_state = {k: v.detach().cpu() for k, v in state_dict.items()}
1319
+
1320
+ if save_safetensors:
1321
+ import safetensors.torch as st
1322
+
1323
+ st.save_file(cpu_state, os.path.join(save_directory, "model.safetensors"))
1324
+ else:
1325
+ torch.save(cpu_state, os.path.join(save_directory, "pytorch_model.bin"))
1326
+
1327
+ if gene_vocab is not None:
1328
+ _json_dump(gene_vocab, os.path.join(save_directory, "gene_vocab.json"))
1329
+ if meta_vocab is not None:
1330
+ _json_dump(meta_vocab, os.path.join(save_directory, "meta_vocab.json"))
1331
+ if manifest is not None:
1332
+ _json_dump(manifest, os.path.join(save_directory, "manifest.json"))
1333
+ if extra_files:
1334
+ for name, obj in extra_files.items():
1335
+ _json_dump(obj, os.path.join(save_directory, name))
1336
+
1337
+ @classmethod
1338
+ def from_pretrained(
1339
+ cls,
1340
+ load_directory: str,
1341
+ *,
1342
+ device: Optional[str] = None,
1343
+ dtype: Optional[torch.dtype] = None,
1344
+ strict: bool = True,
1345
+ load_assets: bool = True,
1346
+ ) -> Tuple["CellContrastivePredictModel", Optional[Dict[str, Any]], Optional[Dict[str, Any]], Optional[Any]]:
1347
+ cfg_path = os.path.join(load_directory, "model_config.json")
1348
+ if not os.path.exists(cfg_path):
1349
+ raise FileNotFoundError(f"Missing model_config.json in: {load_directory}")
1350
+
1351
+ with open(cfg_path, "r", encoding="utf-8") as f:
1352
+ cfg_dict = json.load(f)
1353
+
1354
+ cfg_fields = {f.name for f in dataclass_fields(FoundationConfig)}
1355
+ filtered_cfg = {k: v for k, v in cfg_dict.items() if k in cfg_fields}
1356
+ cfg = FoundationConfig(**filtered_cfg)
1357
+ model = cls(cfg)
1358
+
1359
+ st_path = os.path.join(load_directory, "model.safetensors")
1360
+ pt_path = os.path.join(load_directory, "pytorch_model.bin")
1361
+ if os.path.exists(st_path):
1362
+ import safetensors.torch as st
1363
+
1364
+ state_dict = st.load_file(st_path, device="cpu")
1365
+ elif os.path.exists(pt_path):
1366
+ state_dict = torch.load(pt_path, map_location="cpu")
1367
+ else:
1368
+ raise FileNotFoundError(f"Missing model weights in: {load_directory}")
1369
+
1370
+ if "token_center" not in state_dict:
1371
+ state_dict["token_center"] = torch.zeros_like(model.token_center)
1372
+
1373
+ for missing_prefix, source_prefix in (
1374
+ ("student_ibot_head.", "student_head."),
1375
+ ("teacher_ibot_head.", "teacher_head."),
1376
+ ):
1377
+ if any(key.startswith(missing_prefix) for key in state_dict):
1378
+ continue
1379
+ for model_key in model.state_dict().keys():
1380
+ if not model_key.startswith(missing_prefix):
1381
+ continue
1382
+ source_key = source_prefix + model_key[len(missing_prefix) :]
1383
+ if source_key in state_dict:
1384
+ state_dict[model_key] = state_dict[source_key].clone()
1385
+
1386
+ model.load_state_dict(state_dict, strict=strict)
1387
+ if dtype is not None:
1388
+ model = model.to(dtype=dtype)
1389
+ if device is not None:
1390
+ model = model.to(device=device)
1391
+
1392
+ gene_vocab = None
1393
+ meta_vocab = None
1394
+ manifest = None
1395
+ if load_assets:
1396
+ gene_vocab_path = os.path.join(load_directory, "gene_vocab.json")
1397
+ meta_vocab_path = os.path.join(load_directory, "meta_vocab.json")
1398
+ manifest_path = os.path.join(load_directory, "manifest.json")
1399
+ if os.path.exists(gene_vocab_path):
1400
+ with open(gene_vocab_path, "r", encoding="utf-8") as f:
1401
+ gene_vocab = json.load(f)
1402
+ if os.path.exists(meta_vocab_path):
1403
+ with open(meta_vocab_path, "r", encoding="utf-8") as f:
1404
+ meta_vocab = json.load(f)
1405
+ if os.path.exists(manifest_path):
1406
+ with open(manifest_path, "r", encoding="utf-8") as f:
1407
+ manifest = json.load(f)
1408
+ return model, gene_vocab, meta_vocab, manifest
1409
+
1410
+
1411
+ __all__ = ["CellContrastivePredictModel", "FoundationConfig"]
scRep_pretrain/train_utils.py ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import time
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List, Optional, Tuple
7
+
8
+ import torch
9
+ from transformers import Trainer
10
+
11
+ from .manifest import build_manifest_from_dir
12
+ from .vocab import build_gene_vocab_from_manifest, build_meta_vocab_from_manifest
13
+
14
+
15
+ logger = logging.getLogger("scRep_train")
16
+
17
+
18
+ # =============================================================================
19
+ # Distributed helpers
20
+ # =============================================================================
21
+ def dist_is_initialized() -> bool:
22
+ return torch.distributed.is_available() and torch.distributed.is_initialized()
23
+
24
+
25
+ def dist_rank_world() -> Tuple[int, int]:
26
+ if dist_is_initialized():
27
+ return torch.distributed.get_rank(), torch.distributed.get_world_size()
28
+ rank = int(os.environ.get("RANK", "0"))
29
+ world = int(os.environ.get("WORLD_SIZE", "1"))
30
+ return rank, world
31
+
32
+
33
+ def dist_barrier() -> None:
34
+ if dist_is_initialized():
35
+ torch.distributed.barrier()
36
+
37
+
38
+ def is_world_process_zero() -> bool:
39
+ rank, _ = dist_rank_world()
40
+ return rank == 0
41
+
42
+
43
+ def wait_for_file(path: str, timeout: float = 300.0, poll: float = 0.5) -> None:
44
+ """Wait until a file exists and is readable."""
45
+ p = Path(path)
46
+ start = time.time()
47
+ last_err = None
48
+ while time.time() - start < timeout:
49
+ if p.exists():
50
+ try:
51
+ with open(p, "r") as f:
52
+ _ = f.read(1)
53
+ return
54
+ except Exception as e:
55
+ last_err = e
56
+ time.sleep(poll)
57
+ raise TimeoutError(f"Timed out waiting for file: {path}. Last error: {last_err}")
58
+
59
+
60
+ # =============================================================================
61
+ # JSON helpers
62
+ # =============================================================================
63
+ def load_json(path: str) -> Any:
64
+ with open(path, "r") as f:
65
+ return json.load(f)
66
+
67
+
68
+ def save_json(obj: Any, path: str) -> None:
69
+ os.makedirs(os.path.dirname(path), exist_ok=True)
70
+ tmp_path = path + ".tmp"
71
+ with open(tmp_path, "w") as f:
72
+ json.dump(obj, f, ensure_ascii=False, indent=2)
73
+ f.flush()
74
+ os.fsync(f.fileno())
75
+ os.replace(tmp_path, path)
76
+
77
+
78
+ # =============================================================================
79
+ # FSDP / Trainer helpers
80
+ # =============================================================================
81
+ def parse_fsdp_list(fsdp_str: Optional[str]) -> Optional[List[str]]:
82
+ s = (fsdp_str or "").strip()
83
+ if not s:
84
+ return None
85
+ s = s.replace(",", " ")
86
+ parts = [p.strip() for p in s.split() if p.strip()]
87
+ return parts if parts else None
88
+
89
+
90
+ def load_fsdp_config(path: Optional[str]) -> Optional[Dict[str, Any]]:
91
+ if not path:
92
+ return None
93
+ if not os.path.exists(path):
94
+ raise FileNotFoundError(f"fsdp_config not found: {path}")
95
+ return load_json(path)
96
+
97
+
98
+ def normalize_report_to(report_to: str):
99
+ return [] if report_to == "none" else report_to
100
+
101
+
102
+ class TrainerBatchWrapperCollator:
103
+ """Wrap the collated batch as {"batch": ...} for Trainer."""
104
+ def __init__(self, base_collator):
105
+ self.base = base_collator
106
+
107
+ def __call__(self, examples):
108
+ return {"batch": self.base(examples)}
109
+
110
+
111
+ # =============================================================================
112
+ # Asset helpers
113
+ # =============================================================================
114
+ def gene_vocab_to_map(gene_vocab: Dict[str, Any]) -> Dict[str, int]:
115
+ gene_offset = int(gene_vocab.get("gene_offset", 2))
116
+ gene_names = list(gene_vocab["gene_names"])
117
+ return {str(name): i + gene_offset for i, name in enumerate(gene_names)}
118
+
119
+
120
+ def _normalize_meta_keys(meta_keys: str) -> List[str]:
121
+ return [k.strip() for k in meta_keys.split(",") if k.strip()]
122
+
123
+
124
+ def _asset_metadata(args) -> Dict[str, Any]:
125
+ return {
126
+ "data_dir": os.path.abspath(args.data_dir),
127
+ "manifest_json": os.path.abspath(args.manifest_json) if args.manifest_json else "",
128
+ "gene_vocab_json": os.path.abspath(args.gene_vocab_json) if getattr(args, "gene_vocab_json", "") else "",
129
+ "use_raw": bool(args.use_raw),
130
+ "backed": args.backed,
131
+ "meta_keys": _normalize_meta_keys(args.meta_keys),
132
+ }
133
+
134
+
135
+ def prepare_scratch_assets(args, output_dir: str) -> Tuple[Any, Dict[str, Any], Dict[str, Any], Dict[str, int], Dict[str, Dict[str, int]], Dict[str, int]]:
136
+ is_main = is_world_process_zero()
137
+
138
+ manifest_path = os.path.join(output_dir, "manifest.json")
139
+ gene_vocab_path = os.path.join(output_dir, "gene_vocab.json")
140
+ meta_vocab_path = os.path.join(output_dir, "meta_vocab.json")
141
+ metadata_path = os.path.join(output_dir, "asset_metadata.json")
142
+
143
+ expected_metadata = _asset_metadata(args)
144
+ cache_complete = all(
145
+ os.path.exists(path)
146
+ for path in (manifest_path, gene_vocab_path, meta_vocab_path, metadata_path)
147
+ )
148
+ metadata_matches = False
149
+ cache_reason = "missing cached asset files"
150
+ if cache_complete:
151
+ cached_metadata = load_json(metadata_path)
152
+ metadata_matches = cached_metadata == expected_metadata
153
+ if not metadata_matches:
154
+ cache_reason = "cached asset metadata does not match current dataset/config"
155
+
156
+ if cache_complete and metadata_matches:
157
+ logger.info("loading cached scratch assets from %s", output_dir)
158
+ manifest = load_json(manifest_path)
159
+ gene_vocab = load_json(gene_vocab_path)
160
+ meta_vocab = load_json(meta_vocab_path)
161
+ gene_name_to_id = gene_vocab_to_map(gene_vocab)
162
+ meta_maps = meta_vocab["meta_maps"]
163
+ meta_vocab_sizes = meta_vocab["meta_vocab_sizes"]
164
+ logger.info(
165
+ "loaded cached scratch assets: manifest=%s datasets, genes=%s, meta_keys=%s",
166
+ len(manifest),
167
+ gene_vocab.get("gene_vocab_size", 0),
168
+ list(meta_vocab_sizes.keys()),
169
+ )
170
+ return manifest, gene_vocab, meta_vocab, gene_name_to_id, meta_maps, meta_vocab_sizes
171
+
172
+ if is_main:
173
+ logger.info("scratch assets cache miss in %s: %s", output_dir, cache_reason)
174
+ os.makedirs(output_dir, exist_ok=True)
175
+
176
+ if args.manifest_json and os.path.exists(args.manifest_json):
177
+ logger.info("building manifest from explicit manifest_json=%s", args.manifest_json)
178
+ manifest = load_json(args.manifest_json)
179
+ save_json(manifest, manifest_path)
180
+ else:
181
+ logger.info("building manifest from data_dir=%s", args.data_dir)
182
+ manifest_start = time.time()
183
+ manifest = build_manifest_from_dir(
184
+ args.data_dir,
185
+ backed=args.backed,
186
+ use_raw=bool(args.use_raw),
187
+ )
188
+ if len(manifest) == 0:
189
+ raise RuntimeError(
190
+ "manifest is empty when building from `data_dir`. "
191
+ "This usually means your h5ad files were filtered out.\n\n"
192
+ "Common causes:\n"
193
+ "1) `--use_raw` is enabled but your .h5ad has no `adata.raw`.\n"
194
+ "2) Wrong `data_dir` / file pattern (no .h5ad found).\n"
195
+ "3) Required obs/meta columns mismatch (meta_keys).\n\n"
196
+ f"args.use_raw={args.use_raw}, data_dir={args.data_dir}"
197
+ )
198
+ save_json(manifest, manifest_path)
199
+ logger.info("manifest saved to %s in %.1fs", manifest_path, time.time() - manifest_start)
200
+
201
+ meta_keys = _normalize_meta_keys(args.meta_keys)
202
+ if getattr(args, "gene_vocab_json", ""):
203
+ logger.info("loading fixed gene vocab from %s", args.gene_vocab_json)
204
+ gene_vocab = load_json(args.gene_vocab_json)
205
+ gene_names = list(gene_vocab["gene_names"])
206
+ gene_name_to_id = gene_vocab_to_map(gene_vocab)
207
+ logger.info("fixed gene vocab loaded with %s genes", len(gene_names))
208
+ else:
209
+ logger.info("building gene vocab from %s manifest entries", len(manifest))
210
+ gene_start = time.time()
211
+ gene_name_to_id, gene_names = build_gene_vocab_from_manifest(
212
+ manifest,
213
+ use_raw=args.use_raw,
214
+ backed=args.backed,
215
+ gene_offset=2,
216
+ sort_paths=True,
217
+ )
218
+ logger.info("gene vocab built with %s genes in %.1fs", len(gene_names), time.time() - gene_start)
219
+
220
+ logger.info("building meta vocab for keys=%s", meta_keys)
221
+ meta_start = time.time()
222
+ meta_maps, meta_vocab_sizes = build_meta_vocab_from_manifest(
223
+ manifest,
224
+ keys=meta_keys,
225
+ backed=args.backed,
226
+ sort_paths=True,
227
+ )
228
+ logger.info("meta vocab built in %.1fs", time.time() - meta_start)
229
+
230
+ if not getattr(args, "gene_vocab_json", ""):
231
+ gene_vocab = {
232
+ "gene_offset": 2,
233
+ "gene_names": gene_names,
234
+ "gene_vocab_size": max(gene_name_to_id.values()) + 1,
235
+ }
236
+ meta_vocab = {
237
+ "meta_vocab_sizes": meta_vocab_sizes,
238
+ "meta_maps": meta_maps,
239
+ }
240
+ save_json(gene_vocab, gene_vocab_path)
241
+ save_json(meta_vocab, meta_vocab_path)
242
+ save_json(expected_metadata, metadata_path)
243
+ logger.info("scratch assets saved to %s", output_dir)
244
+ else:
245
+ logger.info("waiting for scratch assets to be prepared in %s", output_dir)
246
+ wait_for_file(metadata_path)
247
+ manifest = load_json(manifest_path)
248
+ gene_vocab = load_json(gene_vocab_path)
249
+ meta_vocab = load_json(meta_vocab_path)
250
+ gene_name_to_id = gene_vocab_to_map(gene_vocab)
251
+ meta_maps = meta_vocab["meta_maps"]
252
+ meta_vocab_sizes = meta_vocab["meta_vocab_sizes"]
253
+ logger.info("loaded scratch assets prepared by rank0 from %s", output_dir)
254
+
255
+ return manifest, gene_vocab, meta_vocab, gene_name_to_id, meta_maps, meta_vocab_sizes
256
+
257
+
258
+ def save_bundle(
259
+ trainer: Trainer,
260
+ model,
261
+ save_dir: str,
262
+ *,
263
+ gene_vocab: Dict[str, Any],
264
+ meta_vocab: Dict[str, Any],
265
+ manifest: Any,
266
+ ) -> None:
267
+ """
268
+ Save a final bundle that is directly loadable by ``CellContrastivePredictModel.from_pretrained``.
269
+
270
+ Under FSDP we explicitly ask Accelerate for a gathered state dict, then save it
271
+ through the unwrapped model. All ranks participate in ``get_state_dict``; only
272
+ world-process-zero writes files.
273
+ """
274
+ os.makedirs(save_dir, exist_ok=True)
275
+
276
+ if hasattr(trainer, "accelerator") and trainer.accelerator is not None:
277
+ unwrapped_model = trainer.accelerator.unwrap_model(model)
278
+ state_dict = trainer.accelerator.get_state_dict(trainer.model)
279
+ if trainer.is_world_process_zero():
280
+ unwrapped_model.save_pretrained(
281
+ save_dir,
282
+ save_safetensors=bool(trainer.args.save_safetensors),
283
+ state_dict=state_dict,
284
+ gene_vocab=gene_vocab,
285
+ meta_vocab=meta_vocab,
286
+ manifest=manifest,
287
+ extra_files={"training_args.json": trainer.args.to_dict()},
288
+ )
289
+ else:
290
+ if not trainer.is_world_process_zero():
291
+ return
292
+ model.save_pretrained(
293
+ save_dir,
294
+ save_safetensors=bool(trainer.args.save_safetensors),
295
+ gene_vocab=gene_vocab,
296
+ meta_vocab=meta_vocab,
297
+ manifest=manifest,
298
+ extra_files={"training_args.json": trainer.args.to_dict()},
299
+ )
scRep_pretrain/vocab.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+ import time
3
+ import logging
4
+
5
+ import anndata as ad
6
+ from typing import Any, Dict, List, Optional, Tuple
7
+
8
+
9
+ # =============================================================================
10
+ # Debug / Logging
11
+ # =============================================================================
12
+ DEBUG_PRINT: bool = False
13
+ logger = logging.getLogger("scRep_train")
14
+
15
+
16
+ def _log(msg: str) -> None:
17
+ if DEBUG_PRINT:
18
+ print(f"[sc-token] {msg}")
19
+
20
+
21
+ def _safe_close_anndata(adata_obj: Any) -> None:
22
+ f = getattr(adata_obj, "file", None)
23
+ if f is not None:
24
+ try:
25
+ f.close()
26
+ except Exception:
27
+ pass
28
+
29
+
30
+ # =============================================================================
31
+ # Global vocab builders
32
+ # =============================================================================
33
+ def build_gene_vocab_from_manifest(
34
+ manifest: List[Dict[str, Any]],
35
+ *,
36
+ use_raw: bool,
37
+ backed: Optional[str] = "r",
38
+ gene_offset: int = 2,
39
+ sort_paths: bool = True,
40
+ print_every: int = 50,
41
+ ) -> Tuple[Dict[str, int], List[str]]:
42
+ """
43
+ Build a global gene vocab:
44
+ gene_name -> token_id
45
+
46
+ Token ids start from `gene_offset` because:
47
+ 0 = PAD
48
+ 1 = CLS
49
+ """
50
+ items = list(manifest)
51
+ if sort_paths:
52
+ items.sort(key=lambda x: str(x.get("path", "")))
53
+
54
+ gene_name_to_id: Dict[str, int] = {}
55
+ _log(f"build gene vocab: files={len(items)}, use_raw={use_raw}")
56
+ logger.info("gene vocab scan start: files=%s use_raw=%s", len(items), use_raw)
57
+
58
+ start_time = time.time()
59
+ for idx, it in enumerate(items, start=1):
60
+ path = it["path"]
61
+ adata_obj = ad.read_h5ad(path, backed=backed)
62
+ try:
63
+ if use_raw:
64
+ if (adata_obj.raw is None) or (adata_obj.raw.var_names is None):
65
+ raise ValueError(f"[{path}] use_raw=True but adata.raw.var_names missing.")
66
+ var_names = adata_obj.raw.var_names
67
+ else:
68
+ if adata_obj.var_names is None:
69
+ raise ValueError(f"[{path}] adata.var_names missing.")
70
+ var_names = adata_obj.var_names
71
+
72
+ for g in map(str, list(var_names)):
73
+ if g not in gene_name_to_id:
74
+ gene_name_to_id[g] = len(gene_name_to_id) + gene_offset
75
+ finally:
76
+ _safe_close_anndata(adata_obj)
77
+
78
+ if idx % max(1, int(print_every)) == 0:
79
+ logger.info(
80
+ "gene vocab scan progress: %s/%s unique_genes=%s elapsed=%.1fs",
81
+ idx, len(items), len(gene_name_to_id), time.time() - start_time,
82
+ )
83
+
84
+ gene_names: List[str] = [None] * len(gene_name_to_id)
85
+ for name, tid in gene_name_to_id.items():
86
+ gene_names[tid - gene_offset] = name
87
+
88
+ _log(f"gene vocab: n_genes={len(gene_names)}, vocab_size={len(gene_names) + gene_offset}")
89
+ logger.info(
90
+ "gene vocab scan done: n_genes=%s vocab_size=%s elapsed=%.1fs",
91
+ len(gene_names), len(gene_names) + gene_offset, time.time() - start_time,
92
+ )
93
+ return gene_name_to_id, gene_names
94
+
95
+
96
+ def build_meta_vocab_from_manifest(
97
+ manifest: List[Dict[str, Any]],
98
+ *,
99
+ keys: List[str],
100
+ backed: Optional[str] = "r",
101
+ sort_paths: bool = True,
102
+ print_every: int = 50,
103
+ ) -> Tuple[Dict[str, Dict[str, int]], Dict[str, int]]:
104
+ """
105
+ Build global meta vocabs:
106
+ meta_value(str) -> id(int)
107
+
108
+ 0 is reserved for unknown / missing.
109
+ """
110
+ items = list(manifest)
111
+ if sort_paths:
112
+ items.sort(key=lambda x: str(x.get("path", "")))
113
+
114
+ keys = list(dict.fromkeys(keys))
115
+ meta_maps: Dict[str, Dict[str, int]] = {k: {} for k in keys}
116
+ if len(keys) == 0:
117
+ logger.info("meta vocab scan skipped because keys=[]")
118
+ return {}, {}
119
+
120
+ _log(f"build meta vocabs: keys={keys}, files={len(items)}")
121
+ logger.info("meta vocab scan start: files=%s keys=%s", len(items), keys)
122
+
123
+ start_time = time.time()
124
+ for idx, it in enumerate(items, start=1):
125
+ path = it["path"]
126
+ adata_obj = ad.read_h5ad(path, backed=backed)
127
+ try:
128
+ for k in keys:
129
+ if k not in adata_obj.obs.columns:
130
+ raise ValueError(f"[{path}] obs has no column '{k}'")
131
+ col = adata_obj.obs[k].astype("category")
132
+ for v in col.cat.categories.astype(str).tolist():
133
+ if v not in meta_maps[k]:
134
+ meta_maps[k][v] = len(meta_maps[k]) + 1
135
+ finally:
136
+ _safe_close_anndata(adata_obj)
137
+
138
+ if idx % max(1, int(print_every)) == 0:
139
+ logger.info(
140
+ "meta vocab scan progress: %s/%s elapsed=%.1fs",
141
+ idx, len(items), time.time() - start_time,
142
+ )
143
+
144
+ meta_vocab_sizes = {k: len(meta_maps[k]) + 1 for k in keys}
145
+ _log(f"meta vocab sizes: {meta_vocab_sizes}")
146
+ logger.info("meta vocab scan done: sizes=%s elapsed=%.1fs", meta_vocab_sizes, time.time() - start_time)
147
+ return meta_maps, meta_vocab_sizes
148
+
149
+ # =============================================================================
150
+ # Token vocab ids
151
+ # =============================================================================
152
+ @dataclass(frozen=True)
153
+ class VocabSpec:
154
+ # gene vocab
155
+ gene_pad_id: int = 0
156
+ gene_cls_id: int = 1
157
+ gene_offset: int = 2 # valid gene ids: [gene_offset, gene_vocab_size-1]
158
+
159
+ # bin vocab
160
+ bin_pad_id: int = 0
161
+ bin_mask_id: int = 1
162
+
163
+ # Reserved exclusively for "unexpressed / dropout-style" states.
164
+ # This id is used for:
165
+ # - weak zero-query targets
166
+ # - zero-corruption of masked observed genes
167
+ bin_zero_id: int = 2
168
+
169
+ # Lowest strictly-positive expression bin id.
170
+ # If n_bins counts positive bins only, then:
171
+ # bin vocab size = n_bins + bin_offset
172
+ # which now correctly becomes n_bins + 3.
173
+ bin_offset: int = 3
174
+
175
+
176
+ def gene_vocab_to_map(gene_vocab: Dict[str, Any]) -> Dict[str, int]:
177
+ """Convert gene_vocab.json to a gene_name -> token_id mapping."""
178
+ gene_offset = int(gene_vocab.get("gene_offset", 2))
179
+ gene_names = list(gene_vocab["gene_names"])
180
+ return {str(name): i + gene_offset for i, name in enumerate(gene_names)}