Add files using upload-large-folder tool
Browse files- .gitattributes +0 -34
- .gitignore +4 -0
- LICENSE +202 -0
- README.md +98 -0
- checkpoints/scRep_20260625_30M/config.json +8 -0
- checkpoints/scRep_20260625_30M/meta_vocab.json +4 -0
- checkpoints/scRep_20260625_30M/model.safetensors +3 -0
- checkpoints/scRep_20260625_30M/tokenizer/gene_names.txt +0 -0
- checkpoints/scRep_20260625_30M/tokenizer/gene_vocab.json +0 -0
- checkpoints/scRep_20260717_3M/config.json +8 -0
- checkpoints/scRep_20260717_3M/meta_vocab.json +4 -0
- checkpoints/scRep_20260717_3M/model.safetensors +3 -0
- checkpoints/scRep_20260717_3M/tokenizer/gene_names.txt +0 -0
- checkpoints/scRep_20260717_3M/tokenizer/gene_vocab.json +0 -0
- examples/embedding_example.ipynb +54 -0
- examples/inference.py +51 -0
- requirements.txt +8 -0
- scRep_inference.py +193 -0
- scRep_pretrain/__init__.py +15 -0
- scRep_pretrain/bin_cache.py +52 -0
- scRep_pretrain/dataset.py +725 -0
- scRep_pretrain/manifest.py +197 -0
- scRep_pretrain/model/__init__.py +10 -0
- scRep_pretrain/model/contrastive_collator.py +981 -0
- scRep_pretrain/model/contrastive_trainer.py +125 -0
- scRep_pretrain/model/foundation_model.py +1411 -0
- scRep_pretrain/train_utils.py +299 -0
- scRep_pretrain/vocab.py +180 -0
.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)}
|