Instructions to use Synthyra/ESMFold2-Experimental-Cutoff2025 with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use Synthyra/ESMFold2-Experimental-Cutoff2025 with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("feature-extraction", model="Synthyra/ESMFold2-Experimental-Cutoff2025", trust_remote_code=True)# Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("Synthyra/ESMFold2-Experimental-Cutoff2025", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
Update FastPLMs runtime files
Browse filesAdd-only FastPLMs files-only publication. Checkpoint weights and complete-artifact attestations are unchanged.
This view is limited to 50 files because it contains too many changes. See raw diff
- LICENSES/FastPLMs-Apache-2.0.txt +203 -0
- LICENSES/biohub-esm/LICENSE.md +9 -0
- LICENSES/biohub-esm/THIRD_PARTY_NOTICE.md +13 -0
- LICENSES/biohub-transformers/LICENSE +203 -0
- LICENSES/protein-ttt/LICENSE +21 -0
- LICENSES/protein-ttt/PROVENANCE.md +8 -0
- README.md +268 -247
- THIRD_PARTY_NOTICES.md +99 -0
- config.json +13 -2
- fastplms/__init__.py +48 -0
- fastplms/attention/__init__.py +63 -0
- fastplms/attention/_core.py +779 -0
- fastplms/attention/_kernel_lock.py +191 -0
- fastplms/attention/interfaces.py +242 -0
- fastplms/embeddings/__init__.py +65 -0
- fastplms/embeddings/pooling.py +210 -0
- fastplms/embeddings/runner.py +1559 -0
- fastplms/embeddings/storage.py +1594 -0
- fastplms/embeddings/types.py +187 -0
- fastplms/models.toml +1223 -0
- fastplms/models/__init__.py +10 -0
- fastplms/models/esm_plusplus/__init__.py +0 -0
- fastplms/models/esm_plusplus/modeling_esm_plusplus.py +1552 -0
- fastplms/models/esmfold2/__init__.py +39 -0
- fastplms/models/esmfold2/attention.py +48 -0
- fastplms/models/esmfold2/configuration_esmfold2.py +306 -0
- fastplms/models/esmfold2/embedding.py +105 -0
- fastplms/models/esmfold2/esmfold2_affine3d.py +605 -0
- fastplms/models/esmfold2/esmfold2_aligner.py +87 -0
- fastplms/models/esmfold2/esmfold2_atom_indexer.py +30 -0
- fastplms/models/esmfold2/esmfold2_conformers.py +402 -0
- fastplms/models/esmfold2/esmfold2_constants.py +156 -0
- fastplms/models/esmfold2/esmfold2_constants_esm3.py +98 -0
- fastplms/models/esmfold2/esmfold2_input_builder.py +244 -0
- fastplms/models/esmfold2/esmfold2_metrics.py +235 -0
- fastplms/models/esmfold2/esmfold2_misc.py +400 -0
- fastplms/models/esmfold2/esmfold2_mmcif_parsing.py +469 -0
- fastplms/models/esmfold2/esmfold2_molecular_complex.py +1016 -0
- fastplms/models/esmfold2/esmfold2_msa.py +577 -0
- fastplms/models/esmfold2/esmfold2_msa_filter_sequences.py +111 -0
- fastplms/models/esmfold2/esmfold2_normalize_coordinates.py +67 -0
- fastplms/models/esmfold2/esmfold2_output.py +201 -0
- fastplms/models/esmfold2/esmfold2_paired_msa.py +282 -0
- fastplms/models/esmfold2/esmfold2_parsing.py +126 -0
- fastplms/models/esmfold2/esmfold2_predicted_aligned_error.py +127 -0
- fastplms/models/esmfold2/esmfold2_prepare_input.py +1130 -0
- fastplms/models/esmfold2/esmfold2_processor.py +332 -0
- fastplms/models/esmfold2/esmfold2_protein_chain.py +1450 -0
- fastplms/models/esmfold2/esmfold2_protein_complex.py +1240 -0
- fastplms/models/esmfold2/esmfold2_protein_structure.py +251 -0
LICENSES/FastPLMs-Apache-2.0.txt
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
PLEASE NOTE THE APACHE LICENSE ONLY APPLIES TO THE CODE IN THE FastPLMs GITHUB AND ASSOCIATED HUGGINGFACE REPOSITORIES, NOT NECESSARILY THE MODEL WEIGHTS. THOSE LICENSES CAN BE FOUND HERE https://github.com/Synthyra/FastPLMs/tree/main/LICENSES
|
| 2 |
+
|
| 3 |
+
Apache License
|
| 4 |
+
Version 2.0, January 2004
|
| 5 |
+
http://www.apache.org/licenses/
|
| 6 |
+
|
| 7 |
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
| 8 |
+
|
| 9 |
+
1. Definitions.
|
| 10 |
+
|
| 11 |
+
"License" shall mean the terms and conditions for use, reproduction,
|
| 12 |
+
and distribution as defined by Sections 1 through 9 of this document.
|
| 13 |
+
|
| 14 |
+
"Licensor" shall mean the copyright owner or entity authorized by
|
| 15 |
+
the copyright owner that is granting the License.
|
| 16 |
+
|
| 17 |
+
"Legal Entity" shall mean the union of the acting entity and all
|
| 18 |
+
other entities that control, are controlled by, or are under common
|
| 19 |
+
control with that entity. For the purposes of this definition,
|
| 20 |
+
"control" means (i) the power, direct or indirect, to cause the
|
| 21 |
+
direction or management of such entity, whether by contract or
|
| 22 |
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
| 23 |
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
| 24 |
+
|
| 25 |
+
"You" (or "Your") shall mean an individual or Legal Entity
|
| 26 |
+
exercising permissions granted by this License.
|
| 27 |
+
|
| 28 |
+
"Source" form shall mean the preferred form for making modifications,
|
| 29 |
+
including but not limited to software source code, documentation
|
| 30 |
+
source, and configuration files.
|
| 31 |
+
|
| 32 |
+
"Object" form shall mean any form resulting from mechanical
|
| 33 |
+
transformation or translation of a Source form, including but
|
| 34 |
+
not limited to compiled object code, generated documentation,
|
| 35 |
+
and conversions to other media types.
|
| 36 |
+
|
| 37 |
+
"Work" shall mean the work of authorship, whether in Source or
|
| 38 |
+
Object form, made available under the License, as indicated by a
|
| 39 |
+
copyright notice that is included in or attached to the work
|
| 40 |
+
(an example is provided in the Appendix below).
|
| 41 |
+
|
| 42 |
+
"Derivative Works" shall mean any work, whether in Source or Object
|
| 43 |
+
form, that is based on (or derived from) the Work and for which the
|
| 44 |
+
editorial revisions, annotations, elaborations, or other modifications
|
| 45 |
+
represent, as a whole, an original work of authorship. For the purposes
|
| 46 |
+
of this License, Derivative Works shall not include works that remain
|
| 47 |
+
separable from, or merely link (or bind by name) to the interfaces of,
|
| 48 |
+
the Work and Derivative Works thereof.
|
| 49 |
+
|
| 50 |
+
"Contribution" shall mean any work of authorship, including
|
| 51 |
+
the original version of the Work and any modifications or additions
|
| 52 |
+
to that Work or Derivative Works thereof, that is intentionally
|
| 53 |
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
| 54 |
+
or by an individual or Legal Entity authorized to submit on behalf of
|
| 55 |
+
the copyright owner. For the purposes of this definition, "submitted"
|
| 56 |
+
means any form of electronic, verbal, or written communication sent
|
| 57 |
+
to the Licensor or its representatives, including but not limited to
|
| 58 |
+
communication on electronic mailing lists, source code control systems,
|
| 59 |
+
and issue tracking systems that are managed by, or on behalf of, the
|
| 60 |
+
Licensor for the purpose of discussing and improving the Work, but
|
| 61 |
+
excluding communication that is conspicuously marked or otherwise
|
| 62 |
+
designated in writing by the copyright owner as "Not a Contribution."
|
| 63 |
+
|
| 64 |
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
| 65 |
+
on behalf of whom a Contribution has been received by Licensor and
|
| 66 |
+
subsequently incorporated within the Work.
|
| 67 |
+
|
| 68 |
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
| 69 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 70 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 71 |
+
copyright license to reproduce, prepare Derivative Works of,
|
| 72 |
+
publicly display, publicly perform, sublicense, and distribute the
|
| 73 |
+
Work and such Derivative Works in Source or Object form.
|
| 74 |
+
|
| 75 |
+
3. Grant of Patent License. Subject to the terms and conditions of
|
| 76 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 77 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 78 |
+
(except as stated in this section) patent license to make, have made,
|
| 79 |
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
| 80 |
+
where such license applies only to those patent claims licensable
|
| 81 |
+
by such Contributor that are necessarily infringed by their
|
| 82 |
+
Contribution(s) alone or by combination of their Contribution(s)
|
| 83 |
+
with the Work to which such Contribution(s) was submitted. If You
|
| 84 |
+
institute patent litigation against any entity (including a
|
| 85 |
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
| 86 |
+
or a Contribution incorporated within the Work constitutes direct
|
| 87 |
+
or contributory patent infringement, then any patent licenses
|
| 88 |
+
granted to You under this License for that Work shall terminate
|
| 89 |
+
as of the date such litigation is filed.
|
| 90 |
+
|
| 91 |
+
4. Redistribution. You may reproduce and distribute copies of the
|
| 92 |
+
Work or Derivative Works thereof in any medium, with or without
|
| 93 |
+
modifications, and in Source or Object form, provided that You
|
| 94 |
+
meet the following conditions:
|
| 95 |
+
|
| 96 |
+
(a) You must give any other recipients of the Work or
|
| 97 |
+
Derivative Works a copy of this License; and
|
| 98 |
+
|
| 99 |
+
(b) You must cause any modified files to carry prominent notices
|
| 100 |
+
stating that You changed the files; and
|
| 101 |
+
|
| 102 |
+
(c) You must retain, in the Source form of any Derivative Works
|
| 103 |
+
that You distribute, all copyright, patent, trademark, and
|
| 104 |
+
attribution notices from the Source form of the Work,
|
| 105 |
+
excluding those notices that do not pertain to any part of
|
| 106 |
+
the Derivative Works; and
|
| 107 |
+
|
| 108 |
+
(d) If the Work includes a "NOTICE" text file as part of its
|
| 109 |
+
distribution, then any Derivative Works that You distribute must
|
| 110 |
+
include a readable copy of the attribution notices contained
|
| 111 |
+
within such NOTICE file, excluding those notices that do not
|
| 112 |
+
pertain to any part of the Derivative Works, in at least one
|
| 113 |
+
of the following places: within a NOTICE text file distributed
|
| 114 |
+
as part of the Derivative Works; within the Source form or
|
| 115 |
+
documentation, if provided along with the Derivative Works; or,
|
| 116 |
+
within a display generated by the Derivative Works, if and
|
| 117 |
+
wherever such third-party notices normally appear. The contents
|
| 118 |
+
of the NOTICE file are for informational purposes only and
|
| 119 |
+
do not modify the License. You may add Your own attribution
|
| 120 |
+
notices within Derivative Works that You distribute, alongside
|
| 121 |
+
or as an addendum to the NOTICE text from the Work, provided
|
| 122 |
+
that such additional attribution notices cannot be construed
|
| 123 |
+
as modifying the License.
|
| 124 |
+
|
| 125 |
+
You may add Your own copyright statement to Your modifications and
|
| 126 |
+
may provide additional or different license terms and conditions
|
| 127 |
+
for use, reproduction, or distribution of Your modifications, or
|
| 128 |
+
for any such Derivative Works as a whole, provided Your use,
|
| 129 |
+
reproduction, and distribution of the Work otherwise complies with
|
| 130 |
+
the conditions stated in this License.
|
| 131 |
+
|
| 132 |
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
| 133 |
+
any Contribution intentionally submitted for inclusion in the Work
|
| 134 |
+
by You to the Licensor shall be under the terms and conditions of
|
| 135 |
+
this License, without any additional terms or conditions.
|
| 136 |
+
Notwithstanding the above, nothing herein shall supersede or modify
|
| 137 |
+
the terms of any separate license agreement you may have executed
|
| 138 |
+
with Licensor regarding such Contributions.
|
| 139 |
+
|
| 140 |
+
6. Trademarks. This License does not grant permission to use the trade
|
| 141 |
+
names, trademarks, service marks, or product names of the Licensor,
|
| 142 |
+
except as required for reasonable and customary use in describing the
|
| 143 |
+
origin of the Work and reproducing the content of the NOTICE file.
|
| 144 |
+
|
| 145 |
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
| 146 |
+
agreed to in writing, Licensor provides the Work (and each
|
| 147 |
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
| 148 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
| 149 |
+
implied, including, without limitation, any warranties or conditions
|
| 150 |
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
| 151 |
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
| 152 |
+
appropriateness of using or redistributing the Work and assume any
|
| 153 |
+
risks associated with Your exercise of permissions under this License.
|
| 154 |
+
|
| 155 |
+
8. Limitation of Liability. In no event and under no legal theory,
|
| 156 |
+
whether in tort (including negligence), contract, or otherwise,
|
| 157 |
+
unless required by applicable law (such as deliberate and grossly
|
| 158 |
+
negligent acts) or agreed to in writing, shall any Contributor be
|
| 159 |
+
liable to You for damages, including any direct, indirect, special,
|
| 160 |
+
incidental, or consequential damages of any character arising as a
|
| 161 |
+
result of this License or out of the use or inability to use the
|
| 162 |
+
Work (including but not limited to damages for loss of goodwill,
|
| 163 |
+
work stoppage, computer failure or malfunction, or any and all
|
| 164 |
+
other commercial damages or losses), even if such Contributor
|
| 165 |
+
has been advised of the possibility of such damages.
|
| 166 |
+
|
| 167 |
+
9. Accepting Warranty or Additional Liability. While redistributing
|
| 168 |
+
the Work or Derivative Works thereof, You may choose to offer,
|
| 169 |
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
| 170 |
+
or other liability obligations and/or rights consistent with this
|
| 171 |
+
License. However, in accepting such obligations, You may act only
|
| 172 |
+
on Your own behalf and on Your sole responsibility, not on behalf
|
| 173 |
+
of any other Contributor, and only if You agree to indemnify,
|
| 174 |
+
defend, and hold each Contributor harmless for any liability
|
| 175 |
+
incurred by, or claims asserted against, such Contributor by reason
|
| 176 |
+
of your accepting any such warranty or additional liability.
|
| 177 |
+
|
| 178 |
+
END OF TERMS AND CONDITIONS
|
| 179 |
+
|
| 180 |
+
APPENDIX: How to apply the Apache License to your work.
|
| 181 |
+
|
| 182 |
+
To apply the Apache License to your work, attach the following
|
| 183 |
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
| 184 |
+
replaced with your own identifying information. (Don't include
|
| 185 |
+
the brackets!) The text should be enclosed in the appropriate
|
| 186 |
+
comment syntax for the file format. We also recommend that a
|
| 187 |
+
file or class name and description of purpose be included on the
|
| 188 |
+
same "printed page" as the copyright notice for easier
|
| 189 |
+
identification within third-party archives.
|
| 190 |
+
|
| 191 |
+
Copyright [yyyy] [name of copyright owner]
|
| 192 |
+
|
| 193 |
+
Licensed under the Apache License, Version 2.0 (the "License");
|
| 194 |
+
you may not use this file except in compliance with the License.
|
| 195 |
+
You may obtain a copy of the License at
|
| 196 |
+
|
| 197 |
+
http://www.apache.org/licenses/LICENSE-2.0
|
| 198 |
+
|
| 199 |
+
Unless required by applicable law or agreed to in writing, software
|
| 200 |
+
distributed under the License is distributed on an "AS IS" BASIS,
|
| 201 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 202 |
+
See the License for the specific language governing permissions and
|
| 203 |
+
limitations under the License.
|
LICENSES/biohub-esm/LICENSE.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
**License (MIT)**
|
| 2 |
+
|
| 3 |
+
Copyright 2026 Chan Zuckerberg Biohub, Inc.
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
| 6 |
+
|
| 7 |
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
| 8 |
+
|
| 9 |
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
LICENSES/biohub-esm/THIRD_PARTY_NOTICE.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
The code in this repository depends on the following third-party libraries:
|
| 2 |
+
|
| 3 |
+
| Library | License | Link |
|
| 4 |
+
|----------|----------|----------|
|
| 5 |
+
| flash-attn | BSD | https://github.com/Dao-AILab/flash-attention/blob/main/LICENSE |
|
| 6 |
+
| PyTorch | BSD | https://github.com/pytorch/pytorch/blob/main/LICENSE |
|
| 7 |
+
| xformers | BSD | https://github.com/facebookresearch/xformers/blob/main/LICENSE |
|
| 8 |
+
| jaxtyping | MIT | https://github.com/patrick-kidger/jaxtyping/blob/main/LICENSE |
|
| 9 |
+
| einops | MIT | https://github.com/arogozhnikov/einops/blob/main/LICENSE |
|
| 10 |
+
| omegaconf | BSD | https://github.com/omry/omegaconf/blob/master/LICENSE |
|
| 11 |
+
| attrs | MIT | https://github.com/python-attrs/attrs/blob/main/LICENSE |
|
| 12 |
+
| scipy | BSD-3-Clause | https://github.com/scipy/scipy/blob/main/LICENSE.txt<br>https://github.com/scipy/scipy/blob/main/LICENSES_bundled.txt |
|
| 13 |
+
| lightning / torchmetrics | Apache 2.0 | https://github.com/Lightning-AI/torchmetrics/blob/master/LICENSE |
|
LICENSES/biohub-transformers/LICENSE
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Copyright 2018- The Hugging Face team. All rights reserved.
|
| 2 |
+
|
| 3 |
+
Apache License
|
| 4 |
+
Version 2.0, January 2004
|
| 5 |
+
http://www.apache.org/licenses/
|
| 6 |
+
|
| 7 |
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
| 8 |
+
|
| 9 |
+
1. Definitions.
|
| 10 |
+
|
| 11 |
+
"License" shall mean the terms and conditions for use, reproduction,
|
| 12 |
+
and distribution as defined by Sections 1 through 9 of this document.
|
| 13 |
+
|
| 14 |
+
"Licensor" shall mean the copyright owner or entity authorized by
|
| 15 |
+
the copyright owner that is granting the License.
|
| 16 |
+
|
| 17 |
+
"Legal Entity" shall mean the union of the acting entity and all
|
| 18 |
+
other entities that control, are controlled by, or are under common
|
| 19 |
+
control with that entity. For the purposes of this definition,
|
| 20 |
+
"control" means (i) the power, direct or indirect, to cause the
|
| 21 |
+
direction or management of such entity, whether by contract or
|
| 22 |
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
| 23 |
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
| 24 |
+
|
| 25 |
+
"You" (or "Your") shall mean an individual or Legal Entity
|
| 26 |
+
exercising permissions granted by this License.
|
| 27 |
+
|
| 28 |
+
"Source" form shall mean the preferred form for making modifications,
|
| 29 |
+
including but not limited to software source code, documentation
|
| 30 |
+
source, and configuration files.
|
| 31 |
+
|
| 32 |
+
"Object" form shall mean any form resulting from mechanical
|
| 33 |
+
transformation or translation of a Source form, including but
|
| 34 |
+
not limited to compiled object code, generated documentation,
|
| 35 |
+
and conversions to other media types.
|
| 36 |
+
|
| 37 |
+
"Work" shall mean the work of authorship, whether in Source or
|
| 38 |
+
Object form, made available under the License, as indicated by a
|
| 39 |
+
copyright notice that is included in or attached to the work
|
| 40 |
+
(an example is provided in the Appendix below).
|
| 41 |
+
|
| 42 |
+
"Derivative Works" shall mean any work, whether in Source or Object
|
| 43 |
+
form, that is based on (or derived from) the Work and for which the
|
| 44 |
+
editorial revisions, annotations, elaborations, or other modifications
|
| 45 |
+
represent, as a whole, an original work of authorship. For the purposes
|
| 46 |
+
of this License, Derivative Works shall not include works that remain
|
| 47 |
+
separable from, or merely link (or bind by name) to the interfaces of,
|
| 48 |
+
the Work and Derivative Works thereof.
|
| 49 |
+
|
| 50 |
+
"Contribution" shall mean any work of authorship, including
|
| 51 |
+
the original version of the Work and any modifications or additions
|
| 52 |
+
to that Work or Derivative Works thereof, that is intentionally
|
| 53 |
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
| 54 |
+
or by an individual or Legal Entity authorized to submit on behalf of
|
| 55 |
+
the copyright owner. For the purposes of this definition, "submitted"
|
| 56 |
+
means any form of electronic, verbal, or written communication sent
|
| 57 |
+
to the Licensor or its representatives, including but not limited to
|
| 58 |
+
communication on electronic mailing lists, source code control systems,
|
| 59 |
+
and issue tracking systems that are managed by, or on behalf of, the
|
| 60 |
+
Licensor for the purpose of discussing and improving the Work, but
|
| 61 |
+
excluding communication that is conspicuously marked or otherwise
|
| 62 |
+
designated in writing by the copyright owner as "Not a Contribution."
|
| 63 |
+
|
| 64 |
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
| 65 |
+
on behalf of whom a Contribution has been received by Licensor and
|
| 66 |
+
subsequently incorporated within the Work.
|
| 67 |
+
|
| 68 |
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
| 69 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 70 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 71 |
+
copyright license to reproduce, prepare Derivative Works of,
|
| 72 |
+
publicly display, publicly perform, sublicense, and distribute the
|
| 73 |
+
Work and such Derivative Works in Source or Object form.
|
| 74 |
+
|
| 75 |
+
3. Grant of Patent License. Subject to the terms and conditions of
|
| 76 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 77 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 78 |
+
(except as stated in this section) patent license to make, have made,
|
| 79 |
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
| 80 |
+
where such license applies only to those patent claims licensable
|
| 81 |
+
by such Contributor that are necessarily infringed by their
|
| 82 |
+
Contribution(s) alone or by combination of their Contribution(s)
|
| 83 |
+
with the Work to which such Contribution(s) was submitted. If You
|
| 84 |
+
institute patent litigation against any entity (including a
|
| 85 |
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
| 86 |
+
or a Contribution incorporated within the Work constitutes direct
|
| 87 |
+
or contributory patent infringement, then any patent licenses
|
| 88 |
+
granted to You under this License for that Work shall terminate
|
| 89 |
+
as of the date such litigation is filed.
|
| 90 |
+
|
| 91 |
+
4. Redistribution. You may reproduce and distribute copies of the
|
| 92 |
+
Work or Derivative Works thereof in any medium, with or without
|
| 93 |
+
modifications, and in Source or Object form, provided that You
|
| 94 |
+
meet the following conditions:
|
| 95 |
+
|
| 96 |
+
(a) You must give any other recipients of the Work or
|
| 97 |
+
Derivative Works a copy of this License; and
|
| 98 |
+
|
| 99 |
+
(b) You must cause any modified files to carry prominent notices
|
| 100 |
+
stating that You changed the files; and
|
| 101 |
+
|
| 102 |
+
(c) You must retain, in the Source form of any Derivative Works
|
| 103 |
+
that You distribute, all copyright, patent, trademark, and
|
| 104 |
+
attribution notices from the Source form of the Work,
|
| 105 |
+
excluding those notices that do not pertain to any part of
|
| 106 |
+
the Derivative Works; and
|
| 107 |
+
|
| 108 |
+
(d) If the Work includes a "NOTICE" text file as part of its
|
| 109 |
+
distribution, then any Derivative Works that You distribute must
|
| 110 |
+
include a readable copy of the attribution notices contained
|
| 111 |
+
within such NOTICE file, excluding those notices that do not
|
| 112 |
+
pertain to any part of the Derivative Works, in at least one
|
| 113 |
+
of the following places: within a NOTICE text file distributed
|
| 114 |
+
as part of the Derivative Works; within the Source form or
|
| 115 |
+
documentation, if provided along with the Derivative Works; or,
|
| 116 |
+
within a display generated by the Derivative Works, if and
|
| 117 |
+
wherever such third-party notices normally appear. The contents
|
| 118 |
+
of the NOTICE file are for informational purposes only and
|
| 119 |
+
do not modify the License. You may add Your own attribution
|
| 120 |
+
notices within Derivative Works that You distribute, alongside
|
| 121 |
+
or as an addendum to the NOTICE text from the Work, provided
|
| 122 |
+
that such additional attribution notices cannot be construed
|
| 123 |
+
as modifying the License.
|
| 124 |
+
|
| 125 |
+
You may add Your own copyright statement to Your modifications and
|
| 126 |
+
may provide additional or different license terms and conditions
|
| 127 |
+
for use, reproduction, or distribution of Your modifications, or
|
| 128 |
+
for any such Derivative Works as a whole, provided Your use,
|
| 129 |
+
reproduction, and distribution of the Work otherwise complies with
|
| 130 |
+
the conditions stated in this License.
|
| 131 |
+
|
| 132 |
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
| 133 |
+
any Contribution intentionally submitted for inclusion in the Work
|
| 134 |
+
by You to the Licensor shall be under the terms and conditions of
|
| 135 |
+
this License, without any additional terms or conditions.
|
| 136 |
+
Notwithstanding the above, nothing herein shall supersede or modify
|
| 137 |
+
the terms of any separate license agreement you may have executed
|
| 138 |
+
with Licensor regarding such Contributions.
|
| 139 |
+
|
| 140 |
+
6. Trademarks. This License does not grant permission to use the trade
|
| 141 |
+
names, trademarks, service marks, or product names of the Licensor,
|
| 142 |
+
except as required for reasonable and customary use in describing the
|
| 143 |
+
origin of the Work and reproducing the content of the NOTICE file.
|
| 144 |
+
|
| 145 |
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
| 146 |
+
agreed to in writing, Licensor provides the Work (and each
|
| 147 |
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
| 148 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
| 149 |
+
implied, including, without limitation, any warranties or conditions
|
| 150 |
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
| 151 |
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
| 152 |
+
appropriateness of using or redistributing the Work and assume any
|
| 153 |
+
risks associated with Your exercise of permissions under this License.
|
| 154 |
+
|
| 155 |
+
8. Limitation of Liability. In no event and under no legal theory,
|
| 156 |
+
whether in tort (including negligence), contract, or otherwise,
|
| 157 |
+
unless required by applicable law (such as deliberate and grossly
|
| 158 |
+
negligent acts) or agreed to in writing, shall any Contributor be
|
| 159 |
+
liable to You for damages, including any direct, indirect, special,
|
| 160 |
+
incidental, or consequential damages of any character arising as a
|
| 161 |
+
result of this License or out of the use or inability to use the
|
| 162 |
+
Work (including but not limited to damages for loss of goodwill,
|
| 163 |
+
work stoppage, computer failure or malfunction, or any and all
|
| 164 |
+
other commercial damages or losses), even if such Contributor
|
| 165 |
+
has been advised of the possibility of such damages.
|
| 166 |
+
|
| 167 |
+
9. Accepting Warranty or Additional Liability. While redistributing
|
| 168 |
+
the Work or Derivative Works thereof, You may choose to offer,
|
| 169 |
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
| 170 |
+
or other liability obligations and/or rights consistent with this
|
| 171 |
+
License. However, in accepting such obligations, You may act only
|
| 172 |
+
on Your own behalf and on Your sole responsibility, not on behalf
|
| 173 |
+
of any other Contributor, and only if You agree to indemnify,
|
| 174 |
+
defend, and hold each Contributor harmless for any liability
|
| 175 |
+
incurred by, or claims asserted against, such Contributor by reason
|
| 176 |
+
of your accepting any such warranty or additional liability.
|
| 177 |
+
|
| 178 |
+
END OF TERMS AND CONDITIONS
|
| 179 |
+
|
| 180 |
+
APPENDIX: How to apply the Apache License to your work.
|
| 181 |
+
|
| 182 |
+
To apply the Apache License to your work, attach the following
|
| 183 |
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
| 184 |
+
replaced with your own identifying information. (Don't include
|
| 185 |
+
the brackets!) The text should be enclosed in the appropriate
|
| 186 |
+
comment syntax for the file format. We also recommend that a
|
| 187 |
+
file or class name and description of purpose be included on the
|
| 188 |
+
same "printed page" as the copyright notice for easier
|
| 189 |
+
identification within third-party archives.
|
| 190 |
+
|
| 191 |
+
Copyright [yyyy] [name of copyright owner]
|
| 192 |
+
|
| 193 |
+
Licensed under the Apache License, Version 2.0 (the "License");
|
| 194 |
+
you may not use this file except in compliance with the License.
|
| 195 |
+
You may obtain a copy of the License at
|
| 196 |
+
|
| 197 |
+
http://www.apache.org/licenses/LICENSE-2.0
|
| 198 |
+
|
| 199 |
+
Unless required by applicable law or agreed to in writing, software
|
| 200 |
+
distributed under the License is distributed on an "AS IS" BASIS,
|
| 201 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 202 |
+
See the License for the specific language governing permissions and
|
| 203 |
+
limitations under the License.
|
LICENSES/protein-ttt/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2024 Anton Bushuiev
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
LICENSES/protein-ttt/PROVENANCE.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ProteinTTT provenance
|
| 2 |
+
|
| 3 |
+
FastPLMs uses `anton-bushuiev/ProteinTTT` revision
|
| 4 |
+
`fde2817cd84b936167cc76ccabf31e5c0fe49962` as the official reference for the
|
| 5 |
+
optional protein test-time training workflow. The repository is pinned at
|
| 6 |
+
`vendor/upstream/protein-ttt/` and is not a production dependency or runtime
|
| 7 |
+
image component. The accompanying `LICENSE` is the verbatim MIT text from that
|
| 8 |
+
revision.
|
README.md
CHANGED
|
@@ -1,247 +1,268 @@
|
|
| 1 |
-
---
|
| 2 |
-
library_name: transformers
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
- protein-
|
| 6 |
-
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
model.
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
`
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
``
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
)
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
```
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
``
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
``
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
``
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
)
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
```
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
``
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
``
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
``
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
``
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
library_name: transformers
|
| 3 |
+
license: "mit"
|
| 4 |
+
tags:
|
| 5 |
+
- protein-language-model
|
| 6 |
+
- fastplms
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
<!-- Generated from src/fastplms/models.toml. Do not edit. -->
|
| 10 |
+
|
| 11 |
+
# Synthyra/ESMFold2-Experimental-Cutoff2025
|
| 12 |
+
|
| 13 |
+
This checkpoint packages the FastPLMs `ESMFold2` implementation.
|
| 14 |
+
|
| 15 |
+
Accepted inputs are raw amino-acid sequences or typed molecular-complex
|
| 16 |
+
specifications; low-level forward accepts prepared feature tensors.
|
| 17 |
+
Supported Transformers entry points are `AutoConfig`, `AutoModel`.
|
| 18 |
+
|
| 19 |
+
## Install and platform requirements
|
| 20 |
+
|
| 21 |
+
Install FastPLMs from the exact source revision paired with this model card:
|
| 22 |
+
|
| 23 |
+
```bash
|
| 24 |
+
python -m pip install \
|
| 25 |
+
"fastplms[structure] @ git+https://github.com/Synthyra/FastPLMs.git@1b9ce023f1e06571cf3e6324be0610ffa53e0a4a"
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
Python 3.11-3.14, PyTorch 2.13, and Transformers 5.13 are required. Structure inference requires the `structure` extra and a CUDA device for the published execution contract. The current validated release target is the exact NVIDIA GH200 on Linux aarch64; Linux x86-64, CPU-only, Windows, and macOS structure runs are not current release evidence. The Hub quick start below requires network
|
| 29 |
+
access on first download. For an air-gapped run, first build the manifest-pinned
|
| 30 |
+
local artifact and use the offline form shown in the example.
|
| 31 |
+
|
| 32 |
+
## Quick start
|
| 33 |
+
|
| 34 |
+
```python
|
| 35 |
+
from transformers import AutoModel
|
| 36 |
+
|
| 37 |
+
model_id = "Synthyra/ESMFold2-Experimental-Cutoff2025"
|
| 38 |
+
model = AutoModel.from_pretrained(
|
| 39 |
+
model_id,
|
| 40 |
+
trust_remote_code=True,
|
| 41 |
+
).eval()
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
This example uses the published Hub repository. For offline validation, build
|
| 45 |
+
the manifest-pinned artifact and replace `model_id` with its local
|
| 46 |
+
`dist/hub/ESMFold2-Experimental-Cutoff2025` path, then pass `local_files_only=True`.
|
| 47 |
+
|
| 48 |
+
Leave attention unspecified for the Transformers default. Supported explicit
|
| 49 |
+
choices are `eager`, `sdpa`, `flex_attention`.
|
| 50 |
+
Pass the selected name through `attn_implementation`.
|
| 51 |
+
When an optimized backend cannot return full attention tensors,
|
| 52 |
+
`output_attentions=True` emits one explicit runtime warning and uses a correctly
|
| 53 |
+
masked eager implementation for that call only. The warning identifies the
|
| 54 |
+
configured backend, effective backend, and reason. Configuration and later
|
| 55 |
+
calls are unchanged.
|
| 56 |
+
For BF16 execution, this family uses FP32 parameters with CUDA BF16 autocast.
|
| 57 |
+
|
| 58 |
+
## Alignment-conditioning contract
|
| 59 |
+
|
| 60 |
+
This is a full 48-block ESMFold2 checkpoint. It supports both
|
| 61 |
+
single-sequence inference and optional MSA-conditioned inference. Typed
|
| 62 |
+
multichain and multimolecule inputs may attach an MSA to each applicable
|
| 63 |
+
protein chain.
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
## Protein folding
|
| 67 |
+
|
| 68 |
+
The single-protein helper returns typed structure and confidence outputs:
|
| 69 |
+
|
| 70 |
+
```python
|
| 71 |
+
result = model.fold_protein(
|
| 72 |
+
"MSTNPKPQRKTKRNT",
|
| 73 |
+
num_loops=1,
|
| 74 |
+
num_sampling_steps=200,
|
| 75 |
+
num_diffusion_samples=1,
|
| 76 |
+
seed=7,
|
| 77 |
+
)
|
| 78 |
+
pdb_text = model.result_to_pdb(result)
|
| 79 |
+
cif_text = model.result_to_cif(result)
|
| 80 |
+
print(result.ptm, result.plddt.mean().item())
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
No target structure is required. For complexes, construct the input from the
|
| 84 |
+
types exposed by the loaded artifact:
|
| 85 |
+
|
| 86 |
+
```python
|
| 87 |
+
types = model.input_types
|
| 88 |
+
complex_input = types.StructurePredictionInput(
|
| 89 |
+
sequences=[
|
| 90 |
+
types.ProteinInput(id="A", sequence="MSTNPKPQRKTKRNT"),
|
| 91 |
+
types.ProteinInput(id="B", sequence="MKTIIALSYIFCLVFA"),
|
| 92 |
+
types.DNAInput(id="C", sequence="ATGC"),
|
| 93 |
+
types.LigandInput(id="L", smiles="O"),
|
| 94 |
+
]
|
| 95 |
+
)
|
| 96 |
+
complex_result = model.fold(
|
| 97 |
+
complex_input,
|
| 98 |
+
num_loops=1,
|
| 99 |
+
num_sampling_steps=200,
|
| 100 |
+
seed=7,
|
| 101 |
+
)
|
| 102 |
+
print(complex_result.ptm, complex_result.plddt.mean().item())
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
The typed interface also supports RNA, protein MSAs, modifications, covalent
|
| 106 |
+
bonds, and distogram conditioning. The public schema recognizes
|
| 107 |
+
`PocketConditioning`, but the pinned official runtime discards it and hard-codes
|
| 108 |
+
a zero pocket feature. FastPLMs therefore rejects non-null pocket conditioning
|
| 109 |
+
instead of silently ignoring it. Prepared `ref_pos` values are component
|
| 110 |
+
reference geometries created during featurization, not target coordinates.
|
| 111 |
+
Predicted coordinates and confidence scores are outputs and do not establish
|
| 112 |
+
biochemical activity.
|
| 113 |
+
|
| 114 |
+
## Learned representation and ESMC precision
|
| 115 |
+
|
| 116 |
+
ESMFold2 combines the ordered 81 ESMC-6B states `H: (b, l, 81, 2560)` with the
|
| 117 |
+
checkpoint's learned projection. Retrieve the resulting residue representation
|
| 118 |
+
through the public embedding API:
|
| 119 |
+
|
| 120 |
+
```python
|
| 121 |
+
representations = model.embed_dataset(
|
| 122 |
+
["MSTNPKPQRKTKRNT", "MKTIIALSYIFCLVFA"],
|
| 123 |
+
batch_size=2,
|
| 124 |
+
full_embeddings=True,
|
| 125 |
+
)
|
| 126 |
+
print(representations[0].tensor.shape) # (sequence_length, 256)
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
`model.embed_dataset(..., full_embeddings=True)` returns one `(l, 256)` residue
|
| 130 |
+
tensor per single-chain input. It rejects complexes, ligands, MSAs,
|
| 131 |
+
chain-separated inputs, `cls`, and `parti` in the embedding path.
|
| 132 |
+
|
| 133 |
+
Set `esmc_precision` to `auto`, `bf16`, `fp32`, or `fp8` when loading.
|
| 134 |
+
`auto` always resolves to BF16. Explicit FP8 is experimental, inference-only,
|
| 135 |
+
and strict:
|
| 136 |
+
|
| 137 |
+
```python
|
| 138 |
+
model.reload_esmc(precision="fp8", device="cuda:0")
|
| 139 |
+
print(model.esmc_precision_status)
|
| 140 |
+
```
|
| 141 |
+
|
| 142 |
+
FP8 raises when the validated CUDA and Transformer Engine path is unavailable.
|
| 143 |
+
Canonical BF16 weights are retained, and transient quantization state is never
|
| 144 |
+
serialized.
|
| 145 |
+
|
| 146 |
+
The ESMC backbone uses SDPA as the recommended highest-fidelity path. Flex
|
| 147 |
+
Attention is supported and non-experimental but can be numerically divergent;
|
| 148 |
+
ESMFold2 does not advertise FlashAttention for the folding interface.
|
| 149 |
+
|
| 150 |
+
| Backend | Support | Measurement status |
|
| 151 |
+
| --- | --- | --- |
|
| 152 |
+
| `sdpa` | Recommended fidelity path | Pending complete validated 30-record frozen-head GH200/aarch64 set |
|
| 153 |
+
| `eager` | Supported | Pending complete validated 30-record frozen-head GH200/aarch64 set |
|
| 154 |
+
| `flex_attention` | Supported, numerically divergent | Pending complete validated 30-record frozen-head GH200/aarch64 set |
|
| 155 |
+
|
| 156 |
+
No threshold, report from another checkpoint, or result from another
|
| 157 |
+
accelerator is substituted for a measurement. A release set contains all
|
| 158 |
+
30 model/backend/panel records from one exact GH200 device and aarch64 runtime:
|
| 159 |
+
18 eager/SDPA/Flex measurements include relative L2, Q99.9, residue
|
| 160 |
+
cosine, pooled cosine, top-1, and Jensen-Shannon distributions; 12
|
| 161 |
+
FlashAttention 2/3 records explicitly attest locked-platform unavailability.
|
| 162 |
+
|
| 163 |
+
## Locked oracle package compatibility exception
|
| 164 |
+
|
| 165 |
+
The frozen oracle lock permits exactly one nonzero `pip check` diagnostic:
|
| 166 |
+
`nvidia-cusparselt-cu13 0.8.1 is not supported on this platform`. It applies only to
|
| 167 |
+
`nvidia-cusparselt-cu13==0.8.1` on
|
| 168 |
+
`NVIDIA GH200 480GB` / `linux` /
|
| 169 |
+
`aarch64`. The vendor filename tag is
|
| 170 |
+
`py3-none-manylinux2014_aarch64`, while the wheel metadata declares
|
| 171 |
+
`py3-none-manylinux2014_sbsa`. The exact wheel is
|
| 172 |
+
`nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_aarch64.whl` with SHA-256 `4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f`.
|
| 173 |
+
FastPLMs accepts this vendor metadata mismatch only after the lock, installed
|
| 174 |
+
inventory, wheel bytes, metadata tag, and target identity all match. The wheel
|
| 175 |
+
is not rewritten (`validated-vendor-metadata-exception-no-wheel-rewrite`). Any additional diagnostic or
|
| 176 |
+
identity drift fails closed.
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
Metrics must be tied to the exact ESMFold2 and ESMC revisions, dtype, current
|
| 180 |
+
GH200/aarch64 device and container images, dependency lock, source attestations,
|
| 181 |
+
and sequence panel. Pending cells are not performance or parity claims.
|
| 182 |
+
|
| 183 |
+
## Hash-pinned CCD runtime asset
|
| 184 |
+
|
| 185 |
+
Structure preparation requires `ccd.pkl` from
|
| 186 |
+
`biohub/ESMFold2@1ebf0e3481a5184eb6171d40615c79e384b48796`. The manifest pins
|
| 187 |
+
its 417,306,584-byte size and SHA-256
|
| 188 |
+
`9ff44b1927c6b9198e38ffe0928706827a09a350c15530beeeabebfa88038fc5`
|
| 189 |
+
under MIT terms. This is a trusted-deserialization boundary: FastPLMs only
|
| 190 |
+
allows the exact manifest repository/revision snapshot link to resolve within
|
| 191 |
+
that repository's contained blob directory; user-supplied asset and `cache_dir`
|
| 192 |
+
symlinks are rejected. The loader creates a private temporary snapshot, verifies
|
| 193 |
+
its size and SHA-256, and unpickles only that loader-owned snapshot, closing
|
| 194 |
+
path-replacement and in-place source-write races. Offline execution requires the
|
| 195 |
+
exact cache object and never downloads a replacement.
|
| 196 |
+
|
| 197 |
+
## Binder-design research example
|
| 198 |
+
|
| 199 |
+
The FastPLMs binder-design workflow uses the experimental Fast Cutoff2025
|
| 200 |
+
checkpoint for differentiable inversion, both experimental Cutoff2025
|
| 201 |
+
checkpoints as critics, and ESM++ as the sequence prior:
|
| 202 |
+
|
| 203 |
+

|
| 204 |
+
|
| 205 |
+
```bash
|
| 206 |
+
python examples/binder_design_fastplms.py \
|
| 207 |
+
--target-name pd-l1 \
|
| 208 |
+
--binder-name minibinder \
|
| 209 |
+
--batch-size 4 \
|
| 210 |
+
--steps 150 \
|
| 211 |
+
--output-dir artifacts/binder-design
|
| 212 |
+
```
|
| 213 |
+
|
| 214 |
+
The workflow ranks candidates by mean iPTM across the approved critics after
|
| 215 |
+
the minibinder isoelectric-point filter. These are model-based prioritization
|
| 216 |
+
signals, not experimental evidence of affinity or specificity. See the
|
| 217 |
+
[complete workflow](https://github.com/Synthyra/FastPLMs/blob/main/docs/binder_design.md).
|
| 218 |
+
|
| 219 |
+
## Runtime contract
|
| 220 |
+
|
| 221 |
+
- Public input: Raw amino-acid sequences or typed molecular-complex specifications; low-level forward accepts prepared feature tensors
|
| 222 |
+
- Advertised AutoClasses: `AutoConfig`, `AutoModel`
|
| 223 |
+
- AutoClass weight status: `AutoConfig` = `FastPLMs extension`, `AutoModel` = `pretrained`
|
| 224 |
+
- Attention implementations: `eager`, `sdpa`, `flex_attention`
|
| 225 |
+
- Precision policies: `auto`, `fp32`, `bf16`, `fp8` (experimental)
|
| 226 |
+
- BF16 execution: `fp32_parameters_autocast`
|
| 227 |
+
- Generation contract: `not_applicable`
|
| 228 |
+
- Optional dependency group: `structure`
|
| 229 |
+
- Weight publication allowed: `true`
|
| 230 |
+
- Weight license status: `resolved`
|
| 231 |
+
- Redistributable: `true`
|
| 232 |
+
- Complete weight publication required: `false`
|
| 233 |
+
|
| 234 |
+
## Provenance
|
| 235 |
+
|
| 236 |
+
- FastPLMs weights: `Synthyra/ESMFold2-Experimental-Cutoff2025@632ff4a9e68f1de78ee956a613267bdcdb5b354d`
|
| 237 |
+
- Runtime revision: `1b9ce023f1e06571cf3e6324be0610ffa53e0a4a`
|
| 238 |
+
- Runtime source-tree SHA-256: `15e781c5f1cd2ba8486e22076df15ffab37d3c00a689bf25280d803f2d60ee74`
|
| 239 |
+
- Runtime bundle SHA-256: `278bb01ff0e426ae5f707c7a93ee720a0e87dfade5afb658921528d784720232`
|
| 240 |
+
- Generator/schema version and complete/runtime-only attestations: recorded in `provenance.json`
|
| 241 |
+
- Official checkpoint: `biohub/ESMFold2-Experimental-Cutoff2025@56f94f5c1069ecde17512c96928850518340d287`
|
| 242 |
+
- Artifact source: `fast`
|
| 243 |
+
- State transform: `identity`
|
| 244 |
+
- BF16 execution: `fp32_parameters_autocast`
|
| 245 |
+
- Pinned upstreams: `biohub-esm`, `biohub-transformers`, `protein-ttt`
|
| 246 |
+
- Reference container: `reference-esmfold2`
|
| 247 |
+
- Release tiers: `check`, `compliance`, `structure`, `feature`, `artifact`, `benchmark`
|
| 248 |
+
- Unresolved required file identities: `0`
|
| 249 |
+
|
| 250 |
+
The local artifact records exact file identities, conversion provenance, source
|
| 251 |
+
revisions, and legal texts in `provenance.json`. A nonzero unresolved count is a
|
| 252 |
+
release blocker.
|
| 253 |
+
|
| 254 |
+
## Validation boundary
|
| 255 |
+
|
| 256 |
+
For tiers declared by the manifest, the release contract compares applicable
|
| 257 |
+
semantic configuration, tokenizer behavior, state keys, shapes, dtypes,
|
| 258 |
+
values, aliases, and representative inference with the pinned official
|
| 259 |
+
implementation. This metadata does not by itself claim that a particular build
|
| 260 |
+
passed, that one backend is faster, or that an output has biological or
|
| 261 |
+
therapeutic validity.
|
| 262 |
+
|
| 263 |
+
## License
|
| 264 |
+
|
| 265 |
+
Checkpoint terms: MIT. The Hub model-card identifier is
|
| 266 |
+
`mit`. Applicable source licenses, notices, attribution,
|
| 267 |
+
and conversion records are distributed with the local artifact. Review them
|
| 268 |
+
before use.
|
THIRD_PARTY_NOTICES.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Third-party notices
|
| 2 |
+
|
| 3 |
+
FastPLMs implements interfaces and checkpoint mappings for independently
|
| 4 |
+
released protein models. The pinned repositories under `vendor/upstream/` are
|
| 5 |
+
parity oracles. Production code does not import them, and runtime images do not
|
| 6 |
+
contain them.
|
| 7 |
+
|
| 8 |
+
This notice is informational and is not legal advice. A checkpoint license can
|
| 9 |
+
differ from the license covering its source implementation. The typed inventory
|
| 10 |
+
in `src/fastplms/models.toml` and the verbatim files under `LICENSES/` are the
|
| 11 |
+
distribution record.
|
| 12 |
+
|
| 13 |
+
## ANKH
|
| 14 |
+
|
| 15 |
+
The pinned ANKH implementation and the mirrored ANKH checkpoints are identified
|
| 16 |
+
as CC BY-NC-SA 4.0. FastPLMs displays those terms but does not enforce them in
|
| 17 |
+
software. Users are responsible for determining whether their use and
|
| 18 |
+
redistribution comply. The complete text is in `LICENSES/ankh/LICENSE.md`.
|
| 19 |
+
|
| 20 |
+
## Profluent-E1
|
| 21 |
+
|
| 22 |
+
Profluent identifies its E1 model code as Apache-2.0. The E1 weights and full
|
| 23 |
+
release are subject to the Profluent-E1 Clickthrough License Agreement and the
|
| 24 |
+
incorporated attribution requirements. Any E1 distribution must retain all of
|
| 25 |
+
the following files:
|
| 26 |
+
|
| 27 |
+
- `LICENSES/e1/LICENSE`, the Profluent-E1 agreement
|
| 28 |
+
- `LICENSES/e1/ATTRIBUTION`, the attribution guidelines
|
| 29 |
+
- `LICENSES/e1/NOTICE`, the required notice
|
| 30 |
+
- `LICENSES/e1/Apache-2.0.txt`, the code license
|
| 31 |
+
- `LICENSES/e1/BSD-3-Clause.txt`, covering the FlashAttention-derived padding
|
| 32 |
+
utility identified by the official E1 source
|
| 33 |
+
- `LICENSES/e1/MODIFICATIONS.md`, the FastPLMs modified-file notice
|
| 34 |
+
|
| 35 |
+
The exact text `Profluent-E1` must remain prominently displayed in E1
|
| 36 |
+
documentation and at each launch of an executable E1 workflow, as required by
|
| 37 |
+
the upstream attribution guidelines. Certain commercial outputs, including
|
| 38 |
+
specified pharmaceutical and target-related outputs, can require the separate
|
| 39 |
+
`Built with Profluent-E1` statement described in `ATTRIBUTION`.
|
| 40 |
+
|
| 41 |
+
## DPLM
|
| 42 |
+
|
| 43 |
+
The pinned ByteDance DPLM repository is Apache-2.0. Its
|
| 44 |
+
[README](https://github.com/bytedance/dplm/blob/8a2e15e53416b4536f03f79ad1f6f6a9cbd5e19d/README.md#overview)
|
| 45 |
+
explicitly defines the repository release as including pretrained DPLM1 and
|
| 46 |
+
DPLM2 weights, and the same revision carries the complete
|
| 47 |
+
[Apache-2.0 license](https://github.com/bytedance/dplm/blob/8a2e15e53416b4536f03f79ad1f6f6a9cbd5e19d/LICENSE).
|
| 48 |
+
FastPLMs records both checkpoint families as Apache-2.0 and distributes the
|
| 49 |
+
verbatim license plus `LICENSES/dplm/PROVENANCE.md`. Converted weights retain
|
| 50 |
+
those terms and remain subject to the ordinary artifact and publication gates.
|
| 51 |
+
|
| 52 |
+
## Biohub
|
| 53 |
+
|
| 54 |
+
The pinned Biohub ESM implementation is MIT and includes a separate
|
| 55 |
+
`THIRD_PARTY_NOTICE.md`; both files are distributed under
|
| 56 |
+
`LICENSES/biohub-esm/`. The pinned Biohub Transformers fork is Apache-2.0, with
|
| 57 |
+
its complete text under `LICENSES/biohub-transformers/`.
|
| 58 |
+
|
| 59 |
+
## Boltz
|
| 60 |
+
|
| 61 |
+
The pinned Boltz source is MIT. The verbatim notice is in
|
| 62 |
+
`LICENSES/boltz/LICENSE`.
|
| 63 |
+
|
| 64 |
+
## Meta ESM and OpenFold
|
| 65 |
+
|
| 66 |
+
The pinned Meta ESM source is MIT. The pinned OpenFold source is Apache-2.0.
|
| 67 |
+
Their verbatim texts and revision-specific provenance notices are under
|
| 68 |
+
`LICENSES/fair-esm/` and `LICENSES/openfold/`.
|
| 69 |
+
|
| 70 |
+
The native H100 ESMFold reference image applies the tracked
|
| 71 |
+
`docker/constraints/openfold-sm90.patch` to the copied OpenFold `setup.py`.
|
| 72 |
+
This build-only change restricts the CUDA extension to `sm90` and selects the
|
| 73 |
+
C++17 standard required by the reference PyTorch version. It leaves the pinned
|
| 74 |
+
submodule, extension source, model classes, checkpoint data, and public API
|
| 75 |
+
unchanged. The complete modified-file record is in
|
| 76 |
+
`LICENSES/openfold/MODIFICATIONS.md`.
|
| 77 |
+
|
| 78 |
+
The isolated reference image also includes Apache-2.0 PyTorch Lightning,
|
| 79 |
+
TorchMetrics, Lightning Utilities, and NVIDIA DLLogger. Their exact versions or
|
| 80 |
+
revision are pinned in `docker/constraints/esmfold.txt`; OpenFold imports them
|
| 81 |
+
eagerly, and FastPLMs production code does not depend on them. DLLogger's exact
|
| 82 |
+
source identity and installed-license handling are recorded in
|
| 83 |
+
`LICENSES/dllogger/PROVENANCE.md`.
|
| 84 |
+
|
| 85 |
+
## ProteinTTT
|
| 86 |
+
|
| 87 |
+
The optional test-time training workflow is validated against the pinned
|
| 88 |
+
ProteinTTT repository under its MIT license. Its verbatim license and
|
| 89 |
+
revision-specific provenance are under `LICENSES/protein-ttt/`.
|
| 90 |
+
|
| 91 |
+
## Conversion and packaging record
|
| 92 |
+
|
| 93 |
+
For every supported family, `src/fastplms/models.toml` records an immutable
|
| 94 |
+
official checkpoint revision, an immutable FastPLMs checkpoint revision, file
|
| 95 |
+
digests, a named state transformation, and a mechanism-level conversion record.
|
| 96 |
+
Generated artifacts reproduce that record in `provenance.json`. A release or
|
| 97 |
+
artifact build must fail when a required file identity, legal text, attribution
|
| 98 |
+
notice, modified-file notice, upstream revision, or conversion record is absent
|
| 99 |
+
or differs from its manifest digest.
|
config.json
CHANGED
|
@@ -3,8 +3,8 @@
|
|
| 3 |
"ESMFold2ExperimentalModel"
|
| 4 |
],
|
| 5 |
"auto_map": {
|
| 6 |
-
"AutoConfig": "
|
| 7 |
-
"AutoModel": "
|
| 8 |
},
|
| 9 |
"confidence_head": {
|
| 10 |
"distogram_bins": 128,
|
|
@@ -25,6 +25,16 @@
|
|
| 25 |
"disable_msa_features": false,
|
| 26 |
"dtype": "float32",
|
| 27 |
"esmc_id": "biohub/ESMC-6B",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
"folding_trunk": {
|
| 29 |
"dropout": 0.25,
|
| 30 |
"n_heads": 8,
|
|
@@ -56,6 +66,7 @@
|
|
| 56 |
},
|
| 57 |
"lm_num_layers": 80,
|
| 58 |
"model_type": "esmfold2",
|
|
|
|
| 59 |
"msa_encoder": {
|
| 60 |
"d_hidden": 32,
|
| 61 |
"d_msa": 128,
|
|
|
|
| 3 |
"ESMFold2ExperimentalModel"
|
| 4 |
],
|
| 5 |
"auto_map": {
|
| 6 |
+
"AutoConfig": "modeling_fastplms.ESMFold2Config",
|
| 7 |
+
"AutoModel": "modeling_fastplms.ESMFold2ExperimentalModel"
|
| 8 |
},
|
| 9 |
"confidence_head": {
|
| 10 |
"distogram_bins": 128,
|
|
|
|
| 25 |
"disable_msa_features": false,
|
| 26 |
"dtype": "float32",
|
| 27 |
"esmc_id": "biohub/ESMC-6B",
|
| 28 |
+
"fastplms_checkpoint_hash": "3a40758a12594cab337bbe7e664d305df722dc1eb1f0cc1ba0c840bf46d22834",
|
| 29 |
+
"fastplms_checkpoint_repo_id": "Synthyra/ESMFold2-Experimental-Cutoff2025",
|
| 30 |
+
"fastplms_checkpoint_revision": "632ff4a9e68f1de78ee956a613267bdcdb5b354d",
|
| 31 |
+
"fastplms_model_id": "esmfold2_experimental_cutoff2025",
|
| 32 |
+
"fastplms_release_tool_revision": "1b9ce023f1e06571cf3e6324be0610ffa53e0a4a",
|
| 33 |
+
"fastplms_release_tool_sha256": "1459b5d7d13d9b07bd97b3eee764f2ce73623e15e32d07ddf6825c2a9509afb9",
|
| 34 |
+
"fastplms_runtime_bundle_sha256": "278bb01ff0e426ae5f707c7a93ee720a0e87dfade5afb658921528d784720232",
|
| 35 |
+
"fastplms_runtime_revision": "1b9ce023f1e06571cf3e6324be0610ffa53e0a4a",
|
| 36 |
+
"fastplms_source_tree_sha256": "15e781c5f1cd2ba8486e22076df15ffab37d3c00a689bf25280d803f2d60ee74",
|
| 37 |
+
"fastplms_weights_revision": "632ff4a9e68f1de78ee956a613267bdcdb5b354d",
|
| 38 |
"folding_trunk": {
|
| 39 |
"dropout": 0.25,
|
| 40 |
"n_heads": 8,
|
|
|
|
| 66 |
},
|
| 67 |
"lm_num_layers": 80,
|
| 68 |
"model_type": "esmfold2",
|
| 69 |
+
"msa_conditioning": true,
|
| 70 |
"msa_encoder": {
|
| 71 |
"d_hidden": 32,
|
| 72 |
"d_msa": 128,
|
fastplms/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastPLMs public package interface.
|
| 2 |
+
|
| 3 |
+
The module uses lazy exports so importing :mod:`fastplms` does not initialize
|
| 4 |
+
Torch, download checkpoints, construct tokenizers, or compile kernels.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from importlib import import_module
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
__version__ = "1.0.0"
|
| 13 |
+
|
| 14 |
+
_LAZY_EXPORTS = {
|
| 15 |
+
"CheckpointSource": ("fastplms.registry", "CheckpointSource"),
|
| 16 |
+
"EmbeddingInput": ("fastplms.embeddings", "EmbeddingInput"),
|
| 17 |
+
"EmbeddingRecord": ("fastplms.embeddings", "EmbeddingRecord"),
|
| 18 |
+
"EmbeddingResult": ("fastplms.embeddings", "EmbeddingResult"),
|
| 19 |
+
"FileDigest": ("fastplms.registry", "FileDigest"),
|
| 20 |
+
"ModelFamily": ("fastplms.registry", "ModelFamily"),
|
| 21 |
+
"ModelRegistry": ("fastplms.registry", "ModelRegistry"),
|
| 22 |
+
"ModelSpec": ("fastplms.registry", "ModelSpec"),
|
| 23 |
+
"OracleAsset": ("fastplms.registry", "OracleAsset"),
|
| 24 |
+
"RegistryError": ("fastplms.registry", "RegistryError"),
|
| 25 |
+
"RuntimeProfile": ("fastplms.runtime", "RuntimeProfile"),
|
| 26 |
+
"UpstreamSource": ("fastplms.registry", "UpstreamSource"),
|
| 27 |
+
"embed_dataset": ("fastplms.embeddings", "embed_dataset"),
|
| 28 |
+
"get_model_registry": ("fastplms.registry", "get_model_registry"),
|
| 29 |
+
"get_model_spec": ("fastplms.registry", "get_model_spec"),
|
| 30 |
+
"load_model_registry": ("fastplms.registry", "load_model_registry"),
|
| 31 |
+
"runtime_profile": ("fastplms.runtime", "runtime_profile"),
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
__all__ = ["__version__", *_LAZY_EXPORTS]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def __getattr__(name: str) -> Any:
|
| 38 |
+
try:
|
| 39 |
+
module_name, attribute_name = _LAZY_EXPORTS[name]
|
| 40 |
+
except KeyError as error:
|
| 41 |
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from error
|
| 42 |
+
value = getattr(import_module(module_name), attribute_name)
|
| 43 |
+
globals()[name] = value
|
| 44 |
+
return value
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def __dir__() -> list[str]:
|
| 48 |
+
return sorted(set(globals()).union(__all__))
|
fastplms/attention/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared attention backends, masks, and optional optimized kernels."""
|
| 2 |
+
|
| 3 |
+
from ._core import (
|
| 4 |
+
VALID_ATTENTION_BACKENDS,
|
| 5 |
+
AttentionBackend,
|
| 6 |
+
BlockMask,
|
| 7 |
+
_ensure_flash_kernels_loaded,
|
| 8 |
+
_get_flex_attention_fn,
|
| 9 |
+
_get_flex_block_mask,
|
| 10 |
+
_kernels_flash_forward,
|
| 11 |
+
_kernels_flash_varlen_forward,
|
| 12 |
+
_unpad_input,
|
| 13 |
+
bool_to_additive_mask,
|
| 14 |
+
clear_flex_attention_caches,
|
| 15 |
+
create_block_mask,
|
| 16 |
+
flex_attention,
|
| 17 |
+
get_attention_mask,
|
| 18 |
+
get_attn_implementation,
|
| 19 |
+
index_first_axis,
|
| 20 |
+
index_put_first_axis,
|
| 21 |
+
kernels_flash_attention_func,
|
| 22 |
+
pad_input,
|
| 23 |
+
resolve_attention_backend,
|
| 24 |
+
resolve_attention_backend_for_call,
|
| 25 |
+
set_config_attn_implementation,
|
| 26 |
+
warn_attention_backend_fallback,
|
| 27 |
+
)
|
| 28 |
+
from .interfaces import (
|
| 29 |
+
FASTPLMS_ATTENTION_FUNCTIONS,
|
| 30 |
+
FASTPLMS_ATTENTION_MASKS,
|
| 31 |
+
FastPLMsAttentionMixin,
|
| 32 |
+
validate_transformers_attention_interfaces,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
__all__ = [
|
| 36 |
+
"FASTPLMS_ATTENTION_FUNCTIONS",
|
| 37 |
+
"FASTPLMS_ATTENTION_MASKS",
|
| 38 |
+
"VALID_ATTENTION_BACKENDS",
|
| 39 |
+
"AttentionBackend",
|
| 40 |
+
"BlockMask",
|
| 41 |
+
"FastPLMsAttentionMixin",
|
| 42 |
+
"_ensure_flash_kernels_loaded",
|
| 43 |
+
"_get_flex_attention_fn",
|
| 44 |
+
"_get_flex_block_mask",
|
| 45 |
+
"_kernels_flash_forward",
|
| 46 |
+
"_kernels_flash_varlen_forward",
|
| 47 |
+
"_unpad_input",
|
| 48 |
+
"bool_to_additive_mask",
|
| 49 |
+
"clear_flex_attention_caches",
|
| 50 |
+
"create_block_mask",
|
| 51 |
+
"flex_attention",
|
| 52 |
+
"get_attention_mask",
|
| 53 |
+
"get_attn_implementation",
|
| 54 |
+
"index_first_axis",
|
| 55 |
+
"index_put_first_axis",
|
| 56 |
+
"kernels_flash_attention_func",
|
| 57 |
+
"pad_input",
|
| 58 |
+
"resolve_attention_backend",
|
| 59 |
+
"resolve_attention_backend_for_call",
|
| 60 |
+
"set_config_attn_implementation",
|
| 61 |
+
"validate_transformers_attention_interfaces",
|
| 62 |
+
"warn_attention_backend_fallback",
|
| 63 |
+
]
|
fastplms/attention/_core.py
ADDED
|
@@ -0,0 +1,779 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Low-level attention kernels and mask construction.
|
| 2 |
+
|
| 3 |
+
The public backend contract lives in :mod:`fastplms.attention`. Optional
|
| 4 |
+
kernels are resolved only after a caller explicitly requests them, so importing
|
| 5 |
+
FastPLMs never downloads or compiles code.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import warnings
|
| 11 |
+
from collections import OrderedDict
|
| 12 |
+
from collections.abc import Callable
|
| 13 |
+
from enum import Enum
|
| 14 |
+
from threading import RLock
|
| 15 |
+
|
| 16 |
+
import torch
|
| 17 |
+
from einops import rearrange
|
| 18 |
+
from torch.nn import functional as F
|
| 19 |
+
|
| 20 |
+
from ._kernel_lock import load_locked_kernel
|
| 21 |
+
|
| 22 |
+
try:
|
| 23 |
+
from torch.nn.attention.flex_attention import BlockMask, create_block_mask, flex_attention
|
| 24 |
+
except ImportError:
|
| 25 |
+
create_block_mask = None
|
| 26 |
+
flex_attention = None
|
| 27 |
+
BlockMask = None
|
| 28 |
+
|
| 29 |
+
_MAX_FLEX_CACHE_ENTRIES = 128
|
| 30 |
+
_compiled_flex_attention: OrderedDict[tuple, object] = OrderedDict()
|
| 31 |
+
_flex_block_masks: OrderedDict[tuple, BlockMask] = OrderedDict()
|
| 32 |
+
_flex_cache_lock = RLock()
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _remember(cache: OrderedDict, key: tuple, value):
|
| 36 |
+
"""Insert an item into a bounded least-recently-used cache."""
|
| 37 |
+
cache[key] = value
|
| 38 |
+
cache.move_to_end(key)
|
| 39 |
+
while len(cache) > _MAX_FLEX_CACHE_ENTRIES:
|
| 40 |
+
cache.popitem(last=False)
|
| 41 |
+
return value
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def clear_flex_attention_caches() -> None:
|
| 45 |
+
"""Drop FastPLMs-owned compiled Flex callables and block masks.
|
| 46 |
+
|
| 47 |
+
This deliberately does not call :func:`torch.compiler.reset`, which would
|
| 48 |
+
clear process-global Torch compilation state owned by unrelated models.
|
| 49 |
+
Active forwards retain their local references and can complete safely.
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
with _flex_cache_lock:
|
| 53 |
+
_compiled_flex_attention.clear()
|
| 54 |
+
_flex_block_masks.clear()
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _get_flex_attention_fn(
|
| 58 |
+
*,
|
| 59 |
+
device: torch.device | None = None,
|
| 60 |
+
dtype: torch.dtype | None = None,
|
| 61 |
+
shape: tuple[int, ...] | None = None,
|
| 62 |
+
sequence_lengths: tuple[int, ...] | None = None,
|
| 63 |
+
mask_semantics: str = "padding",
|
| 64 |
+
):
|
| 65 |
+
"""Return a compiled Flex callable for an explicit execution signature.
|
| 66 |
+
|
| 67 |
+
Compilation depends on execution shape, device, dtype, and mask semantics.
|
| 68 |
+
Per-example padding lengths are represented by the ``BlockMask`` argument
|
| 69 |
+
and must not create a new compiled graph for every batch composition.
|
| 70 |
+
"""
|
| 71 |
+
if flex_attention is None:
|
| 72 |
+
return None
|
| 73 |
+
# Retain the keyword for compatibility with remote-code artifacts while
|
| 74 |
+
# deliberately excluding data-dependent lengths from the compile key.
|
| 75 |
+
del sequence_lengths
|
| 76 |
+
flex_mod = torch.nn.attention.flex_attention
|
| 77 |
+
if getattr(flex_mod, "_FLEX_ATTENTION_DISABLE_COMPILE_DEBUG", False):
|
| 78 |
+
return flex_attention
|
| 79 |
+
key = (
|
| 80 |
+
None if device is None else str(device),
|
| 81 |
+
None if dtype is None else str(dtype),
|
| 82 |
+
shape,
|
| 83 |
+
mask_semantics,
|
| 84 |
+
)
|
| 85 |
+
with _flex_cache_lock:
|
| 86 |
+
compiled = _compiled_flex_attention.get(key)
|
| 87 |
+
if compiled is None:
|
| 88 |
+
compiled = torch.compile(flex_attention, dynamic=False)
|
| 89 |
+
_remember(_compiled_flex_attention, key, compiled)
|
| 90 |
+
else:
|
| 91 |
+
_compiled_flex_attention.move_to_end(key)
|
| 92 |
+
return compiled
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _get_flex_block_mask(
|
| 96 |
+
*,
|
| 97 |
+
mask_pattern: torch.Tensor,
|
| 98 |
+
batch_size: int,
|
| 99 |
+
query_length: int,
|
| 100 |
+
key_value_length: int,
|
| 101 |
+
device: torch.device,
|
| 102 |
+
dtype: torch.dtype | None,
|
| 103 |
+
mask_semantics: str,
|
| 104 |
+
mask_mod: Callable[
|
| 105 |
+
[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor],
|
| 106 |
+
torch.Tensor,
|
| 107 |
+
],
|
| 108 |
+
) -> BlockMask:
|
| 109 |
+
"""Return a bounded, exact-pattern cached Flex ``BlockMask``.
|
| 110 |
+
|
| 111 |
+
The complete pattern is transferred to the host once to avoid a CUDA
|
| 112 |
+
synchronization per batch row. Execution dtype remains part of the key
|
| 113 |
+
because compiled Flex plans can specialize on it even though the pattern
|
| 114 |
+
tensor itself is boolean or integer.
|
| 115 |
+
"""
|
| 116 |
+
if create_block_mask is None:
|
| 117 |
+
raise RuntimeError(
|
| 118 |
+
"'flex_attention' was requested, but torch.create_block_mask is unavailable."
|
| 119 |
+
)
|
| 120 |
+
pattern = mask_pattern.detach().to(device=device).contiguous()
|
| 121 |
+
# One device-to-host transfer is required for an exact cache identity. Use
|
| 122 |
+
# the contiguous buffer directly instead of materializing one Python int
|
| 123 |
+
# per byte, which is prohibitively expensive for long batched sequences.
|
| 124 |
+
host_pattern = pattern.to(device="cpu").contiguous()
|
| 125 |
+
pattern_bytes = host_pattern.view(torch.uint8).numpy().tobytes(order="C")
|
| 126 |
+
cache_key = (
|
| 127 |
+
str(device),
|
| 128 |
+
None if dtype is None else str(dtype),
|
| 129 |
+
(batch_size, query_length, key_value_length),
|
| 130 |
+
str(pattern.dtype),
|
| 131 |
+
pattern_bytes,
|
| 132 |
+
mask_semantics,
|
| 133 |
+
)
|
| 134 |
+
with _flex_cache_lock:
|
| 135 |
+
flex_block_mask = _flex_block_masks.get(cache_key)
|
| 136 |
+
if flex_block_mask is None:
|
| 137 |
+
flex_block_mask = create_block_mask(
|
| 138 |
+
mask_mod,
|
| 139 |
+
batch_size,
|
| 140 |
+
1,
|
| 141 |
+
query_length,
|
| 142 |
+
key_value_length,
|
| 143 |
+
device=device,
|
| 144 |
+
)
|
| 145 |
+
_remember(_flex_block_masks, cache_key, flex_block_mask)
|
| 146 |
+
else:
|
| 147 |
+
_flex_block_masks.move_to_end(cache_key)
|
| 148 |
+
return flex_block_mask
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
# Hugging Face `kernels` exposes slightly different APIs for FlashAttention 2
|
| 152 |
+
# and 3. Detect the loaded variant once so every caller uses the same dispatch.
|
| 153 |
+
def _infer_kernels_flash_variant(kernel) -> str | None:
|
| 154 |
+
if hasattr(kernel, "fwd") and hasattr(kernel, "varlen_fwd"):
|
| 155 |
+
return "flash_attn2"
|
| 156 |
+
if hasattr(kernel, "flash_attn_func") and hasattr(kernel, "flash_attn_varlen_func"):
|
| 157 |
+
return "flash_attn3"
|
| 158 |
+
return None
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _load_kernels_flash(implementation: str) -> tuple[object, str]:
|
| 162 |
+
"""Load exactly the requested FlashAttention kernel.
|
| 163 |
+
|
| 164 |
+
Loading is deferred until backend selection. A FlashAttention-2 request
|
| 165 |
+
never falls through to FlashAttention-3, or vice versa.
|
| 166 |
+
"""
|
| 167 |
+
from fastplms.registry import get_model_registry
|
| 168 |
+
|
| 169 |
+
kernel_spec = get_model_registry().attention_kernels[implementation]
|
| 170 |
+
repository = kernel_spec.repository
|
| 171 |
+
try:
|
| 172 |
+
flash_kernel = load_locked_kernel(repository, kernel_spec.revision)
|
| 173 |
+
except Exception as error:
|
| 174 |
+
raise RuntimeError(
|
| 175 |
+
f"Unable to load the manifest-pinned kernel "
|
| 176 |
+
f"{repository}@{kernel_spec.revision} for {implementation!r}."
|
| 177 |
+
) from error
|
| 178 |
+
flash_kernel_variant = _infer_kernels_flash_variant(flash_kernel)
|
| 179 |
+
if flash_kernel_variant != kernel_spec.expected_variant:
|
| 180 |
+
raise RuntimeError(
|
| 181 |
+
f"{repository}@{kernel_spec.revision} exposed {flash_kernel_variant!r}; "
|
| 182 |
+
f"expected {kernel_spec.expected_variant!r}."
|
| 183 |
+
)
|
| 184 |
+
if not all(
|
| 185 |
+
callable(getattr(flash_kernel, name, None))
|
| 186 |
+
for name in ("flash_attn_func", "flash_attn_varlen_func")
|
| 187 |
+
):
|
| 188 |
+
raise RuntimeError(
|
| 189 |
+
f"{repository}@{kernel_spec.revision} does not expose the "
|
| 190 |
+
"autograd-enabled flash_attn_func and flash_attn_varlen_func APIs."
|
| 191 |
+
)
|
| 192 |
+
return flash_kernel, flash_kernel_variant
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
_FLASH_KERNELS: dict[str, tuple[object, str]] = {}
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _validate_kernels_flash_dtype(
|
| 199 |
+
query_states: torch.Tensor,
|
| 200 |
+
key_states: torch.Tensor,
|
| 201 |
+
value_states: torch.Tensor,
|
| 202 |
+
implementation: str,
|
| 203 |
+
) -> torch.dtype:
|
| 204 |
+
"""Reject dtypes outside the immutable kernel manifest before dispatch."""
|
| 205 |
+
|
| 206 |
+
tensor_dtypes = {query_states.dtype, key_states.dtype, value_states.dtype}
|
| 207 |
+
if len(tensor_dtypes) != 1:
|
| 208 |
+
observed = ", ".join(sorted(str(dtype) for dtype in tensor_dtypes))
|
| 209 |
+
raise RuntimeError(
|
| 210 |
+
f"{implementation!r} requires Q, K, and V to share one dtype; received {observed}."
|
| 211 |
+
)
|
| 212 |
+
runtime_dtype = query_states.dtype
|
| 213 |
+
if (
|
| 214 |
+
runtime_dtype == torch.float32
|
| 215 |
+
and query_states.is_cuda
|
| 216 |
+
and torch.is_autocast_enabled("cuda")
|
| 217 |
+
):
|
| 218 |
+
runtime_dtype = torch.get_autocast_dtype("cuda")
|
| 219 |
+
dtype_names = {
|
| 220 |
+
torch.float32: "float32",
|
| 221 |
+
torch.bfloat16: "bfloat16",
|
| 222 |
+
torch.float16: "float16",
|
| 223 |
+
}
|
| 224 |
+
runtime_dtype_name = dtype_names.get(runtime_dtype, str(runtime_dtype))
|
| 225 |
+
from fastplms.registry import get_model_registry
|
| 226 |
+
|
| 227 |
+
supported = get_model_registry().attention_kernels[implementation].dtypes
|
| 228 |
+
if runtime_dtype_name not in supported:
|
| 229 |
+
expected = ", ".join(supported)
|
| 230 |
+
raise RuntimeError(
|
| 231 |
+
f"{implementation!r} supports only manifest-declared dtype(s) {expected}; "
|
| 232 |
+
f"received {runtime_dtype_name}. Use CUDA BF16 autocast for FP32-resident "
|
| 233 |
+
"models."
|
| 234 |
+
)
|
| 235 |
+
return runtime_dtype
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def _validate_kernels_flash_device(
|
| 239 |
+
query_states: torch.Tensor,
|
| 240 |
+
key_states: torch.Tensor,
|
| 241 |
+
value_states: torch.Tensor,
|
| 242 |
+
implementation: str,
|
| 243 |
+
) -> torch.device:
|
| 244 |
+
"""Require Q, K, and V on one CUDA device before loading a kernel."""
|
| 245 |
+
|
| 246 |
+
devices = (query_states.device, key_states.device, value_states.device)
|
| 247 |
+
if len(set(devices)) != 1:
|
| 248 |
+
observed = ", ".join(str(device) for device in devices)
|
| 249 |
+
raise RuntimeError(
|
| 250 |
+
f"{implementation!r} requires Q, K, and V on one device; received {observed}."
|
| 251 |
+
)
|
| 252 |
+
device = devices[0]
|
| 253 |
+
if device.type != "cuda" or not all(
|
| 254 |
+
tensor.is_cuda for tensor in (query_states, key_states, value_states)
|
| 255 |
+
):
|
| 256 |
+
raise RuntimeError(
|
| 257 |
+
f"{implementation!r} requires CUDA Q, K, and V; received device {device}."
|
| 258 |
+
)
|
| 259 |
+
return device
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def _ensure_flash_kernels_loaded(implementation: str) -> tuple[object, str]:
|
| 263 |
+
cached = _FLASH_KERNELS.get(implementation)
|
| 264 |
+
if cached is not None:
|
| 265 |
+
return cached
|
| 266 |
+
loaded = _load_kernels_flash(implementation)
|
| 267 |
+
_FLASH_KERNELS[implementation] = loaded
|
| 268 |
+
return loaded
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def _kernels_flash_forward(
|
| 272 |
+
query_states: torch.Tensor,
|
| 273 |
+
key_states: torch.Tensor,
|
| 274 |
+
value_states: torch.Tensor,
|
| 275 |
+
causal: bool = False,
|
| 276 |
+
softmax_scale: float | None = None,
|
| 277 |
+
implementation: str = "flash_attention_3",
|
| 278 |
+
) -> torch.Tensor:
|
| 279 |
+
"""Flash-attention forward, optionally overriding the softmax scale.
|
| 280 |
+
|
| 281 |
+
When `softmax_scale is None`, the flash kernel applies its default
|
| 282 |
+
`1 / sqrt(head_dim)`. Pass `softmax_scale=1.0` if the caller has already
|
| 283 |
+
pre-scaled Q (the convention used by ESM2, DPLM, DPLM2, E1, ESMFold).
|
| 284 |
+
Failing to override when Q is pre-scaled applies the scale twice and breaks
|
| 285 |
+
parity with eager attention and SDPA.
|
| 286 |
+
"""
|
| 287 |
+
flash_kernel, flash_kernel_variant = _ensure_flash_kernels_loaded(implementation)
|
| 288 |
+
if flash_kernel_variant == "flash_attn2":
|
| 289 |
+
output = flash_kernel.flash_attn_func(
|
| 290 |
+
q=query_states,
|
| 291 |
+
k=key_states,
|
| 292 |
+
v=value_states,
|
| 293 |
+
dropout_p=0.0,
|
| 294 |
+
softmax_scale=softmax_scale,
|
| 295 |
+
causal=causal,
|
| 296 |
+
)
|
| 297 |
+
return output[0] if isinstance(output, tuple) else output
|
| 298 |
+
if flash_kernel_variant == "flash_attn3":
|
| 299 |
+
output = flash_kernel.flash_attn_func(
|
| 300 |
+
q=query_states,
|
| 301 |
+
k=key_states,
|
| 302 |
+
v=value_states,
|
| 303 |
+
softmax_scale=softmax_scale,
|
| 304 |
+
causal=causal,
|
| 305 |
+
)
|
| 306 |
+
if isinstance(output, tuple):
|
| 307 |
+
return output[0]
|
| 308 |
+
return output
|
| 309 |
+
raise RuntimeError(f"Unsupported FlashAttention kernel variant: {flash_kernel_variant}")
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def _kernels_flash_varlen_forward(
|
| 313 |
+
query_states: torch.Tensor,
|
| 314 |
+
key_states: torch.Tensor,
|
| 315 |
+
value_states: torch.Tensor,
|
| 316 |
+
cu_seqlens_q: torch.Tensor,
|
| 317 |
+
cu_seqlens_k: torch.Tensor,
|
| 318 |
+
max_seqlen_in_batch_q: int,
|
| 319 |
+
max_seqlen_in_batch_k: int,
|
| 320 |
+
causal: bool = False,
|
| 321 |
+
softmax_scale: float | None = None,
|
| 322 |
+
implementation: str = "flash_attention_3",
|
| 323 |
+
) -> torch.Tensor:
|
| 324 |
+
"""Varlen flash-attention forward, optionally overriding the softmax scale.
|
| 325 |
+
|
| 326 |
+
See `_kernels_flash_forward` docstring for why `softmax_scale=1.0` must be
|
| 327 |
+
passed when Q has been pre-scaled by the caller.
|
| 328 |
+
"""
|
| 329 |
+
flash_kernel, flash_kernel_variant = _ensure_flash_kernels_loaded(implementation)
|
| 330 |
+
if flash_kernel_variant == "flash_attn2":
|
| 331 |
+
output = flash_kernel.flash_attn_varlen_func(
|
| 332 |
+
q=query_states,
|
| 333 |
+
k=key_states,
|
| 334 |
+
v=value_states,
|
| 335 |
+
cu_seqlens_q=cu_seqlens_q,
|
| 336 |
+
cu_seqlens_k=cu_seqlens_k,
|
| 337 |
+
max_seqlen_q=max_seqlen_in_batch_q,
|
| 338 |
+
max_seqlen_k=max_seqlen_in_batch_k,
|
| 339 |
+
dropout_p=0.0,
|
| 340 |
+
softmax_scale=softmax_scale,
|
| 341 |
+
causal=causal,
|
| 342 |
+
)
|
| 343 |
+
return output[0] if isinstance(output, tuple) else output
|
| 344 |
+
if flash_kernel_variant == "flash_attn3":
|
| 345 |
+
output = flash_kernel.flash_attn_varlen_func(
|
| 346 |
+
q=query_states,
|
| 347 |
+
k=key_states,
|
| 348 |
+
v=value_states,
|
| 349 |
+
cu_seqlens_q=cu_seqlens_q,
|
| 350 |
+
cu_seqlens_k=cu_seqlens_k,
|
| 351 |
+
max_seqlen_q=max_seqlen_in_batch_q,
|
| 352 |
+
max_seqlen_k=max_seqlen_in_batch_k,
|
| 353 |
+
softmax_scale=softmax_scale,
|
| 354 |
+
causal=causal,
|
| 355 |
+
)
|
| 356 |
+
if isinstance(output, tuple):
|
| 357 |
+
return output[0]
|
| 358 |
+
return output
|
| 359 |
+
raise RuntimeError(f"Unsupported FlashAttention kernel variant: {flash_kernel_variant}")
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
# Varlen flash attention runs only on real tokens. These helpers remove padding
|
| 363 |
+
# before the kernel call and restore the original padded batch shape afterward.
|
| 364 |
+
class IndexFirstAxis(torch.autograd.Function):
|
| 365 |
+
@staticmethod
|
| 366 |
+
def forward(ctx, input, indices) -> torch.Tensor:
|
| 367 |
+
ctx.save_for_backward(indices)
|
| 368 |
+
if input.ndim < 2:
|
| 369 |
+
raise ValueError(
|
| 370 |
+
"index_first_axis input must have at least two dimensions; "
|
| 371 |
+
f"received shape {tuple(input.shape)}."
|
| 372 |
+
)
|
| 373 |
+
if indices.ndim != 1:
|
| 374 |
+
raise ValueError(
|
| 375 |
+
"index_first_axis indices must be one-dimensional; "
|
| 376 |
+
f"received shape {tuple(indices.shape)}."
|
| 377 |
+
)
|
| 378 |
+
ctx.first_axis_dim, other_shape = input.shape[0], input.shape[1:]
|
| 379 |
+
second_dim = other_shape.numel()
|
| 380 |
+
return torch.gather(
|
| 381 |
+
rearrange(input, "b ... -> b (...)"), 0, indices.unsqueeze(1).expand(-1, second_dim)
|
| 382 |
+
).reshape(-1, *other_shape)
|
| 383 |
+
|
| 384 |
+
@staticmethod
|
| 385 |
+
def backward(ctx, grad_output) -> tuple[torch.Tensor, None]:
|
| 386 |
+
(indices,) = ctx.saved_tensors
|
| 387 |
+
if grad_output.ndim < 2:
|
| 388 |
+
raise RuntimeError(
|
| 389 |
+
"index_first_axis received an invalid gradient with fewer than "
|
| 390 |
+
"two dimensions."
|
| 391 |
+
)
|
| 392 |
+
other_shape = grad_output.shape[1:]
|
| 393 |
+
grad_output = rearrange(grad_output, "b ... -> b (...)")
|
| 394 |
+
grad_input = torch.zeros(
|
| 395 |
+
[ctx.first_axis_dim, grad_output.shape[1]],
|
| 396 |
+
device=grad_output.device,
|
| 397 |
+
dtype=grad_output.dtype,
|
| 398 |
+
)
|
| 399 |
+
grad_input.scatter_(0, indices.unsqueeze(1).expand(-1, grad_output.shape[1]), grad_output)
|
| 400 |
+
return grad_input.reshape(ctx.first_axis_dim, *other_shape), None
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
class IndexPutFirstAxis(torch.autograd.Function):
|
| 404 |
+
@staticmethod
|
| 405 |
+
def forward(ctx, values, indices, first_axis_dim) -> torch.Tensor:
|
| 406 |
+
ctx.save_for_backward(indices)
|
| 407 |
+
if indices.ndim != 1:
|
| 408 |
+
raise ValueError(
|
| 409 |
+
"index_put_first_axis indices must be one-dimensional; "
|
| 410 |
+
f"received shape {tuple(indices.shape)}."
|
| 411 |
+
)
|
| 412 |
+
if values.ndim < 2:
|
| 413 |
+
raise ValueError(
|
| 414 |
+
"index_put_first_axis values must have at least two dimensions; "
|
| 415 |
+
f"received shape {tuple(values.shape)}."
|
| 416 |
+
)
|
| 417 |
+
output = torch.zeros(
|
| 418 |
+
first_axis_dim, *values.shape[1:], device=values.device, dtype=values.dtype
|
| 419 |
+
)
|
| 420 |
+
output[indices] = values
|
| 421 |
+
return output
|
| 422 |
+
|
| 423 |
+
@staticmethod
|
| 424 |
+
def backward(ctx, grad_output) -> tuple[torch.Tensor, None, None]:
|
| 425 |
+
(indices,) = ctx.saved_tensors
|
| 426 |
+
return grad_output[indices], None, None
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
index_first_axis = IndexFirstAxis.apply
|
| 430 |
+
index_put_first_axis = IndexPutFirstAxis.apply
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
def pad_input(
|
| 434 |
+
hidden_states: torch.Tensor, indices: torch.Tensor, batch: int, seqlen: int
|
| 435 |
+
) -> torch.Tensor:
|
| 436 |
+
output = index_put_first_axis(hidden_states, indices, batch * seqlen)
|
| 437 |
+
return rearrange(output, "(b s) ... -> b s ...", b=batch)
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
def _unpad_input(
|
| 441 |
+
query_layer: torch.Tensor,
|
| 442 |
+
key_layer: torch.Tensor,
|
| 443 |
+
value_layer: torch.Tensor,
|
| 444 |
+
attention_mask_2d: torch.Tensor,
|
| 445 |
+
) -> tuple[
|
| 446 |
+
torch.Tensor,
|
| 447 |
+
torch.Tensor,
|
| 448 |
+
torch.Tensor,
|
| 449 |
+
torch.Tensor,
|
| 450 |
+
tuple[torch.Tensor, torch.Tensor],
|
| 451 |
+
tuple[int, int],
|
| 452 |
+
]:
|
| 453 |
+
batch_size, seq_len, num_heads, head_dim = query_layer.shape
|
| 454 |
+
seqlens = attention_mask_2d.sum(dim=1).int()
|
| 455 |
+
cu_seqlens = F.pad(seqlens.cumsum(0, dtype=torch.int32), (1, 0))
|
| 456 |
+
max_seqlen = int(seqlens.max().item())
|
| 457 |
+
indices = attention_mask_2d.flatten().nonzero(as_tuple=False).flatten()
|
| 458 |
+
query_layer = index_first_axis(
|
| 459 |
+
query_layer.reshape(batch_size * seq_len, num_heads, head_dim), indices
|
| 460 |
+
)
|
| 461 |
+
key_layer = index_first_axis(
|
| 462 |
+
key_layer.reshape(batch_size * seq_len, num_heads, head_dim), indices
|
| 463 |
+
)
|
| 464 |
+
value_layer = index_first_axis(
|
| 465 |
+
value_layer.reshape(batch_size * seq_len, num_heads, head_dim), indices
|
| 466 |
+
)
|
| 467 |
+
return (
|
| 468 |
+
query_layer,
|
| 469 |
+
key_layer,
|
| 470 |
+
value_layer,
|
| 471 |
+
indices,
|
| 472 |
+
(cu_seqlens, cu_seqlens),
|
| 473 |
+
(max_seqlen, max_seqlen),
|
| 474 |
+
)
|
| 475 |
+
|
| 476 |
+
|
| 477 |
+
def _validate_flash_padding_mask(
|
| 478 |
+
query_states: torch.Tensor,
|
| 479 |
+
key_states: torch.Tensor,
|
| 480 |
+
value_states: torch.Tensor,
|
| 481 |
+
attention_mask_2d: torch.Tensor,
|
| 482 |
+
) -> torch.Tensor:
|
| 483 |
+
"""Validate the self-attention padding mask used by the varlen kernels."""
|
| 484 |
+
|
| 485 |
+
if attention_mask_2d.ndim != 2:
|
| 486 |
+
raise ValueError("FlashAttention padding masks must have shape (batch, sequence_length).")
|
| 487 |
+
expected_shape = query_states.shape[:2]
|
| 488 |
+
if tuple(attention_mask_2d.shape) != tuple(expected_shape):
|
| 489 |
+
raise ValueError(
|
| 490 |
+
"FlashAttention padding mask shape must match the query batch and "
|
| 491 |
+
f"sequence dimensions; expected {tuple(expected_shape)}, received "
|
| 492 |
+
f"{tuple(attention_mask_2d.shape)}."
|
| 493 |
+
)
|
| 494 |
+
if key_states.shape[:2] != expected_shape or value_states.shape[:2] != expected_shape:
|
| 495 |
+
raise ValueError(
|
| 496 |
+
"Masked FlashAttention requires Q, K, and V to share batch and sequence dimensions."
|
| 497 |
+
)
|
| 498 |
+
if attention_mask_2d.device != query_states.device:
|
| 499 |
+
raise ValueError("FlashAttention padding mask and Q, K, and V must be on the same device.")
|
| 500 |
+
return attention_mask_2d.to(dtype=torch.bool)
|
| 501 |
+
|
| 502 |
+
|
| 503 |
+
def kernels_flash_attention_func(
|
| 504 |
+
query_states: torch.Tensor,
|
| 505 |
+
key_states: torch.Tensor,
|
| 506 |
+
value_states: torch.Tensor,
|
| 507 |
+
attention_mask_2d: torch.Tensor | None = None,
|
| 508 |
+
causal: bool = False,
|
| 509 |
+
softmax_scale: float | None = None,
|
| 510 |
+
implementation: str = "flash_attention_3",
|
| 511 |
+
) -> torch.Tensor:
|
| 512 |
+
"""Public flash-attention entry point with optional padding handling.
|
| 513 |
+
|
| 514 |
+
`softmax_scale`:
|
| 515 |
+
None -> kernel applies its default `1 / sqrt(head_dim)`.
|
| 516 |
+
float -> kernel uses the given scale (pass 1.0 when Q is pre-scaled
|
| 517 |
+
by the caller).
|
| 518 |
+
|
| 519 |
+
Caller contract: if a model family pre-scales Q by `1/sqrt(head_dim)`
|
| 520 |
+
before calling this function (ESM2, DPLM, DPLM2, E1, and ESMFold do), pass
|
| 521 |
+
`softmax_scale=1.0`. Otherwise the flash kernel applies its default scale
|
| 522 |
+
again, yielding an effective `1/head_dim` scale that drifts across layers.
|
| 523 |
+
"""
|
| 524 |
+
_validate_kernels_flash_device(
|
| 525 |
+
query_states,
|
| 526 |
+
key_states,
|
| 527 |
+
value_states,
|
| 528 |
+
implementation,
|
| 529 |
+
)
|
| 530 |
+
runtime_dtype = _validate_kernels_flash_dtype(
|
| 531 |
+
query_states,
|
| 532 |
+
key_states,
|
| 533 |
+
value_states,
|
| 534 |
+
implementation,
|
| 535 |
+
)
|
| 536 |
+
if query_states.dtype != runtime_dtype:
|
| 537 |
+
query_states = query_states.to(dtype=runtime_dtype)
|
| 538 |
+
key_states = key_states.to(dtype=runtime_dtype)
|
| 539 |
+
value_states = value_states.to(dtype=runtime_dtype)
|
| 540 |
+
if attention_mask_2d is not None:
|
| 541 |
+
attention_mask_2d = _validate_flash_padding_mask(
|
| 542 |
+
query_states,
|
| 543 |
+
key_states,
|
| 544 |
+
value_states,
|
| 545 |
+
attention_mask_2d,
|
| 546 |
+
)
|
| 547 |
+
_ensure_flash_kernels_loaded(implementation)
|
| 548 |
+
if attention_mask_2d is not None:
|
| 549 |
+
batch_size, q_len = query_states.shape[:2]
|
| 550 |
+
(
|
| 551 |
+
query_states,
|
| 552 |
+
key_states,
|
| 553 |
+
value_states,
|
| 554 |
+
indices_q,
|
| 555 |
+
(cu_seqlens_q, cu_seqlens_k),
|
| 556 |
+
(max_seqlen_q, max_seqlen_k),
|
| 557 |
+
) = _unpad_input(query_states, key_states, value_states, attention_mask_2d)
|
| 558 |
+
attn_output_unpad = _kernels_flash_varlen_forward(
|
| 559 |
+
query_states=query_states,
|
| 560 |
+
key_states=key_states,
|
| 561 |
+
value_states=value_states,
|
| 562 |
+
cu_seqlens_q=cu_seqlens_q,
|
| 563 |
+
cu_seqlens_k=cu_seqlens_k,
|
| 564 |
+
max_seqlen_in_batch_q=max_seqlen_q,
|
| 565 |
+
max_seqlen_in_batch_k=max_seqlen_k,
|
| 566 |
+
causal=causal,
|
| 567 |
+
softmax_scale=softmax_scale,
|
| 568 |
+
implementation=implementation,
|
| 569 |
+
)
|
| 570 |
+
output = pad_input(attn_output_unpad, indices_q, batch_size, q_len)
|
| 571 |
+
return output.masked_fill(~attention_mask_2d[:, :, None, None], 0)
|
| 572 |
+
else:
|
| 573 |
+
return _kernels_flash_forward(
|
| 574 |
+
query_states=query_states,
|
| 575 |
+
key_states=key_states,
|
| 576 |
+
value_states=value_states,
|
| 577 |
+
causal=causal,
|
| 578 |
+
softmax_scale=softmax_scale,
|
| 579 |
+
implementation=implementation,
|
| 580 |
+
)
|
| 581 |
+
|
| 582 |
+
|
| 583 |
+
# User-facing backend strings follow the Transformers attention interface.
|
| 584 |
+
# Keep ``str`` plus ``Enum`` so stringification stays compatible with existing
|
| 585 |
+
# configuration serialization rather than adopting ``StrEnum.__str__``.
|
| 586 |
+
class AttentionBackend(str, Enum): # noqa: UP042
|
| 587 |
+
EAGER = "eager"
|
| 588 |
+
SDPA = "sdpa"
|
| 589 |
+
FLEX_ATTENTION = "flex_attention"
|
| 590 |
+
FLASH_ATTENTION_2 = "flash_attention_2"
|
| 591 |
+
FLASH_ATTENTION_3 = "flash_attention_3"
|
| 592 |
+
|
| 593 |
+
# Internal spelling retained to keep attention modules concise. It is an
|
| 594 |
+
# enum alias, not an accepted public backend string.
|
| 595 |
+
FLEX = FLEX_ATTENTION
|
| 596 |
+
|
| 597 |
+
@property
|
| 598 |
+
def is_flash(self) -> bool:
|
| 599 |
+
return self in {
|
| 600 |
+
AttentionBackend.FLASH_ATTENTION_2,
|
| 601 |
+
AttentionBackend.FLASH_ATTENTION_3,
|
| 602 |
+
}
|
| 603 |
+
|
| 604 |
+
|
| 605 |
+
VALID_ATTENTION_BACKENDS = tuple(b.value for b in AttentionBackend)
|
| 606 |
+
|
| 607 |
+
|
| 608 |
+
def warn_attention_backend_fallback(
|
| 609 |
+
requested_backend: str | AttentionBackend,
|
| 610 |
+
*,
|
| 611 |
+
effective_backend: str | AttentionBackend,
|
| 612 |
+
reason: str,
|
| 613 |
+
) -> None:
|
| 614 |
+
"""Warn when one forward call cannot honor the configured backend."""
|
| 615 |
+
|
| 616 |
+
requested = resolve_attention_backend(requested_backend).value
|
| 617 |
+
effective = resolve_attention_backend(effective_backend).value
|
| 618 |
+
if requested == effective:
|
| 619 |
+
return
|
| 620 |
+
warnings.warn(
|
| 621 |
+
f"{reason} The requested {requested!r} attention implementation cannot "
|
| 622 |
+
f"satisfy this call, so FastPLMs is using {effective!r} attention for this "
|
| 623 |
+
"call only. This can change performance and memory use; the configured "
|
| 624 |
+
"backend remains unchanged for subsequent calls.",
|
| 625 |
+
RuntimeWarning,
|
| 626 |
+
stacklevel=3,
|
| 627 |
+
)
|
| 628 |
+
|
| 629 |
+
|
| 630 |
+
def resolve_attention_backend_for_call(
|
| 631 |
+
requested_backend: str | AttentionBackend,
|
| 632 |
+
*,
|
| 633 |
+
output_attentions: bool,
|
| 634 |
+
) -> AttentionBackend:
|
| 635 |
+
"""Resolve the effective backend for one call and report substitutions once."""
|
| 636 |
+
|
| 637 |
+
requested = resolve_attention_backend(requested_backend)
|
| 638 |
+
if not output_attentions or requested == AttentionBackend.EAGER:
|
| 639 |
+
return requested
|
| 640 |
+
warn_attention_backend_fallback(
|
| 641 |
+
requested,
|
| 642 |
+
effective_backend=AttentionBackend.EAGER,
|
| 643 |
+
reason=(
|
| 644 |
+
"output_attentions=True requires the full materialized attention probability "
|
| 645 |
+
"matrix, which optimized PyTorch attention APIs do not return."
|
| 646 |
+
),
|
| 647 |
+
)
|
| 648 |
+
return AttentionBackend.EAGER
|
| 649 |
+
|
| 650 |
+
|
| 651 |
+
def resolve_attention_backend(
|
| 652 |
+
requested_backend: str | AttentionBackend | None,
|
| 653 |
+
) -> AttentionBackend:
|
| 654 |
+
"""Validate a backend without silently substituting another implementation."""
|
| 655 |
+
if requested_backend is None:
|
| 656 |
+
requested_backend = AttentionBackend.SDPA.value
|
| 657 |
+
if isinstance(requested_backend, AttentionBackend):
|
| 658 |
+
resolved = requested_backend
|
| 659 |
+
else:
|
| 660 |
+
try:
|
| 661 |
+
resolved = AttentionBackend(requested_backend)
|
| 662 |
+
except ValueError as error:
|
| 663 |
+
raise ValueError(
|
| 664 |
+
f"Unsupported attention implementation {requested_backend!r}; "
|
| 665 |
+
f"expected one of {VALID_ATTENTION_BACKENDS}."
|
| 666 |
+
) from error
|
| 667 |
+
if resolved == AttentionBackend.FLEX_ATTENTION and flex_attention is None:
|
| 668 |
+
raise RuntimeError(
|
| 669 |
+
"'flex_attention' was requested, but this PyTorch build does not provide it."
|
| 670 |
+
)
|
| 671 |
+
return resolved
|
| 672 |
+
|
| 673 |
+
|
| 674 |
+
def get_attn_implementation(config) -> str:
|
| 675 |
+
"""Read the Transformers attention setting, defaulting to SDPA."""
|
| 676 |
+
requested = getattr(config, "_attn_implementation", None)
|
| 677 |
+
if requested is None:
|
| 678 |
+
requested = getattr(config, "attn_backend", None)
|
| 679 |
+
return resolve_attention_backend(requested).value
|
| 680 |
+
|
| 681 |
+
|
| 682 |
+
def set_config_attn_implementation(config, implementation: str) -> str:
|
| 683 |
+
"""Set both the Transformers field and the internal dispatch field."""
|
| 684 |
+
resolved = resolve_attention_backend(implementation).value
|
| 685 |
+
if hasattr(config, "_attn_implementation_internal"):
|
| 686 |
+
config._attn_implementation_internal = resolved
|
| 687 |
+
else:
|
| 688 |
+
config._attn_implementation = resolved
|
| 689 |
+
# Existing checkpoint configs contain this field. Keeping it synchronized
|
| 690 |
+
# preserves their state schema while the public API uses attn_implementation.
|
| 691 |
+
config.attn_backend = resolved
|
| 692 |
+
return resolved
|
| 693 |
+
|
| 694 |
+
|
| 695 |
+
@torch.compiler.disable
|
| 696 |
+
def get_attention_mask(
|
| 697 |
+
effective_backend: AttentionBackend,
|
| 698 |
+
batch_size: int,
|
| 699 |
+
seq_len: int,
|
| 700 |
+
device: torch.device,
|
| 701 |
+
attention_mask: torch.Tensor | None = None,
|
| 702 |
+
dtype: torch.dtype | None = None,
|
| 703 |
+
mask_semantics: str = "padding",
|
| 704 |
+
) -> tuple[torch.Tensor | None, torch.Tensor | None, BlockMask | None]:
|
| 705 |
+
"""Build padding masks once for all encoder layers.
|
| 706 |
+
|
| 707 |
+
Returns (attention_mask_2d, attention_mask_4d, flex_block_mask).
|
| 708 |
+
"""
|
| 709 |
+
if attention_mask is None:
|
| 710 |
+
return None, None, None
|
| 711 |
+
|
| 712 |
+
if attention_mask.ndim != 2:
|
| 713 |
+
raise ValueError(
|
| 714 |
+
"attention_mask must have shape (batch, sequence_length); "
|
| 715 |
+
f"received rank {attention_mask.ndim} with shape {tuple(attention_mask.shape)}."
|
| 716 |
+
)
|
| 717 |
+
expected_shape = (batch_size, seq_len)
|
| 718 |
+
if tuple(attention_mask.shape) != expected_shape:
|
| 719 |
+
raise ValueError(
|
| 720 |
+
"attention_mask shape must match the input batch and sequence dimensions; "
|
| 721 |
+
f"expected {expected_shape}, received {tuple(attention_mask.shape)}."
|
| 722 |
+
)
|
| 723 |
+
attention_mask_2d = attention_mask.to(device=device, dtype=torch.bool)
|
| 724 |
+
if not bool(attention_mask_2d.any(dim=1).all()):
|
| 725 |
+
raise ValueError("attention_mask must keep at least one valid key per batch row.")
|
| 726 |
+
|
| 727 |
+
effective_backend = resolve_attention_backend(effective_backend)
|
| 728 |
+
|
| 729 |
+
if effective_backend.is_flash:
|
| 730 |
+
return attention_mask_2d, None, None
|
| 731 |
+
|
| 732 |
+
if effective_backend == AttentionBackend.FLEX_ATTENTION:
|
| 733 |
+
if create_block_mask is None:
|
| 734 |
+
raise RuntimeError(
|
| 735 |
+
"'flex_attention' was requested, but torch.create_block_mask is unavailable."
|
| 736 |
+
)
|
| 737 |
+
def mask_mod(batch_idx, head_idx, q_idx, kv_idx):
|
| 738 |
+
del head_idx, q_idx
|
| 739 |
+
# Match eager and SDPA: padding masks suppress invalid keys only.
|
| 740 |
+
# Invalid queries still attend to real keys and therefore remain
|
| 741 |
+
# finite; downstream residue masks exclude their outputs.
|
| 742 |
+
return attention_mask_2d[batch_idx, kv_idx]
|
| 743 |
+
|
| 744 |
+
flex_block_mask = _get_flex_block_mask(
|
| 745 |
+
mask_pattern=attention_mask_2d,
|
| 746 |
+
batch_size=batch_size,
|
| 747 |
+
query_length=seq_len,
|
| 748 |
+
key_value_length=seq_len,
|
| 749 |
+
device=device,
|
| 750 |
+
dtype=dtype,
|
| 751 |
+
mask_semantics=mask_semantics,
|
| 752 |
+
mask_mod=mask_mod,
|
| 753 |
+
)
|
| 754 |
+
return attention_mask_2d, None, flex_block_mask
|
| 755 |
+
|
| 756 |
+
# SDPA/manual masks only keys. Padding queries still attend to real keys, so
|
| 757 |
+
# their outputs stay finite instead of softmaxing over all -inf scores.
|
| 758 |
+
attention_mask_4d = attention_mask_2d[:, None, None, :]
|
| 759 |
+
return attention_mask_2d, attention_mask_4d, None
|
| 760 |
+
|
| 761 |
+
|
| 762 |
+
def bool_to_additive_mask(
|
| 763 |
+
bool_mask: torch.Tensor,
|
| 764 |
+
dtype: torch.dtype,
|
| 765 |
+
) -> torch.Tensor:
|
| 766 |
+
"""Convert a bool mask (True = valid) to a float additive mask (0.0 valid, -inf invalid).
|
| 767 |
+
|
| 768 |
+
Why this exists: calling `bool_mask.masked_fill(bool_mask.logical_not(), float('-inf'))`
|
| 769 |
+
directly on a bool tensor returns a bool tensor because `-inf` casts to `True`.
|
| 770 |
+
That silently drops the mask. Always allocate a float tensor first, then fill it.
|
| 771 |
+
This helper is the sanctioned way to build an SDPA additive mask from a bool validity mask.
|
| 772 |
+
"""
|
| 773 |
+
if bool_mask.dtype != torch.bool:
|
| 774 |
+
raise TypeError(
|
| 775 |
+
f"bool_to_additive_mask requires a bool tensor, got dtype={bool_mask.dtype}"
|
| 776 |
+
)
|
| 777 |
+
additive = torch.zeros_like(bool_mask, dtype=dtype)
|
| 778 |
+
additive.masked_fill_(bool_mask.logical_not(), float("-inf"))
|
| 779 |
+
return additive
|
fastplms/attention/_kernel_lock.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Resolve and validate Hugging Face kernels before importing their binaries."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import importlib.metadata
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def require_kernels_package() -> None:
|
| 13 |
+
"""Fail early when the precompiled-kernel runtime is not installed."""
|
| 14 |
+
try:
|
| 15 |
+
import kernels # noqa: F401
|
| 16 |
+
except ImportError as error:
|
| 17 |
+
raise RuntimeError(
|
| 18 |
+
"Precompiled FlashAttention requires the FastPLMs 'flash' extra."
|
| 19 |
+
) from error
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _kernel_lock_path() -> Path:
|
| 23 |
+
"""Return the lock from an artifact, checkout, or installed distribution."""
|
| 24 |
+
source_path = Path(__file__).resolve()
|
| 25 |
+
candidates = [
|
| 26 |
+
source_path.parents[1] / "kernels.lock",
|
| 27 |
+
source_path.parents[3] / "kernels.lock",
|
| 28 |
+
]
|
| 29 |
+
try:
|
| 30 |
+
import fastplms
|
| 31 |
+
|
| 32 |
+
candidates.extend(Path(root) / "kernels.lock" for root in fastplms.__path__)
|
| 33 |
+
except (ImportError, AttributeError):
|
| 34 |
+
pass
|
| 35 |
+
for candidate in candidates:
|
| 36 |
+
if candidate.is_file():
|
| 37 |
+
return candidate
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
distribution = importlib.metadata.distribution("fastplms")
|
| 41 |
+
except importlib.metadata.PackageNotFoundError as error:
|
| 42 |
+
raise RuntimeError("FastPLMs was installed without kernels.lock.") from error
|
| 43 |
+
for relative in distribution.files or ():
|
| 44 |
+
if relative.name != "kernels.lock":
|
| 45 |
+
continue
|
| 46 |
+
candidate = Path(distribution.locate_file(relative))
|
| 47 |
+
if candidate.is_file():
|
| 48 |
+
return candidate
|
| 49 |
+
raise RuntimeError("The installed FastPLMs distribution does not contain kernels.lock.")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _locked_entry(lock_path: Path, repository: str) -> dict[str, Any]:
|
| 53 |
+
try:
|
| 54 |
+
data = json.loads(lock_path.read_text(encoding="utf-8"))
|
| 55 |
+
except (OSError, json.JSONDecodeError) as error:
|
| 56 |
+
raise RuntimeError(f"Unable to read the packaged kernel lock: {lock_path}") from error
|
| 57 |
+
if not isinstance(data, list):
|
| 58 |
+
raise RuntimeError("kernels.lock must contain a JSON list.")
|
| 59 |
+
if any(not isinstance(entry, dict) for entry in data):
|
| 60 |
+
raise RuntimeError("Every kernels.lock entry must be a JSON object.")
|
| 61 |
+
matches = [entry for entry in data if entry.get("repo_id") == repository]
|
| 62 |
+
if len(matches) != 1:
|
| 63 |
+
raise RuntimeError(
|
| 64 |
+
f"kernels.lock must contain exactly one entry for {repository!r}; found {len(matches)}."
|
| 65 |
+
)
|
| 66 |
+
return matches[0]
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _offline_mode() -> bool:
|
| 70 |
+
"""Return whether Hub access was explicitly disabled for this process."""
|
| 71 |
+
|
| 72 |
+
enabled_values = {"1", "on", "true", "yes"}
|
| 73 |
+
return any(
|
| 74 |
+
os.environ.get(name, "").strip().lower() in enabled_values
|
| 75 |
+
for name in ("HF_HUB_OFFLINE", "TRANSFORMERS_OFFLINE")
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _offline_snapshot_path(repository: str, revision: str) -> Path:
|
| 80 |
+
"""Locate one exact, possibly sparse, kernel snapshot without using Hub APIs."""
|
| 81 |
+
|
| 82 |
+
try:
|
| 83 |
+
from huggingface_hub import constants
|
| 84 |
+
from huggingface_hub.file_download import repo_folder_name
|
| 85 |
+
except ImportError as error:
|
| 86 |
+
raise RuntimeError("Offline kernel loading requires huggingface-hub.") from error
|
| 87 |
+
|
| 88 |
+
cache_root = Path(os.environ.get("KERNELS_CACHE") or constants.HF_HUB_CACHE).resolve()
|
| 89 |
+
repository_root = (
|
| 90 |
+
cache_root / repo_folder_name(repo_id=repository, repo_type="kernel")
|
| 91 |
+
).resolve()
|
| 92 |
+
snapshot = repository_root / "snapshots" / revision
|
| 93 |
+
if not snapshot.is_dir():
|
| 94 |
+
raise RuntimeError(
|
| 95 |
+
f"The exact offline kernel snapshot {repository}@{revision} is not cached under "
|
| 96 |
+
f"{cache_root}. Run `kernels download` before enabling offline mode."
|
| 97 |
+
)
|
| 98 |
+
if repository_root not in snapshot.resolve().parents:
|
| 99 |
+
raise RuntimeError(f"Refusing kernel snapshot outside its cache repository: {snapshot}")
|
| 100 |
+
return snapshot
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _load_offline_locked_kernel(
|
| 104 |
+
repository: str,
|
| 105 |
+
revision: str,
|
| 106 |
+
variant_locks: dict[str, object],
|
| 107 |
+
) -> object:
|
| 108 |
+
"""Validate and import the one compatible variant from a sparse Hub snapshot."""
|
| 109 |
+
snapshot = _offline_snapshot_path(repository, revision)
|
| 110 |
+
build_root = snapshot / "build"
|
| 111 |
+
if not build_root.is_dir():
|
| 112 |
+
raise RuntimeError(f"The cached kernel snapshot has no build directory: {snapshot}")
|
| 113 |
+
|
| 114 |
+
cached_names = sorted(entry.name for entry in build_root.iterdir() if entry.is_dir())
|
| 115 |
+
unexpected = sorted(set(cached_names).difference(variant_locks))
|
| 116 |
+
if unexpected:
|
| 117 |
+
raise RuntimeError(
|
| 118 |
+
f"The cached {repository}@{revision} snapshot contains unlocked variants: "
|
| 119 |
+
f"{', '.join(unexpected)}"
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
try:
|
| 123 |
+
from kernels import get_local_kernel
|
| 124 |
+
from kernels.utils import validate_kernel
|
| 125 |
+
from kernels.variants import get_variants_local, resolve_variants
|
| 126 |
+
except ImportError as error:
|
| 127 |
+
raise RuntimeError(
|
| 128 |
+
"Precompiled FlashAttention requires the FastPLMs 'flash' extra."
|
| 129 |
+
) from error
|
| 130 |
+
|
| 131 |
+
parsed = get_variants_local(build_root)
|
| 132 |
+
parsed_names = {variant.variant_str for variant in parsed}
|
| 133 |
+
invalid = sorted(set(cached_names).difference(parsed_names))
|
| 134 |
+
if invalid:
|
| 135 |
+
raise RuntimeError(
|
| 136 |
+
f"The cached {repository}@{revision} snapshot contains invalid variants: "
|
| 137 |
+
f"{', '.join(invalid)}"
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
compatible, _ = resolve_variants(parsed)
|
| 141 |
+
if len(compatible) != 1:
|
| 142 |
+
names = ", ".join(variant.variant_str for variant in compatible) or "none"
|
| 143 |
+
raise RuntimeError(
|
| 144 |
+
f"Expected exactly one compatible cached variant for {repository}@{revision}; "
|
| 145 |
+
f"found {names}."
|
| 146 |
+
)
|
| 147 |
+
variant_name = compatible[0].variant_str
|
| 148 |
+
variant_lock = variant_locks.get(variant_name)
|
| 149 |
+
expected_hash = getattr(variant_lock, "hash", None)
|
| 150 |
+
if not isinstance(expected_hash, str) or not expected_hash.startswith("sha256-"):
|
| 151 |
+
raise RuntimeError(f"The kernel lock for {variant_name} has no valid SHA-256 digest.")
|
| 152 |
+
|
| 153 |
+
# Hash validation deliberately happens before import. This operates on the
|
| 154 |
+
# sparse snapshot produced by `kernels download` and avoids Hub 1.23's
|
| 155 |
+
# full-snapshot completeness check in offline mode.
|
| 156 |
+
validate_kernel(repo_path=snapshot, variant=variant_name, hash=expected_hash)
|
| 157 |
+
return get_local_kernel(build_root / variant_name)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def load_locked_kernel(repository: str, revision: str) -> object:
|
| 161 |
+
"""Download, hash-validate, then import one immutable precompiled kernel."""
|
| 162 |
+
require_kernels_package()
|
| 163 |
+
try:
|
| 164 |
+
from kernels import get_local_kernel, install_kernel
|
| 165 |
+
from kernels.lockfile import KernelLock
|
| 166 |
+
except ImportError as error:
|
| 167 |
+
raise RuntimeError(
|
| 168 |
+
"Precompiled FlashAttention requires the FastPLMs 'flash' extra."
|
| 169 |
+
) from error
|
| 170 |
+
|
| 171 |
+
lock_path = _kernel_lock_path()
|
| 172 |
+
kernel_lock = KernelLock.from_json(_locked_entry(lock_path, repository))
|
| 173 |
+
if kernel_lock.sha != revision:
|
| 174 |
+
raise RuntimeError(
|
| 175 |
+
f"The typed manifest pins {repository}@{revision}, but kernels.lock pins "
|
| 176 |
+
f"{kernel_lock.sha}."
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
if _offline_mode():
|
| 180 |
+
return _load_offline_locked_kernel(repository, revision, kernel_lock.variants)
|
| 181 |
+
|
| 182 |
+
# `install_kernel` downloads data without importing it and validates the
|
| 183 |
+
# selected build against the tracked variant hash. Only then is the exact
|
| 184 |
+
# validated path imported directly. Offline mode uses the sparse-cache
|
| 185 |
+
# resolver above because Hub 1.23 rejects partial snapshots as incomplete.
|
| 186 |
+
validated_path = install_kernel(
|
| 187 |
+
repository,
|
| 188 |
+
revision=kernel_lock.sha,
|
| 189 |
+
variant_locks=kernel_lock.variants,
|
| 190 |
+
)
|
| 191 |
+
return get_local_kernel(validated_path)
|
fastplms/attention/interfaces.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Transformers-compatible attention selection for FastPLMs models."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from collections.abc import Mapping
|
| 6 |
+
from functools import partial
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
import torch
|
| 10 |
+
from transformers import AttentionInterface, AttentionMaskInterface
|
| 11 |
+
|
| 12 |
+
from ._core import (
|
| 13 |
+
AttentionBackend,
|
| 14 |
+
get_attn_implementation,
|
| 15 |
+
kernels_flash_attention_func,
|
| 16 |
+
resolve_attention_backend,
|
| 17 |
+
set_config_attn_implementation,
|
| 18 |
+
)
|
| 19 |
+
from ._kernel_lock import require_kernels_package
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _kernels_attention_forward(
|
| 23 |
+
module: torch.nn.Module,
|
| 24 |
+
query: torch.Tensor,
|
| 25 |
+
key: torch.Tensor,
|
| 26 |
+
value: torch.Tensor,
|
| 27 |
+
attention_mask: torch.Tensor | None,
|
| 28 |
+
*,
|
| 29 |
+
implementation: str,
|
| 30 |
+
**kwargs: Any,
|
| 31 |
+
) -> tuple[torch.Tensor, None]:
|
| 32 |
+
"""Run one canonical FlashAttention backend through Hugging Face kernels.
|
| 33 |
+
|
| 34 |
+
Transformers attention functions receive Q, K, and V with shape
|
| 35 |
+
(b, h, l, d) and return an output with shape (b, l, h, d). The shared
|
| 36 |
+
FastPLMs kernel adapter uses the latter layout internally.
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
dropout = float(kwargs.get("dropout", 0.0) or 0.0)
|
| 40 |
+
if module.training and dropout:
|
| 41 |
+
raise RuntimeError(
|
| 42 |
+
"Hugging Face kernels FlashAttention is inference-only when attention dropout "
|
| 43 |
+
"is nonzero. Use SDPA for this training configuration."
|
| 44 |
+
)
|
| 45 |
+
causal = bool(kwargs.get("is_causal", getattr(module, "is_causal", False)))
|
| 46 |
+
softmax_scale = kwargs.get("scaling")
|
| 47 |
+
output = kernels_flash_attention_func(
|
| 48 |
+
query_states=query.transpose(1, 2).contiguous(),
|
| 49 |
+
key_states=key.transpose(1, 2).contiguous(),
|
| 50 |
+
value_states=value.transpose(1, 2).contiguous(),
|
| 51 |
+
attention_mask_2d=attention_mask,
|
| 52 |
+
causal=causal,
|
| 53 |
+
softmax_scale=softmax_scale,
|
| 54 |
+
implementation=implementation,
|
| 55 |
+
)
|
| 56 |
+
return output, None
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# Keep FastPLMs' kernels-only adapters local to this registry instance.
|
| 60 |
+
# ``GeneralInterface.register`` updates Transformers' class-wide mapping, so
|
| 61 |
+
# using it here would replace the canonical FlashAttention handlers for every
|
| 62 |
+
# model in the process, including models unrelated to FastPLMs.
|
| 63 |
+
FASTPLMS_ATTENTION_FUNCTIONS = AttentionInterface()
|
| 64 |
+
FASTPLMS_ATTENTION_MASKS = AttentionMaskInterface()
|
| 65 |
+
FASTPLMS_ATTENTION_FUNCTIONS["flash_attention_2"] = partial(
|
| 66 |
+
_kernels_attention_forward,
|
| 67 |
+
implementation="flash_attention_2",
|
| 68 |
+
)
|
| 69 |
+
FASTPLMS_ATTENTION_FUNCTIONS["flash_attention_3"] = partial(
|
| 70 |
+
_kernels_attention_forward,
|
| 71 |
+
implementation="flash_attention_3",
|
| 72 |
+
)
|
| 73 |
+
for _flash_name in ("flash_attention_2", "flash_attention_3"):
|
| 74 |
+
FASTPLMS_ATTENTION_MASKS[_flash_name] = FASTPLMS_ATTENTION_MASKS[_flash_name]
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class FastPLMsAttentionMixin:
|
| 78 |
+
"""Synchronize Transformers attention selection with custom model layers.
|
| 79 |
+
|
| 80 |
+
Model families retain their checkpoint parameter names. Only runtime
|
| 81 |
+
attributes are updated when ``set_attn_implementation`` is called.
|
| 82 |
+
"""
|
| 83 |
+
|
| 84 |
+
_supports_sdpa = True
|
| 85 |
+
_supports_flex_attn = True
|
| 86 |
+
# Transformers 5.13 uses the singular flag during model construction. A
|
| 87 |
+
# family opts in only when its manifest entry advertises at least one of
|
| 88 |
+
# the two FastPLMs kernels-only FlashAttention implementations.
|
| 89 |
+
_supports_flash_attn = False
|
| 90 |
+
_supports_flash_attn_2 = False
|
| 91 |
+
_supports_flash_attn_3 = False
|
| 92 |
+
_fastplms_attention_implementations = (
|
| 93 |
+
"eager",
|
| 94 |
+
"sdpa",
|
| 95 |
+
"flex_attention",
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
def _validate_attention_name(self, implementation: str) -> None:
|
| 99 |
+
if implementation not in self._fastplms_attention_implementations:
|
| 100 |
+
raise ValueError(
|
| 101 |
+
f"{type(self).__name__} does not support {implementation!r}; expected one of "
|
| 102 |
+
f"{self._fastplms_attention_implementations}."
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
def _check_and_adjust_attn_implementation(
|
| 106 |
+
self,
|
| 107 |
+
attn_implementation: str | None,
|
| 108 |
+
is_init_check: bool = False,
|
| 109 |
+
allow_all_kernels: bool = False,
|
| 110 |
+
) -> str:
|
| 111 |
+
"""Resolve attention without invoking Transformers' source-Flash probe.
|
| 112 |
+
|
| 113 |
+
The standard ``flash_attention_2`` and ``flash_attention_3`` names are
|
| 114 |
+
retained for the Transformers API, but FastPLMs resolves them only
|
| 115 |
+
through the exact Hugging Face ``kernels`` artifacts pinned by
|
| 116 |
+
``models.toml``. Repository-qualified or otherwise external kernels
|
| 117 |
+
are never accepted through this model hook.
|
| 118 |
+
"""
|
| 119 |
+
|
| 120 |
+
if allow_all_kernels:
|
| 121 |
+
raise ValueError("FastPLMs does not load external attention kernels.")
|
| 122 |
+
if attn_implementation is None:
|
| 123 |
+
return super()._check_and_adjust_attn_implementation(
|
| 124 |
+
None,
|
| 125 |
+
is_init_check=is_init_check,
|
| 126 |
+
allow_all_kernels=False,
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
self._validate_attention_name(attn_implementation)
|
| 130 |
+
if attn_implementation in {"flash_attention_2", "flash_attention_3"}:
|
| 131 |
+
if not self._supports_flash_attn:
|
| 132 |
+
raise ValueError(
|
| 133 |
+
f"{type(self).__name__} does not advertise kernels-only FlashAttention."
|
| 134 |
+
)
|
| 135 |
+
# Validate the lightweight Python dependency here, but defer binary
|
| 136 |
+
# download and import until Q, K, and V have passed the CUDA gate.
|
| 137 |
+
require_kernels_package()
|
| 138 |
+
return attn_implementation
|
| 139 |
+
|
| 140 |
+
return super()._check_and_adjust_attn_implementation(
|
| 141 |
+
attn_implementation,
|
| 142 |
+
is_init_check=is_init_check,
|
| 143 |
+
allow_all_kernels=False,
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
def __init__(self, config, *args: Any, **kwargs: Any) -> None:
|
| 147 |
+
sentinel = object()
|
| 148 |
+
internal = getattr(config, "_attn_implementation_internal", sentinel)
|
| 149 |
+
canonical = (
|
| 150 |
+
getattr(config, "_attn_implementation", None) if internal is sentinel else internal
|
| 151 |
+
)
|
| 152 |
+
legacy = getattr(config, "attn_backend", None)
|
| 153 |
+
requested = canonical if canonical is not None else legacy
|
| 154 |
+
if requested is not None:
|
| 155 |
+
if not isinstance(requested, str):
|
| 156 |
+
raise TypeError(
|
| 157 |
+
"The configured attention implementation must be a string or None; "
|
| 158 |
+
f"received {type(requested).__name__}."
|
| 159 |
+
)
|
| 160 |
+
self._validate_attention_name(requested)
|
| 161 |
+
# ``PreTrainedModel.__init__`` resolves a missing Transformers
|
| 162 |
+
# implementation to the family default. Legacy FastPLMs configs
|
| 163 |
+
# persist their explicit choice in ``attn_backend``, so forward it
|
| 164 |
+
# into the canonical Transformers field before the base class can
|
| 165 |
+
# replace it with SDPA. A non-None canonical value still wins,
|
| 166 |
+
# including an explicit ``attn_implementation=...`` load override.
|
| 167 |
+
if canonical is None and legacy is not None:
|
| 168 |
+
set_config_attn_implementation(config, legacy)
|
| 169 |
+
super().__init__(config, *args, **kwargs)
|
| 170 |
+
# Transformers resolves an unspecified implementation during the base
|
| 171 |
+
# model initialization. Synchronize that choice before family layers
|
| 172 |
+
# are constructed.
|
| 173 |
+
resolved = get_attn_implementation(config)
|
| 174 |
+
self._validate_attention_name(resolved)
|
| 175 |
+
set_config_attn_implementation(config, resolved)
|
| 176 |
+
|
| 177 |
+
def set_attn_implementation(
|
| 178 |
+
self,
|
| 179 |
+
attn_implementation: str | Mapping[str, str],
|
| 180 |
+
allow_all_kernels: bool = False,
|
| 181 |
+
) -> None:
|
| 182 |
+
"""Select an advertised backend and update every instantiated layer."""
|
| 183 |
+
if isinstance(attn_implementation, Mapping):
|
| 184 |
+
if set(attn_implementation) == {""}:
|
| 185 |
+
attn_implementation = attn_implementation[""]
|
| 186 |
+
else:
|
| 187 |
+
raise ValueError(
|
| 188 |
+
"FastPLMs models have one attention backbone; pass a string or {'': name}."
|
| 189 |
+
)
|
| 190 |
+
resolved_name = self._check_and_adjust_attn_implementation(
|
| 191 |
+
attn_implementation,
|
| 192 |
+
is_init_check=False,
|
| 193 |
+
allow_all_kernels=allow_all_kernels,
|
| 194 |
+
)
|
| 195 |
+
set_config_attn_implementation(self.config, resolved_name)
|
| 196 |
+
resolved = resolve_attention_backend(resolved_name)
|
| 197 |
+
for module in self.modules():
|
| 198 |
+
if module is self:
|
| 199 |
+
continue
|
| 200 |
+
for attribute in ("attn_backend", "attention_backend", "_attn_backend"):
|
| 201 |
+
if attribute not in module.__dict__:
|
| 202 |
+
continue
|
| 203 |
+
current = module.__dict__[attribute]
|
| 204 |
+
module.__dict__[attribute] = (
|
| 205 |
+
resolved if isinstance(current, AttentionBackend) else resolved_name
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def validate_transformers_attention_interfaces() -> None:
|
| 210 |
+
"""Verify that Transformers exposes functions and masks for every backend.
|
| 211 |
+
|
| 212 |
+
Transformers 5.13 registers these canonical names. The FastPLMs function
|
| 213 |
+
overrides remain instance-local and do not replace process-global handlers.
|
| 214 |
+
"""
|
| 215 |
+
function_registry = FASTPLMS_ATTENTION_FUNCTIONS
|
| 216 |
+
mask_registry = FASTPLMS_ATTENTION_MASKS
|
| 217 |
+
missing_functions = [
|
| 218 |
+
name
|
| 219 |
+
for name in (
|
| 220 |
+
"sdpa",
|
| 221 |
+
"flex_attention",
|
| 222 |
+
"flash_attention_2",
|
| 223 |
+
"flash_attention_3",
|
| 224 |
+
)
|
| 225 |
+
if name not in function_registry
|
| 226 |
+
]
|
| 227 |
+
missing_masks = [
|
| 228 |
+
name
|
| 229 |
+
for name in (
|
| 230 |
+
"eager",
|
| 231 |
+
"sdpa",
|
| 232 |
+
"flex_attention",
|
| 233 |
+
"flash_attention_2",
|
| 234 |
+
"flash_attention_3",
|
| 235 |
+
)
|
| 236 |
+
if name not in mask_registry
|
| 237 |
+
]
|
| 238 |
+
if missing_functions or missing_masks:
|
| 239 |
+
raise RuntimeError(
|
| 240 |
+
"Transformers attention registry is incomplete: "
|
| 241 |
+
f"functions={missing_functions}, masks={missing_masks}."
|
| 242 |
+
)
|
fastplms/embeddings/__init__.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ordered, residue-aware protein embedding utilities."""
|
| 2 |
+
|
| 3 |
+
from .pooling import POOLING_NAMES, Pooler, pagerank_weights
|
| 4 |
+
from .runner import (
|
| 5 |
+
EmbeddingMixin,
|
| 6 |
+
embed_dataset,
|
| 7 |
+
iter_fasta,
|
| 8 |
+
parse_fasta,
|
| 9 |
+
select_hidden_state_embeddings,
|
| 10 |
+
)
|
| 11 |
+
from .storage import (
|
| 12 |
+
DEFAULT_SHARD_SIZE,
|
| 13 |
+
append_sqlite_records,
|
| 14 |
+
convert_legacy_sqlite,
|
| 15 |
+
garbage_collect_safetensors_generations,
|
| 16 |
+
initialize_sqlite_run,
|
| 17 |
+
load_legacy_pth,
|
| 18 |
+
load_result,
|
| 19 |
+
load_safetensors_result,
|
| 20 |
+
load_sqlite_result,
|
| 21 |
+
save_result,
|
| 22 |
+
save_safetensors_result,
|
| 23 |
+
save_sqlite_result,
|
| 24 |
+
tensor_sha256,
|
| 25 |
+
update_sqlite_run_metadata,
|
| 26 |
+
)
|
| 27 |
+
from .types import (
|
| 28 |
+
EmbeddingBatch,
|
| 29 |
+
EmbeddingInput,
|
| 30 |
+
EmbeddingRecord,
|
| 31 |
+
EmbeddingResult,
|
| 32 |
+
LazyTensorReference,
|
| 33 |
+
TensorValue,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
__all__ = [
|
| 37 |
+
"DEFAULT_SHARD_SIZE",
|
| 38 |
+
"POOLING_NAMES",
|
| 39 |
+
"EmbeddingBatch",
|
| 40 |
+
"EmbeddingInput",
|
| 41 |
+
"EmbeddingMixin",
|
| 42 |
+
"EmbeddingRecord",
|
| 43 |
+
"EmbeddingResult",
|
| 44 |
+
"LazyTensorReference",
|
| 45 |
+
"Pooler",
|
| 46 |
+
"TensorValue",
|
| 47 |
+
"append_sqlite_records",
|
| 48 |
+
"convert_legacy_sqlite",
|
| 49 |
+
"embed_dataset",
|
| 50 |
+
"garbage_collect_safetensors_generations",
|
| 51 |
+
"initialize_sqlite_run",
|
| 52 |
+
"iter_fasta",
|
| 53 |
+
"load_legacy_pth",
|
| 54 |
+
"load_result",
|
| 55 |
+
"load_safetensors_result",
|
| 56 |
+
"load_sqlite_result",
|
| 57 |
+
"pagerank_weights",
|
| 58 |
+
"parse_fasta",
|
| 59 |
+
"save_result",
|
| 60 |
+
"save_safetensors_result",
|
| 61 |
+
"save_sqlite_result",
|
| 62 |
+
"select_hidden_state_embeddings",
|
| 63 |
+
"tensor_sha256",
|
| 64 |
+
"update_sqlite_run_metadata",
|
| 65 |
+
]
|
fastplms/embeddings/pooling.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Residue-aware pooling implemented entirely with PyTorch."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import math
|
| 6 |
+
from collections.abc import Sequence
|
| 7 |
+
|
| 8 |
+
import torch
|
| 9 |
+
from torch import Tensor
|
| 10 |
+
|
| 11 |
+
POOLING_NAMES = frozenset({"mean", "max", "norm", "median", "std", "var", "cls", "parti"})
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _validate_inputs(X: Tensor, M: Tensor) -> Tensor:
|
| 15 |
+
if not isinstance(X, Tensor) or not isinstance(M, Tensor):
|
| 16 |
+
raise TypeError("X and M must be tensors.")
|
| 17 |
+
if X.ndim != 3:
|
| 18 |
+
raise ValueError(f"X must have shape (b, l, d), got {tuple(X.shape)}.")
|
| 19 |
+
if not X.is_floating_point():
|
| 20 |
+
raise TypeError("X must use a floating-point embedding dtype.")
|
| 21 |
+
if M.shape != X.shape[:2]:
|
| 22 |
+
raise ValueError(f"M must have shape (b, l)={tuple(X.shape[:2])}, got {tuple(M.shape)}.")
|
| 23 |
+
if M.is_complex():
|
| 24 |
+
raise TypeError("M must be a boolean or binary numeric residue mask.")
|
| 25 |
+
if not bool(torch.isfinite(M).all()) or not bool(((M == 0) | (M == 1)).all()):
|
| 26 |
+
raise ValueError("M must contain only finite binary mask values.")
|
| 27 |
+
M = M.to(device=X.device, dtype=torch.bool)
|
| 28 |
+
if not bool(M.any(dim=1).all()):
|
| 29 |
+
raise ValueError("Every sample must contain at least one biological residue.")
|
| 30 |
+
if not bool((torch.isfinite(X) | ~M.unsqueeze(-1)).all()):
|
| 31 |
+
raise ValueError("Biological residue embeddings produced non-finite output.")
|
| 32 |
+
return M
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _pooled_attention(attentions: Tensor | Sequence[Tensor], *, batch_size: int) -> Tensor:
|
| 36 |
+
"""Max-pool layer/head attention A to shape ``(b, l, l)``.
|
| 37 |
+
|
| 38 |
+
``parti`` historically keeps the strongest directed edge across the
|
| 39 |
+
available attention maps before PageRank. Replacing NetworkX with Torch
|
| 40 |
+
must not change that reduction.
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
if isinstance(attentions, Sequence):
|
| 44 |
+
if not attentions:
|
| 45 |
+
raise ValueError("parti received an empty attention sequence.")
|
| 46 |
+
# Each A_i has shape (b, h, l, l).
|
| 47 |
+
A = torch.stack(tuple(attentions), dim=1)
|
| 48 |
+
else:
|
| 49 |
+
A = attentions
|
| 50 |
+
|
| 51 |
+
if A.ndim == 5:
|
| 52 |
+
if A.shape[0] != batch_size and A.shape[1] == batch_size:
|
| 53 |
+
A = A.transpose(0, 1)
|
| 54 |
+
if A.shape[0] != batch_size:
|
| 55 |
+
raise ValueError("Five-dimensional attentions must use (b, n, h, l, l).")
|
| 56 |
+
A = A.flatten(1, 2).amax(dim=1)
|
| 57 |
+
elif A.ndim == 4:
|
| 58 |
+
if A.shape[0] != batch_size:
|
| 59 |
+
raise ValueError("Four-dimensional attentions must use (b, h, l, l).")
|
| 60 |
+
A = A.amax(dim=1)
|
| 61 |
+
elif A.ndim == 3:
|
| 62 |
+
if A.shape[0] != batch_size:
|
| 63 |
+
raise ValueError("Three-dimensional attentions must use (b, l, l).")
|
| 64 |
+
else:
|
| 65 |
+
raise ValueError("Attentions must have shape (b, l, l), (b, h, l, l), or (b, n, h, l, l).")
|
| 66 |
+
return A
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def pagerank_weights(
|
| 70 |
+
A: Tensor,
|
| 71 |
+
*,
|
| 72 |
+
damping: float = 0.85,
|
| 73 |
+
tolerance: float = 1e-6,
|
| 74 |
+
max_iterations: int = 100,
|
| 75 |
+
) -> Tensor:
|
| 76 |
+
"""Compute PageRank weights for a non-negative attention matrix A.
|
| 77 |
+
|
| 78 |
+
A has shape ``(l, l)``. Rows are normalized into transition
|
| 79 |
+
probabilities; dangling rows transition uniformly.
|
| 80 |
+
"""
|
| 81 |
+
|
| 82 |
+
if not isinstance(A, Tensor):
|
| 83 |
+
raise TypeError("A must be a tensor.")
|
| 84 |
+
if A.ndim != 2 or A.shape[0] != A.shape[1]:
|
| 85 |
+
raise ValueError(f"A must be square, got shape {tuple(A.shape)}.")
|
| 86 |
+
if not A.is_floating_point():
|
| 87 |
+
raise TypeError("A must use a floating-point attention dtype.")
|
| 88 |
+
if not isinstance(damping, (int, float)) or isinstance(damping, bool):
|
| 89 |
+
raise TypeError("damping must be a finite float in [0, 1).")
|
| 90 |
+
if not math.isfinite(float(damping)) or not 0 <= damping < 1:
|
| 91 |
+
raise ValueError("damping must be a finite float in [0, 1).")
|
| 92 |
+
if not isinstance(tolerance, (int, float)) or isinstance(tolerance, bool):
|
| 93 |
+
raise TypeError("tolerance must be a positive finite float.")
|
| 94 |
+
if not math.isfinite(float(tolerance)) or tolerance <= 0:
|
| 95 |
+
raise ValueError("tolerance must be a positive finite float.")
|
| 96 |
+
if not isinstance(max_iterations, int) or isinstance(max_iterations, bool):
|
| 97 |
+
raise TypeError("max_iterations must be a positive integer.")
|
| 98 |
+
if max_iterations <= 0:
|
| 99 |
+
raise ValueError("max_iterations must be a positive integer.")
|
| 100 |
+
length = A.shape[0]
|
| 101 |
+
if length == 0:
|
| 102 |
+
raise ValueError("PageRank requires at least one residue.")
|
| 103 |
+
if not bool(torch.isfinite(A).all()):
|
| 104 |
+
raise ValueError("A must contain only finite attention values.")
|
| 105 |
+
work_dtype = torch.float64 if A.dtype == torch.float64 else torch.float32
|
| 106 |
+
P = A.detach().to(dtype=work_dtype).clamp_min(0)
|
| 107 |
+
row_sum = P.sum(dim=-1, keepdim=True)
|
| 108 |
+
uniform = torch.full_like(P, 1.0 / length)
|
| 109 |
+
P = torch.where(row_sum > 0, P / row_sum.clamp_min(torch.finfo(work_dtype).tiny), uniform)
|
| 110 |
+
p = torch.full((length,), 1.0 / length, device=P.device, dtype=work_dtype)
|
| 111 |
+
teleport = (1.0 - damping) / length
|
| 112 |
+
for _ in range(max_iterations):
|
| 113 |
+
p_next = teleport + damping * (P.transpose(0, 1) @ p)
|
| 114 |
+
if torch.linalg.vector_norm(p_next - p, ord=1) <= tolerance:
|
| 115 |
+
p = p_next
|
| 116 |
+
break
|
| 117 |
+
p = p_next
|
| 118 |
+
return p / p.sum()
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
class Pooler:
|
| 122 |
+
"""Apply one or more pooling operations to biological residue rows."""
|
| 123 |
+
|
| 124 |
+
def __init__(self, pooling: str | Sequence[str] = ("mean",)) -> None:
|
| 125 |
+
pooling_value: object = pooling
|
| 126 |
+
if isinstance(pooling_value, (bytes, bytearray)) or not isinstance(
|
| 127 |
+
pooling_value, (str, Sequence)
|
| 128 |
+
):
|
| 129 |
+
raise TypeError("pooling must be a name or a sequence of names.")
|
| 130 |
+
names = (pooling_value,) if isinstance(pooling_value, str) else tuple(pooling_value)
|
| 131 |
+
if not all(isinstance(name, str) for name in names):
|
| 132 |
+
raise TypeError("pooling names must be strings.")
|
| 133 |
+
if not names:
|
| 134 |
+
raise ValueError("At least one pooling operation is required.")
|
| 135 |
+
unknown = set(names) - POOLING_NAMES
|
| 136 |
+
if unknown:
|
| 137 |
+
raise ValueError(f"Unknown pooling operations: {sorted(unknown)}.")
|
| 138 |
+
duplicates = sorted({name for name in names if names.count(name) > 1})
|
| 139 |
+
if duplicates:
|
| 140 |
+
raise ValueError(f"Duplicate pooling operations are not supported: {duplicates}.")
|
| 141 |
+
self.names = names
|
| 142 |
+
|
| 143 |
+
def output_slices(self, d: int) -> dict[str, tuple[int, int]]:
|
| 144 |
+
"""Return the output interval assigned to each pooler."""
|
| 145 |
+
|
| 146 |
+
if not isinstance(d, int) or isinstance(d, bool):
|
| 147 |
+
raise TypeError("d must be a positive integer.")
|
| 148 |
+
if d <= 0:
|
| 149 |
+
raise ValueError("d must be a positive integer.")
|
| 150 |
+
return {name: (i * d, (i + 1) * d) for i, name in enumerate(self.names)}
|
| 151 |
+
|
| 152 |
+
def __call__(
|
| 153 |
+
self,
|
| 154 |
+
X: Tensor,
|
| 155 |
+
residue_mask: Tensor,
|
| 156 |
+
*,
|
| 157 |
+
attentions: Tensor | Sequence[Tensor] | None = None,
|
| 158 |
+
attention_backend: str | None = None,
|
| 159 |
+
) -> Tensor:
|
| 160 |
+
M = _validate_inputs(X, residue_mask)
|
| 161 |
+
M_expanded = M.unsqueeze(-1)
|
| 162 |
+
count = M_expanded.sum(dim=1).clamp_min(1)
|
| 163 |
+
X_residues = X.masked_fill(~M_expanded, 0)
|
| 164 |
+
outputs: list[Tensor] = []
|
| 165 |
+
|
| 166 |
+
for name in self.names:
|
| 167 |
+
if name == "mean":
|
| 168 |
+
Y = X_residues.sum(dim=1) / count
|
| 169 |
+
elif name == "max":
|
| 170 |
+
Y = X.masked_fill(~M_expanded, -torch.inf).max(dim=1).values
|
| 171 |
+
elif name == "norm":
|
| 172 |
+
Y = torch.linalg.vector_norm(X_residues, ord=2, dim=1)
|
| 173 |
+
elif name == "median":
|
| 174 |
+
Y = X.masked_fill(~M_expanded, torch.nan).nanmedian(dim=1).values
|
| 175 |
+
elif name in {"var", "std"}:
|
| 176 |
+
mean = X_residues.sum(dim=1, keepdim=True) / count.unsqueeze(1)
|
| 177 |
+
centered = (X - mean).masked_fill(~M_expanded, 0)
|
| 178 |
+
variance = (centered**2).sum(dim=1) / count
|
| 179 |
+
Y = variance.sqrt() if name == "std" else variance
|
| 180 |
+
elif name == "cls":
|
| 181 |
+
Y = X[:, 0]
|
| 182 |
+
else:
|
| 183 |
+
if attention_backend != "eager":
|
| 184 |
+
raise ValueError(
|
| 185 |
+
"parti requires attn_implementation='eager' so full "
|
| 186 |
+
"attention matrices are available."
|
| 187 |
+
)
|
| 188 |
+
if attentions is None:
|
| 189 |
+
raise ValueError("parti requires model attention matrices.")
|
| 190 |
+
if int(M.sum(dim=1).max().item()) > 2048:
|
| 191 |
+
raise ValueError("parti supports at most 2,048 biological residues.")
|
| 192 |
+
A = _pooled_attention(attentions, batch_size=X.shape[0]).to(X.device)
|
| 193 |
+
pooled: list[Tensor] = []
|
| 194 |
+
for X_i, M_i, A_i in zip(X, M, A, strict=True):
|
| 195 |
+
indices = M_i.nonzero(as_tuple=True)[0]
|
| 196 |
+
A_residue = A_i.index_select(0, indices).index_select(1, indices)
|
| 197 |
+
w = pagerank_weights(A_residue).to(dtype=X.dtype)
|
| 198 |
+
pooled.append(w @ X_i.index_select(0, indices))
|
| 199 |
+
Y = torch.stack(pooled)
|
| 200 |
+
if not bool(torch.isfinite(Y).all()):
|
| 201 |
+
raise ValueError(
|
| 202 |
+
f"Pooling operation {name!r} produced non-finite output from "
|
| 203 |
+
"biological residue embeddings."
|
| 204 |
+
)
|
| 205 |
+
outputs.append(Y)
|
| 206 |
+
|
| 207 |
+
return torch.cat(outputs, dim=-1)
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
__all__ = ["POOLING_NAMES", "Pooler", "pagerank_weights"]
|
fastplms/embeddings/runner.py
ADDED
|
@@ -0,0 +1,1559 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Model-independent dataset embedding orchestration."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import hashlib
|
| 6 |
+
import json
|
| 7 |
+
import platform
|
| 8 |
+
import sqlite3
|
| 9 |
+
import tempfile
|
| 10 |
+
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
|
| 11 |
+
from contextlib import contextmanager
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any, overload
|
| 14 |
+
|
| 15 |
+
import torch
|
| 16 |
+
from torch import Tensor
|
| 17 |
+
|
| 18 |
+
from .pooling import Pooler
|
| 19 |
+
from .storage import (
|
| 20 |
+
SafetensorsStreamWriter,
|
| 21 |
+
append_sqlite_records,
|
| 22 |
+
initialize_sqlite_run,
|
| 23 |
+
load_result,
|
| 24 |
+
load_sqlite_result,
|
| 25 |
+
safetensors_result_exists,
|
| 26 |
+
save_result,
|
| 27 |
+
tensor_sha256,
|
| 28 |
+
update_sqlite_run_metadata,
|
| 29 |
+
)
|
| 30 |
+
from .types import (
|
| 31 |
+
EmbeddingBatch,
|
| 32 |
+
EmbeddingInput,
|
| 33 |
+
EmbeddingRecord,
|
| 34 |
+
EmbeddingResult,
|
| 35 |
+
LazyTensorReference,
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
_MAX_PARTI_RESIDUES = 2_048
|
| 39 |
+
_RUN_FINGERPRINT_SCHEMA_VERSION = 3
|
| 40 |
+
_MODEL_STATE_HASH_CHUNK_BYTES = 16 * 1024**2
|
| 41 |
+
_DEFAULT_BATCH_WINDOW_MULTIPLIER = 16
|
| 42 |
+
_SUPPORTED_STORAGE_FORMATS = frozenset({"safetensors", "sqlite"})
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _validate_parti_length(M: Tensor) -> None:
|
| 46 |
+
"""Reject an oversized attention graph before model inference."""
|
| 47 |
+
|
| 48 |
+
n_residues = int(M.to(dtype=torch.int64).sum(dim=1).max().item())
|
| 49 |
+
if n_residues > _MAX_PARTI_RESIDUES:
|
| 50 |
+
raise ValueError(f"parti supports at most {_MAX_PARTI_RESIDUES:,} biological residues.")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def select_hidden_state_embeddings(
|
| 54 |
+
last_hidden_state: Tensor,
|
| 55 |
+
hidden_states: tuple[Tensor, ...] | None,
|
| 56 |
+
*,
|
| 57 |
+
hidden_state_index: int = -1,
|
| 58 |
+
store_all_hidden_states: bool = False,
|
| 59 |
+
) -> Tensor:
|
| 60 |
+
"""Select one hidden state or stack every state without changing values."""
|
| 61 |
+
if store_all_hidden_states:
|
| 62 |
+
if not hidden_states:
|
| 63 |
+
raise ValueError("store_all_hidden_states requires model hidden states.")
|
| 64 |
+
# H has shape (b, n, l, d), where n follows the model's output order.
|
| 65 |
+
return torch.stack(hidden_states, dim=1)
|
| 66 |
+
if hidden_state_index == -1:
|
| 67 |
+
return last_hidden_state
|
| 68 |
+
if not hidden_states:
|
| 69 |
+
raise ValueError("hidden_state_index requires model hidden states.")
|
| 70 |
+
return hidden_states[hidden_state_index]
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def iter_fasta(path: str | Path) -> Iterator[EmbeddingInput]:
|
| 74 |
+
"""Yield FASTA records in source order without reading the file into memory."""
|
| 75 |
+
|
| 76 |
+
identifier: str | None = None
|
| 77 |
+
sequence_parts: list[str] = []
|
| 78 |
+
found_record = False
|
| 79 |
+
with Path(path).open("r", encoding="utf-8") as handle:
|
| 80 |
+
for line_number, raw_line in enumerate(handle, start=1):
|
| 81 |
+
line = raw_line.strip()
|
| 82 |
+
if not line:
|
| 83 |
+
continue
|
| 84 |
+
if line.startswith(">"):
|
| 85 |
+
if identifier is not None:
|
| 86 |
+
found_record = True
|
| 87 |
+
yield EmbeddingInput(identifier, "".join(sequence_parts))
|
| 88 |
+
identifier = line[1:].strip().split(maxsplit=1)[0]
|
| 89 |
+
if not identifier:
|
| 90 |
+
raise ValueError(f"Missing FASTA identifier on line {line_number}.")
|
| 91 |
+
sequence_parts = []
|
| 92 |
+
else:
|
| 93 |
+
if identifier is None:
|
| 94 |
+
raise ValueError(
|
| 95 |
+
f"Sequence data precedes the first FASTA header on line {line_number}."
|
| 96 |
+
)
|
| 97 |
+
sequence_parts.append("".join(line.split()))
|
| 98 |
+
if identifier is not None:
|
| 99 |
+
found_record = True
|
| 100 |
+
yield EmbeddingInput(identifier, "".join(sequence_parts))
|
| 101 |
+
if not found_record:
|
| 102 |
+
raise ValueError(f"No FASTA records found in {path}.")
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def parse_fasta(path: str | Path) -> list[EmbeddingInput]:
|
| 106 |
+
"""Parse FASTA records while preserving identifiers, order, and duplicates."""
|
| 107 |
+
|
| 108 |
+
return list(iter_fasta(path))
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _normalize_input_item(
|
| 112 |
+
position: int,
|
| 113 |
+
item: str | EmbeddingInput | tuple[str, str],
|
| 114 |
+
) -> EmbeddingInput:
|
| 115 |
+
if isinstance(item, EmbeddingInput):
|
| 116 |
+
return item
|
| 117 |
+
if isinstance(item, str):
|
| 118 |
+
return EmbeddingInput(str(position), item)
|
| 119 |
+
if isinstance(item, tuple) and len(item) == 2:
|
| 120 |
+
return EmbeddingInput(str(item[0]), str(item[1]))
|
| 121 |
+
raise TypeError(
|
| 122 |
+
"inputs must contain sequences, EmbeddingInput values, or (id, sequence) tuples."
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
class _InputSpool(Sequence[EmbeddingInput]):
|
| 127 |
+
"""Immutable disk-backed normalized inputs with an incremental digest."""
|
| 128 |
+
|
| 129 |
+
def __init__(
|
| 130 |
+
self,
|
| 131 |
+
values: Iterable[str | EmbeddingInput | tuple[str, str]],
|
| 132 |
+
) -> None:
|
| 133 |
+
self._temporary: tempfile.TemporaryDirectory[str] | None = tempfile.TemporaryDirectory(
|
| 134 |
+
prefix="fastplms-inputs-"
|
| 135 |
+
)
|
| 136 |
+
self.path = Path(self._temporary.name) / "inputs.sqlite"
|
| 137 |
+
self._connection: sqlite3.Connection | None = sqlite3.connect(self.path)
|
| 138 |
+
self._connection.execute(
|
| 139 |
+
"CREATE TABLE inputs ("
|
| 140 |
+
"position INTEGER PRIMARY KEY, input_id TEXT NOT NULL, sequence TEXT NOT NULL)"
|
| 141 |
+
)
|
| 142 |
+
digest = hashlib.sha256()
|
| 143 |
+
count = 0
|
| 144 |
+
pending: list[tuple[int, str, str]] = []
|
| 145 |
+
try:
|
| 146 |
+
for position, item in enumerate(values):
|
| 147 |
+
record = _normalize_input_item(position, item)
|
| 148 |
+
for value in (record.id, record.sequence):
|
| 149 |
+
encoded = value.encode("utf-8")
|
| 150 |
+
digest.update(len(encoded).to_bytes(8, "big"))
|
| 151 |
+
digest.update(encoded)
|
| 152 |
+
pending.append((position, record.id, record.sequence))
|
| 153 |
+
count += 1
|
| 154 |
+
if len(pending) == 1_024:
|
| 155 |
+
self._connection.executemany("INSERT INTO inputs VALUES (?, ?, ?)", pending)
|
| 156 |
+
pending.clear()
|
| 157 |
+
if pending:
|
| 158 |
+
self._connection.executemany("INSERT INTO inputs VALUES (?, ?, ?)", pending)
|
| 159 |
+
if count == 0:
|
| 160 |
+
raise ValueError("inputs must contain at least one sequence.")
|
| 161 |
+
self._connection.commit()
|
| 162 |
+
self._connection.close()
|
| 163 |
+
self._connection = sqlite3.connect(
|
| 164 |
+
f"{self.path.resolve().as_uri()}?mode=ro",
|
| 165 |
+
uri=True,
|
| 166 |
+
)
|
| 167 |
+
except BaseException:
|
| 168 |
+
self.close()
|
| 169 |
+
raise
|
| 170 |
+
digest.update(count.to_bytes(8, "big"))
|
| 171 |
+
self.input_fingerprint = digest.hexdigest()
|
| 172 |
+
self._count = count
|
| 173 |
+
|
| 174 |
+
def _require_connection(self) -> sqlite3.Connection:
|
| 175 |
+
if self._connection is None:
|
| 176 |
+
raise RuntimeError("Input spool is closed.")
|
| 177 |
+
return self._connection
|
| 178 |
+
|
| 179 |
+
def __len__(self) -> int:
|
| 180 |
+
return self._count
|
| 181 |
+
|
| 182 |
+
def __iter__(self) -> Iterator[EmbeddingInput]:
|
| 183 |
+
cursor = self._require_connection().execute(
|
| 184 |
+
"SELECT input_id, sequence FROM inputs ORDER BY position"
|
| 185 |
+
)
|
| 186 |
+
while rows := cursor.fetchmany(1_024):
|
| 187 |
+
for input_id, sequence in rows:
|
| 188 |
+
yield EmbeddingInput(input_id, sequence)
|
| 189 |
+
|
| 190 |
+
@overload
|
| 191 |
+
def __getitem__(self, index: int, /) -> EmbeddingInput: ...
|
| 192 |
+
|
| 193 |
+
@overload
|
| 194 |
+
def __getitem__(self, index: slice, /) -> list[EmbeddingInput]: ...
|
| 195 |
+
|
| 196 |
+
def __getitem__(self, index: int | slice) -> EmbeddingInput | list[EmbeddingInput]:
|
| 197 |
+
connection = self._require_connection()
|
| 198 |
+
|
| 199 |
+
if isinstance(index, slice):
|
| 200 |
+
start, stop, step = index.indices(self._count)
|
| 201 |
+
if step != 1:
|
| 202 |
+
return [self[position] for position in range(start, stop, step)]
|
| 203 |
+
rows = connection.execute(
|
| 204 |
+
"SELECT input_id, sequence FROM inputs "
|
| 205 |
+
"WHERE position >= ? AND position < ? ORDER BY position",
|
| 206 |
+
(start, stop),
|
| 207 |
+
).fetchall()
|
| 208 |
+
return [EmbeddingInput(input_id, sequence) for input_id, sequence in rows]
|
| 209 |
+
position = index + self._count if index < 0 else index
|
| 210 |
+
if position < 0 or position >= self._count:
|
| 211 |
+
raise IndexError(index)
|
| 212 |
+
row = connection.execute(
|
| 213 |
+
"SELECT input_id, sequence FROM inputs WHERE position = ?", (position,)
|
| 214 |
+
).fetchone()
|
| 215 |
+
if row is None:
|
| 216 |
+
raise IndexError(index)
|
| 217 |
+
return EmbeddingInput(row[0], row[1])
|
| 218 |
+
|
| 219 |
+
def close(self) -> None:
|
| 220 |
+
connection = getattr(self, "_connection", None)
|
| 221 |
+
if connection is not None:
|
| 222 |
+
connection.close()
|
| 223 |
+
self._connection = None
|
| 224 |
+
temporary = getattr(self, "_temporary", None)
|
| 225 |
+
if temporary is not None:
|
| 226 |
+
temporary.cleanup()
|
| 227 |
+
self._temporary = None
|
| 228 |
+
|
| 229 |
+
def __del__(self) -> None:
|
| 230 |
+
self.close()
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def _normalize_inputs(
|
| 234 |
+
inputs: (Iterable[str | EmbeddingInput | tuple[str, str]] | Mapping[str, str] | str | Path),
|
| 235 |
+
*,
|
| 236 |
+
disk_backed: bool,
|
| 237 |
+
) -> Sequence[EmbeddingInput]:
|
| 238 |
+
is_fasta_path = isinstance(inputs, Path)
|
| 239 |
+
if isinstance(inputs, str):
|
| 240 |
+
try:
|
| 241 |
+
is_fasta_path = Path(inputs).is_file()
|
| 242 |
+
except OSError:
|
| 243 |
+
is_fasta_path = False
|
| 244 |
+
should_spool = disk_backed or is_fasta_path or not isinstance(inputs, (str, Sequence, Mapping))
|
| 245 |
+
values: Iterable[str | EmbeddingInput | tuple[str, str]]
|
| 246 |
+
if isinstance(inputs, Path):
|
| 247 |
+
values = iter_fasta(inputs)
|
| 248 |
+
elif isinstance(inputs, str):
|
| 249 |
+
values = iter_fasta(inputs) if is_fasta_path else [inputs]
|
| 250 |
+
elif isinstance(inputs, Mapping):
|
| 251 |
+
values = inputs.items()
|
| 252 |
+
else:
|
| 253 |
+
values = inputs
|
| 254 |
+
if should_spool:
|
| 255 |
+
return _InputSpool(values)
|
| 256 |
+
records: list[EmbeddingInput] = []
|
| 257 |
+
for position, item in enumerate(values):
|
| 258 |
+
records.append(_normalize_input_item(position, item))
|
| 259 |
+
if not records:
|
| 260 |
+
raise ValueError("inputs must contain at least one sequence.")
|
| 261 |
+
return records
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def _validate_untruncated_lengths(
|
| 265 |
+
records: Sequence[EmbeddingInput],
|
| 266 |
+
*,
|
| 267 |
+
max_length: int | None,
|
| 268 |
+
truncate: bool,
|
| 269 |
+
) -> None:
|
| 270 |
+
"""Fail before inference when a biological-residue limit would be exceeded."""
|
| 271 |
+
|
| 272 |
+
if max_length is None or truncate:
|
| 273 |
+
return
|
| 274 |
+
for position, record in enumerate(records):
|
| 275 |
+
residue_count = len(record.sequence)
|
| 276 |
+
if residue_count > max_length:
|
| 277 |
+
raise ValueError(
|
| 278 |
+
f"Input at position {position} with id {record.id!r} has "
|
| 279 |
+
f"{residue_count} biological residues, exceeding max_length={max_length} "
|
| 280 |
+
"while truncate=False."
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def _model_device(model: Any) -> torch.device:
|
| 285 |
+
try:
|
| 286 |
+
return torch.device(next(model.parameters()).device)
|
| 287 |
+
except (AttributeError, StopIteration):
|
| 288 |
+
return torch.device("cpu")
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
def _attention_backend(model: Any) -> str | None:
|
| 292 |
+
config = getattr(model, "config", None)
|
| 293 |
+
for name in ("_attn_implementation", "attn_implementation", "attn_backend"):
|
| 294 |
+
value = getattr(config, name, None)
|
| 295 |
+
if value:
|
| 296 |
+
return str(value)
|
| 297 |
+
return None
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def _attention_kernel_metadata(backend: str | None) -> dict[str, Any] | None:
|
| 301 |
+
if backend not in {"flash_attention_2", "flash_attention_3"}:
|
| 302 |
+
return None
|
| 303 |
+
from fastplms.registry import get_model_registry
|
| 304 |
+
|
| 305 |
+
spec = get_model_registry().attention_kernels[backend]
|
| 306 |
+
return {
|
| 307 |
+
"repository": spec.repository,
|
| 308 |
+
"revision": spec.revision,
|
| 309 |
+
"version": spec.version,
|
| 310 |
+
"expected_variant": spec.expected_variant,
|
| 311 |
+
"dtypes": list(spec.dtypes),
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
def _fingerprint_jsonable(value: Any) -> Any:
|
| 316 |
+
if isinstance(value, Mapping):
|
| 317 |
+
return {str(key): _fingerprint_jsonable(item) for key, item in value.items()}
|
| 318 |
+
if isinstance(value, (list, tuple)):
|
| 319 |
+
return [_fingerprint_jsonable(item) for item in value]
|
| 320 |
+
if isinstance(value, (set, frozenset)):
|
| 321 |
+
return sorted((_fingerprint_jsonable(item) for item in value), key=repr)
|
| 322 |
+
if isinstance(value, Path):
|
| 323 |
+
return str(value)
|
| 324 |
+
if isinstance(value, Tensor):
|
| 325 |
+
return {
|
| 326 |
+
"dtype": str(value.dtype).removeprefix("torch."),
|
| 327 |
+
"shape": list(value.shape),
|
| 328 |
+
"sha256": tensor_sha256(value),
|
| 329 |
+
}
|
| 330 |
+
if isinstance(value, torch.dtype):
|
| 331 |
+
return str(value).removeprefix("torch.")
|
| 332 |
+
if isinstance(value, torch.device):
|
| 333 |
+
return str(value)
|
| 334 |
+
if value is None or isinstance(value, (str, int, float, bool)):
|
| 335 |
+
return value
|
| 336 |
+
return {
|
| 337 |
+
"class": f"{value.__class__.__module__}.{value.__class__.__qualname__}",
|
| 338 |
+
"value": str(value),
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def _tokenizer_content_sha256(tokenizer: Any) -> str:
|
| 343 |
+
content: dict[str, Any] = {
|
| 344 |
+
"init_kwargs": getattr(tokenizer, "init_kwargs", None),
|
| 345 |
+
"special_tokens_map": getattr(tokenizer, "special_tokens_map", None),
|
| 346 |
+
"model_max_length": getattr(tokenizer, "model_max_length", None),
|
| 347 |
+
"padding_side": getattr(tokenizer, "padding_side", None),
|
| 348 |
+
"truncation_side": getattr(tokenizer, "truncation_side", None),
|
| 349 |
+
}
|
| 350 |
+
get_vocab = getattr(tokenizer, "get_vocab", None)
|
| 351 |
+
if callable(get_vocab):
|
| 352 |
+
content["vocabulary"] = get_vocab()
|
| 353 |
+
get_added_vocab = getattr(tokenizer, "get_added_vocab", None)
|
| 354 |
+
if callable(get_added_vocab):
|
| 355 |
+
content["added_vocabulary"] = get_added_vocab()
|
| 356 |
+
backend = getattr(tokenizer, "backend_tokenizer", None)
|
| 357 |
+
backend_to_str = getattr(backend, "to_str", None)
|
| 358 |
+
if callable(backend_to_str):
|
| 359 |
+
content["backend"] = backend_to_str()
|
| 360 |
+
serialized = json.dumps(
|
| 361 |
+
_fingerprint_jsonable(content),
|
| 362 |
+
sort_keys=True,
|
| 363 |
+
separators=(",", ":"),
|
| 364 |
+
ensure_ascii=False,
|
| 365 |
+
).encode()
|
| 366 |
+
return hashlib.sha256(serialized).hexdigest()
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
def _tokenizer_metadata(model: Any, tokenizer: Any | None) -> dict[str, Any]:
|
| 370 |
+
resolved = tokenizer if tokenizer is not None else getattr(model, "tokenizer", None)
|
| 371 |
+
if resolved is None:
|
| 372 |
+
# Raw-sequence families such as E1 retain their loader context on the
|
| 373 |
+
# model/encoder rather than exposing a Transformers tokenizer. Bind the
|
| 374 |
+
# non-secret source policy to resume identity without serializing a Hub
|
| 375 |
+
# token or forcing lazy tokenizer initialization.
|
| 376 |
+
for candidate in (model, getattr(model, "model", None)):
|
| 377 |
+
settings = getattr(candidate, "__dict__", {}).get("_fastplms_tokenizer_kwargs")
|
| 378 |
+
if isinstance(settings, Mapping):
|
| 379 |
+
token_value = settings.get("token")
|
| 380 |
+
return {
|
| 381 |
+
"mode": "native-sequence",
|
| 382 |
+
"source": (
|
| 383 |
+
str(settings.get("tokenizer_source"))
|
| 384 |
+
if settings.get("tokenizer_source") is not None
|
| 385 |
+
else None
|
| 386 |
+
),
|
| 387 |
+
"revision": settings.get("revision"),
|
| 388 |
+
"cache_dir": (
|
| 389 |
+
str(settings.get("cache_dir"))
|
| 390 |
+
if settings.get("cache_dir") is not None
|
| 391 |
+
else None
|
| 392 |
+
),
|
| 393 |
+
"local_files_only": bool(settings.get("local_files_only", False)),
|
| 394 |
+
"token_policy": (
|
| 395 |
+
"disabled"
|
| 396 |
+
if token_value is False
|
| 397 |
+
else "provided"
|
| 398 |
+
if token_value is not None
|
| 399 |
+
else "default"
|
| 400 |
+
),
|
| 401 |
+
}
|
| 402 |
+
return {"mode": "native-sequence"}
|
| 403 |
+
return {
|
| 404 |
+
"mode": "tokenizer",
|
| 405 |
+
"class": f"{resolved.__class__.__module__}.{resolved.__class__.__qualname__}",
|
| 406 |
+
"name_or_path": getattr(resolved, "name_or_path", None),
|
| 407 |
+
"vocab_size": getattr(resolved, "vocab_size", None),
|
| 408 |
+
"special_token_ids": list(getattr(resolved, "all_special_ids", ())),
|
| 409 |
+
"content_sha256": _tokenizer_content_sha256(resolved),
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
@contextmanager
|
| 414 |
+
def _temporary_eval(model: Any) -> Iterator[None]:
|
| 415 |
+
was_training = getattr(model, "training", None)
|
| 416 |
+
eval_method = getattr(model, "eval", None)
|
| 417 |
+
train_method = getattr(model, "train", None)
|
| 418 |
+
if (
|
| 419 |
+
not isinstance(was_training, bool)
|
| 420 |
+
or not callable(eval_method)
|
| 421 |
+
or not callable(train_method)
|
| 422 |
+
):
|
| 423 |
+
yield
|
| 424 |
+
return
|
| 425 |
+
eval_method()
|
| 426 |
+
try:
|
| 427 |
+
yield
|
| 428 |
+
finally:
|
| 429 |
+
train_method(was_training)
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def _software_versions() -> dict[str, str | None]:
|
| 433 |
+
try:
|
| 434 |
+
import fastplms
|
| 435 |
+
|
| 436 |
+
fastplms_version = fastplms.__version__
|
| 437 |
+
except (AttributeError, ImportError):
|
| 438 |
+
fastplms_version = None
|
| 439 |
+
try:
|
| 440 |
+
import safetensors
|
| 441 |
+
|
| 442 |
+
safetensors_version = safetensors.__version__
|
| 443 |
+
except ImportError:
|
| 444 |
+
safetensors_version = None
|
| 445 |
+
try:
|
| 446 |
+
import transformers
|
| 447 |
+
|
| 448 |
+
transformers_version = transformers.__version__
|
| 449 |
+
except ImportError:
|
| 450 |
+
transformers_version = None
|
| 451 |
+
return {
|
| 452 |
+
"fastplms": fastplms_version,
|
| 453 |
+
"python": platform.python_version(),
|
| 454 |
+
"safetensors": safetensors_version,
|
| 455 |
+
"torch": torch.__version__,
|
| 456 |
+
"torch_cuda": torch.version.cuda,
|
| 457 |
+
"transformers": transformers_version,
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
def _adapter_identity_metadata(model: Any) -> dict[str, Any] | None:
|
| 462 |
+
"""Return deterministic PEFT/adapter identity without tensor payloads."""
|
| 463 |
+
|
| 464 |
+
peft_config = getattr(model, "peft_config", None)
|
| 465 |
+
if not isinstance(peft_config, Mapping) or not peft_config:
|
| 466 |
+
return None
|
| 467 |
+
configurations: dict[str, Any] = {}
|
| 468 |
+
for name, config in sorted(peft_config.items(), key=lambda item: str(item[0])):
|
| 469 |
+
to_dict = getattr(config, "to_dict", None)
|
| 470 |
+
if callable(to_dict):
|
| 471 |
+
value = to_dict()
|
| 472 |
+
else:
|
| 473 |
+
try:
|
| 474 |
+
value = vars(config)
|
| 475 |
+
except TypeError:
|
| 476 |
+
value = config
|
| 477 |
+
configurations[str(name)] = _fingerprint_jsonable(value)
|
| 478 |
+
active_adapters = getattr(model, "active_adapters", None)
|
| 479 |
+
if callable(active_adapters):
|
| 480 |
+
active_adapters = active_adapters()
|
| 481 |
+
return {
|
| 482 |
+
"active": _fingerprint_jsonable(active_adapters),
|
| 483 |
+
"configurations": configurations,
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
|
| 487 |
+
def _execution_identity_metadata(model: Any) -> dict[str, Any]:
|
| 488 |
+
"""Capture runtime policy that can change persisted numerical results."""
|
| 489 |
+
|
| 490 |
+
parameter_dtypes = sorted(
|
| 491 |
+
{
|
| 492 |
+
str(parameter.dtype).removeprefix("torch.")
|
| 493 |
+
for parameter in getattr(model, "parameters", lambda: ())()
|
| 494 |
+
}
|
| 495 |
+
)
|
| 496 |
+
return {
|
| 497 |
+
"device": _model_device(model).type,
|
| 498 |
+
"hf_device_map": _fingerprint_jsonable(getattr(model, "hf_device_map", None)),
|
| 499 |
+
"parameter_dtypes": parameter_dtypes,
|
| 500 |
+
"software": _software_versions(),
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
|
| 504 |
+
def _biological_residue_mask(
|
| 505 |
+
input_ids: Tensor,
|
| 506 |
+
attention_mask: Tensor,
|
| 507 |
+
tokenizer: Any,
|
| 508 |
+
) -> Tensor:
|
| 509 |
+
"""Remove padding and tokenizer-declared special tokens from M."""
|
| 510 |
+
|
| 511 |
+
M = attention_mask.to(dtype=torch.bool)
|
| 512 |
+
special_ids = tuple(int(token_id) for token_id in getattr(tokenizer, "all_special_ids", ()))
|
| 513 |
+
if special_ids:
|
| 514 |
+
specials = torch.tensor(special_ids, device=input_ids.device, dtype=input_ids.dtype)
|
| 515 |
+
M = M & ~torch.isin(input_ids, specials)
|
| 516 |
+
return M
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
def _generic_embedding_batch(
|
| 520 |
+
model: Any,
|
| 521 |
+
sequences: list[str],
|
| 522 |
+
*,
|
| 523 |
+
tokenizer: Any | None,
|
| 524 |
+
max_length: int | None,
|
| 525 |
+
truncate: bool,
|
| 526 |
+
need_attentions: bool,
|
| 527 |
+
model_kwargs: dict[str, Any],
|
| 528 |
+
) -> EmbeddingBatch:
|
| 529 |
+
config = getattr(model, "config", None)
|
| 530 |
+
model_type = str(getattr(config, "model_type", "")).lower()
|
| 531 |
+
if tokenizer is None:
|
| 532 |
+
tokenizer = getattr(model, "tokenizer", None)
|
| 533 |
+
|
| 534 |
+
if tokenizer is None and model_type == "e1":
|
| 535 |
+
output = model._embed(sequences, return_attention_mask=True, **model_kwargs)
|
| 536 |
+
if not isinstance(output, tuple) or len(output) != 2:
|
| 537 |
+
raise TypeError("E1 _embed must return (X, residue_mask).")
|
| 538 |
+
X, M = output
|
| 539 |
+
preparer = getattr(model, "prep_tokens", None)
|
| 540 |
+
if preparer is not None and hasattr(preparer, "get_batch_kwargs"):
|
| 541 |
+
prepared = preparer.get_batch_kwargs(sequences, device=X.device)
|
| 542 |
+
input_ids = prepared["input_ids"]
|
| 543 |
+
boundary_ids = preparer.boundary_token_ids.to(
|
| 544 |
+
device=input_ids.device, dtype=input_ids.dtype
|
| 545 |
+
)
|
| 546 |
+
# E1 wraps each raw sequence in BOS, context-label, terminal-label,
|
| 547 |
+
# and EOS tokens. Only amino-acid rows are biological residues.
|
| 548 |
+
M = M.to(dtype=torch.bool) & ~torch.isin(input_ids, boundary_ids)
|
| 549 |
+
if need_attentions:
|
| 550 |
+
raise ValueError("parti is not available for tokenizer-free E1 embedding.")
|
| 551 |
+
return EmbeddingBatch(X=X, residue_mask=M.to(dtype=torch.bool))
|
| 552 |
+
if tokenizer is None:
|
| 553 |
+
raise ValueError("A tokenizer is required for this model's embedding path.")
|
| 554 |
+
|
| 555 |
+
tokenize_kwargs: dict[str, Any] = {
|
| 556 |
+
"return_tensors": "pt",
|
| 557 |
+
"padding": True,
|
| 558 |
+
"truncation": truncate,
|
| 559 |
+
}
|
| 560 |
+
if max_length is not None and truncate:
|
| 561 |
+
# ``max_length`` is a biological-residue limit. Tokenizer limits include
|
| 562 |
+
# boundary tokens, so reserve their declared width instead of dropping
|
| 563 |
+
# residues at the exact boundary.
|
| 564 |
+
special_token_count = 0
|
| 565 |
+
num_special_tokens_to_add = getattr(tokenizer, "num_special_tokens_to_add", None)
|
| 566 |
+
if callable(num_special_tokens_to_add):
|
| 567 |
+
special_token_count = int(num_special_tokens_to_add(pair=False))
|
| 568 |
+
tokenize_kwargs["max_length"] = max_length + special_token_count
|
| 569 |
+
sequence_tokenizer = getattr(model, "_tokenize_sequence_batch", None)
|
| 570 |
+
if callable(sequence_tokenizer):
|
| 571 |
+
encoded = sequence_tokenizer(sequences, tokenizer=tokenizer, **tokenize_kwargs)
|
| 572 |
+
else:
|
| 573 |
+
encoded = tokenizer(sequences, **tokenize_kwargs)
|
| 574 |
+
device = _model_device(model)
|
| 575 |
+
input_ids = encoded["input_ids"].to(device)
|
| 576 |
+
attention_mask = encoded.get("attention_mask", input_ids.new_ones(input_ids.shape)).to(device)
|
| 577 |
+
M = _biological_residue_mask(input_ids, attention_mask, tokenizer)
|
| 578 |
+
if need_attentions:
|
| 579 |
+
# Validate l before either the backbone or its quadratic attention graph
|
| 580 |
+
# is materialized. M has shape (b, l).
|
| 581 |
+
_validate_parti_length(M)
|
| 582 |
+
X = model._embed(input_ids, attention_mask, **model_kwargs)
|
| 583 |
+
attentions = None
|
| 584 |
+
if need_attentions:
|
| 585 |
+
output = model(
|
| 586 |
+
input_ids=input_ids,
|
| 587 |
+
attention_mask=attention_mask,
|
| 588 |
+
output_attentions=True,
|
| 589 |
+
return_dict=True,
|
| 590 |
+
)
|
| 591 |
+
attentions = getattr(output, "attentions", None)
|
| 592 |
+
if attentions is None:
|
| 593 |
+
raise ValueError("The model did not return attentions required by parti.")
|
| 594 |
+
return EmbeddingBatch(X=X, residue_mask=M, attentions=attentions)
|
| 595 |
+
|
| 596 |
+
|
| 597 |
+
def _first_metadata_value(*values: Any) -> Any:
|
| 598 |
+
for value in values:
|
| 599 |
+
if isinstance(value, str):
|
| 600 |
+
if value.strip():
|
| 601 |
+
return value
|
| 602 |
+
elif value is not None:
|
| 603 |
+
return value
|
| 604 |
+
return None
|
| 605 |
+
|
| 606 |
+
|
| 607 |
+
def _model_identity_metadata(model: Any) -> dict[str, Any]:
|
| 608 |
+
"""Resolve model and checkpoint identity, including local artifact fallbacks."""
|
| 609 |
+
|
| 610 |
+
config = getattr(model, "config", None)
|
| 611 |
+
checkpoint_revision = _first_metadata_value(
|
| 612 |
+
getattr(config, "fastplms_checkpoint_revision", None),
|
| 613 |
+
getattr(config, "_commit_hash", None),
|
| 614 |
+
)
|
| 615 |
+
return {
|
| 616 |
+
"model_id": _first_metadata_value(
|
| 617 |
+
getattr(config, "fastplms_model_id", None),
|
| 618 |
+
getattr(config, "_name_or_path", None),
|
| 619 |
+
),
|
| 620 |
+
"model_revision": _first_metadata_value(
|
| 621 |
+
getattr(config, "_commit_hash", None),
|
| 622 |
+
checkpoint_revision,
|
| 623 |
+
),
|
| 624 |
+
"checkpoint_repo_id": getattr(config, "fastplms_checkpoint_repo_id", None),
|
| 625 |
+
"checkpoint_revision": checkpoint_revision,
|
| 626 |
+
"checkpoint_hash": _first_metadata_value(
|
| 627 |
+
getattr(model, "checkpoint_hash", None),
|
| 628 |
+
getattr(config, "checkpoint_hash", None),
|
| 629 |
+
getattr(config, "fastplms_checkpoint_hash", None),
|
| 630 |
+
),
|
| 631 |
+
"weights_revision": getattr(config, "fastplms_weights_revision", None),
|
| 632 |
+
"runtime_revision": getattr(config, "fastplms_runtime_revision", None),
|
| 633 |
+
"source_tree_sha256": getattr(config, "fastplms_source_tree_sha256", None),
|
| 634 |
+
"runtime_bundle_sha256": getattr(config, "fastplms_runtime_bundle_sha256", None),
|
| 635 |
+
}
|
| 636 |
+
|
| 637 |
+
|
| 638 |
+
def _bounded_tensor_chunks(X: Tensor, max_elements: int) -> Iterable[Tensor]:
|
| 639 |
+
"""Yield X in logical row-major order without materializing a full copy."""
|
| 640 |
+
|
| 641 |
+
if X.numel() == 0:
|
| 642 |
+
return
|
| 643 |
+
if X.ndim == 0:
|
| 644 |
+
yield X
|
| 645 |
+
return
|
| 646 |
+
trailing_elements = 1
|
| 647 |
+
for size in X.shape[1:]:
|
| 648 |
+
trailing_elements *= int(size)
|
| 649 |
+
if trailing_elements <= max_elements:
|
| 650 |
+
rows_per_chunk = max(1, max_elements // trailing_elements)
|
| 651 |
+
for start in range(0, X.shape[0], rows_per_chunk):
|
| 652 |
+
yield X[start : start + rows_per_chunk]
|
| 653 |
+
return
|
| 654 |
+
for row in X:
|
| 655 |
+
yield from _bounded_tensor_chunks(row, max_elements)
|
| 656 |
+
|
| 657 |
+
|
| 658 |
+
def _model_state_sha256(model: Any) -> str:
|
| 659 |
+
"""Hash named parameters and persistent buffers using bounded CPU copies."""
|
| 660 |
+
|
| 661 |
+
# Never cache this digest from tensor identity or ``Tensor._version``.
|
| 662 |
+
# ``Parameter.data`` and independent tensor aliases can mutate shared storage
|
| 663 |
+
# without changing either signal, while persisted resume identity must bind
|
| 664 |
+
# the authoritative bytes visible at the start of this run.
|
| 665 |
+
state = model.state_dict(keep_vars=True)
|
| 666 |
+
digest = hashlib.sha256()
|
| 667 |
+
for name, value in sorted(state.items()):
|
| 668 |
+
if not isinstance(value, Tensor):
|
| 669 |
+
raise TypeError(f"Model state entry {name!r} is not a tensor.")
|
| 670 |
+
if value.is_meta:
|
| 671 |
+
raise ValueError(
|
| 672 |
+
f"Cannot fingerprint meta-device model state entry {name!r}; pass "
|
| 673 |
+
"model_state_fingerprint with a caller-owned state identity."
|
| 674 |
+
)
|
| 675 |
+
header = json.dumps(
|
| 676 |
+
{
|
| 677 |
+
"name": name,
|
| 678 |
+
"dtype": str(value.dtype).removeprefix("torch."),
|
| 679 |
+
"shape": list(value.shape),
|
| 680 |
+
},
|
| 681 |
+
sort_keys=True,
|
| 682 |
+
separators=(",", ":"),
|
| 683 |
+
).encode()
|
| 684 |
+
digest.update(len(header).to_bytes(8, "big"))
|
| 685 |
+
digest.update(header)
|
| 686 |
+
max_elements = max(1, _MODEL_STATE_HASH_CHUNK_BYTES // value.element_size())
|
| 687 |
+
for chunk in _bounded_tensor_chunks(value.detach(), max_elements):
|
| 688 |
+
cpu_chunk = chunk.to(device="cpu").contiguous()
|
| 689 |
+
digest.update(cpu_chunk.reshape(-1).view(torch.uint8).numpy().tobytes())
|
| 690 |
+
return digest.hexdigest()
|
| 691 |
+
|
| 692 |
+
|
| 693 |
+
def _input_sha256(records: Iterable[EmbeddingInput]) -> str:
|
| 694 |
+
"""Hash an ordered input stream without constructing a duplicate JSON payload."""
|
| 695 |
+
|
| 696 |
+
precomputed = getattr(records, "input_fingerprint", None)
|
| 697 |
+
if isinstance(precomputed, str):
|
| 698 |
+
return precomputed
|
| 699 |
+
digest = hashlib.sha256()
|
| 700 |
+
count = 0
|
| 701 |
+
for record in records:
|
| 702 |
+
count += 1
|
| 703 |
+
for value in (record.id, record.sequence):
|
| 704 |
+
encoded = value.encode("utf-8")
|
| 705 |
+
digest.update(len(encoded).to_bytes(8, "big"))
|
| 706 |
+
digest.update(encoded)
|
| 707 |
+
digest.update(count.to_bytes(8, "big"))
|
| 708 |
+
return digest.hexdigest()
|
| 709 |
+
|
| 710 |
+
|
| 711 |
+
def _run_fingerprint(
|
| 712 |
+
model: Any,
|
| 713 |
+
records: Sequence[EmbeddingInput],
|
| 714 |
+
*,
|
| 715 |
+
pooling: Sequence[str],
|
| 716 |
+
full_embeddings: bool,
|
| 717 |
+
max_length: int | None,
|
| 718 |
+
truncate: bool,
|
| 719 |
+
dtype: torch.dtype | None,
|
| 720 |
+
model_kwargs: dict[str, Any],
|
| 721 |
+
tokenizer_metadata: dict[str, Any],
|
| 722 |
+
model_state_fingerprint: str | None,
|
| 723 |
+
persist_output: bool,
|
| 724 |
+
embedding_context: Mapping[str, Any],
|
| 725 |
+
batch_size: int,
|
| 726 |
+
batch_window_size: int,
|
| 727 |
+
max_tokens_per_batch: int | None,
|
| 728 |
+
) -> tuple[str, str, str | None, str]:
|
| 729 |
+
input_fingerprint = _input_sha256(records)
|
| 730 |
+
attention_backend = _attention_backend(model)
|
| 731 |
+
model_identity = _model_identity_metadata(model)
|
| 732 |
+
if model_state_fingerprint is None and persist_output:
|
| 733 |
+
resolved_model_state_fingerprint = _model_state_sha256(model)
|
| 734 |
+
model_state_fingerprint_source = "computed"
|
| 735 |
+
elif model_state_fingerprint is not None:
|
| 736 |
+
resolved_model_state_fingerprint = model_state_fingerprint.strip()
|
| 737 |
+
if not resolved_model_state_fingerprint:
|
| 738 |
+
raise ValueError("model_state_fingerprint must not be empty.")
|
| 739 |
+
model_state_fingerprint_source = "caller"
|
| 740 |
+
else:
|
| 741 |
+
resolved_model_state_fingerprint = None
|
| 742 |
+
model_state_fingerprint_source = "not-computed"
|
| 743 |
+
payload = {
|
| 744 |
+
"fingerprint_schema_version": _RUN_FINGERPRINT_SCHEMA_VERSION,
|
| 745 |
+
"input_fingerprint": input_fingerprint,
|
| 746 |
+
"model_state_fingerprint": resolved_model_state_fingerprint,
|
| 747 |
+
"model_state_fingerprint_source": model_state_fingerprint_source,
|
| 748 |
+
"model_class": f"{model.__class__.__module__}.{model.__class__.__qualname__}",
|
| 749 |
+
**model_identity,
|
| 750 |
+
"attention_backend": attention_backend,
|
| 751 |
+
"attention_kernel": _attention_kernel_metadata(attention_backend),
|
| 752 |
+
"layer": repr(
|
| 753 |
+
getattr(model, "embedding_layer", model_kwargs.get("hidden_state_index", -1))
|
| 754 |
+
),
|
| 755 |
+
"projection": getattr(model, "embedding_projection", None),
|
| 756 |
+
"esmc_source": getattr(model, "_esmc_source", None),
|
| 757 |
+
"esmc_revision": getattr(model, "_esmc_source_revision", None),
|
| 758 |
+
"esmc_files": getattr(model, "_esmc_source_files", None),
|
| 759 |
+
"token_policy": getattr(model, "embedding_token_policy", None),
|
| 760 |
+
"tokenizer": tokenizer_metadata,
|
| 761 |
+
"adapter": _adapter_identity_metadata(model),
|
| 762 |
+
"execution": _execution_identity_metadata(model),
|
| 763 |
+
"embedding_context": _fingerprint_jsonable(embedding_context),
|
| 764 |
+
"pooling": list(pooling),
|
| 765 |
+
"full_embeddings": full_embeddings,
|
| 766 |
+
"max_length": max_length,
|
| 767 |
+
"truncate": truncate,
|
| 768 |
+
"dtype": str(dtype) if dtype is not None else None,
|
| 769 |
+
"batching": {
|
| 770 |
+
"batch_size": batch_size,
|
| 771 |
+
"batch_window_size": batch_window_size,
|
| 772 |
+
"max_tokens_per_batch": max_tokens_per_batch,
|
| 773 |
+
"input_storage": ("disk-spool" if isinstance(records, _InputSpool) else "memory"),
|
| 774 |
+
},
|
| 775 |
+
"model_kwargs": {
|
| 776 |
+
key: _fingerprint_jsonable(value) for key, value in sorted(model_kwargs.items())
|
| 777 |
+
},
|
| 778 |
+
"residue_mask_policy": "attention-mask-minus-special-tokens",
|
| 779 |
+
}
|
| 780 |
+
run_fingerprint = hashlib.sha256(
|
| 781 |
+
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
| 782 |
+
).hexdigest()
|
| 783 |
+
return (
|
| 784 |
+
input_fingerprint,
|
| 785 |
+
run_fingerprint,
|
| 786 |
+
resolved_model_state_fingerprint,
|
| 787 |
+
model_state_fingerprint_source,
|
| 788 |
+
)
|
| 789 |
+
|
| 790 |
+
|
| 791 |
+
def _output_exists(path: str | Path, format: str) -> bool:
|
| 792 |
+
path = Path(path)
|
| 793 |
+
if format == "sqlite":
|
| 794 |
+
return path.is_file()
|
| 795 |
+
return safetensors_result_exists(path)
|
| 796 |
+
|
| 797 |
+
|
| 798 |
+
def _output_descriptor(position: int, record: EmbeddingRecord) -> dict[str, Any]:
|
| 799 |
+
tensor = record.tensor
|
| 800 |
+
if isinstance(tensor, LazyTensorReference):
|
| 801 |
+
dtype = tensor.dtype
|
| 802 |
+
shape = tensor.shape
|
| 803 |
+
digest = tensor.sha256
|
| 804 |
+
else:
|
| 805 |
+
dtype = str(tensor.dtype).removeprefix("torch.")
|
| 806 |
+
shape = tuple(tensor.shape)
|
| 807 |
+
digest = tensor_sha256(tensor)
|
| 808 |
+
return {
|
| 809 |
+
"position": position,
|
| 810 |
+
"id": record.id,
|
| 811 |
+
"dtype": dtype,
|
| 812 |
+
"shape": shape,
|
| 813 |
+
"sha256": digest,
|
| 814 |
+
}
|
| 815 |
+
|
| 816 |
+
|
| 817 |
+
def _ordered_string_sha256(values: Sequence[str]) -> str:
|
| 818 |
+
digest = hashlib.sha256()
|
| 819 |
+
for value in values:
|
| 820 |
+
encoded = value.encode("utf-8")
|
| 821 |
+
digest.update(len(encoded).to_bytes(8, "big"))
|
| 822 |
+
digest.update(encoded)
|
| 823 |
+
digest.update(len(values).to_bytes(8, "big"))
|
| 824 |
+
return digest.hexdigest()
|
| 825 |
+
|
| 826 |
+
|
| 827 |
+
def _embedding_context(
|
| 828 |
+
model: Any,
|
| 829 |
+
records: Sequence[EmbeddingInput],
|
| 830 |
+
*,
|
| 831 |
+
hidden_state_source: str,
|
| 832 |
+
decoder_inputs: Sequence[str] | None,
|
| 833 |
+
decoder_input_ids: Tensor | None,
|
| 834 |
+
decoder_attention_mask: Tensor | None,
|
| 835 |
+
model_kwargs: Mapping[str, Any],
|
| 836 |
+
) -> tuple[dict[str, Any], tuple[str, ...] | None]:
|
| 837 |
+
if hidden_state_source not in {"encoder", "decoder"}:
|
| 838 |
+
raise ValueError("hidden_state_source must be 'encoder' or 'decoder'.")
|
| 839 |
+
hidden_state_index = model_kwargs.get("hidden_state_index", -1)
|
| 840 |
+
if not isinstance(hidden_state_index, int) or isinstance(hidden_state_index, bool):
|
| 841 |
+
raise TypeError("hidden_state_index must be an integer.")
|
| 842 |
+
store_all_hidden_states = model_kwargs.get("store_all_hidden_states", False)
|
| 843 |
+
if not isinstance(store_all_hidden_states, bool):
|
| 844 |
+
raise TypeError("store_all_hidden_states must be a boolean.")
|
| 845 |
+
normalized_decoder_inputs: tuple[str, ...] | None = None
|
| 846 |
+
has_decoder_inputs = decoder_inputs is not None
|
| 847 |
+
has_decoder_ids = decoder_input_ids is not None
|
| 848 |
+
if hidden_state_source == "encoder":
|
| 849 |
+
if has_decoder_inputs or has_decoder_ids or decoder_attention_mask is not None:
|
| 850 |
+
raise ValueError("Decoder inputs are only valid when hidden_state_source='decoder'.")
|
| 851 |
+
else:
|
| 852 |
+
if has_decoder_inputs == has_decoder_ids:
|
| 853 |
+
raise ValueError(
|
| 854 |
+
"Decoder embedding requires exactly one of decoder_inputs or decoder_input_ids."
|
| 855 |
+
)
|
| 856 |
+
decoder_input_fingerprint: str | None = None
|
| 857 |
+
if decoder_inputs is not None:
|
| 858 |
+
if isinstance(decoder_inputs, (str, bytes)) or not isinstance(decoder_inputs, Sequence):
|
| 859 |
+
raise TypeError("decoder_inputs must be an aligned sequence of strings.")
|
| 860 |
+
normalized_decoder_inputs = tuple(decoder_inputs)
|
| 861 |
+
if not all(isinstance(value, str) and value for value in normalized_decoder_inputs):
|
| 862 |
+
raise ValueError("decoder_inputs must contain non-empty strings.")
|
| 863 |
+
if len(normalized_decoder_inputs) != len(records):
|
| 864 |
+
raise ValueError("decoder_inputs must align one-to-one with embedding inputs.")
|
| 865 |
+
decoder_input_fingerprint = _ordered_string_sha256(normalized_decoder_inputs)
|
| 866 |
+
if decoder_attention_mask is not None:
|
| 867 |
+
raise ValueError("decoder_attention_mask requires decoder_input_ids.")
|
| 868 |
+
if decoder_input_ids is not None:
|
| 869 |
+
if not isinstance(decoder_input_ids, Tensor) or decoder_input_ids.ndim != 2:
|
| 870 |
+
raise ValueError("decoder_input_ids must have shape (batch, sequence).")
|
| 871 |
+
if decoder_input_ids.shape[0] != len(records):
|
| 872 |
+
raise ValueError("decoder_input_ids must align one-to-one with embedding inputs.")
|
| 873 |
+
if decoder_input_ids.dtype == torch.bool or decoder_input_ids.is_floating_point():
|
| 874 |
+
raise TypeError("decoder_input_ids must use an integer token dtype.")
|
| 875 |
+
decoder_input_fingerprint = tensor_sha256(decoder_input_ids)
|
| 876 |
+
decoder_mask_fingerprint: str | None = None
|
| 877 |
+
if decoder_attention_mask is not None:
|
| 878 |
+
if not isinstance(decoder_attention_mask, Tensor):
|
| 879 |
+
raise TypeError("decoder_attention_mask must be a tensor.")
|
| 880 |
+
if decoder_input_ids is None or decoder_attention_mask.shape != decoder_input_ids.shape:
|
| 881 |
+
raise ValueError("decoder_attention_mask must match decoder_input_ids shape.")
|
| 882 |
+
decoder_mask_fingerprint = tensor_sha256(decoder_attention_mask)
|
| 883 |
+
|
| 884 |
+
context: dict[str, Any] = {
|
| 885 |
+
"hidden_state_source": hidden_state_source,
|
| 886 |
+
"hidden_state_index": hidden_state_index,
|
| 887 |
+
"store_all_hidden_states": store_all_hidden_states,
|
| 888 |
+
"decoder_input_fingerprint": decoder_input_fingerprint,
|
| 889 |
+
"decoder_attention_mask_fingerprint": decoder_mask_fingerprint,
|
| 890 |
+
"decoder_alignment": "input-position" if hidden_state_source == "decoder" else None,
|
| 891 |
+
}
|
| 892 |
+
metadata_hook = getattr(model, "_embedding_metadata", None)
|
| 893 |
+
model_metadata: Mapping[str, Any] | None = None
|
| 894 |
+
if callable(metadata_hook):
|
| 895 |
+
model_metadata = metadata_hook(**context)
|
| 896 |
+
if not isinstance(model_metadata, Mapping):
|
| 897 |
+
raise TypeError("_embedding_metadata must return a mapping.")
|
| 898 |
+
context["model_embedding"] = _fingerprint_jsonable(model_metadata)
|
| 899 |
+
if hidden_state_source == "decoder":
|
| 900 |
+
has_decoder_batch = callable(getattr(model, "_embedding_batch", None))
|
| 901 |
+
declares_decoder_stack = (
|
| 902 |
+
model_metadata is not None and model_metadata.get("hidden_state_stack") == "decoder"
|
| 903 |
+
)
|
| 904 |
+
if not has_decoder_batch or not declares_decoder_stack:
|
| 905 |
+
raise ValueError(
|
| 906 |
+
f"{model.__class__.__name__} does not declare decoder embedding support."
|
| 907 |
+
)
|
| 908 |
+
return context, normalized_decoder_inputs
|
| 909 |
+
|
| 910 |
+
|
| 911 |
+
def _planned_batches(
|
| 912 |
+
records: Sequence[EmbeddingInput],
|
| 913 |
+
positions: range,
|
| 914 |
+
*,
|
| 915 |
+
batch_size: int,
|
| 916 |
+
max_tokens_per_batch: int | None,
|
| 917 |
+
max_length: int | None,
|
| 918 |
+
truncate: bool,
|
| 919 |
+
) -> Iterator[list[int]]:
|
| 920 |
+
"""Length-bucket one bounded window while retaining stable output positions."""
|
| 921 |
+
|
| 922 |
+
def effective_length(position: int) -> int:
|
| 923 |
+
length = len(records[position].sequence)
|
| 924 |
+
return min(length, max_length) if truncate and max_length is not None else length
|
| 925 |
+
|
| 926 |
+
ordered = sorted(positions, key=lambda position: (-effective_length(position), position))
|
| 927 |
+
batch: list[int] = []
|
| 928 |
+
longest = 0
|
| 929 |
+
for position in ordered:
|
| 930 |
+
length = effective_length(position)
|
| 931 |
+
if max_tokens_per_batch is not None and length > max_tokens_per_batch:
|
| 932 |
+
raise ValueError(
|
| 933 |
+
f"Input at position {position} has {length} residues, exceeding "
|
| 934 |
+
f"max_tokens_per_batch={max_tokens_per_batch}."
|
| 935 |
+
)
|
| 936 |
+
candidate_longest = max(longest, length)
|
| 937 |
+
exceeds_tokens = (
|
| 938 |
+
max_tokens_per_batch is not None
|
| 939 |
+
and candidate_longest * (len(batch) + 1) > max_tokens_per_batch
|
| 940 |
+
)
|
| 941 |
+
if batch and (len(batch) >= batch_size or exceeds_tokens):
|
| 942 |
+
yield batch
|
| 943 |
+
batch = []
|
| 944 |
+
longest = 0
|
| 945 |
+
batch.append(position)
|
| 946 |
+
longest = max(longest, length)
|
| 947 |
+
if batch:
|
| 948 |
+
yield batch
|
| 949 |
+
|
| 950 |
+
|
| 951 |
+
def embed_dataset(
|
| 952 |
+
model: Any,
|
| 953 |
+
inputs: (Iterable[str | EmbeddingInput | tuple[str, str]] | Mapping[str, str] | str | Path),
|
| 954 |
+
*,
|
| 955 |
+
batch_size: int = 2,
|
| 956 |
+
pooling: str | Sequence[str] | None = None,
|
| 957 |
+
full_embeddings: bool = False,
|
| 958 |
+
output: str | Path | None = None,
|
| 959 |
+
format: str = "safetensors",
|
| 960 |
+
resume: bool = True,
|
| 961 |
+
tokenizer: Any | None = None,
|
| 962 |
+
max_length: int | None = None,
|
| 963 |
+
truncate: bool = True,
|
| 964 |
+
dtype: torch.dtype | None = torch.float32,
|
| 965 |
+
shard_size: int = 2 * 1024**3,
|
| 966 |
+
model_state_fingerprint: str | None = None,
|
| 967 |
+
batch_window_size: int | None = None,
|
| 968 |
+
max_tokens_per_batch: int | None = None,
|
| 969 |
+
hidden_state_source: str = "encoder",
|
| 970 |
+
decoder_inputs: Sequence[str] | None = None,
|
| 971 |
+
decoder_input_ids: Tensor | None = None,
|
| 972 |
+
decoder_attention_mask: Tensor | None = None,
|
| 973 |
+
_embedding_batch_fn: Callable[..., EmbeddingBatch] | None = None,
|
| 974 |
+
_embedding_batch_identity: Mapping[str, Any] | None = None,
|
| 975 |
+
_allowed_unsupported_pooling: Sequence[str] = (),
|
| 976 |
+
**model_kwargs: Any,
|
| 977 |
+
) -> EmbeddingResult:
|
| 978 |
+
"""Embed protein sequences with stable ordering and residue-only pooling."""
|
| 979 |
+
|
| 980 |
+
for name, value in (
|
| 981 |
+
("batch_size", batch_size),
|
| 982 |
+
("shard_size", shard_size),
|
| 983 |
+
):
|
| 984 |
+
if not isinstance(value, int) or isinstance(value, bool):
|
| 985 |
+
raise TypeError(f"{name} must be a positive integer.")
|
| 986 |
+
if value <= 0:
|
| 987 |
+
raise ValueError(f"{name} must be a positive integer.")
|
| 988 |
+
for optional_name, optional_value in (
|
| 989 |
+
("max_length", max_length),
|
| 990 |
+
("max_tokens_per_batch", max_tokens_per_batch),
|
| 991 |
+
("batch_window_size", batch_window_size),
|
| 992 |
+
):
|
| 993 |
+
if optional_value is not None and (
|
| 994 |
+
not isinstance(optional_value, int) or isinstance(optional_value, bool)
|
| 995 |
+
):
|
| 996 |
+
raise TypeError(f"{optional_name} must be a positive integer when provided.")
|
| 997 |
+
if optional_value is not None and optional_value <= 0:
|
| 998 |
+
raise ValueError(f"{optional_name} must be a positive integer when provided.")
|
| 999 |
+
for name, value in (
|
| 1000 |
+
("full_embeddings", full_embeddings),
|
| 1001 |
+
("resume", resume),
|
| 1002 |
+
("truncate", truncate),
|
| 1003 |
+
):
|
| 1004 |
+
if not isinstance(value, bool):
|
| 1005 |
+
raise TypeError(f"{name} must be a boolean.")
|
| 1006 |
+
if not isinstance(format, str):
|
| 1007 |
+
raise TypeError("format must be a string.")
|
| 1008 |
+
if output is not None and not isinstance(output, (str, Path)):
|
| 1009 |
+
raise TypeError("output must be a path or None.")
|
| 1010 |
+
if model_state_fingerprint is not None and (
|
| 1011 |
+
not isinstance(model_state_fingerprint, str) or not model_state_fingerprint
|
| 1012 |
+
):
|
| 1013 |
+
raise ValueError("model_state_fingerprint must be a non-empty string when provided.")
|
| 1014 |
+
if hidden_state_source not in {"encoder", "decoder"}:
|
| 1015 |
+
raise ValueError("hidden_state_source must be 'encoder' or 'decoder'.")
|
| 1016 |
+
hidden_state_index = model_kwargs.get("hidden_state_index", -1)
|
| 1017 |
+
if not isinstance(hidden_state_index, int) or isinstance(hidden_state_index, bool):
|
| 1018 |
+
raise TypeError("hidden_state_index must be an integer.")
|
| 1019 |
+
store_all_hidden_states = model_kwargs.get("store_all_hidden_states", False)
|
| 1020 |
+
if not isinstance(store_all_hidden_states, bool):
|
| 1021 |
+
raise TypeError("store_all_hidden_states must be a boolean.")
|
| 1022 |
+
if decoder_input_ids is not None:
|
| 1023 |
+
if not isinstance(decoder_input_ids, Tensor):
|
| 1024 |
+
raise TypeError("decoder_input_ids must be a tensor.")
|
| 1025 |
+
if decoder_input_ids.is_meta:
|
| 1026 |
+
raise ValueError("decoder_input_ids cannot be a meta tensor.")
|
| 1027 |
+
if decoder_input_ids.ndim != 2 or decoder_input_ids.shape[1] == 0:
|
| 1028 |
+
raise ValueError("decoder_input_ids must have non-empty shape (batch, sequence).")
|
| 1029 |
+
if decoder_input_ids.dtype not in {torch.int32, torch.int64}:
|
| 1030 |
+
raise TypeError("decoder_input_ids must use torch.int32 or torch.int64.")
|
| 1031 |
+
if decoder_attention_mask is not None:
|
| 1032 |
+
if not isinstance(decoder_attention_mask, Tensor):
|
| 1033 |
+
raise TypeError("decoder_attention_mask must be a tensor.")
|
| 1034 |
+
if decoder_attention_mask.is_meta:
|
| 1035 |
+
raise ValueError("decoder_attention_mask cannot be a meta tensor.")
|
| 1036 |
+
if decoder_attention_mask.is_complex() or not bool(
|
| 1037 |
+
torch.isfinite(decoder_attention_mask).all()
|
| 1038 |
+
):
|
| 1039 |
+
raise ValueError("decoder_attention_mask must contain finite binary values.")
|
| 1040 |
+
if not bool(((decoder_attention_mask == 0) | (decoder_attention_mask == 1)).all()):
|
| 1041 |
+
raise ValueError("decoder_attention_mask must contain finite binary values.")
|
| 1042 |
+
pooling_names = (
|
| 1043 |
+
(("mean",) if not full_embeddings else ())
|
| 1044 |
+
if pooling is None
|
| 1045 |
+
else ((pooling,) if isinstance(pooling, str) else tuple(pooling))
|
| 1046 |
+
)
|
| 1047 |
+
if full_embeddings and pooling is not None:
|
| 1048 |
+
raise ValueError("full_embeddings=True cannot be combined with pooling.")
|
| 1049 |
+
if not full_embeddings and not pooling_names:
|
| 1050 |
+
raise ValueError("pooling is required unless full_embeddings=True.")
|
| 1051 |
+
pooler = Pooler(pooling_names) if pooling_names else None
|
| 1052 |
+
|
| 1053 |
+
if batch_size <= 0:
|
| 1054 |
+
raise ValueError("batch_size must be positive.")
|
| 1055 |
+
if format == "pth" or (output is not None and Path(output).suffix.lower() == ".pth"):
|
| 1056 |
+
raise ValueError("Writing pickle-based .pth embeddings is not supported.")
|
| 1057 |
+
if format not in _SUPPORTED_STORAGE_FORMATS:
|
| 1058 |
+
raise ValueError("format must be 'safetensors' or 'sqlite'.")
|
| 1059 |
+
if max_length is not None and max_length <= 0:
|
| 1060 |
+
raise ValueError("max_length must be positive when provided.")
|
| 1061 |
+
if max_tokens_per_batch is not None and max_tokens_per_batch <= 0:
|
| 1062 |
+
raise ValueError("max_tokens_per_batch must be positive when provided.")
|
| 1063 |
+
if not isinstance(dtype, (torch.dtype, type(None))):
|
| 1064 |
+
raise TypeError("dtype must be a torch.dtype or None.")
|
| 1065 |
+
if batch_window_size is not None and batch_window_size <= 0:
|
| 1066 |
+
raise ValueError("batch_window_size must be positive when provided.")
|
| 1067 |
+
if _embedding_batch_fn is not None and not callable(_embedding_batch_fn):
|
| 1068 |
+
raise TypeError("_embedding_batch_fn must be callable when provided.")
|
| 1069 |
+
if _embedding_batch_fn is not None and _embedding_batch_identity is None:
|
| 1070 |
+
raise ValueError(
|
| 1071 |
+
"_embedding_batch_identity is required with _embedding_batch_fn so persisted "
|
| 1072 |
+
"runs bind the family-specific embedding behavior."
|
| 1073 |
+
)
|
| 1074 |
+
if _embedding_batch_identity is not None and not isinstance(_embedding_batch_identity, Mapping):
|
| 1075 |
+
raise TypeError("_embedding_batch_identity must be a mapping when provided.")
|
| 1076 |
+
if isinstance(_allowed_unsupported_pooling, (str, bytes)) or not isinstance(
|
| 1077 |
+
_allowed_unsupported_pooling, Sequence
|
| 1078 |
+
):
|
| 1079 |
+
raise TypeError("_allowed_unsupported_pooling must be a sequence of pooler names.")
|
| 1080 |
+
if not all(isinstance(name, str) for name in _allowed_unsupported_pooling):
|
| 1081 |
+
raise TypeError("_allowed_unsupported_pooling must contain only strings.")
|
| 1082 |
+
allowed_unsupported_pooling = frozenset(_allowed_unsupported_pooling)
|
| 1083 |
+
if allowed_unsupported_pooling and _embedding_batch_fn is None:
|
| 1084 |
+
raise ValueError(
|
| 1085 |
+
"_allowed_unsupported_pooling is only valid with a family-specific _embedding_batch_fn."
|
| 1086 |
+
)
|
| 1087 |
+
resolved_batch_window_size = (
|
| 1088 |
+
batch_size * _DEFAULT_BATCH_WINDOW_MULTIPLIER
|
| 1089 |
+
if batch_window_size is None
|
| 1090 |
+
else batch_window_size
|
| 1091 |
+
)
|
| 1092 |
+
if resolved_batch_window_size < batch_size:
|
| 1093 |
+
raise ValueError("batch_window_size must be at least batch_size.")
|
| 1094 |
+
records = _normalize_inputs(inputs, disk_backed=output is not None)
|
| 1095 |
+
_validate_untruncated_lengths(
|
| 1096 |
+
records,
|
| 1097 |
+
max_length=max_length,
|
| 1098 |
+
truncate=truncate,
|
| 1099 |
+
)
|
| 1100 |
+
pooling_names = (
|
| 1101 |
+
(("mean",) if not full_embeddings else ())
|
| 1102 |
+
if pooling is None
|
| 1103 |
+
else ((pooling,) if isinstance(pooling, str) else tuple(pooling))
|
| 1104 |
+
)
|
| 1105 |
+
if full_embeddings:
|
| 1106 |
+
if pooling is not None:
|
| 1107 |
+
raise ValueError("full_embeddings=True cannot be combined with pooling.")
|
| 1108 |
+
elif not pooling_names:
|
| 1109 |
+
raise ValueError("pooling is required unless full_embeddings=True.")
|
| 1110 |
+
store_all_hidden_states = bool(model_kwargs.get("store_all_hidden_states", False))
|
| 1111 |
+
if store_all_hidden_states and not full_embeddings:
|
| 1112 |
+
raise ValueError("store_all_hidden_states=True requires full_embeddings=True.")
|
| 1113 |
+
|
| 1114 |
+
unsupported = set(getattr(model, "embedding_unsupported_pooling", ()))
|
| 1115 |
+
unknown_pooling_overrides = allowed_unsupported_pooling.difference(unsupported)
|
| 1116 |
+
if unknown_pooling_overrides:
|
| 1117 |
+
raise ValueError(
|
| 1118 |
+
"_allowed_unsupported_pooling may only override poolers declared unsupported "
|
| 1119 |
+
f"by the model; unknown overrides: {sorted(unknown_pooling_overrides)}."
|
| 1120 |
+
)
|
| 1121 |
+
unsupported.difference_update(allowed_unsupported_pooling)
|
| 1122 |
+
requested_unsupported = unsupported.intersection(pooling_names)
|
| 1123 |
+
if requested_unsupported:
|
| 1124 |
+
raise ValueError(
|
| 1125 |
+
f"{model.__class__.__name__} does not support pooling operations "
|
| 1126 |
+
f"{sorted(requested_unsupported)}."
|
| 1127 |
+
)
|
| 1128 |
+
|
| 1129 |
+
# Constructing the pooler validates names and duplicate operations before
|
| 1130 |
+
# any checkpoint hashing, tokenization, or inference occurs.
|
| 1131 |
+
pooler = Pooler(pooling_names) if pooling_names else None
|
| 1132 |
+
embedding_context, normalized_decoder_inputs = _embedding_context(
|
| 1133 |
+
model,
|
| 1134 |
+
records,
|
| 1135 |
+
hidden_state_source=hidden_state_source,
|
| 1136 |
+
decoder_inputs=decoder_inputs,
|
| 1137 |
+
decoder_input_ids=decoder_input_ids,
|
| 1138 |
+
decoder_attention_mask=decoder_attention_mask,
|
| 1139 |
+
model_kwargs=model_kwargs,
|
| 1140 |
+
)
|
| 1141 |
+
if _embedding_batch_identity is not None:
|
| 1142 |
+
embedding_context["family_adapter"] = _fingerprint_jsonable(_embedding_batch_identity)
|
| 1143 |
+
if allowed_unsupported_pooling:
|
| 1144 |
+
embedding_context["family_adapter_pooling_override"] = sorted(
|
| 1145 |
+
allowed_unsupported_pooling
|
| 1146 |
+
)
|
| 1147 |
+
|
| 1148 |
+
tokenizer_metadata = _tokenizer_metadata(model, tokenizer)
|
| 1149 |
+
(
|
| 1150 |
+
input_fingerprint,
|
| 1151 |
+
run_fingerprint,
|
| 1152 |
+
resolved_model_state_fingerprint,
|
| 1153 |
+
model_state_fingerprint_source,
|
| 1154 |
+
) = _run_fingerprint(
|
| 1155 |
+
model,
|
| 1156 |
+
records,
|
| 1157 |
+
pooling=pooling_names,
|
| 1158 |
+
full_embeddings=full_embeddings,
|
| 1159 |
+
max_length=max_length,
|
| 1160 |
+
truncate=truncate,
|
| 1161 |
+
dtype=dtype,
|
| 1162 |
+
model_kwargs=model_kwargs,
|
| 1163 |
+
tokenizer_metadata=tokenizer_metadata,
|
| 1164 |
+
model_state_fingerprint=model_state_fingerprint,
|
| 1165 |
+
persist_output=output is not None,
|
| 1166 |
+
embedding_context=embedding_context,
|
| 1167 |
+
batch_size=batch_size,
|
| 1168 |
+
batch_window_size=resolved_batch_window_size,
|
| 1169 |
+
max_tokens_per_batch=max_tokens_per_batch,
|
| 1170 |
+
)
|
| 1171 |
+
output_already_exists = output is not None and _output_exists(output, format)
|
| 1172 |
+
existing: EmbeddingResult | None = None
|
| 1173 |
+
start_position = 0
|
| 1174 |
+
if output is not None and resume and output_already_exists:
|
| 1175 |
+
if format == "sqlite":
|
| 1176 |
+
try:
|
| 1177 |
+
existing = load_sqlite_result(output, run_id=run_fingerprint)
|
| 1178 |
+
except KeyError:
|
| 1179 |
+
existing = load_result(output, format=format)
|
| 1180 |
+
else:
|
| 1181 |
+
existing = load_result(output, format=format)
|
| 1182 |
+
if existing.metadata.get("fingerprint_schema_version") != (_RUN_FINGERPRINT_SCHEMA_VERSION):
|
| 1183 |
+
raise ValueError(
|
| 1184 |
+
"Existing embeddings use an incompatible run fingerprint schema; "
|
| 1185 |
+
"choose another output or set resume=False."
|
| 1186 |
+
)
|
| 1187 |
+
if existing.metadata.get("run_fingerprint") != run_fingerprint:
|
| 1188 |
+
raise ValueError(
|
| 1189 |
+
"Existing embeddings were produced by a different run fingerprint; "
|
| 1190 |
+
"choose another output or set resume=False."
|
| 1191 |
+
)
|
| 1192 |
+
if len(existing) > len(records):
|
| 1193 |
+
raise ValueError(
|
| 1194 |
+
"Existing embeddings are not an ordered prefix of the requested inputs."
|
| 1195 |
+
)
|
| 1196 |
+
prefix_matches = all(
|
| 1197 |
+
(observed.id, observed.sequence) == (expected.id, expected.sequence)
|
| 1198 |
+
for expected, observed in zip(records, existing, strict=False)
|
| 1199 |
+
)
|
| 1200 |
+
if not prefix_matches:
|
| 1201 |
+
raise ValueError(
|
| 1202 |
+
"Existing embeddings are not an ordered prefix of the requested inputs."
|
| 1203 |
+
)
|
| 1204 |
+
if len(existing) == len(records) and existing.metadata.get("complete", True):
|
| 1205 |
+
return existing
|
| 1206 |
+
start_position = len(existing)
|
| 1207 |
+
|
| 1208 |
+
sqlite_run_id: str | None = None
|
| 1209 |
+
sqlite_replace_on_first_commit = False
|
| 1210 |
+
sqlite_initial_metadata: dict[str, Any] | None = None
|
| 1211 |
+
if output is not None and format == "sqlite":
|
| 1212 |
+
sqlite_initial_metadata = {
|
| 1213 |
+
"format_version": 1,
|
| 1214 |
+
"fingerprint_schema_version": _RUN_FINGERPRINT_SCHEMA_VERSION,
|
| 1215 |
+
"run_fingerprint": run_fingerprint,
|
| 1216 |
+
"input_fingerprint": input_fingerprint,
|
| 1217 |
+
"model_state_fingerprint": resolved_model_state_fingerprint,
|
| 1218 |
+
"model_state_fingerprint_source": model_state_fingerprint_source,
|
| 1219 |
+
"complete": False,
|
| 1220 |
+
}
|
| 1221 |
+
sqlite_run_id = run_fingerprint
|
| 1222 |
+
if not resume and output_already_exists:
|
| 1223 |
+
try:
|
| 1224 |
+
load_sqlite_result(output, run_id=run_fingerprint)
|
| 1225 |
+
except KeyError:
|
| 1226 |
+
pass
|
| 1227 |
+
else:
|
| 1228 |
+
# Keep an exact prior run readable until replacement inference
|
| 1229 |
+
# has produced the first complete commit window.
|
| 1230 |
+
sqlite_replace_on_first_commit = True
|
| 1231 |
+
if not sqlite_replace_on_first_commit:
|
| 1232 |
+
initialize_sqlite_run(
|
| 1233 |
+
output,
|
| 1234 |
+
sqlite_initial_metadata,
|
| 1235 |
+
resume=resume,
|
| 1236 |
+
)
|
| 1237 |
+
|
| 1238 |
+
stream_safetensors = output is not None and format == "safetensors"
|
| 1239 |
+
attention_backend = _attention_backend(model)
|
| 1240 |
+
output_records: list[EmbeddingRecord] = (
|
| 1241 |
+
[] if sqlite_run_id is not None or stream_safetensors else list(existing or ())
|
| 1242 |
+
)
|
| 1243 |
+
output_descriptors: list[dict[str, Any]] | None = [] if output is None else None
|
| 1244 |
+
pool_slices: dict[str, tuple[int, int]] = {}
|
| 1245 |
+
if existing and pooler is not None:
|
| 1246 |
+
pooled_width = existing[0].load_tensor().shape[-1]
|
| 1247 |
+
if pooled_width % len(pooling_names) != 0:
|
| 1248 |
+
raise ValueError("Stored pooled width is inconsistent with pooling metadata.")
|
| 1249 |
+
pool_slices = pooler.output_slices(pooled_width // len(pooling_names))
|
| 1250 |
+
|
| 1251 |
+
safetensors_writer: SafetensorsStreamWriter | None = None
|
| 1252 |
+
if stream_safetensors:
|
| 1253 |
+
if output is None:
|
| 1254 |
+
raise RuntimeError("Safetensors streaming was enabled without an output destination.")
|
| 1255 |
+
transactional_overwrite = output_already_exists and not resume
|
| 1256 |
+
safetensors_writer = SafetensorsStreamWriter(
|
| 1257 |
+
output,
|
| 1258 |
+
{
|
| 1259 |
+
"format_version": 1,
|
| 1260 |
+
"fingerprint_schema_version": _RUN_FINGERPRINT_SCHEMA_VERSION,
|
| 1261 |
+
"run_fingerprint": run_fingerprint,
|
| 1262 |
+
"input_fingerprint": input_fingerprint,
|
| 1263 |
+
"model_state_fingerprint": resolved_model_state_fingerprint,
|
| 1264 |
+
"model_state_fingerprint_source": model_state_fingerprint_source,
|
| 1265 |
+
"complete": False,
|
| 1266 |
+
},
|
| 1267 |
+
shard_size=shard_size,
|
| 1268 |
+
existing=existing or (),
|
| 1269 |
+
reuse_existing=bool(resume and existing is not None),
|
| 1270 |
+
publish_initial=not transactional_overwrite,
|
| 1271 |
+
publish_incremental=not transactional_overwrite,
|
| 1272 |
+
)
|
| 1273 |
+
need_attentions = "parti" in pooling_names
|
| 1274 |
+
|
| 1275 |
+
config = getattr(model, "config", None)
|
| 1276 |
+
model_type = str(getattr(config, "model_type", "")).lower()
|
| 1277 |
+
resolved_tokenizer = tokenizer if tokenizer is not None else getattr(model, "tokenizer", None)
|
| 1278 |
+
with _temporary_eval(model), torch.inference_mode():
|
| 1279 |
+
for window_start in range(start_position, len(records), resolved_batch_window_size):
|
| 1280 |
+
window_stop = min(window_start + resolved_batch_window_size, len(records))
|
| 1281 |
+
window_records = records[window_start:window_stop]
|
| 1282 |
+
if not isinstance(window_records, Sequence):
|
| 1283 |
+
raise RuntimeError("The immutable embedding spool returned a non-sequence window.")
|
| 1284 |
+
window_results: dict[int, EmbeddingRecord] = {}
|
| 1285 |
+
for local_positions in _planned_batches(
|
| 1286 |
+
window_records,
|
| 1287 |
+
range(len(window_records)),
|
| 1288 |
+
batch_size=batch_size,
|
| 1289 |
+
max_tokens_per_batch=max_tokens_per_batch,
|
| 1290 |
+
max_length=max_length,
|
| 1291 |
+
truncate=truncate,
|
| 1292 |
+
):
|
| 1293 |
+
batch_positions = [window_start + position for position in local_positions]
|
| 1294 |
+
batch_records = [window_records[position] for position in local_positions]
|
| 1295 |
+
sequences = [
|
| 1296 |
+
record.sequence[:max_length]
|
| 1297 |
+
if truncate and max_length is not None
|
| 1298 |
+
else record.sequence
|
| 1299 |
+
for record in batch_records
|
| 1300 |
+
]
|
| 1301 |
+
batch_model_kwargs = dict(model_kwargs)
|
| 1302 |
+
if model_type == "fast_ankh" or hidden_state_source == "decoder":
|
| 1303 |
+
batch_model_kwargs["hidden_state_source"] = hidden_state_source
|
| 1304 |
+
if normalized_decoder_inputs is not None:
|
| 1305 |
+
batch_model_kwargs["decoder_inputs"] = [
|
| 1306 |
+
normalized_decoder_inputs[position] for position in batch_positions
|
| 1307 |
+
]
|
| 1308 |
+
if decoder_input_ids is not None:
|
| 1309 |
+
indices = torch.tensor(
|
| 1310 |
+
batch_positions,
|
| 1311 |
+
device=decoder_input_ids.device,
|
| 1312 |
+
dtype=torch.long,
|
| 1313 |
+
)
|
| 1314 |
+
batch_model_kwargs["decoder_input_ids"] = decoder_input_ids.index_select(
|
| 1315 |
+
0, indices
|
| 1316 |
+
)
|
| 1317 |
+
if decoder_attention_mask is not None:
|
| 1318 |
+
indices = torch.tensor(
|
| 1319 |
+
batch_positions,
|
| 1320 |
+
device=decoder_attention_mask.device,
|
| 1321 |
+
dtype=torch.long,
|
| 1322 |
+
)
|
| 1323 |
+
batch_model_kwargs["decoder_attention_mask"] = (
|
| 1324 |
+
decoder_attention_mask.index_select(0, indices)
|
| 1325 |
+
)
|
| 1326 |
+
custom_batch = _embedding_batch_fn or getattr(model, "_embedding_batch", None)
|
| 1327 |
+
if custom_batch is not None:
|
| 1328 |
+
if model_type == "fast_ankh":
|
| 1329 |
+
batch = custom_batch(
|
| 1330 |
+
sequences,
|
| 1331 |
+
tokenizer=resolved_tokenizer,
|
| 1332 |
+
max_length=max_length,
|
| 1333 |
+
truncate=truncate,
|
| 1334 |
+
need_attentions=need_attentions,
|
| 1335 |
+
**batch_model_kwargs,
|
| 1336 |
+
)
|
| 1337 |
+
else:
|
| 1338 |
+
batch = custom_batch(sequences, **batch_model_kwargs)
|
| 1339 |
+
if not isinstance(batch, EmbeddingBatch):
|
| 1340 |
+
raise TypeError("_embedding_batch must return EmbeddingBatch.")
|
| 1341 |
+
else:
|
| 1342 |
+
batch = _generic_embedding_batch(
|
| 1343 |
+
model,
|
| 1344 |
+
sequences,
|
| 1345 |
+
tokenizer=tokenizer,
|
| 1346 |
+
max_length=max_length,
|
| 1347 |
+
truncate=truncate,
|
| 1348 |
+
need_attentions=need_attentions,
|
| 1349 |
+
model_kwargs=batch_model_kwargs,
|
| 1350 |
+
)
|
| 1351 |
+
X = batch.X
|
| 1352 |
+
raw_mask = batch.residue_mask
|
| 1353 |
+
if not isinstance(X, Tensor) or not isinstance(raw_mask, Tensor):
|
| 1354 |
+
raise TypeError("Embedding batches must provide Tensor X and residue_mask.")
|
| 1355 |
+
if X.is_meta or raw_mask.is_meta:
|
| 1356 |
+
raise ValueError("Embedding batches cannot contain meta tensors.")
|
| 1357 |
+
if not X.is_floating_point():
|
| 1358 |
+
raise TypeError("Embedding batches must use a floating-point X dtype.")
|
| 1359 |
+
if raw_mask.is_complex() or not bool(torch.isfinite(raw_mask).all()):
|
| 1360 |
+
raise ValueError("Embedding residue_mask must contain finite binary values.")
|
| 1361 |
+
if not bool(((raw_mask == 0) | (raw_mask == 1)).all()):
|
| 1362 |
+
raise ValueError("Embedding residue_mask must contain finite binary values.")
|
| 1363 |
+
M = raw_mask.to(device=X.device, dtype=torch.bool)
|
| 1364 |
+
valid_X_shape = (
|
| 1365 |
+
X.ndim == 3
|
| 1366 |
+
and X.shape[0] == len(batch_records)
|
| 1367 |
+
and X.shape[-1] > 0
|
| 1368 |
+
and M.shape == X.shape[:2]
|
| 1369 |
+
)
|
| 1370 |
+
valid_all_states_shape = (
|
| 1371 |
+
X.ndim == 4
|
| 1372 |
+
and store_all_hidden_states
|
| 1373 |
+
and full_embeddings
|
| 1374 |
+
and X.shape[0] == len(batch_records)
|
| 1375 |
+
and X.shape[1] > 0
|
| 1376 |
+
and X.shape[-1] > 0
|
| 1377 |
+
and M.shape == (X.shape[0], X.shape[2])
|
| 1378 |
+
)
|
| 1379 |
+
if not (valid_X_shape or valid_all_states_shape):
|
| 1380 |
+
raise ValueError(
|
| 1381 |
+
"Embedding batches must provide X with shape (b, l, d), or "
|
| 1382 |
+
"(b, states, l, d) when storing all hidden states, and "
|
| 1383 |
+
"residue_mask with shape (b, l)."
|
| 1384 |
+
)
|
| 1385 |
+
if not bool(M.any(dim=1).all()):
|
| 1386 |
+
raise ValueError("Every embedding sample must contain a biological residue.")
|
| 1387 |
+
finite_selected = (
|
| 1388 |
+
torch.isfinite(X) | ~M.unsqueeze(-1)
|
| 1389 |
+
if X.ndim == 3
|
| 1390 |
+
else torch.isfinite(X) | ~M[:, None, :, None]
|
| 1391 |
+
)
|
| 1392 |
+
if not bool(finite_selected.all()):
|
| 1393 |
+
raise ValueError("Biological residue embeddings produced non-finite output.")
|
| 1394 |
+
if need_attentions:
|
| 1395 |
+
# Validate the biological graph only after mask integrity is established.
|
| 1396 |
+
_validate_parti_length(M)
|
| 1397 |
+
if dtype is not None:
|
| 1398 |
+
X = X.to(dtype=dtype)
|
| 1399 |
+
|
| 1400 |
+
if full_embeddings:
|
| 1401 |
+
if X.ndim == 4:
|
| 1402 |
+
values = [
|
| 1403 |
+
X_i[:, M_i, :].detach().cpu() for X_i, M_i in zip(X, M, strict=True)
|
| 1404 |
+
]
|
| 1405 |
+
else:
|
| 1406 |
+
values = [X_i[M_i].detach().cpu() for X_i, M_i in zip(X, M, strict=True)]
|
| 1407 |
+
else:
|
| 1408 |
+
if pooler is None:
|
| 1409 |
+
raise RuntimeError(
|
| 1410 |
+
"Pooled embedding output was requested without an initialized pooler."
|
| 1411 |
+
)
|
| 1412 |
+
Y = pooler(
|
| 1413 |
+
X,
|
| 1414 |
+
M,
|
| 1415 |
+
attentions=batch.attentions,
|
| 1416 |
+
attention_backend=attention_backend,
|
| 1417 |
+
)
|
| 1418 |
+
pool_slices = pooler.output_slices(X.shape[-1])
|
| 1419 |
+
values = list(Y.detach().cpu().unbind(0))
|
| 1420 |
+
for position, record, value in zip(
|
| 1421 |
+
batch_positions, batch_records, values, strict=True
|
| 1422 |
+
):
|
| 1423 |
+
window_results[position] = EmbeddingRecord(record.id, record.sequence, value)
|
| 1424 |
+
|
| 1425 |
+
new_records = [
|
| 1426 |
+
window_results[position] for position in range(window_start, window_stop)
|
| 1427 |
+
]
|
| 1428 |
+
if output_descriptors is not None:
|
| 1429 |
+
output_descriptors.extend(
|
| 1430 |
+
_output_descriptor(window_start + offset, record)
|
| 1431 |
+
for offset, record in enumerate(new_records)
|
| 1432 |
+
)
|
| 1433 |
+
if output is not None and sqlite_run_id is not None:
|
| 1434 |
+
append_sqlite_records(
|
| 1435 |
+
output,
|
| 1436 |
+
sqlite_run_id,
|
| 1437 |
+
window_start,
|
| 1438 |
+
new_records,
|
| 1439 |
+
replace_metadata=(
|
| 1440 |
+
sqlite_initial_metadata if sqlite_replace_on_first_commit else None
|
| 1441 |
+
),
|
| 1442 |
+
)
|
| 1443 |
+
sqlite_replace_on_first_commit = False
|
| 1444 |
+
elif safetensors_writer is not None:
|
| 1445 |
+
safetensors_writer.append(new_records)
|
| 1446 |
+
else:
|
| 1447 |
+
output_records.extend(new_records)
|
| 1448 |
+
|
| 1449 |
+
software_versions = _software_versions()
|
| 1450 |
+
projection = getattr(model, "embedding_projection", None)
|
| 1451 |
+
resolved_layer = getattr(
|
| 1452 |
+
model,
|
| 1453 |
+
"embedding_layer",
|
| 1454 |
+
model_kwargs.get("hidden_state_index", -1),
|
| 1455 |
+
)
|
| 1456 |
+
token_policy = getattr(
|
| 1457 |
+
model,
|
| 1458 |
+
"embedding_token_policy",
|
| 1459 |
+
{
|
| 1460 |
+
"unit": "residue",
|
| 1461 |
+
"include": ["biological residues"],
|
| 1462 |
+
"exclude": [
|
| 1463 |
+
"BOS",
|
| 1464 |
+
"EOS",
|
| 1465 |
+
"padding",
|
| 1466 |
+
"chain delimiters",
|
| 1467 |
+
"non-protein tokens",
|
| 1468 |
+
],
|
| 1469 |
+
},
|
| 1470 |
+
)
|
| 1471 |
+
model_identity = _model_identity_metadata(model)
|
| 1472 |
+
metadata: dict[str, Any] = {
|
| 1473 |
+
"format_version": 1,
|
| 1474 |
+
"fingerprint_schema_version": _RUN_FINGERPRINT_SCHEMA_VERSION,
|
| 1475 |
+
"run_fingerprint": run_fingerprint,
|
| 1476 |
+
"input_fingerprint": input_fingerprint,
|
| 1477 |
+
"model_state_fingerprint": resolved_model_state_fingerprint,
|
| 1478 |
+
"model_state_fingerprint_source": model_state_fingerprint_source,
|
| 1479 |
+
"model_class": f"{model.__class__.__module__}.{model.__class__.__qualname__}",
|
| 1480 |
+
**model_identity,
|
| 1481 |
+
"dtype": str(dtype).removeprefix("torch.") if dtype is not None else "model",
|
| 1482 |
+
"attention_backend": attention_backend,
|
| 1483 |
+
"attention_kernel": _attention_kernel_metadata(attention_backend),
|
| 1484 |
+
"layer": resolved_layer,
|
| 1485 |
+
"projection": projection,
|
| 1486 |
+
"esmc_source": getattr(model, "_esmc_source", None),
|
| 1487 |
+
"esmc_revision": getattr(model, "_esmc_source_revision", None),
|
| 1488 |
+
"esmc_files": getattr(model, "_esmc_source_files", None),
|
| 1489 |
+
"token_policy": token_policy,
|
| 1490 |
+
"tokenizer": tokenizer_metadata,
|
| 1491 |
+
**embedding_context,
|
| 1492 |
+
"pooling": list(pooling_names),
|
| 1493 |
+
"pool_slices": pool_slices,
|
| 1494 |
+
"full_embeddings": full_embeddings,
|
| 1495 |
+
"max_length": max_length,
|
| 1496 |
+
"truncate": truncate,
|
| 1497 |
+
"truncation": {"enabled": truncate, "max_length": max_length},
|
| 1498 |
+
"batching": {
|
| 1499 |
+
"batch_size": batch_size,
|
| 1500 |
+
"batch_window_size": resolved_batch_window_size,
|
| 1501 |
+
"max_tokens_per_batch": max_tokens_per_batch,
|
| 1502 |
+
"input_storage": ("disk-spool" if isinstance(records, _InputSpool) else "memory"),
|
| 1503 |
+
"ordering": "bounded-length-bucketed-stable-output",
|
| 1504 |
+
"resume_commit_granularity": (
|
| 1505 |
+
"not-applicable"
|
| 1506 |
+
if output is None
|
| 1507 |
+
else "batch-window"
|
| 1508 |
+
if format == "sqlite"
|
| 1509 |
+
else "shard-flush"
|
| 1510 |
+
),
|
| 1511 |
+
},
|
| 1512 |
+
"residue_mask_policy": "biological-residues-only",
|
| 1513 |
+
"record_count": len(records),
|
| 1514 |
+
"descriptor_index": (
|
| 1515 |
+
"memory-metadata"
|
| 1516 |
+
if output is None
|
| 1517 |
+
else "sqlite-records"
|
| 1518 |
+
if format == "sqlite"
|
| 1519 |
+
else "safetensors-generation-index"
|
| 1520 |
+
),
|
| 1521 |
+
"storage_format": format if output is not None else "memory",
|
| 1522 |
+
"software": software_versions,
|
| 1523 |
+
"execution": _execution_identity_metadata(model),
|
| 1524 |
+
"adapter": _adapter_identity_metadata(model),
|
| 1525 |
+
"torch_version": software_versions["torch"],
|
| 1526 |
+
"transformers_version": software_versions["transformers"],
|
| 1527 |
+
"complete": True,
|
| 1528 |
+
}
|
| 1529 |
+
if output_descriptors is not None:
|
| 1530 |
+
metadata["outputs"] = output_descriptors
|
| 1531 |
+
metadata["tensor_hashes"] = [item["sha256"] for item in output_descriptors]
|
| 1532 |
+
status = getattr(model, "esmc_precision_status", None)
|
| 1533 |
+
if status is not None:
|
| 1534 |
+
metadata["esmc_precision"] = status.as_dict() if hasattr(status, "as_dict") else status
|
| 1535 |
+
if output is not None and sqlite_run_id is not None:
|
| 1536 |
+
update_sqlite_run_metadata(output, sqlite_run_id, metadata)
|
| 1537 |
+
return load_sqlite_result(output, run_id=sqlite_run_id)
|
| 1538 |
+
if safetensors_writer is not None:
|
| 1539 |
+
return safetensors_writer.publish(complete=True, metadata=metadata)
|
| 1540 |
+
result = EmbeddingResult(output_records, metadata)
|
| 1541 |
+
if output is not None:
|
| 1542 |
+
return save_result(result, output, format=format, shard_size=shard_size)
|
| 1543 |
+
return result
|
| 1544 |
+
|
| 1545 |
+
|
| 1546 |
+
class EmbeddingMixin:
|
| 1547 |
+
"""Small delegation mixin shared by FastPLMs model classes."""
|
| 1548 |
+
|
| 1549 |
+
def embed_dataset(self, inputs: Any, **kwargs: Any) -> EmbeddingResult:
|
| 1550 |
+
return embed_dataset(self, inputs, **kwargs)
|
| 1551 |
+
|
| 1552 |
+
|
| 1553 |
+
__all__ = [
|
| 1554 |
+
"EmbeddingMixin",
|
| 1555 |
+
"embed_dataset",
|
| 1556 |
+
"iter_fasta",
|
| 1557 |
+
"parse_fasta",
|
| 1558 |
+
"select_hidden_state_embeddings",
|
| 1559 |
+
]
|
fastplms/embeddings/storage.py
ADDED
|
@@ -0,0 +1,1594 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lossless, reproducible storage for :mod:`fastplms.embeddings`."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import hashlib
|
| 6 |
+
import io
|
| 7 |
+
import json
|
| 8 |
+
import sqlite3
|
| 9 |
+
import struct
|
| 10 |
+
from bisect import bisect_right
|
| 11 |
+
from collections.abc import Iterable, Iterator, Sequence
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any, cast, overload
|
| 14 |
+
from uuid import uuid4
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
import torch
|
| 18 |
+
from torch import Tensor
|
| 19 |
+
|
| 20 |
+
from .types import (
|
| 21 |
+
EmbeddingRecord,
|
| 22 |
+
EmbeddingResult,
|
| 23 |
+
LazyTensorReference,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
_DTYPE_NAMES: dict[torch.dtype, str] = {
|
| 27 |
+
torch.float16: "float16",
|
| 28 |
+
torch.bfloat16: "bfloat16",
|
| 29 |
+
torch.float32: "float32",
|
| 30 |
+
torch.float64: "float64",
|
| 31 |
+
torch.int64: "int64",
|
| 32 |
+
torch.int32: "int32",
|
| 33 |
+
torch.int16: "int16",
|
| 34 |
+
torch.int8: "int8",
|
| 35 |
+
torch.uint8: "uint8",
|
| 36 |
+
torch.bool: "bool",
|
| 37 |
+
}
|
| 38 |
+
_NAME_DTYPES = {name: dtype for dtype, name in _DTYPE_NAMES.items()}
|
| 39 |
+
DEFAULT_SHARD_SIZE = 2 * 1024**3
|
| 40 |
+
_MAX_RECORDS_PER_DESCRIPTOR_SHARD = 1_024
|
| 41 |
+
_TENSOR_HASH_CHUNK_BYTES = 16 * 1024**2
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _jsonable(value: Any) -> Any:
|
| 45 |
+
if isinstance(value, dict):
|
| 46 |
+
return {str(key): _jsonable(item) for key, item in value.items()}
|
| 47 |
+
if isinstance(value, (list, tuple)):
|
| 48 |
+
return [_jsonable(item) for item in value]
|
| 49 |
+
if isinstance(value, Path):
|
| 50 |
+
return str(value)
|
| 51 |
+
if isinstance(value, torch.dtype):
|
| 52 |
+
return str(value).removeprefix("torch.")
|
| 53 |
+
if isinstance(value, torch.device):
|
| 54 |
+
return str(value)
|
| 55 |
+
if value is None or isinstance(value, (str, int, float, bool)):
|
| 56 |
+
return value
|
| 57 |
+
return repr(value)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _persistent_metadata(
|
| 61 |
+
metadata: dict[str, Any],
|
| 62 |
+
*,
|
| 63 |
+
descriptor_index: str,
|
| 64 |
+
record_count: int | None = None,
|
| 65 |
+
) -> dict[str, Any]:
|
| 66 |
+
"""Remove per-record copies from metadata and identify the authoritative index."""
|
| 67 |
+
|
| 68 |
+
cleaned_value = _jsonable(metadata)
|
| 69 |
+
if not isinstance(cleaned_value, dict):
|
| 70 |
+
raise TypeError("Embedding metadata must serialize to a JSON object.")
|
| 71 |
+
cleaned: dict[str, Any] = cleaned_value
|
| 72 |
+
cleaned.pop("outputs", None)
|
| 73 |
+
cleaned.pop("tensor_hashes", None)
|
| 74 |
+
cleaned["descriptor_index"] = descriptor_index
|
| 75 |
+
if record_count is not None:
|
| 76 |
+
cleaned["record_count"] = record_count
|
| 77 |
+
return cleaned
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _tensor_bytes(X: Tensor) -> bytes:
|
| 81 |
+
"""Return the exact contiguous byte representation of X."""
|
| 82 |
+
|
| 83 |
+
X = X.detach().cpu().contiguous()
|
| 84 |
+
return X.view(torch.uint8).numpy().tobytes()
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _bounded_tensor_chunks(X: Tensor, max_bytes: int) -> Iterator[Tensor]:
|
| 88 |
+
"""Yield row-major CPU chunks without materializing one full byte string."""
|
| 89 |
+
|
| 90 |
+
flattened = X.detach().to(device="cpu").reshape(-1)
|
| 91 |
+
if flattened.numel() == 0:
|
| 92 |
+
return
|
| 93 |
+
chunk_elements = max(1, max_bytes // flattened.element_size())
|
| 94 |
+
for start in range(0, flattened.numel(), chunk_elements):
|
| 95 |
+
chunk = flattened[start : start + chunk_elements]
|
| 96 |
+
if chunk.stride(0) != 1:
|
| 97 |
+
chunk = chunk.clone(memory_format=torch.contiguous_format)
|
| 98 |
+
yield chunk
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _tensor_hash_chunks(X: Tensor) -> Iterator[bytes]:
|
| 102 |
+
for chunk in _bounded_tensor_chunks(X, _TENSOR_HASH_CHUNK_BYTES):
|
| 103 |
+
yield chunk.view(torch.uint8).numpy().tobytes()
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def tensor_sha256(X: Tensor) -> str:
|
| 107 |
+
"""Hash dtype, shape, and exact tensor bytes."""
|
| 108 |
+
|
| 109 |
+
if not isinstance(X, Tensor):
|
| 110 |
+
raise TypeError("X must be a tensor.")
|
| 111 |
+
if X.dtype not in _DTYPE_NAMES:
|
| 112 |
+
raise TypeError(f"Unsupported tensor dtype {X.dtype}.")
|
| 113 |
+
if X.is_meta:
|
| 114 |
+
raise ValueError("Cannot hash a meta tensor without storage.")
|
| 115 |
+
if X.layout != torch.strided:
|
| 116 |
+
raise TypeError("Only strided tensors can be hashed.")
|
| 117 |
+
digest = hashlib.sha256()
|
| 118 |
+
digest.update(_DTYPE_NAMES[X.dtype].encode())
|
| 119 |
+
digest.update(json.dumps(tuple(X.shape)).encode())
|
| 120 |
+
for chunk in _tensor_hash_chunks(X):
|
| 121 |
+
digest.update(chunk)
|
| 122 |
+
return digest.hexdigest()
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _encode_tensor(X: Tensor) -> tuple[str, str, bytes]:
|
| 126 |
+
if X.dtype not in _DTYPE_NAMES:
|
| 127 |
+
raise TypeError(f"Unsupported tensor dtype {X.dtype}.")
|
| 128 |
+
shape = json.dumps(tuple(X.shape), separators=(",", ":"))
|
| 129 |
+
return _DTYPE_NAMES[X.dtype], shape, _tensor_bytes(X)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _decode_tensor(dtype_name: str, shape_json: str, data: bytes) -> Tensor:
|
| 133 |
+
try:
|
| 134 |
+
dtype = _NAME_DTYPES[dtype_name]
|
| 135 |
+
except KeyError as error:
|
| 136 |
+
raise ValueError(f"Unsupported stored dtype {dtype_name!r}.") from error
|
| 137 |
+
shape = tuple(json.loads(shape_json))
|
| 138 |
+
# uint8 is used only as a byte-level carrier, preserving BF16 bits exactly.
|
| 139 |
+
byte_array = np.frombuffer(data, dtype=np.uint8).copy()
|
| 140 |
+
X = torch.from_numpy(byte_array).view(dtype)
|
| 141 |
+
return X.reshape(shape).clone()
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def _index_path(path: str | Path) -> Path:
|
| 145 |
+
path = Path(path)
|
| 146 |
+
if path.suffix == ".json":
|
| 147 |
+
return path
|
| 148 |
+
if path.suffix == ".safetensors":
|
| 149 |
+
return path.with_suffix(".json")
|
| 150 |
+
return path / "index.json"
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def _run_manifest_path(path: str | Path) -> Path:
|
| 154 |
+
path = Path(path)
|
| 155 |
+
if path.name == "index.json":
|
| 156 |
+
return path.with_name("run.json")
|
| 157 |
+
if path.suffix == ".json":
|
| 158 |
+
return path.with_name(f"{path.stem}.run.json")
|
| 159 |
+
if path.suffix == ".safetensors":
|
| 160 |
+
return path.with_suffix(".run.json")
|
| 161 |
+
return path / "run.json"
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _resolve_index_child(root: Path, relative: str, *, label: str) -> Path:
|
| 165 |
+
relative_path = Path(relative)
|
| 166 |
+
candidate = (root / relative_path).resolve()
|
| 167 |
+
if relative_path.is_absolute() or candidate.parent != root.resolve():
|
| 168 |
+
raise ValueError(f"Safetensors {label} references a file outside its output directory.")
|
| 169 |
+
return candidate
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _canonical_json_bytes(payload: dict[str, Any]) -> bytes:
|
| 173 |
+
return (json.dumps(payload, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _load_authoritative_index(
|
| 177 |
+
path: str | Path,
|
| 178 |
+
) -> tuple[dict[str, Any], Path, dict[str, Any]]:
|
| 179 |
+
"""Load the index selected by the atomic run-manifest commit record."""
|
| 180 |
+
|
| 181 |
+
stable_index_path = _index_path(path)
|
| 182 |
+
run_manifest_path = _run_manifest_path(path)
|
| 183 |
+
if not run_manifest_path.is_file():
|
| 184 |
+
raise ValueError(f"Missing safetensors run manifest: {run_manifest_path}.")
|
| 185 |
+
run_manifest = json.loads(run_manifest_path.read_text(encoding="utf-8"))
|
| 186 |
+
if not isinstance(run_manifest, dict):
|
| 187 |
+
raise ValueError("Safetensors run manifest must contain a JSON object.")
|
| 188 |
+
if run_manifest.get("format") != "fastplms-embedding-run":
|
| 189 |
+
raise ValueError(f"Not a FastPLMs embedding run manifest: {run_manifest_path}.")
|
| 190 |
+
version = run_manifest.get("version")
|
| 191 |
+
index_reference = run_manifest.get("index")
|
| 192 |
+
if not isinstance(index_reference, dict):
|
| 193 |
+
raise ValueError("Safetensors run manifest contains an invalid index reference.")
|
| 194 |
+
if version == 1:
|
| 195 |
+
snapshot = run_manifest.get("index_payload")
|
| 196 |
+
if isinstance(snapshot, dict):
|
| 197 |
+
payload = snapshot
|
| 198 |
+
index_bytes = _canonical_json_bytes(payload)
|
| 199 |
+
elif snapshot is None:
|
| 200 |
+
index_bytes = stable_index_path.read_bytes()
|
| 201 |
+
payload = json.loads(index_bytes.decode("utf-8"))
|
| 202 |
+
if not isinstance(payload, dict):
|
| 203 |
+
raise ValueError("Safetensors index must contain a JSON object.")
|
| 204 |
+
else:
|
| 205 |
+
raise ValueError("Safetensors run manifest contains an invalid index snapshot.")
|
| 206 |
+
expected = {
|
| 207 |
+
"file": stable_index_path.name,
|
| 208 |
+
"sha256": hashlib.sha256(index_bytes).hexdigest(),
|
| 209 |
+
}
|
| 210 |
+
index_path = stable_index_path
|
| 211 |
+
elif version == 2:
|
| 212 |
+
relative = index_reference.get("file")
|
| 213 |
+
if not isinstance(relative, str):
|
| 214 |
+
raise ValueError("Safetensors run manifest index file is invalid.")
|
| 215 |
+
index_path = _resolve_index_child(stable_index_path.parent, relative, label="run manifest")
|
| 216 |
+
index_bytes = index_path.read_bytes()
|
| 217 |
+
payload = json.loads(index_bytes.decode("utf-8"))
|
| 218 |
+
if not isinstance(payload, dict):
|
| 219 |
+
raise ValueError("Safetensors generation index must contain a JSON object.")
|
| 220 |
+
if payload.get("version") != 2:
|
| 221 |
+
raise ValueError("Safetensors v2 run manifest must reference a v2 generation index.")
|
| 222 |
+
expected = {
|
| 223 |
+
"file": relative,
|
| 224 |
+
"sha256": hashlib.sha256(index_bytes).hexdigest(),
|
| 225 |
+
}
|
| 226 |
+
else:
|
| 227 |
+
raise ValueError(f"Unsupported safetensors run manifest version {version!r}.")
|
| 228 |
+
if index_reference != expected:
|
| 229 |
+
raise ValueError("Safetensors run manifest does not match its index.")
|
| 230 |
+
if payload.get("format") != "fastplms-embedding-safetensors":
|
| 231 |
+
raise ValueError(f"Not a FastPLMs embedding index: {index_path}.")
|
| 232 |
+
record_count = payload.get("record_count")
|
| 233 |
+
if record_count is None:
|
| 234 |
+
legacy_records = payload.get("records", ())
|
| 235 |
+
if not isinstance(legacy_records, list):
|
| 236 |
+
raise ValueError("Safetensors index contains invalid records.")
|
| 237 |
+
record_count = len(legacy_records)
|
| 238 |
+
if not isinstance(record_count, int) or isinstance(record_count, bool) or record_count < 0:
|
| 239 |
+
raise ValueError("Safetensors record count must be a non-negative integer.")
|
| 240 |
+
if run_manifest.get("record_count") != record_count:
|
| 241 |
+
raise ValueError("Safetensors run manifest record count does not match its index.")
|
| 242 |
+
metadata = payload.get("metadata", {})
|
| 243 |
+
if not isinstance(metadata, dict):
|
| 244 |
+
raise ValueError("Safetensors index metadata must contain a JSON object.")
|
| 245 |
+
if metadata.get("record_count", record_count) != record_count:
|
| 246 |
+
raise ValueError("Safetensors metadata record count does not match its index.")
|
| 247 |
+
if version == 1 and run_manifest.get("metadata") != payload.get("metadata"):
|
| 248 |
+
raise ValueError("Safetensors run manifest metadata does not match its index.")
|
| 249 |
+
return payload, index_path, run_manifest
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def safetensors_result_exists(path: str | Path) -> bool:
|
| 253 |
+
"""Return whether an authoritative committed safetensors run exists."""
|
| 254 |
+
|
| 255 |
+
try:
|
| 256 |
+
_load_authoritative_index(path)
|
| 257 |
+
except (OSError, ValueError, json.JSONDecodeError):
|
| 258 |
+
return False
|
| 259 |
+
return True
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def _load_safetensor(path: Path, key: str) -> Tensor:
|
| 263 |
+
try:
|
| 264 |
+
from safetensors import safe_open
|
| 265 |
+
except ImportError as error:
|
| 266 |
+
raise ImportError("Loading embeddings requires the 'safetensors' package.") from error
|
| 267 |
+
with safe_open(path, framework="pt", device="cpu") as handle:
|
| 268 |
+
return cast(Tensor, handle.get_tensor(key))
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def _safetensors_shard_prefix(path: str | Path) -> str:
|
| 272 |
+
requested_path = Path(path)
|
| 273 |
+
if requested_path.suffix in {".json", ".safetensors"}:
|
| 274 |
+
return f"{requested_path.stem}-embeddings"
|
| 275 |
+
return "embeddings"
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def _authoritative_index_payload(path: str | Path) -> dict[str, Any] | None:
|
| 279 |
+
"""Return the last atomically committed generation index when available."""
|
| 280 |
+
|
| 281 |
+
try:
|
| 282 |
+
payload, _, _ = _load_authoritative_index(path)
|
| 283 |
+
except (OSError, ValueError, json.JSONDecodeError):
|
| 284 |
+
return None
|
| 285 |
+
return payload
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
def _referenced_shards(
|
| 289 |
+
index_path: Path,
|
| 290 |
+
payload: dict[str, Any] | None = None,
|
| 291 |
+
) -> set[Path]:
|
| 292 |
+
if payload is None:
|
| 293 |
+
payload = _authoritative_index_payload(index_path)
|
| 294 |
+
if payload is None:
|
| 295 |
+
return set()
|
| 296 |
+
shards: set[Path] = set()
|
| 297 |
+
for descriptor_shard in payload.get("descriptor_shards", ()):
|
| 298 |
+
tensor_file = descriptor_shard.get("tensor_file")
|
| 299 |
+
if isinstance(tensor_file, str):
|
| 300 |
+
candidate = _resolve_index_child(
|
| 301 |
+
index_path.parent, tensor_file, label="descriptor index"
|
| 302 |
+
)
|
| 303 |
+
shards.add(candidate)
|
| 304 |
+
for item in payload.get("records", ()):
|
| 305 |
+
relative = item.get("tensor", {}).get("file")
|
| 306 |
+
if not isinstance(relative, str):
|
| 307 |
+
continue
|
| 308 |
+
candidate = (index_path.parent / relative).resolve()
|
| 309 |
+
if candidate.parent == index_path.parent.resolve():
|
| 310 |
+
shards.add(candidate)
|
| 311 |
+
return shards
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def _validate_tensor_descriptor(
|
| 315 |
+
tensor: dict[str, Any],
|
| 316 |
+
) -> tuple[str, str, tuple[int, ...], str]:
|
| 317 |
+
key = tensor.get("key")
|
| 318 |
+
if not isinstance(key, str) or not key:
|
| 319 |
+
raise ValueError("Safetensors descriptor tensor key is invalid.")
|
| 320 |
+
dtype = tensor.get("dtype")
|
| 321 |
+
if not isinstance(dtype, str) or dtype not in _NAME_DTYPES:
|
| 322 |
+
raise ValueError("Safetensors descriptor tensor dtype is invalid.")
|
| 323 |
+
raw_shape = tensor.get("shape")
|
| 324 |
+
if not isinstance(raw_shape, (list, tuple)) or not all(
|
| 325 |
+
isinstance(dimension, int) and not isinstance(dimension, bool) and dimension >= 0
|
| 326 |
+
for dimension in raw_shape
|
| 327 |
+
):
|
| 328 |
+
raise ValueError("Safetensors descriptor tensor shape is invalid.")
|
| 329 |
+
sha256 = tensor.get("sha256")
|
| 330 |
+
if (
|
| 331 |
+
not isinstance(sha256, str)
|
| 332 |
+
or len(sha256) != 64
|
| 333 |
+
or sha256 != sha256.lower()
|
| 334 |
+
or any(character not in "0123456789abcdef" for character in sha256)
|
| 335 |
+
):
|
| 336 |
+
raise ValueError("Safetensors descriptor tensor SHA-256 is invalid.")
|
| 337 |
+
return key, dtype, tuple(raw_shape), sha256
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
def _record_from_safetensors_descriptor(root: Path, item: dict[str, Any]) -> EmbeddingRecord:
|
| 341 |
+
if not isinstance(item, dict):
|
| 342 |
+
raise ValueError("Safetensors record descriptor must contain a JSON object.")
|
| 343 |
+
record_id = item.get("id")
|
| 344 |
+
sequence = item.get("sequence")
|
| 345 |
+
if not isinstance(record_id, str) or not record_id:
|
| 346 |
+
raise ValueError("Safetensors descriptor record ID is invalid.")
|
| 347 |
+
if not isinstance(sequence, str) or not sequence:
|
| 348 |
+
raise ValueError("Safetensors descriptor sequence is invalid.")
|
| 349 |
+
tensor = item.get("tensor")
|
| 350 |
+
if not isinstance(tensor, dict):
|
| 351 |
+
raise ValueError("Safetensors descriptor is missing tensor metadata.")
|
| 352 |
+
relative = tensor.get("file")
|
| 353 |
+
if not isinstance(relative, str) or not relative:
|
| 354 |
+
raise ValueError("Safetensors descriptor tensor file is invalid.")
|
| 355 |
+
key, dtype, shape, sha256 = _validate_tensor_descriptor(tensor)
|
| 356 |
+
tensor_path = _resolve_index_child(root, relative, label="descriptor")
|
| 357 |
+
if not tensor_path.is_file():
|
| 358 |
+
raise ValueError(f"Safetensors tensor shard is missing: {relative}.")
|
| 359 |
+
|
| 360 |
+
def load_tensor() -> Tensor:
|
| 361 |
+
return _load_safetensor(tensor_path, key)
|
| 362 |
+
|
| 363 |
+
reference = LazyTensorReference(
|
| 364 |
+
source=str(tensor_path),
|
| 365 |
+
key=key,
|
| 366 |
+
dtype=dtype,
|
| 367 |
+
shape=shape,
|
| 368 |
+
sha256=sha256,
|
| 369 |
+
_loader=load_tensor,
|
| 370 |
+
)
|
| 371 |
+
return EmbeddingRecord(record_id, sequence, reference)
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
class _SafetensorsRecordSequence(Sequence[EmbeddingRecord]):
|
| 375 |
+
"""Lazy immutable view over bounded descriptor JSONL shards."""
|
| 376 |
+
|
| 377 |
+
_fastplms_immutable_sequence = True
|
| 378 |
+
|
| 379 |
+
def __init__(self, root: Path, descriptor_shards: Sequence[dict[str, Any]]) -> None:
|
| 380 |
+
if not isinstance(descriptor_shards, (list, tuple)):
|
| 381 |
+
raise ValueError("Safetensors generation index has invalid descriptor shards.")
|
| 382 |
+
self.root = root
|
| 383 |
+
self.shards = tuple(descriptor_shards)
|
| 384 |
+
cumulative: list[int] = []
|
| 385 |
+
total = 0
|
| 386 |
+
for shard in self.shards:
|
| 387 |
+
if not isinstance(shard, dict):
|
| 388 |
+
raise ValueError("Safetensors descriptor shard entry is invalid.")
|
| 389 |
+
relative = shard.get("file")
|
| 390 |
+
declared_count = shard.get("count")
|
| 391 |
+
if (
|
| 392 |
+
not isinstance(declared_count, int)
|
| 393 |
+
or isinstance(declared_count, bool)
|
| 394 |
+
or declared_count < 0
|
| 395 |
+
):
|
| 396 |
+
raise ValueError("Safetensors descriptor shard count is invalid.")
|
| 397 |
+
declared_sha256 = shard.get("sha256")
|
| 398 |
+
if not isinstance(declared_sha256, str) or len(declared_sha256) != 64:
|
| 399 |
+
raise ValueError("Safetensors descriptor shard SHA-256 is invalid.")
|
| 400 |
+
if not isinstance(relative, str):
|
| 401 |
+
raise ValueError("Safetensors descriptor index file is invalid.")
|
| 402 |
+
descriptor_path = _resolve_index_child(root, relative, label="index")
|
| 403 |
+
tensor_file = shard.get("tensor_file")
|
| 404 |
+
if not isinstance(tensor_file, str):
|
| 405 |
+
raise ValueError("Safetensors descriptor tensor file is invalid.")
|
| 406 |
+
tensor_path = _resolve_index_child(root, tensor_file, label="index")
|
| 407 |
+
if not tensor_path.is_file():
|
| 408 |
+
raise ValueError(f"Safetensors tensor shard is missing: {tensor_file}.")
|
| 409 |
+
digest = hashlib.sha256()
|
| 410 |
+
count = 0
|
| 411 |
+
with descriptor_path.open("rb") as handle:
|
| 412 |
+
for line in handle:
|
| 413 |
+
digest.update(line)
|
| 414 |
+
if line.strip():
|
| 415 |
+
item = json.loads(line)
|
| 416 |
+
if not isinstance(item, dict):
|
| 417 |
+
raise ValueError("Safetensors record descriptor must be a JSON object.")
|
| 418 |
+
item_tensor = item.get("tensor")
|
| 419 |
+
if not isinstance(item_tensor, dict):
|
| 420 |
+
raise ValueError("Safetensors descriptor is missing tensor metadata.")
|
| 421 |
+
item_tensor_file = item_tensor.get("file")
|
| 422 |
+
if not isinstance(item_tensor_file, str):
|
| 423 |
+
raise ValueError("Safetensors descriptor tensor file is invalid.")
|
| 424 |
+
_resolve_index_child(root, item_tensor_file, label="descriptor")
|
| 425 |
+
if item_tensor_file != tensor_file:
|
| 426 |
+
raise ValueError(
|
| 427 |
+
"Safetensors descriptor tensor file does not match its shard."
|
| 428 |
+
)
|
| 429 |
+
count += 1
|
| 430 |
+
_validate_tensor_descriptor(item_tensor)
|
| 431 |
+
if digest.hexdigest() != declared_sha256 or count != declared_count:
|
| 432 |
+
raise ValueError(
|
| 433 |
+
f"Safetensors descriptor shard failed integrity validation: {relative}."
|
| 434 |
+
)
|
| 435 |
+
total += count
|
| 436 |
+
cumulative.append(total)
|
| 437 |
+
self._cumulative = tuple(cumulative)
|
| 438 |
+
self._count = total
|
| 439 |
+
|
| 440 |
+
def __len__(self) -> int:
|
| 441 |
+
return self._count
|
| 442 |
+
|
| 443 |
+
def _iter_shard(self, shard_index: int) -> Iterator[EmbeddingRecord]:
|
| 444 |
+
descriptor_path = _resolve_index_child(
|
| 445 |
+
self.root, str(self.shards[shard_index]["file"]), label="index"
|
| 446 |
+
)
|
| 447 |
+
with descriptor_path.open("r", encoding="utf-8") as handle:
|
| 448 |
+
for line in handle:
|
| 449 |
+
if line.strip():
|
| 450 |
+
yield _record_from_safetensors_descriptor(self.root, json.loads(line))
|
| 451 |
+
|
| 452 |
+
def __iter__(self) -> Iterator[EmbeddingRecord]:
|
| 453 |
+
for shard_index in range(len(self.shards)):
|
| 454 |
+
yield from self._iter_shard(shard_index)
|
| 455 |
+
|
| 456 |
+
@overload
|
| 457 |
+
def __getitem__(self, index: int, /) -> EmbeddingRecord: ...
|
| 458 |
+
|
| 459 |
+
@overload
|
| 460 |
+
def __getitem__(self, index: slice, /) -> Sequence[EmbeddingRecord]: ...
|
| 461 |
+
|
| 462 |
+
def __getitem__(self, index: int | slice) -> EmbeddingRecord | Sequence[EmbeddingRecord]:
|
| 463 |
+
if isinstance(index, slice):
|
| 464 |
+
start, stop, step = index.indices(self._count)
|
| 465 |
+
return [self[position] for position in range(start, stop, step)]
|
| 466 |
+
position = index + self._count if index < 0 else index
|
| 467 |
+
if position < 0 or position >= self._count:
|
| 468 |
+
raise IndexError(index)
|
| 469 |
+
shard_index = bisect_right(self._cumulative, position)
|
| 470 |
+
previous = self._cumulative[shard_index - 1] if shard_index else 0
|
| 471 |
+
local_position = position - previous
|
| 472 |
+
for offset, record in enumerate(self._iter_shard(shard_index)):
|
| 473 |
+
if offset == local_position:
|
| 474 |
+
return record
|
| 475 |
+
raise IndexError(index)
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
class SafetensorsStreamWriter:
|
| 479 |
+
"""Bounded-memory, resumable publisher with immutable retained generations."""
|
| 480 |
+
|
| 481 |
+
def __init__(
|
| 482 |
+
self,
|
| 483 |
+
path: str | Path,
|
| 484 |
+
metadata: dict[str, Any],
|
| 485 |
+
*,
|
| 486 |
+
shard_size: int = DEFAULT_SHARD_SIZE,
|
| 487 |
+
existing: Iterable[EmbeddingRecord] = (),
|
| 488 |
+
reuse_existing: bool = False,
|
| 489 |
+
publish_initial: bool = True,
|
| 490 |
+
publish_incremental: bool = True,
|
| 491 |
+
) -> None:
|
| 492 |
+
try:
|
| 493 |
+
from safetensors.torch import save_file
|
| 494 |
+
except ImportError as error:
|
| 495 |
+
raise ImportError("Saving embeddings requires the 'safetensors' package.") from error
|
| 496 |
+
if shard_size <= 0:
|
| 497 |
+
raise ValueError("shard_size must be positive.")
|
| 498 |
+
|
| 499 |
+
self.path = Path(path)
|
| 500 |
+
self.index_path = _index_path(path)
|
| 501 |
+
self.run_manifest_path = _run_manifest_path(path)
|
| 502 |
+
self.index_path.parent.mkdir(parents=True, exist_ok=True)
|
| 503 |
+
self.metadata = _persistent_metadata(
|
| 504 |
+
metadata,
|
| 505 |
+
descriptor_index="safetensors-generation-index",
|
| 506 |
+
record_count=0,
|
| 507 |
+
)
|
| 508 |
+
self.shard_size = shard_size
|
| 509 |
+
self.publish_incremental = publish_incremental
|
| 510 |
+
self._save_file = save_file
|
| 511 |
+
authoritative_payload = _authoritative_index_payload(path)
|
| 512 |
+
prefix = _safetensors_shard_prefix(path)
|
| 513 |
+
# A random generation identity prevents a new writer from reusing a
|
| 514 |
+
# previously published or interrupted generation name. Published files
|
| 515 |
+
# are immutable and remain available to lazy readers until explicit GC.
|
| 516 |
+
self._generation = uuid4().hex
|
| 517 |
+
self._prefix = prefix
|
| 518 |
+
self._shard_index = 0
|
| 519 |
+
self._seed_index = 0
|
| 520 |
+
self._commit_index = 0
|
| 521 |
+
self._descriptor_shards: list[dict[str, Any]] = []
|
| 522 |
+
self._record_count = 0
|
| 523 |
+
self._current: dict[str, Tensor] = {}
|
| 524 |
+
self._pending: list[tuple[EmbeddingRecord, str, str, tuple[int, ...], str]] = []
|
| 525 |
+
self._current_size = 0
|
| 526 |
+
if reuse_existing:
|
| 527 |
+
if authoritative_payload is None:
|
| 528 |
+
raise ValueError("Cannot resume without an authoritative safetensors index.")
|
| 529 |
+
authoritative_metadata = authoritative_payload.get("metadata")
|
| 530 |
+
if not isinstance(authoritative_metadata, dict) or authoritative_metadata.get(
|
| 531 |
+
"run_fingerprint"
|
| 532 |
+
) != self.metadata.get("run_fingerprint"):
|
| 533 |
+
raise ValueError("Cannot resume a safetensors run with a different fingerprint.")
|
| 534 |
+
expected_prefix_length = (
|
| 535 |
+
len(existing) if isinstance(existing, Sequence) else sum(1 for _ in existing)
|
| 536 |
+
)
|
| 537 |
+
if authoritative_payload.get("version") == 2:
|
| 538 |
+
self._descriptor_shards = list(authoritative_payload.get("descriptor_shards", ()))
|
| 539 |
+
self._record_count = int(authoritative_payload.get("record_count", 0))
|
| 540 |
+
else:
|
| 541 |
+
legacy_records = list(authoritative_payload.get("records", ()))
|
| 542 |
+
self._record_count = len(legacy_records)
|
| 543 |
+
if legacy_records:
|
| 544 |
+
self._descriptor_shards.extend(self._write_descriptor_seed(legacy_records))
|
| 545 |
+
if expected_prefix_length != self._record_count:
|
| 546 |
+
raise ValueError(
|
| 547 |
+
"The resumable safetensors prefix does not match the validated "
|
| 548 |
+
"embedding records."
|
| 549 |
+
)
|
| 550 |
+
|
| 551 |
+
if publish_initial:
|
| 552 |
+
self._publish_metadata(complete=False)
|
| 553 |
+
|
| 554 |
+
def _write_descriptor_file(
|
| 555 |
+
self,
|
| 556 |
+
name: str,
|
| 557 |
+
descriptors: Sequence[dict[str, Any]],
|
| 558 |
+
*,
|
| 559 |
+
tensor_file: str,
|
| 560 |
+
) -> dict[str, Any]:
|
| 561 |
+
temporary = self.index_path.parent / f".{name}.tmp"
|
| 562 |
+
destination = self.index_path.parent / name
|
| 563 |
+
if temporary.exists() or destination.exists():
|
| 564 |
+
raise FileExistsError(
|
| 565 |
+
f"Refusing to reuse immutable safetensors generation path {destination}."
|
| 566 |
+
)
|
| 567 |
+
digest = hashlib.sha256()
|
| 568 |
+
with temporary.open("wb") as handle:
|
| 569 |
+
for item in descriptors:
|
| 570 |
+
encoded = (
|
| 571 |
+
json.dumps(item, sort_keys=True, separators=(",", ":")).encode("utf-8") + b"\n"
|
| 572 |
+
)
|
| 573 |
+
handle.write(encoded)
|
| 574 |
+
digest.update(encoded)
|
| 575 |
+
temporary.replace(destination)
|
| 576 |
+
return {
|
| 577 |
+
"file": name,
|
| 578 |
+
"sha256": digest.hexdigest(),
|
| 579 |
+
"count": len(descriptors),
|
| 580 |
+
"tensor_file": tensor_file,
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
+
def _write_descriptor_seed(self, records: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 584 |
+
groups: list[tuple[str, list[dict[str, Any]]]] = []
|
| 585 |
+
for record in records:
|
| 586 |
+
tensor_file = str(record["tensor"]["file"])
|
| 587 |
+
if (
|
| 588 |
+
not groups
|
| 589 |
+
or groups[-1][0] != tensor_file
|
| 590 |
+
or len(groups[-1][1]) == _MAX_RECORDS_PER_DESCRIPTOR_SHARD
|
| 591 |
+
):
|
| 592 |
+
groups.append((tensor_file, []))
|
| 593 |
+
groups[-1][1].append(record)
|
| 594 |
+
descriptor_shards: list[dict[str, Any]] = []
|
| 595 |
+
for tensor_file, descriptors in groups:
|
| 596 |
+
self._seed_index += 1
|
| 597 |
+
name = (
|
| 598 |
+
f"{self._prefix}-records-run-{self._generation}-seed-{self._seed_index:05d}.jsonl"
|
| 599 |
+
)
|
| 600 |
+
descriptor_shards.append(
|
| 601 |
+
self._write_descriptor_file(name, descriptors, tensor_file=tensor_file)
|
| 602 |
+
)
|
| 603 |
+
return descriptor_shards
|
| 604 |
+
|
| 605 |
+
def _write_shard(self) -> None:
|
| 606 |
+
if not self._current:
|
| 607 |
+
return
|
| 608 |
+
self._shard_index += 1
|
| 609 |
+
name = f"{self._prefix}-run-{self._generation}-{self._shard_index:05d}.safetensors"
|
| 610 |
+
temporary = self.index_path.parent / f".{name}.tmp"
|
| 611 |
+
destination = self.index_path.parent / name
|
| 612 |
+
if temporary.exists() or destination.exists():
|
| 613 |
+
raise FileExistsError(
|
| 614 |
+
f"Refusing to reuse immutable safetensors generation path {destination}."
|
| 615 |
+
)
|
| 616 |
+
self._save_file(self._current, temporary)
|
| 617 |
+
temporary.replace(destination)
|
| 618 |
+
descriptors: list[dict[str, Any]] = []
|
| 619 |
+
for record, key, dtype_name, shape, digest in self._pending:
|
| 620 |
+
descriptors.append(
|
| 621 |
+
{
|
| 622 |
+
"id": record.id,
|
| 623 |
+
"sequence": record.sequence,
|
| 624 |
+
"tensor": {
|
| 625 |
+
"file": name,
|
| 626 |
+
"key": key,
|
| 627 |
+
"dtype": dtype_name,
|
| 628 |
+
"shape": list(shape),
|
| 629 |
+
"sha256": digest,
|
| 630 |
+
},
|
| 631 |
+
}
|
| 632 |
+
)
|
| 633 |
+
descriptor_name = (
|
| 634 |
+
f"{self._prefix}-records-run-{self._generation}-{self._shard_index:05d}.jsonl"
|
| 635 |
+
)
|
| 636 |
+
self._descriptor_shards.append(
|
| 637 |
+
self._write_descriptor_file(descriptor_name, descriptors, tensor_file=name)
|
| 638 |
+
)
|
| 639 |
+
self._record_count += len(descriptors)
|
| 640 |
+
self._current = {}
|
| 641 |
+
self._pending = []
|
| 642 |
+
self._current_size = 0
|
| 643 |
+
|
| 644 |
+
def append(
|
| 645 |
+
self,
|
| 646 |
+
records: Iterable[EmbeddingRecord],
|
| 647 |
+
*,
|
| 648 |
+
publish: bool | None = None,
|
| 649 |
+
) -> None:
|
| 650 |
+
"""Persist records while retaining at most one shard of tensors."""
|
| 651 |
+
|
| 652 |
+
for record in records:
|
| 653 |
+
position = self._record_count + len(self._pending)
|
| 654 |
+
tensor = record.load_tensor().detach().cpu().contiguous()
|
| 655 |
+
if tensor.dtype not in _DTYPE_NAMES:
|
| 656 |
+
raise TypeError(f"Unsupported tensor dtype {tensor.dtype}.")
|
| 657 |
+
nbytes = tensor.numel() * tensor.element_size()
|
| 658 |
+
if nbytes > self.shard_size:
|
| 659 |
+
raise ValueError(
|
| 660 |
+
f"Embedding {position} requires {nbytes} bytes and cannot fit in a "
|
| 661 |
+
f"{self.shard_size}-byte safetensors shard."
|
| 662 |
+
)
|
| 663 |
+
if self._current and (
|
| 664 |
+
self._current_size + nbytes > self.shard_size
|
| 665 |
+
or len(self._pending) == _MAX_RECORDS_PER_DESCRIPTOR_SHARD
|
| 666 |
+
):
|
| 667 |
+
self._write_shard()
|
| 668 |
+
if self.publish_incremental:
|
| 669 |
+
self._publish_metadata(complete=False)
|
| 670 |
+
position = self._record_count
|
| 671 |
+
key = f"embedding_{position:08d}"
|
| 672 |
+
self._current[key] = tensor
|
| 673 |
+
self._current_size += nbytes
|
| 674 |
+
self._pending.append(
|
| 675 |
+
(
|
| 676 |
+
record,
|
| 677 |
+
key,
|
| 678 |
+
_DTYPE_NAMES[tensor.dtype],
|
| 679 |
+
tuple(tensor.shape),
|
| 680 |
+
tensor_sha256(tensor),
|
| 681 |
+
)
|
| 682 |
+
)
|
| 683 |
+
if publish:
|
| 684 |
+
self.publish(complete=False)
|
| 685 |
+
|
| 686 |
+
def _publish_metadata(
|
| 687 |
+
self,
|
| 688 |
+
*,
|
| 689 |
+
complete: bool,
|
| 690 |
+
metadata: dict[str, Any] | None = None,
|
| 691 |
+
) -> EmbeddingResult:
|
| 692 |
+
"""Atomically expose one self-consistent metadata generation."""
|
| 693 |
+
|
| 694 |
+
if metadata is not None:
|
| 695 |
+
self.metadata = _persistent_metadata(
|
| 696 |
+
metadata,
|
| 697 |
+
descriptor_index="safetensors-generation-index",
|
| 698 |
+
)
|
| 699 |
+
self.metadata["complete"] = complete
|
| 700 |
+
self.metadata["record_count"] = self._record_count
|
| 701 |
+
self._commit_index += 1
|
| 702 |
+
payload = {
|
| 703 |
+
"version": 2,
|
| 704 |
+
"format": "fastplms-embedding-safetensors",
|
| 705 |
+
"metadata": self.metadata,
|
| 706 |
+
"record_count": self._record_count,
|
| 707 |
+
"descriptor_shards": self._descriptor_shards,
|
| 708 |
+
}
|
| 709 |
+
generation_index_name = (
|
| 710 |
+
f"{self._prefix}-index-run-{self._generation}-{self._commit_index:05d}.json"
|
| 711 |
+
)
|
| 712 |
+
generation_index_path = self.index_path.parent / generation_index_name
|
| 713 |
+
temporary_generation_index = generation_index_path.with_name(
|
| 714 |
+
f".{generation_index_path.name}.tmp"
|
| 715 |
+
)
|
| 716 |
+
if temporary_generation_index.exists() or generation_index_path.exists():
|
| 717 |
+
raise FileExistsError(
|
| 718 |
+
f"Refusing to reuse immutable safetensors generation index {generation_index_path}."
|
| 719 |
+
)
|
| 720 |
+
encoded_index = _canonical_json_bytes(payload)
|
| 721 |
+
temporary_generation_index.write_bytes(encoded_index)
|
| 722 |
+
temporary_generation_index.replace(generation_index_path)
|
| 723 |
+
|
| 724 |
+
index_sha256 = hashlib.sha256(encoded_index).hexdigest()
|
| 725 |
+
index_reference = {
|
| 726 |
+
"file": generation_index_name,
|
| 727 |
+
"sha256": index_sha256,
|
| 728 |
+
}
|
| 729 |
+
run_manifest = {
|
| 730 |
+
"version": 2,
|
| 731 |
+
"format": "fastplms-embedding-run",
|
| 732 |
+
"index": index_reference,
|
| 733 |
+
"record_count": self._record_count,
|
| 734 |
+
}
|
| 735 |
+
pointer_identity = f"{self._generation}-{self._commit_index:05d}"
|
| 736 |
+
temporary_manifest = self.run_manifest_path.with_name(
|
| 737 |
+
f".{self.run_manifest_path.name}.{pointer_identity}.tmp"
|
| 738 |
+
)
|
| 739 |
+
temporary_manifest.write_bytes(_canonical_json_bytes(run_manifest))
|
| 740 |
+
temporary_manifest.replace(self.run_manifest_path)
|
| 741 |
+
|
| 742 |
+
# ``index.json`` is a non-authoritative convenience pointer. The run
|
| 743 |
+
# manifest is committed first, so interruption here cannot invalidate
|
| 744 |
+
# the newly committed generation.
|
| 745 |
+
stable_pointer = {
|
| 746 |
+
"version": 2,
|
| 747 |
+
"format": "fastplms-embedding-index-pointer",
|
| 748 |
+
"index": index_reference,
|
| 749 |
+
}
|
| 750 |
+
temporary_index = self.index_path.with_name(
|
| 751 |
+
f".{self.index_path.name}.{pointer_identity}.tmp"
|
| 752 |
+
)
|
| 753 |
+
temporary_index.write_bytes(_canonical_json_bytes(stable_pointer))
|
| 754 |
+
temporary_index.replace(self.index_path)
|
| 755 |
+
|
| 756 |
+
return load_safetensors_result(self.index_path)
|
| 757 |
+
|
| 758 |
+
def publish(
|
| 759 |
+
self,
|
| 760 |
+
*,
|
| 761 |
+
complete: bool,
|
| 762 |
+
metadata: dict[str, Any] | None = None,
|
| 763 |
+
) -> EmbeddingResult:
|
| 764 |
+
"""Flush the current shard and atomically expose a consistent generation."""
|
| 765 |
+
|
| 766 |
+
self._write_shard()
|
| 767 |
+
return self._publish_metadata(complete=complete, metadata=metadata)
|
| 768 |
+
|
| 769 |
+
|
| 770 |
+
def save_safetensors_result(
|
| 771 |
+
result: EmbeddingResult,
|
| 772 |
+
path: str | Path,
|
| 773 |
+
*,
|
| 774 |
+
shard_size: int = DEFAULT_SHARD_SIZE,
|
| 775 |
+
) -> EmbeddingResult:
|
| 776 |
+
"""Write sharded safetensors without materializing the full result."""
|
| 777 |
+
|
| 778 |
+
writer = SafetensorsStreamWriter(
|
| 779 |
+
path,
|
| 780 |
+
result.metadata,
|
| 781 |
+
shard_size=shard_size,
|
| 782 |
+
publish_initial=False,
|
| 783 |
+
publish_incremental=False,
|
| 784 |
+
)
|
| 785 |
+
writer.append(result, publish=False)
|
| 786 |
+
return writer.publish(complete=bool(result.metadata.get("complete", True)))
|
| 787 |
+
|
| 788 |
+
|
| 789 |
+
def load_safetensors_result(path: str | Path) -> EmbeddingResult:
|
| 790 |
+
"""Load an indexed safetensors result without loading tensor payloads."""
|
| 791 |
+
|
| 792 |
+
payload, index_path, _ = _load_authoritative_index(path)
|
| 793 |
+
if payload.get("version") == 2:
|
| 794 |
+
lazy_records = _SafetensorsRecordSequence(
|
| 795 |
+
index_path.parent, payload.get("descriptor_shards", ())
|
| 796 |
+
)
|
| 797 |
+
if len(lazy_records) != payload.get("record_count"):
|
| 798 |
+
raise ValueError("Safetensors descriptor count does not match its generation index.")
|
| 799 |
+
return EmbeddingResult(lazy_records, payload.get("metadata", {}))
|
| 800 |
+
|
| 801 |
+
records: list[EmbeddingRecord] = []
|
| 802 |
+
for item in payload["records"]:
|
| 803 |
+
records.append(_record_from_safetensors_descriptor(index_path.parent, item))
|
| 804 |
+
return EmbeddingResult(records, payload.get("metadata", {}))
|
| 805 |
+
|
| 806 |
+
|
| 807 |
+
def garbage_collect_safetensors_generations(
|
| 808 |
+
path: str | Path,
|
| 809 |
+
*,
|
| 810 |
+
dry_run: bool = True,
|
| 811 |
+
confirm_no_active_readers_or_writers: bool = False,
|
| 812 |
+
) -> tuple[Path, ...]:
|
| 813 |
+
"""Remove non-authoritative generations after an explicit exclusivity check.
|
| 814 |
+
|
| 815 |
+
Safetensors results retain immutable historical generations because an
|
| 816 |
+
already-open :class:`EmbeddingResult` resolves tensors through those exact
|
| 817 |
+
descriptor and shard paths. Destructive collection is therefore safe only
|
| 818 |
+
when the caller guarantees that no reader or writer for ``path`` remains
|
| 819 |
+
active. ``dry_run=True`` is the default and returns the paths that would be
|
| 820 |
+
removed without changing the output directory.
|
| 821 |
+
"""
|
| 822 |
+
|
| 823 |
+
if not isinstance(dry_run, bool):
|
| 824 |
+
raise TypeError("dry_run must be a bool.")
|
| 825 |
+
if not isinstance(confirm_no_active_readers_or_writers, bool):
|
| 826 |
+
raise TypeError("confirm_no_active_readers_or_writers must be a bool.")
|
| 827 |
+
if not dry_run and not confirm_no_active_readers_or_writers:
|
| 828 |
+
raise ValueError(
|
| 829 |
+
"Destructive safetensors generation collection requires "
|
| 830 |
+
"confirm_no_active_readers_or_writers=True."
|
| 831 |
+
)
|
| 832 |
+
|
| 833 |
+
# Validate the full descriptor graph before identifying anything as stale.
|
| 834 |
+
load_safetensors_result(path)
|
| 835 |
+
payload, authoritative_index_path, _ = _load_authoritative_index(path)
|
| 836 |
+
stable_index_path = _index_path(path)
|
| 837 |
+
run_manifest_path = _run_manifest_path(path)
|
| 838 |
+
root = stable_index_path.parent
|
| 839 |
+
prefix = _safetensors_shard_prefix(path)
|
| 840 |
+
protected = {
|
| 841 |
+
stable_index_path.resolve(),
|
| 842 |
+
run_manifest_path.resolve(),
|
| 843 |
+
authoritative_index_path.resolve(),
|
| 844 |
+
*_referenced_shards(stable_index_path, payload),
|
| 845 |
+
}
|
| 846 |
+
for descriptor_shard in payload.get("descriptor_shards", ()):
|
| 847 |
+
relative = descriptor_shard.get("file")
|
| 848 |
+
if isinstance(relative, str):
|
| 849 |
+
protected.add(_resolve_index_child(root, relative, label="index").resolve())
|
| 850 |
+
|
| 851 |
+
candidates: set[Path] = set()
|
| 852 |
+
for pattern in (
|
| 853 |
+
f"{prefix}-run-*-*.safetensors",
|
| 854 |
+
f"{prefix}-records-run-*.jsonl",
|
| 855 |
+
f"{prefix}-index-run-*.json",
|
| 856 |
+
f".{prefix}-*.tmp",
|
| 857 |
+
):
|
| 858 |
+
candidates.update(root.glob(pattern))
|
| 859 |
+
candidates.update(root.glob(f".{stable_index_path.name}.*.tmp"))
|
| 860 |
+
candidates.update(root.glob(f".{run_manifest_path.name}.*.tmp"))
|
| 861 |
+
|
| 862 |
+
stale = tuple(
|
| 863 |
+
sorted(
|
| 864 |
+
(candidate for candidate in candidates if candidate.resolve() not in protected),
|
| 865 |
+
key=lambda candidate: candidate.name,
|
| 866 |
+
)
|
| 867 |
+
)
|
| 868 |
+
if not dry_run:
|
| 869 |
+
for candidate in stale:
|
| 870 |
+
candidate.unlink(missing_ok=True)
|
| 871 |
+
return stale
|
| 872 |
+
|
| 873 |
+
|
| 874 |
+
def _ensure_sqlite_schema(connection: sqlite3.Connection) -> None:
|
| 875 |
+
connection.executescript(
|
| 876 |
+
"""
|
| 877 |
+
PRAGMA foreign_keys = ON;
|
| 878 |
+
CREATE TABLE IF NOT EXISTS runs (
|
| 879 |
+
run_id TEXT PRIMARY KEY,
|
| 880 |
+
metadata_json TEXT NOT NULL,
|
| 881 |
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 882 |
+
published_order INTEGER
|
| 883 |
+
);
|
| 884 |
+
CREATE TABLE IF NOT EXISTS tensors (
|
| 885 |
+
run_id TEXT NOT NULL,
|
| 886 |
+
position INTEGER NOT NULL,
|
| 887 |
+
dtype TEXT NOT NULL,
|
| 888 |
+
shape_json TEXT NOT NULL,
|
| 889 |
+
data BLOB NOT NULL,
|
| 890 |
+
sha256 TEXT NOT NULL,
|
| 891 |
+
PRIMARY KEY (run_id, position),
|
| 892 |
+
FOREIGN KEY (run_id) REFERENCES runs(run_id) ON DELETE CASCADE
|
| 893 |
+
);
|
| 894 |
+
CREATE TABLE IF NOT EXISTS records (
|
| 895 |
+
run_id TEXT NOT NULL,
|
| 896 |
+
position INTEGER NOT NULL,
|
| 897 |
+
record_id TEXT NOT NULL,
|
| 898 |
+
sequence TEXT NOT NULL,
|
| 899 |
+
PRIMARY KEY (run_id, position),
|
| 900 |
+
FOREIGN KEY (run_id, position) REFERENCES tensors(run_id, position)
|
| 901 |
+
ON DELETE CASCADE
|
| 902 |
+
);
|
| 903 |
+
"""
|
| 904 |
+
)
|
| 905 |
+
run_columns = {str(row[1]) for row in connection.execute("PRAGMA table_info(runs)").fetchall()}
|
| 906 |
+
if "published_order" not in run_columns:
|
| 907 |
+
connection.execute("ALTER TABLE runs ADD COLUMN published_order INTEGER")
|
| 908 |
+
# Databases created before staged publication exposed every stored run.
|
| 909 |
+
# Preserve that view for historical runs containing committed records.
|
| 910 |
+
connection.execute(
|
| 911 |
+
"UPDATE runs SET published_order = rowid "
|
| 912 |
+
"WHERE published_order IS NULL AND EXISTS ("
|
| 913 |
+
"SELECT 1 FROM records WHERE records.run_id = runs.run_id)"
|
| 914 |
+
)
|
| 915 |
+
connection.execute(
|
| 916 |
+
"CREATE INDEX IF NOT EXISTS runs_published_order_idx ON runs(published_order)"
|
| 917 |
+
)
|
| 918 |
+
if "published_order" not in run_columns:
|
| 919 |
+
# Schema upgrades run before callers open their data transaction.
|
| 920 |
+
# End the migration transaction explicitly so BEGIN IMMEDIATE below
|
| 921 |
+
# remains valid on existing databases.
|
| 922 |
+
connection.commit()
|
| 923 |
+
|
| 924 |
+
|
| 925 |
+
def save_sqlite_result(result: EmbeddingResult, path: str | Path) -> EmbeddingResult:
|
| 926 |
+
"""Transactionally store an ordered result in normalized SQLite tables."""
|
| 927 |
+
|
| 928 |
+
path = Path(path)
|
| 929 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 930 |
+
run_id = str(result.metadata.get("run_fingerprint", ""))
|
| 931 |
+
if not run_id:
|
| 932 |
+
raise ValueError("SQLite results require metadata['run_fingerprint'].")
|
| 933 |
+
metadata_json = json.dumps(
|
| 934 |
+
_persistent_metadata(
|
| 935 |
+
result.metadata,
|
| 936 |
+
descriptor_index="sqlite-records",
|
| 937 |
+
record_count=len(result),
|
| 938 |
+
),
|
| 939 |
+
sort_keys=True,
|
| 940 |
+
)
|
| 941 |
+
with sqlite3.connect(path, timeout=30) as connection:
|
| 942 |
+
_ensure_sqlite_schema(connection)
|
| 943 |
+
connection.execute("PRAGMA journal_mode = WAL")
|
| 944 |
+
connection.execute("BEGIN IMMEDIATE")
|
| 945 |
+
connection.execute("DELETE FROM runs WHERE run_id = ?", (run_id,))
|
| 946 |
+
connection.execute(
|
| 947 |
+
"INSERT INTO runs(run_id, metadata_json, published_order) "
|
| 948 |
+
"SELECT ?, ?, COALESCE(MAX(published_order), 0) + 1 FROM runs",
|
| 949 |
+
(run_id, metadata_json),
|
| 950 |
+
)
|
| 951 |
+
for position, record in enumerate(result):
|
| 952 |
+
X = record.load_tensor().detach().cpu().contiguous()
|
| 953 |
+
dtype_name, shape_json, data = _encode_tensor(X)
|
| 954 |
+
digest = tensor_sha256(X)
|
| 955 |
+
connection.execute(
|
| 956 |
+
"INSERT INTO tensors VALUES (?, ?, ?, ?, ?, ?)",
|
| 957 |
+
(run_id, position, dtype_name, shape_json, data, digest),
|
| 958 |
+
)
|
| 959 |
+
connection.execute(
|
| 960 |
+
"INSERT INTO records VALUES (?, ?, ?, ?)",
|
| 961 |
+
(run_id, position, record.id, record.sequence),
|
| 962 |
+
)
|
| 963 |
+
connection.commit()
|
| 964 |
+
return load_sqlite_result(path, run_id=run_id)
|
| 965 |
+
|
| 966 |
+
|
| 967 |
+
def initialize_sqlite_run(
|
| 968 |
+
path: str | Path,
|
| 969 |
+
metadata: dict[str, Any],
|
| 970 |
+
*,
|
| 971 |
+
resume: bool,
|
| 972 |
+
) -> str:
|
| 973 |
+
"""Create a resumable SQLite run without buffering tensor results."""
|
| 974 |
+
|
| 975 |
+
path = Path(path)
|
| 976 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 977 |
+
run_id = str(metadata.get("run_fingerprint", ""))
|
| 978 |
+
if not run_id:
|
| 979 |
+
raise ValueError("SQLite runs require metadata['run_fingerprint'].")
|
| 980 |
+
with sqlite3.connect(path, timeout=30) as connection:
|
| 981 |
+
_ensure_sqlite_schema(connection)
|
| 982 |
+
connection.execute("PRAGMA journal_mode = WAL")
|
| 983 |
+
connection.execute("BEGIN IMMEDIATE")
|
| 984 |
+
exists = connection.execute("SELECT 1 FROM runs WHERE run_id = ?", (run_id,)).fetchone()
|
| 985 |
+
if exists and not resume:
|
| 986 |
+
connection.execute("DELETE FROM runs WHERE run_id = ?", (run_id,))
|
| 987 |
+
exists = None
|
| 988 |
+
if exists is None:
|
| 989 |
+
initial_metadata = _persistent_metadata(
|
| 990 |
+
metadata,
|
| 991 |
+
descriptor_index="sqlite-records",
|
| 992 |
+
record_count=0,
|
| 993 |
+
)
|
| 994 |
+
connection.execute(
|
| 995 |
+
"INSERT INTO runs(run_id, metadata_json) VALUES (?, ?)",
|
| 996 |
+
(run_id, json.dumps(initial_metadata, sort_keys=True)),
|
| 997 |
+
)
|
| 998 |
+
connection.commit()
|
| 999 |
+
return run_id
|
| 1000 |
+
|
| 1001 |
+
|
| 1002 |
+
def append_sqlite_records(
|
| 1003 |
+
path: str | Path,
|
| 1004 |
+
run_id: str,
|
| 1005 |
+
start_position: int,
|
| 1006 |
+
records: list[EmbeddingRecord],
|
| 1007 |
+
*,
|
| 1008 |
+
replace_metadata: dict[str, Any] | None = None,
|
| 1009 |
+
) -> None:
|
| 1010 |
+
"""Commit one ordered embedding batch so an interrupted run can resume."""
|
| 1011 |
+
|
| 1012 |
+
if not isinstance(run_id, str) or not run_id:
|
| 1013 |
+
raise ValueError("run_id must be a non-empty string.")
|
| 1014 |
+
if not isinstance(start_position, int) or isinstance(start_position, bool):
|
| 1015 |
+
raise TypeError("start_position must be a non-negative integer.")
|
| 1016 |
+
if start_position < 0:
|
| 1017 |
+
raise ValueError("start_position must be a non-negative integer.")
|
| 1018 |
+
if not isinstance(records, list) or not all(
|
| 1019 |
+
isinstance(record, EmbeddingRecord) for record in records
|
| 1020 |
+
):
|
| 1021 |
+
raise TypeError("records must be a list of EmbeddingRecord values.")
|
| 1022 |
+
|
| 1023 |
+
with sqlite3.connect(Path(path), timeout=30) as connection:
|
| 1024 |
+
_ensure_sqlite_schema(connection)
|
| 1025 |
+
connection.execute("PRAGMA journal_mode = WAL")
|
| 1026 |
+
connection.execute("BEGIN IMMEDIATE")
|
| 1027 |
+
if replace_metadata is not None:
|
| 1028 |
+
replacement_run_id = str(replace_metadata.get("run_fingerprint", ""))
|
| 1029 |
+
if replacement_run_id != run_id:
|
| 1030 |
+
raise ValueError("Replacement metadata must match the SQLite run ID.")
|
| 1031 |
+
initial_metadata = _persistent_metadata(
|
| 1032 |
+
replace_metadata,
|
| 1033 |
+
descriptor_index="sqlite-records",
|
| 1034 |
+
record_count=0,
|
| 1035 |
+
)
|
| 1036 |
+
connection.execute("DELETE FROM runs WHERE run_id = ?", (run_id,))
|
| 1037 |
+
connection.execute(
|
| 1038 |
+
"INSERT INTO runs(run_id, metadata_json) VALUES (?, ?)",
|
| 1039 |
+
(run_id, json.dumps(initial_metadata, sort_keys=True)),
|
| 1040 |
+
)
|
| 1041 |
+
if connection.execute("SELECT 1 FROM runs WHERE run_id = ?", (run_id,)).fetchone() is None:
|
| 1042 |
+
raise KeyError(f"Missing SQLite embedding run {run_id}.")
|
| 1043 |
+
current_count, minimum_position, maximum_position = connection.execute(
|
| 1044 |
+
"SELECT COUNT(*), MIN(position), MAX(position) FROM records WHERE run_id = ?",
|
| 1045 |
+
(run_id,),
|
| 1046 |
+
).fetchone()
|
| 1047 |
+
if current_count and (minimum_position != 0 or maximum_position != current_count - 1):
|
| 1048 |
+
raise ValueError("SQLite embedding run has a non-contiguous record prefix.")
|
| 1049 |
+
if start_position != current_count:
|
| 1050 |
+
raise ValueError(
|
| 1051 |
+
f"start_position={start_position} does not match the contiguous "
|
| 1052 |
+
f"SQLite prefix length {current_count}."
|
| 1053 |
+
)
|
| 1054 |
+
for offset, record in enumerate(records):
|
| 1055 |
+
position = start_position + offset
|
| 1056 |
+
X = record.load_tensor().detach().cpu().contiguous()
|
| 1057 |
+
dtype_name, shape_json, data = _encode_tensor(X)
|
| 1058 |
+
digest = tensor_sha256(X)
|
| 1059 |
+
connection.execute(
|
| 1060 |
+
"INSERT INTO tensors VALUES (?, ?, ?, ?, ?, ?)",
|
| 1061 |
+
(run_id, position, dtype_name, shape_json, data, digest),
|
| 1062 |
+
)
|
| 1063 |
+
connection.execute(
|
| 1064 |
+
"INSERT INTO records VALUES (?, ?, ?, ?)",
|
| 1065 |
+
(run_id, position, record.id, record.sequence),
|
| 1066 |
+
)
|
| 1067 |
+
row = connection.execute(
|
| 1068 |
+
"SELECT metadata_json FROM runs WHERE run_id = ?", (run_id,)
|
| 1069 |
+
).fetchone()
|
| 1070 |
+
if row is None:
|
| 1071 |
+
raise KeyError(f"Missing SQLite embedding run {run_id}.")
|
| 1072 |
+
metadata = json.loads(row[0])
|
| 1073 |
+
if not isinstance(metadata, dict):
|
| 1074 |
+
raise ValueError("SQLite run metadata must contain a JSON object.")
|
| 1075 |
+
metadata["record_count"] = start_position + len(records)
|
| 1076 |
+
metadata["descriptor_index"] = "sqlite-records"
|
| 1077 |
+
connection.execute(
|
| 1078 |
+
"UPDATE runs SET metadata_json = ? WHERE run_id = ?",
|
| 1079 |
+
(json.dumps(metadata, sort_keys=True), run_id),
|
| 1080 |
+
)
|
| 1081 |
+
if records:
|
| 1082 |
+
connection.execute(
|
| 1083 |
+
"UPDATE runs SET published_order = ("
|
| 1084 |
+
"SELECT COALESCE(MAX(published_order), 0) + 1 FROM runs"
|
| 1085 |
+
") WHERE run_id = ? AND published_order IS NULL",
|
| 1086 |
+
(run_id,),
|
| 1087 |
+
)
|
| 1088 |
+
connection.commit()
|
| 1089 |
+
|
| 1090 |
+
|
| 1091 |
+
def update_sqlite_run_metadata(path: str | Path, run_id: str, metadata: dict[str, Any]) -> None:
|
| 1092 |
+
"""Finalize reproducibility metadata after the last streamed batch."""
|
| 1093 |
+
|
| 1094 |
+
with sqlite3.connect(Path(path), timeout=30) as connection:
|
| 1095 |
+
row = connection.execute(
|
| 1096 |
+
"SELECT COUNT(*) FROM records WHERE run_id = ?", (run_id,)
|
| 1097 |
+
).fetchone()
|
| 1098 |
+
record_count = int(row[0]) if row is not None else 0
|
| 1099 |
+
cleaned_metadata = _persistent_metadata(
|
| 1100 |
+
metadata,
|
| 1101 |
+
descriptor_index="sqlite-records",
|
| 1102 |
+
record_count=record_count,
|
| 1103 |
+
)
|
| 1104 |
+
updated = connection.execute(
|
| 1105 |
+
"UPDATE runs SET metadata_json = ? WHERE run_id = ?",
|
| 1106 |
+
(json.dumps(cleaned_metadata, sort_keys=True), run_id),
|
| 1107 |
+
).rowcount
|
| 1108 |
+
if updated != 1:
|
| 1109 |
+
raise KeyError(f"Missing SQLite embedding run {run_id}.")
|
| 1110 |
+
connection.commit()
|
| 1111 |
+
|
| 1112 |
+
|
| 1113 |
+
def _connect_sqlite_read_only(path: Path) -> sqlite3.Connection:
|
| 1114 |
+
if not path.is_file():
|
| 1115 |
+
raise FileNotFoundError(path)
|
| 1116 |
+
return sqlite3.connect(f"{path.resolve().as_uri()}?mode=ro", uri=True, timeout=30)
|
| 1117 |
+
|
| 1118 |
+
|
| 1119 |
+
def _validate_sqlite_result_schema(connection: sqlite3.Connection, path: Path) -> None:
|
| 1120 |
+
tables = {
|
| 1121 |
+
str(row[0])
|
| 1122 |
+
for row in connection.execute(
|
| 1123 |
+
"SELECT name FROM sqlite_master WHERE type = 'table'"
|
| 1124 |
+
).fetchall()
|
| 1125 |
+
}
|
| 1126 |
+
required = {"runs", "records", "tensors"}
|
| 1127 |
+
if not required.issubset(tables):
|
| 1128 |
+
raise ValueError(
|
| 1129 |
+
f"Not a FastPLMs embedding SQLite database: {path}. "
|
| 1130 |
+
"Use convert_legacy_sqlite() for a legacy embeddings table."
|
| 1131 |
+
)
|
| 1132 |
+
|
| 1133 |
+
|
| 1134 |
+
def _load_sqlite_tensor(path: Path, run_id: str, position: int) -> Tensor:
|
| 1135 |
+
with _connect_sqlite_read_only(path) as connection:
|
| 1136 |
+
row = connection.execute(
|
| 1137 |
+
"SELECT dtype, shape_json, data FROM tensors WHERE run_id = ? AND position = ?",
|
| 1138 |
+
(run_id, position),
|
| 1139 |
+
).fetchone()
|
| 1140 |
+
if row is None:
|
| 1141 |
+
raise KeyError(f"Missing SQLite tensor {run_id}:{position}.")
|
| 1142 |
+
return _decode_tensor(*row)
|
| 1143 |
+
|
| 1144 |
+
|
| 1145 |
+
def _validate_sqlite_descriptor_row(
|
| 1146 |
+
row: Sequence[Any],
|
| 1147 |
+
) -> tuple[int, str, str, str, str, str]:
|
| 1148 |
+
if len(row) != 6:
|
| 1149 |
+
raise ValueError("SQLite embedding descriptor has an invalid column count.")
|
| 1150 |
+
position, record_id, sequence, dtype_name, shape_json, digest = row
|
| 1151 |
+
if not isinstance(position, int) or isinstance(position, bool) or position < 0:
|
| 1152 |
+
raise ValueError("SQLite embedding position is invalid.")
|
| 1153 |
+
if not isinstance(record_id, str) or not record_id:
|
| 1154 |
+
raise ValueError("SQLite embedding record ID is invalid.")
|
| 1155 |
+
if not isinstance(sequence, str) or not sequence:
|
| 1156 |
+
raise ValueError("SQLite embedding sequence is invalid.")
|
| 1157 |
+
if not isinstance(shape_json, str):
|
| 1158 |
+
raise ValueError("SQLite embedding tensor shape is invalid.")
|
| 1159 |
+
try:
|
| 1160 |
+
shape = json.loads(shape_json)
|
| 1161 |
+
except json.JSONDecodeError as error:
|
| 1162 |
+
raise ValueError("SQLite embedding tensor shape is invalid.") from error
|
| 1163 |
+
_validate_tensor_descriptor(
|
| 1164 |
+
{
|
| 1165 |
+
"key": f"embedding_{position}",
|
| 1166 |
+
"dtype": dtype_name,
|
| 1167 |
+
"shape": shape,
|
| 1168 |
+
"sha256": digest,
|
| 1169 |
+
}
|
| 1170 |
+
)
|
| 1171 |
+
return position, record_id, sequence, dtype_name, shape_json, digest
|
| 1172 |
+
|
| 1173 |
+
|
| 1174 |
+
def _sqlite_record_from_row(path: Path, run_id: str, row: Sequence[Any]) -> EmbeddingRecord:
|
| 1175 |
+
position, record_id, sequence, dtype_name, shape_json, digest = _validate_sqlite_descriptor_row(
|
| 1176 |
+
row
|
| 1177 |
+
)
|
| 1178 |
+
|
| 1179 |
+
def load_tensor() -> Tensor:
|
| 1180 |
+
return _load_sqlite_tensor(path, run_id, position)
|
| 1181 |
+
|
| 1182 |
+
reference = LazyTensorReference(
|
| 1183 |
+
source=str(path),
|
| 1184 |
+
key=f"{run_id}:{position}",
|
| 1185 |
+
dtype=dtype_name,
|
| 1186 |
+
shape=tuple(json.loads(shape_json)),
|
| 1187 |
+
sha256=digest,
|
| 1188 |
+
_loader=load_tensor,
|
| 1189 |
+
)
|
| 1190 |
+
return EmbeddingRecord(record_id, sequence, reference)
|
| 1191 |
+
|
| 1192 |
+
|
| 1193 |
+
class _SQLiteRecordSequence(Sequence[EmbeddingRecord]):
|
| 1194 |
+
"""Lazy immutable descriptor view over one SQLite embedding run."""
|
| 1195 |
+
|
| 1196 |
+
_fastplms_immutable_sequence = True
|
| 1197 |
+
|
| 1198 |
+
def __init__(self, path: Path, run_id: str, count: int) -> None:
|
| 1199 |
+
self.path = path
|
| 1200 |
+
self.run_id = run_id
|
| 1201 |
+
self._count = count
|
| 1202 |
+
|
| 1203 |
+
@staticmethod
|
| 1204 |
+
def _row_query() -> str:
|
| 1205 |
+
return (
|
| 1206 |
+
"SELECT r.position, r.record_id, r.sequence, t.dtype, t.shape_json, t.sha256 "
|
| 1207 |
+
"FROM records r JOIN tensors t USING (run_id, position) "
|
| 1208 |
+
"WHERE r.run_id = ?"
|
| 1209 |
+
)
|
| 1210 |
+
|
| 1211 |
+
def __len__(self) -> int:
|
| 1212 |
+
return self._count
|
| 1213 |
+
|
| 1214 |
+
def __iter__(self) -> Iterator[EmbeddingRecord]:
|
| 1215 |
+
with _connect_sqlite_read_only(self.path) as connection:
|
| 1216 |
+
cursor = connection.execute(f"{self._row_query()} ORDER BY r.position", (self.run_id,))
|
| 1217 |
+
while rows := cursor.fetchmany(1_024):
|
| 1218 |
+
for row in rows:
|
| 1219 |
+
yield _sqlite_record_from_row(self.path, self.run_id, row)
|
| 1220 |
+
|
| 1221 |
+
@overload
|
| 1222 |
+
def __getitem__(self, index: int, /) -> EmbeddingRecord: ...
|
| 1223 |
+
|
| 1224 |
+
@overload
|
| 1225 |
+
def __getitem__(self, index: slice, /) -> Sequence[EmbeddingRecord]: ...
|
| 1226 |
+
|
| 1227 |
+
def __getitem__(self, index: int | slice) -> EmbeddingRecord | Sequence[EmbeddingRecord]:
|
| 1228 |
+
if isinstance(index, slice):
|
| 1229 |
+
start, stop, step = index.indices(self._count)
|
| 1230 |
+
return [self[position] for position in range(start, stop, step)]
|
| 1231 |
+
position = index + self._count if index < 0 else index
|
| 1232 |
+
if position < 0 or position >= self._count:
|
| 1233 |
+
raise IndexError(index)
|
| 1234 |
+
with _connect_sqlite_read_only(self.path) as connection:
|
| 1235 |
+
row = connection.execute(
|
| 1236 |
+
f"{self._row_query()} AND r.position = ?",
|
| 1237 |
+
(self.run_id, position),
|
| 1238 |
+
).fetchone()
|
| 1239 |
+
if row is None:
|
| 1240 |
+
raise IndexError(index)
|
| 1241 |
+
return _sqlite_record_from_row(self.path, self.run_id, row)
|
| 1242 |
+
|
| 1243 |
+
|
| 1244 |
+
def load_sqlite_result(
|
| 1245 |
+
path: str | Path,
|
| 1246 |
+
*,
|
| 1247 |
+
run_id: str | None = None,
|
| 1248 |
+
positions: Iterable[int] | None = None,
|
| 1249 |
+
record_ids: Iterable[str] | None = None,
|
| 1250 |
+
sequences: Iterable[str] | None = None,
|
| 1251 |
+
) -> EmbeddingResult:
|
| 1252 |
+
"""Load one SQLite run read-only, optionally in explicit selector order.
|
| 1253 |
+
|
| 1254 |
+
Exactly one selector may be supplied. Repeated selectors are retained. An
|
| 1255 |
+
ID or sequence selector that matches multiple stored rows returns those
|
| 1256 |
+
rows in their original order for every occurrence of that selector.
|
| 1257 |
+
"""
|
| 1258 |
+
|
| 1259 |
+
path = Path(path).resolve()
|
| 1260 |
+
supplied_selectors = sum(
|
| 1261 |
+
selector is not None for selector in (positions, record_ids, sequences)
|
| 1262 |
+
)
|
| 1263 |
+
if supplied_selectors > 1:
|
| 1264 |
+
raise ValueError("Choose at most one of positions, record_ids, or sequences.")
|
| 1265 |
+
normalized_positions = tuple(positions) if positions is not None else None
|
| 1266 |
+
normalized_ids = tuple(record_ids) if record_ids is not None else None
|
| 1267 |
+
normalized_sequences = tuple(sequences) if sequences is not None else None
|
| 1268 |
+
if normalized_positions is not None and not all(
|
| 1269 |
+
isinstance(position, int) and not isinstance(position, bool) and position >= 0
|
| 1270 |
+
for position in normalized_positions
|
| 1271 |
+
):
|
| 1272 |
+
raise ValueError("positions must contain non-negative integers.")
|
| 1273 |
+
for name, values in (
|
| 1274 |
+
("record_ids", normalized_ids),
|
| 1275 |
+
("sequences", normalized_sequences),
|
| 1276 |
+
):
|
| 1277 |
+
if values is not None and not all(isinstance(value, str) for value in values):
|
| 1278 |
+
raise TypeError(f"{name} must contain strings.")
|
| 1279 |
+
|
| 1280 |
+
with _connect_sqlite_read_only(path) as connection:
|
| 1281 |
+
_validate_sqlite_result_schema(connection, path)
|
| 1282 |
+
if run_id is None:
|
| 1283 |
+
run_columns = {
|
| 1284 |
+
str(info[1]) for info in connection.execute("PRAGMA table_info(runs)").fetchall()
|
| 1285 |
+
}
|
| 1286 |
+
if "published_order" in run_columns:
|
| 1287 |
+
row = connection.execute(
|
| 1288 |
+
"SELECT run_id, metadata_json FROM runs "
|
| 1289 |
+
"WHERE published_order IS NOT NULL "
|
| 1290 |
+
"ORDER BY published_order DESC, rowid DESC LIMIT 1"
|
| 1291 |
+
).fetchone()
|
| 1292 |
+
else:
|
| 1293 |
+
row = connection.execute(
|
| 1294 |
+
"SELECT run_id, metadata_json FROM runs "
|
| 1295 |
+
"ORDER BY created_at DESC, rowid DESC LIMIT 1"
|
| 1296 |
+
).fetchone()
|
| 1297 |
+
else:
|
| 1298 |
+
row = connection.execute(
|
| 1299 |
+
"SELECT run_id, metadata_json FROM runs WHERE run_id = ?", (run_id,)
|
| 1300 |
+
).fetchone()
|
| 1301 |
+
if row is None:
|
| 1302 |
+
raise KeyError(f"No embedding run found in {path}.")
|
| 1303 |
+
selected_run, metadata_json = row
|
| 1304 |
+
metadata = json.loads(metadata_json)
|
| 1305 |
+
if not isinstance(metadata, dict):
|
| 1306 |
+
raise ValueError("SQLite run metadata must contain a JSON object.")
|
| 1307 |
+
row_prefix = (
|
| 1308 |
+
"SELECT r.position, r.record_id, r.sequence, t.dtype, t.shape_json, t.sha256 "
|
| 1309 |
+
"FROM records r JOIN tensors t USING (run_id, position) "
|
| 1310 |
+
"WHERE r.run_id = ?"
|
| 1311 |
+
)
|
| 1312 |
+
record_count, minimum_position, maximum_position = connection.execute(
|
| 1313 |
+
"SELECT COUNT(*), MIN(position), MAX(position) FROM records WHERE run_id = ?",
|
| 1314 |
+
(selected_run,),
|
| 1315 |
+
).fetchone()
|
| 1316 |
+
(tensor_count,) = connection.execute(
|
| 1317 |
+
"SELECT COUNT(*) FROM tensors WHERE run_id = ?", (selected_run,)
|
| 1318 |
+
).fetchone()
|
| 1319 |
+
(joined_count,) = connection.execute(
|
| 1320 |
+
"SELECT COUNT(*) FROM records r JOIN tensors t USING (run_id, position) "
|
| 1321 |
+
"WHERE r.run_id = ?",
|
| 1322 |
+
(selected_run,),
|
| 1323 |
+
).fetchone()
|
| 1324 |
+
if (
|
| 1325 |
+
tensor_count != record_count
|
| 1326 |
+
or joined_count != record_count
|
| 1327 |
+
or (record_count and (minimum_position != 0 or maximum_position != record_count - 1))
|
| 1328 |
+
):
|
| 1329 |
+
raise ValueError("SQLite embedding run has inconsistent or non-contiguous records.")
|
| 1330 |
+
metadata_count = metadata.get("record_count")
|
| 1331 |
+
if (
|
| 1332 |
+
not isinstance(metadata_count, int)
|
| 1333 |
+
or isinstance(metadata_count, bool)
|
| 1334 |
+
or metadata_count != record_count
|
| 1335 |
+
):
|
| 1336 |
+
raise ValueError("SQLite metadata record count does not match stored records.")
|
| 1337 |
+
descriptor_cursor = connection.execute(f"{row_prefix} ORDER BY r.position", (selected_run,))
|
| 1338 |
+
while descriptor_rows := descriptor_cursor.fetchmany(1_024):
|
| 1339 |
+
for descriptor_row in descriptor_rows:
|
| 1340 |
+
_validate_sqlite_descriptor_row(descriptor_row)
|
| 1341 |
+
if supplied_selectors == 0:
|
| 1342 |
+
rows: list[tuple[Any, ...]] | None = None
|
| 1343 |
+
else:
|
| 1344 |
+
selector_values: tuple[Any, ...]
|
| 1345 |
+
selector_column: str
|
| 1346 |
+
if normalized_positions is not None:
|
| 1347 |
+
selector_values = normalized_positions
|
| 1348 |
+
selector_column = "r.position"
|
| 1349 |
+
elif normalized_ids is not None:
|
| 1350 |
+
selector_values = normalized_ids
|
| 1351 |
+
selector_column = "r.record_id"
|
| 1352 |
+
else:
|
| 1353 |
+
if normalized_sequences is None:
|
| 1354 |
+
raise RuntimeError("Filtered SQLite retrieval resolved no selector values.")
|
| 1355 |
+
selector_values = normalized_sequences
|
| 1356 |
+
selector_column = "r.sequence"
|
| 1357 |
+
fetched: list[tuple[Any, ...]] = []
|
| 1358 |
+
unique_values = tuple(dict.fromkeys(selector_values))
|
| 1359 |
+
for start in range(0, len(unique_values), 900):
|
| 1360 |
+
chunk = unique_values[start : start + 900]
|
| 1361 |
+
placeholders = ",".join("?" for _ in chunk)
|
| 1362 |
+
fetched.extend(
|
| 1363 |
+
connection.execute(
|
| 1364 |
+
f"{row_prefix} AND {selector_column} IN ({placeholders}) "
|
| 1365 |
+
"ORDER BY r.position",
|
| 1366 |
+
(selected_run, *chunk),
|
| 1367 |
+
).fetchall()
|
| 1368 |
+
)
|
| 1369 |
+
value_index = (
|
| 1370 |
+
0 if normalized_positions is not None else (1 if normalized_ids is not None else 2)
|
| 1371 |
+
)
|
| 1372 |
+
matched: dict[Any, list[tuple[Any, ...]]] = {}
|
| 1373 |
+
for fetched_row in sorted(fetched, key=lambda item: int(item[0])):
|
| 1374 |
+
matched.setdefault(fetched_row[value_index], []).append(fetched_row)
|
| 1375 |
+
missing = [value for value in selector_values if value not in matched]
|
| 1376 |
+
if missing:
|
| 1377 |
+
raise KeyError(f"SQLite embedding selectors were not found: {missing!r}.")
|
| 1378 |
+
rows = [
|
| 1379 |
+
fetched_row for value in selector_values for fetched_row in matched.get(value, ())
|
| 1380 |
+
]
|
| 1381 |
+
|
| 1382 |
+
if rows is None:
|
| 1383 |
+
return EmbeddingResult(
|
| 1384 |
+
_SQLiteRecordSequence(path, selected_run, int(record_count)),
|
| 1385 |
+
metadata,
|
| 1386 |
+
)
|
| 1387 |
+
records = [_sqlite_record_from_row(path, selected_run, selected_row) for selected_row in rows]
|
| 1388 |
+
if supplied_selectors:
|
| 1389 |
+
metadata = dict(metadata)
|
| 1390 |
+
metadata["selection"] = {
|
| 1391 |
+
"kind": (
|
| 1392 |
+
"positions"
|
| 1393 |
+
if normalized_positions is not None
|
| 1394 |
+
else "record_ids"
|
| 1395 |
+
if normalized_ids is not None
|
| 1396 |
+
else "sequences"
|
| 1397 |
+
),
|
| 1398 |
+
"count": len(rows),
|
| 1399 |
+
"duplicate_policy": "preserve-request-order",
|
| 1400 |
+
}
|
| 1401 |
+
return EmbeddingResult(records, metadata)
|
| 1402 |
+
|
| 1403 |
+
|
| 1404 |
+
def load_legacy_pth(path: str | Path, *, allow_unsafe_pickle: bool = False) -> EmbeddingResult:
|
| 1405 |
+
"""Import a legacy mapping-only ``.pth`` file after explicit opt-in."""
|
| 1406 |
+
|
| 1407 |
+
if not allow_unsafe_pickle:
|
| 1408 |
+
raise ValueError(
|
| 1409 |
+
"Legacy .pth loading can execute pickle payloads. Pass "
|
| 1410 |
+
"allow_unsafe_pickle=True only for a trusted file."
|
| 1411 |
+
)
|
| 1412 |
+
payload = torch.load(Path(path), map_location="cpu", weights_only=False)
|
| 1413 |
+
if not isinstance(payload, dict):
|
| 1414 |
+
raise ValueError("A legacy .pth embedding file must contain a mapping.")
|
| 1415 |
+
records: list[EmbeddingRecord] = []
|
| 1416 |
+
for position, (sequence, X) in enumerate(payload.items()):
|
| 1417 |
+
if not isinstance(sequence, str) or not isinstance(X, Tensor):
|
| 1418 |
+
raise ValueError("Legacy embedding mappings must use str keys and Tensor values.")
|
| 1419 |
+
records.append(EmbeddingRecord(str(position), sequence, X.detach().cpu()))
|
| 1420 |
+
return EmbeddingResult(records, {"format": "legacy-pth", "unsafe_pickle": True})
|
| 1421 |
+
|
| 1422 |
+
|
| 1423 |
+
_LEGACY_COMPACT_VERSION = 0x01
|
| 1424 |
+
_LEGACY_CODE_DTYPES: dict[int, tuple[np.dtype[Any], torch.dtype]] = {
|
| 1425 |
+
0: (np.dtype(np.float16), torch.float16),
|
| 1426 |
+
# Legacy BF16 blobs stored FP16 payload bytes and converted back to BF16.
|
| 1427 |
+
1: (np.dtype(np.float16), torch.bfloat16),
|
| 1428 |
+
2: (np.dtype(np.float32), torch.float32),
|
| 1429 |
+
}
|
| 1430 |
+
|
| 1431 |
+
|
| 1432 |
+
def _decode_legacy_sqlite_blob(
|
| 1433 |
+
data: bytes,
|
| 1434 |
+
*,
|
| 1435 |
+
fallback_shape: tuple[int, ...] | None,
|
| 1436 |
+
allow_unsafe_pickle: bool,
|
| 1437 |
+
) -> Tensor:
|
| 1438 |
+
if len(data) >= 6 and data[0] == _LEGACY_COMPACT_VERSION:
|
| 1439 |
+
dtype_code = int(data[1])
|
| 1440 |
+
if dtype_code not in _LEGACY_CODE_DTYPES:
|
| 1441 |
+
raise ValueError(f"Unsupported legacy compact dtype code {dtype_code}.")
|
| 1442 |
+
(ndim,) = struct.unpack_from("<i", data, 2)
|
| 1443 |
+
if ndim < 0 or ndim > 16 or len(data) < 6 + 4 * ndim:
|
| 1444 |
+
raise ValueError("Malformed legacy compact embedding header.")
|
| 1445 |
+
shape = tuple(int(value) for value in struct.unpack_from(f"<{ndim}i", data, 6))
|
| 1446 |
+
if any(size < 0 for size in shape):
|
| 1447 |
+
raise ValueError("Malformed negative legacy embedding dimension.")
|
| 1448 |
+
numpy_dtype, target_dtype = _LEGACY_CODE_DTYPES[dtype_code]
|
| 1449 |
+
offset = 6 + 4 * ndim
|
| 1450 |
+
expected = int(np.prod(shape, dtype=np.int64)) * numpy_dtype.itemsize
|
| 1451 |
+
if len(data) - offset != expected:
|
| 1452 |
+
raise ValueError("Legacy compact embedding payload length does not match shape.")
|
| 1453 |
+
array = np.frombuffer(data, dtype=numpy_dtype, offset=offset).copy().reshape(shape)
|
| 1454 |
+
return torch.from_numpy(array).to(dtype=target_dtype)
|
| 1455 |
+
|
| 1456 |
+
try:
|
| 1457 |
+
loaded = torch.load(io.BytesIO(data), map_location="cpu", weights_only=True)
|
| 1458 |
+
except Exception as safe_error:
|
| 1459 |
+
if allow_unsafe_pickle:
|
| 1460 |
+
loaded = torch.load(io.BytesIO(data), map_location="cpu", weights_only=False)
|
| 1461 |
+
elif fallback_shape is None:
|
| 1462 |
+
raise ValueError(
|
| 1463 |
+
"Legacy embedding blob is neither compact nor safely loadable. "
|
| 1464 |
+
"Provide fallback_shape for raw FP32 bytes, or set "
|
| 1465 |
+
"allow_unsafe_pickle=True only for a trusted database."
|
| 1466 |
+
) from safe_error
|
| 1467 |
+
else:
|
| 1468 |
+
expected = int(np.prod(fallback_shape, dtype=np.int64)) * 4
|
| 1469 |
+
if len(data) != expected:
|
| 1470 |
+
raise ValueError(
|
| 1471 |
+
"Legacy raw FP32 payload length does not match fallback_shape."
|
| 1472 |
+
) from safe_error
|
| 1473 |
+
array = np.frombuffer(data, dtype=np.float32).copy().reshape(fallback_shape)
|
| 1474 |
+
return torch.from_numpy(array)
|
| 1475 |
+
if not isinstance(loaded, Tensor):
|
| 1476 |
+
raise ValueError("Legacy serialized embedding payload must contain one tensor.")
|
| 1477 |
+
return loaded.detach().cpu()
|
| 1478 |
+
|
| 1479 |
+
|
| 1480 |
+
def convert_legacy_sqlite(
|
| 1481 |
+
source: str | Path,
|
| 1482 |
+
output: str | Path,
|
| 1483 |
+
*,
|
| 1484 |
+
fallback_shape: tuple[int, ...] | None = None,
|
| 1485 |
+
allow_unsafe_pickle: bool = False,
|
| 1486 |
+
metadata: dict[str, Any] | None = None,
|
| 1487 |
+
) -> EmbeddingResult:
|
| 1488 |
+
"""Convert the v0 ``embeddings(sequence, embedding)`` database safely.
|
| 1489 |
+
|
| 1490 |
+
The source is opened read-only. Compact blobs and ``weights_only`` Torch
|
| 1491 |
+
tensors are accepted by default. Unsafe general pickle deserialization
|
| 1492 |
+
remains an explicit opt-in.
|
| 1493 |
+
"""
|
| 1494 |
+
|
| 1495 |
+
source_path = Path(source)
|
| 1496 |
+
output_path = Path(output)
|
| 1497 |
+
if source_path.resolve() == output_path.resolve():
|
| 1498 |
+
raise ValueError("Legacy SQLite conversion requires a different output path.")
|
| 1499 |
+
if fallback_shape is not None and (
|
| 1500 |
+
not fallback_shape or any(not isinstance(size, int) or size < 0 for size in fallback_shape)
|
| 1501 |
+
):
|
| 1502 |
+
raise ValueError("fallback_shape must contain non-negative integer dimensions.")
|
| 1503 |
+
with _connect_sqlite_read_only(source_path) as connection:
|
| 1504 |
+
columns = {
|
| 1505 |
+
str(row[1]) for row in connection.execute("PRAGMA table_info(embeddings)").fetchall()
|
| 1506 |
+
}
|
| 1507 |
+
if not {"sequence", "embedding"}.issubset(columns):
|
| 1508 |
+
raise ValueError("Legacy SQLite database must contain embeddings(sequence, embedding).")
|
| 1509 |
+
rows = connection.execute(
|
| 1510 |
+
"SELECT sequence, embedding FROM embeddings ORDER BY rowid"
|
| 1511 |
+
).fetchall()
|
| 1512 |
+
if not rows:
|
| 1513 |
+
raise ValueError("Legacy SQLite database contains no embeddings.")
|
| 1514 |
+
|
| 1515 |
+
records: list[EmbeddingRecord] = []
|
| 1516 |
+
content_digest = hashlib.sha256()
|
| 1517 |
+
for position, (sequence, data) in enumerate(rows):
|
| 1518 |
+
if not isinstance(sequence, str) or not sequence:
|
| 1519 |
+
raise ValueError("Legacy embedding sequences must be non-empty strings.")
|
| 1520 |
+
if not isinstance(data, bytes):
|
| 1521 |
+
data = bytes(data)
|
| 1522 |
+
tensor = _decode_legacy_sqlite_blob(
|
| 1523 |
+
data,
|
| 1524 |
+
fallback_shape=fallback_shape,
|
| 1525 |
+
allow_unsafe_pickle=allow_unsafe_pickle,
|
| 1526 |
+
)
|
| 1527 |
+
tensor_digest = tensor_sha256(tensor)
|
| 1528 |
+
for value in (sequence.encode("utf-8"), tensor_digest.encode("ascii")):
|
| 1529 |
+
content_digest.update(len(value).to_bytes(8, "big"))
|
| 1530 |
+
content_digest.update(value)
|
| 1531 |
+
records.append(EmbeddingRecord(str(position), sequence, tensor))
|
| 1532 |
+
|
| 1533 |
+
content_sha256 = content_digest.hexdigest()
|
| 1534 |
+
run_fingerprint = hashlib.sha256(
|
| 1535 |
+
f"fastplms-legacy-sqlite-v1:{content_sha256}".encode("ascii")
|
| 1536 |
+
).hexdigest()
|
| 1537 |
+
converted_metadata: dict[str, Any] = {
|
| 1538 |
+
"format_version": 1,
|
| 1539 |
+
"run_fingerprint": run_fingerprint,
|
| 1540 |
+
"source_format": "legacy-fastplms-sqlite-v0",
|
| 1541 |
+
"source_content_sha256": content_sha256,
|
| 1542 |
+
"unsafe_pickle": allow_unsafe_pickle,
|
| 1543 |
+
"complete": True,
|
| 1544 |
+
}
|
| 1545 |
+
if metadata:
|
| 1546 |
+
converted_metadata["conversion_metadata"] = _jsonable(metadata)
|
| 1547 |
+
return save_sqlite_result(
|
| 1548 |
+
EmbeddingResult(records, converted_metadata),
|
| 1549 |
+
output_path,
|
| 1550 |
+
)
|
| 1551 |
+
|
| 1552 |
+
|
| 1553 |
+
def save_result(
|
| 1554 |
+
result: EmbeddingResult,
|
| 1555 |
+
path: str | Path,
|
| 1556 |
+
*,
|
| 1557 |
+
format: str = "safetensors",
|
| 1558 |
+
shard_size: int = DEFAULT_SHARD_SIZE,
|
| 1559 |
+
) -> EmbeddingResult:
|
| 1560 |
+
if format == "safetensors":
|
| 1561 |
+
return save_safetensors_result(result, path, shard_size=shard_size)
|
| 1562 |
+
if format == "sqlite":
|
| 1563 |
+
return save_sqlite_result(result, path)
|
| 1564 |
+
if format == "pth":
|
| 1565 |
+
raise ValueError("Writing pickle-based .pth embeddings is not supported.")
|
| 1566 |
+
raise ValueError("format must be 'safetensors' or 'sqlite'.")
|
| 1567 |
+
|
| 1568 |
+
|
| 1569 |
+
def load_result(path: str | Path, *, format: str = "safetensors") -> EmbeddingResult:
|
| 1570 |
+
if format == "safetensors":
|
| 1571 |
+
return load_safetensors_result(path)
|
| 1572 |
+
if format == "sqlite":
|
| 1573 |
+
return load_sqlite_result(path)
|
| 1574 |
+
raise ValueError("format must be 'safetensors' or 'sqlite'.")
|
| 1575 |
+
|
| 1576 |
+
|
| 1577 |
+
__all__ = [
|
| 1578 |
+
"DEFAULT_SHARD_SIZE",
|
| 1579 |
+
"SafetensorsStreamWriter",
|
| 1580 |
+
"append_sqlite_records",
|
| 1581 |
+
"convert_legacy_sqlite",
|
| 1582 |
+
"garbage_collect_safetensors_generations",
|
| 1583 |
+
"initialize_sqlite_run",
|
| 1584 |
+
"load_legacy_pth",
|
| 1585 |
+
"load_result",
|
| 1586 |
+
"load_safetensors_result",
|
| 1587 |
+
"load_sqlite_result",
|
| 1588 |
+
"safetensors_result_exists",
|
| 1589 |
+
"save_result",
|
| 1590 |
+
"save_safetensors_result",
|
| 1591 |
+
"save_sqlite_result",
|
| 1592 |
+
"tensor_sha256",
|
| 1593 |
+
"update_sqlite_run_metadata",
|
| 1594 |
+
]
|
fastplms/embeddings/types.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Public value types for dataset embedding."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from collections.abc import Callable, Iterator, Mapping, Sequence
|
| 6 |
+
from dataclasses import dataclass, field
|
| 7 |
+
from typing import Any, Literal, overload
|
| 8 |
+
|
| 9 |
+
from torch import Tensor
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@dataclass(frozen=True, slots=True)
|
| 13 |
+
class EmbeddingInput:
|
| 14 |
+
"""One named protein sequence supplied to :func:`embed_dataset`."""
|
| 15 |
+
|
| 16 |
+
id: str
|
| 17 |
+
sequence: str
|
| 18 |
+
|
| 19 |
+
def __post_init__(self) -> None:
|
| 20 |
+
if not isinstance(self.id, str) or not self.id:
|
| 21 |
+
raise ValueError("EmbeddingInput.id must be a non-empty string.")
|
| 22 |
+
if not isinstance(self.sequence, str) or not self.sequence:
|
| 23 |
+
raise ValueError("EmbeddingInput.sequence must be a non-empty string.")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass(frozen=True, slots=True)
|
| 27 |
+
class LazyTensorReference:
|
| 28 |
+
"""A tensor stored outside memory and loaded only when requested."""
|
| 29 |
+
|
| 30 |
+
source: str
|
| 31 |
+
key: str
|
| 32 |
+
dtype: str
|
| 33 |
+
shape: tuple[int, ...]
|
| 34 |
+
sha256: str
|
| 35 |
+
_loader: Callable[[], Tensor] = field(repr=False, compare=False)
|
| 36 |
+
|
| 37 |
+
def load(self, *, verify: bool = True) -> Tensor:
|
| 38 |
+
"""Load X and optionally verify its content digest."""
|
| 39 |
+
|
| 40 |
+
if not isinstance(verify, bool):
|
| 41 |
+
raise TypeError("verify must be a boolean.")
|
| 42 |
+
X = self._loader()
|
| 43 |
+
if not isinstance(X, Tensor):
|
| 44 |
+
raise TypeError(f"Stored tensor loader for {self.key!r} must return a Tensor.")
|
| 45 |
+
if tuple(X.shape) != self.shape:
|
| 46 |
+
raise ValueError(
|
| 47 |
+
f"Stored tensor {self.key!r} has shape {tuple(X.shape)}, expected {self.shape}."
|
| 48 |
+
)
|
| 49 |
+
dtype = str(X.dtype).removeprefix("torch.")
|
| 50 |
+
if dtype != self.dtype:
|
| 51 |
+
raise ValueError(
|
| 52 |
+
f"Stored tensor {self.key!r} has dtype {dtype!r}, expected {self.dtype!r}."
|
| 53 |
+
)
|
| 54 |
+
if verify:
|
| 55 |
+
from .storage import tensor_sha256
|
| 56 |
+
|
| 57 |
+
digest = tensor_sha256(X)
|
| 58 |
+
if digest != self.sha256:
|
| 59 |
+
raise ValueError(f"Stored tensor {self.key!r} failed SHA-256 verification.")
|
| 60 |
+
return X
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
TensorValue = Tensor | LazyTensorReference
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@dataclass(frozen=True, slots=True)
|
| 67 |
+
class EmbeddingRecord:
|
| 68 |
+
"""One ordered embedding result."""
|
| 69 |
+
|
| 70 |
+
id: str
|
| 71 |
+
sequence: str
|
| 72 |
+
tensor: TensorValue
|
| 73 |
+
|
| 74 |
+
def __post_init__(self) -> None:
|
| 75 |
+
if not isinstance(self.id, str) or not self.id:
|
| 76 |
+
raise ValueError("EmbeddingRecord.id must be a non-empty string.")
|
| 77 |
+
if not isinstance(self.sequence, str) or not self.sequence:
|
| 78 |
+
raise ValueError("EmbeddingRecord.sequence must be a non-empty string.")
|
| 79 |
+
if not isinstance(self.tensor, (Tensor, LazyTensorReference)):
|
| 80 |
+
raise TypeError("EmbeddingRecord.tensor must be a Tensor or LazyTensorReference.")
|
| 81 |
+
|
| 82 |
+
def load_tensor(self, *, verify: bool = True) -> Tensor:
|
| 83 |
+
"""Return X regardless of whether this record is memory-backed or lazy."""
|
| 84 |
+
|
| 85 |
+
if not isinstance(verify, bool):
|
| 86 |
+
raise TypeError("verify must be a boolean.")
|
| 87 |
+
if isinstance(self.tensor, LazyTensorReference):
|
| 88 |
+
return self.tensor.load(verify=verify)
|
| 89 |
+
return self.tensor
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class EmbeddingResult(Sequence[EmbeddingRecord]):
|
| 93 |
+
"""Ordered embedding records and the metadata needed to reproduce them."""
|
| 94 |
+
|
| 95 |
+
def __init__(
|
| 96 |
+
self,
|
| 97 |
+
records: Sequence[EmbeddingRecord],
|
| 98 |
+
metadata: Mapping[str, Any] | None = None,
|
| 99 |
+
) -> None:
|
| 100 |
+
self.records: Sequence[EmbeddingRecord] = (
|
| 101 |
+
records if getattr(records, "_fastplms_immutable_sequence", False) else tuple(records)
|
| 102 |
+
)
|
| 103 |
+
self.metadata = dict(metadata or {})
|
| 104 |
+
|
| 105 |
+
def __len__(self) -> int:
|
| 106 |
+
return len(self.records)
|
| 107 |
+
|
| 108 |
+
def __iter__(self) -> Iterator[EmbeddingRecord]:
|
| 109 |
+
return iter(self.records)
|
| 110 |
+
|
| 111 |
+
@overload
|
| 112 |
+
def __getitem__(self, index: int, /) -> EmbeddingRecord: ...
|
| 113 |
+
|
| 114 |
+
@overload
|
| 115 |
+
def __getitem__(self, index: slice, /) -> Sequence[EmbeddingRecord]: ...
|
| 116 |
+
|
| 117 |
+
def __getitem__(self, index: int | slice) -> EmbeddingRecord | Sequence[EmbeddingRecord]:
|
| 118 |
+
return self.records[index]
|
| 119 |
+
|
| 120 |
+
def as_dict(
|
| 121 |
+
self,
|
| 122 |
+
*,
|
| 123 |
+
key: Literal["id", "sequence"] = "id",
|
| 124 |
+
duplicates: Literal["error", "first", "last"] = "error",
|
| 125 |
+
materialize: bool = True,
|
| 126 |
+
) -> dict[str, TensorValue]:
|
| 127 |
+
"""Convert records to a mapping under an explicit duplicate policy."""
|
| 128 |
+
|
| 129 |
+
if key not in {"id", "sequence"}:
|
| 130 |
+
raise ValueError("key must be 'id' or 'sequence'.")
|
| 131 |
+
if duplicates not in {"error", "first", "last"}:
|
| 132 |
+
raise ValueError("duplicates must be 'error', 'first', or 'last'.")
|
| 133 |
+
if not isinstance(materialize, bool):
|
| 134 |
+
raise TypeError("materialize must be a boolean.")
|
| 135 |
+
output: dict[str, TensorValue] = {}
|
| 136 |
+
for record in self.records:
|
| 137 |
+
record_key = getattr(record, key)
|
| 138 |
+
if record_key in output:
|
| 139 |
+
if duplicates == "error":
|
| 140 |
+
raise ValueError(
|
| 141 |
+
f"Duplicate {key} {record_key!r}; choose duplicates='first' "
|
| 142 |
+
"or duplicates='last' explicitly."
|
| 143 |
+
)
|
| 144 |
+
if duplicates == "first":
|
| 145 |
+
continue
|
| 146 |
+
output[record_key] = record.load_tensor() if materialize else record.tensor
|
| 147 |
+
return output
|
| 148 |
+
|
| 149 |
+
def materialize(self, *, verify: bool = True) -> EmbeddingResult:
|
| 150 |
+
"""Return an equivalent result with every X loaded into CPU memory."""
|
| 151 |
+
|
| 152 |
+
if not isinstance(verify, bool):
|
| 153 |
+
raise TypeError("verify must be a boolean.")
|
| 154 |
+
return EmbeddingResult(
|
| 155 |
+
[
|
| 156 |
+
EmbeddingRecord(
|
| 157 |
+
id=record.id,
|
| 158 |
+
sequence=record.sequence,
|
| 159 |
+
tensor=record.load_tensor(verify=verify),
|
| 160 |
+
)
|
| 161 |
+
for record in self.records
|
| 162 |
+
],
|
| 163 |
+
self.metadata,
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
@dataclass(frozen=True, slots=True)
|
| 168 |
+
class EmbeddingBatch:
|
| 169 |
+
"""Internal model-to-runner contract.
|
| 170 |
+
|
| 171 |
+
``X`` has shape ``(b, l, d)`` and ``residue_mask`` has shape ``(b, l)``.
|
| 172 |
+
``attentions`` may contain layer/head attention matrices for ``parti``.
|
| 173 |
+
"""
|
| 174 |
+
|
| 175 |
+
X: Tensor
|
| 176 |
+
residue_mask: Tensor
|
| 177 |
+
attentions: Tensor | tuple[Tensor, ...] | None = None
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
__all__ = [
|
| 181 |
+
"EmbeddingBatch",
|
| 182 |
+
"EmbeddingInput",
|
| 183 |
+
"EmbeddingRecord",
|
| 184 |
+
"EmbeddingResult",
|
| 185 |
+
"LazyTensorReference",
|
| 186 |
+
"TensorValue",
|
| 187 |
+
]
|
fastplms/models.toml
ADDED
|
@@ -0,0 +1,1223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
schema_version = 1
|
| 2 |
+
legal_files = [
|
| 3 |
+
"LICENSE=sha256:2d2b50c7b1414bff1189a1db1f0cfb92e3e064b50f4c2b1019827b683e1b629a",
|
| 4 |
+
"THIRD_PARTY_NOTICES.md=sha256:25704b3c76404696cae52e7fca13088d329f70f412687340351259e86cd62baa",
|
| 5 |
+
]
|
| 6 |
+
|
| 7 |
+
[[attention_kernels]]
|
| 8 |
+
implementation = "flash_attention_2"
|
| 9 |
+
repository = "kernels-community/flash-attn2"
|
| 10 |
+
revision = "db6b51744f0cd7061386442c09df890fc6d9f47e"
|
| 11 |
+
version = 2
|
| 12 |
+
expected_variant = "flash_attn2"
|
| 13 |
+
dtypes = ["bfloat16"]
|
| 14 |
+
|
| 15 |
+
[[attention_kernels]]
|
| 16 |
+
implementation = "flash_attention_3"
|
| 17 |
+
repository = "kernels-community/flash-attn3"
|
| 18 |
+
revision = "43f0bd269777115d94ff826e0d113ce9c1c9087b"
|
| 19 |
+
version = 1
|
| 20 |
+
expected_variant = "flash_attn3"
|
| 21 |
+
dtypes = ["bfloat16"]
|
| 22 |
+
|
| 23 |
+
[[runtime_assets]]
|
| 24 |
+
id = "esmfold2_ccd"
|
| 25 |
+
repository = "biohub/ESMFold2"
|
| 26 |
+
revision = "1ebf0e3481a5184eb6171d40615c79e384b48796"
|
| 27 |
+
path = "ccd.pkl"
|
| 28 |
+
sha256 = "9ff44b1927c6b9198e38ffe0928706827a09a350c15530beeeabebfa88038fc5"
|
| 29 |
+
size = 417306584
|
| 30 |
+
consumer_family = "esmfold2"
|
| 31 |
+
trust_kind = "hash_pinned_pickle"
|
| 32 |
+
license = "MIT"
|
| 33 |
+
offline_behavior = "requires_cached_verified_file"
|
| 34 |
+
|
| 35 |
+
[[upstreams]]
|
| 36 |
+
id = "ankh"
|
| 37 |
+
path = "vendor/upstream/ankh"
|
| 38 |
+
url = "https://github.com/agemagician/Ankh.git"
|
| 39 |
+
revision = "02b4e25ce5389b9e771c9df6e546c62af1216f8e"
|
| 40 |
+
license = "CC-BY-NC-SA-4.0"
|
| 41 |
+
license_files = ["LICENSE.md"]
|
| 42 |
+
license_digests = ["LICENSE.md=sha256:cd041d7f9f52936e8824ac3f754e9c67410763205fc8a7020ba74fc8b6edc088"]
|
| 43 |
+
distribution_files = ["LICENSE.md=sha256:cd041d7f9f52936e8824ac3f754e9c67410763205fc8a7020ba74fc8b6edc088"]
|
| 44 |
+
|
| 45 |
+
[[upstreams]]
|
| 46 |
+
id = "biohub-esm"
|
| 47 |
+
path = "vendor/upstream/biohub-esm"
|
| 48 |
+
url = "https://github.com/Biohub/esm.git"
|
| 49 |
+
revision = "82ee35553d39169d678f784c8d3f8712ffd7d2c4"
|
| 50 |
+
license = "MIT"
|
| 51 |
+
license_files = ["LICENSE.md", "THIRD_PARTY_NOTICE.md"]
|
| 52 |
+
license_digests = [
|
| 53 |
+
"LICENSE.md=sha256:b63df9ca1dd96b3b21eec226b51b236d0bd152ac20eafc43aad46bf832b48d8a",
|
| 54 |
+
"THIRD_PARTY_NOTICE.md=sha256:5bff8515ba4e0f53abdc43714c180b79c5b606160497d98de741a369cb9b6a23",
|
| 55 |
+
]
|
| 56 |
+
distribution_files = [
|
| 57 |
+
"LICENSE.md=sha256:b63df9ca1dd96b3b21eec226b51b236d0bd152ac20eafc43aad46bf832b48d8a",
|
| 58 |
+
"THIRD_PARTY_NOTICE.md=sha256:5bff8515ba4e0f53abdc43714c180b79c5b606160497d98de741a369cb9b6a23",
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
+
[[upstreams]]
|
| 62 |
+
id = "biohub-transformers"
|
| 63 |
+
path = "vendor/upstream/biohub-transformers"
|
| 64 |
+
url = "https://github.com/Biohub/transformers.git"
|
| 65 |
+
revision = "3a8956fb4d4ea16b0ec8e71deef2c2909b6a5cbf"
|
| 66 |
+
license = "Apache-2.0"
|
| 67 |
+
license_files = ["LICENSE"]
|
| 68 |
+
license_digests = ["LICENSE=sha256:77fd4710def9ec3c0f6225800e0235f15a425abd4a8b03559127fcd782612049"]
|
| 69 |
+
distribution_files = ["LICENSE=sha256:77fd4710def9ec3c0f6225800e0235f15a425abd4a8b03559127fcd782612049"]
|
| 70 |
+
|
| 71 |
+
[[upstreams]]
|
| 72 |
+
id = "boltz"
|
| 73 |
+
path = "vendor/upstream/boltz"
|
| 74 |
+
url = "https://github.com/jwohlwend/boltz.git"
|
| 75 |
+
revision = "b1ebfc46ecf57f5414e0d1a6f9027bbb122c53bc"
|
| 76 |
+
license = "MIT"
|
| 77 |
+
license_files = ["LICENSE"]
|
| 78 |
+
license_digests = ["LICENSE=sha256:f0667fd5e66c51e1ba8ddaa0249c6d7225b30037e02c45782d8f2c2943ac2617"]
|
| 79 |
+
distribution_files = ["LICENSE=sha256:f0667fd5e66c51e1ba8ddaa0249c6d7225b30037e02c45782d8f2c2943ac2617"]
|
| 80 |
+
|
| 81 |
+
[[upstreams]]
|
| 82 |
+
id = "dplm"
|
| 83 |
+
path = "vendor/upstream/dplm"
|
| 84 |
+
url = "https://github.com/bytedance/dplm.git"
|
| 85 |
+
revision = "8a2e15e53416b4536f03f79ad1f6f6a9cbd5e19d"
|
| 86 |
+
license = "Apache-2.0"
|
| 87 |
+
license_files = ["LICENSE"]
|
| 88 |
+
license_digests = ["LICENSE=sha256:cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30"]
|
| 89 |
+
distribution_files = [
|
| 90 |
+
"LICENSE=sha256:cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30",
|
| 91 |
+
"PROVENANCE.md=sha256:a659f74be9073cf1ad2d2f7071531ca56959b421f111152cf4c41184ace5970e",
|
| 92 |
+
]
|
| 93 |
+
|
| 94 |
+
[[upstreams]]
|
| 95 |
+
id = "e1"
|
| 96 |
+
path = "vendor/upstream/e1"
|
| 97 |
+
url = "https://github.com/Profluent-AI/E1.git"
|
| 98 |
+
revision = "bfd2620a602248499f3d2583d85a7ecddf0b6e02"
|
| 99 |
+
license = "Apache-2.0 AND Profluent-E1-Agreement"
|
| 100 |
+
license_files = ["LICENSE", "ATTRIBUTION", "NOTICE"]
|
| 101 |
+
license_digests = [
|
| 102 |
+
"LICENSE=sha256:8ef1dd556091544db3044164a8015424a3dcb3450fb3765a81b88463551bbe81",
|
| 103 |
+
"ATTRIBUTION=sha256:deb22b250f6491b649eda5c63e080dd56486b8d2736cea6a52ef875436214367",
|
| 104 |
+
"NOTICE=sha256:6de9db0320b4ee82f665c0951d8fd4cd53701a659c9dbce9bc3e3ea6afc4c6b3",
|
| 105 |
+
]
|
| 106 |
+
distribution_files = [
|
| 107 |
+
"LICENSE=sha256:8ef1dd556091544db3044164a8015424a3dcb3450fb3765a81b88463551bbe81",
|
| 108 |
+
"ATTRIBUTION=sha256:deb22b250f6491b649eda5c63e080dd56486b8d2736cea6a52ef875436214367",
|
| 109 |
+
"NOTICE=sha256:6de9db0320b4ee82f665c0951d8fd4cd53701a659c9dbce9bc3e3ea6afc4c6b3",
|
| 110 |
+
"Apache-2.0.txt=sha256:cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30",
|
| 111 |
+
"BSD-3-Clause.txt=sha256:36e1987f2f17db7f8ad36cd7a37dbb7aeaaf0ab68b97ab4b9d3556f3a7a76ae8",
|
| 112 |
+
"MODIFICATIONS.md=sha256:2506f47c0f5475af8e8ff2cff13eb8b79e8e25a08a054cdd617bf336536750ca",
|
| 113 |
+
]
|
| 114 |
+
|
| 115 |
+
[[upstreams]]
|
| 116 |
+
id = "fair-esm"
|
| 117 |
+
path = "vendor/upstream/fair-esm"
|
| 118 |
+
url = "https://github.com/facebookresearch/esm.git"
|
| 119 |
+
revision = "2b369911bb5b4b0dda914521b9475cad1656b2ac"
|
| 120 |
+
license = "MIT"
|
| 121 |
+
license_files = ["LICENSE"]
|
| 122 |
+
license_digests = ["LICENSE=sha256:da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93"]
|
| 123 |
+
distribution_files = [
|
| 124 |
+
"LICENSE=sha256:da6d3703ed11cbe42bd212c725957c98da23cbff1998c05fa4b3d976d1a58e93",
|
| 125 |
+
"PROVENANCE.md=sha256:950adb94daf15e646ddf226dacfe2a8e77801aa0793e439a9a3490a48eb666e7",
|
| 126 |
+
]
|
| 127 |
+
|
| 128 |
+
[[upstreams]]
|
| 129 |
+
id = "openfold"
|
| 130 |
+
path = "vendor/upstream/openfold"
|
| 131 |
+
url = "https://github.com/aqlaboratory/openfold.git"
|
| 132 |
+
revision = "4b41059694619831a7db195b7e0988fc4ff3a307"
|
| 133 |
+
license = "Apache-2.0"
|
| 134 |
+
license_files = ["LICENSE"]
|
| 135 |
+
license_digests = ["LICENSE=sha256:cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30"]
|
| 136 |
+
distribution_files = [
|
| 137 |
+
"LICENSE=sha256:cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30",
|
| 138 |
+
"MODIFICATIONS.md=sha256:fd6f0aa1086a0c996cf967b326d18e965660cda0ad5c7f36a3474a8490720da3",
|
| 139 |
+
"PROVENANCE.md=sha256:48c903db43a217a3126afaefbac60b7ddac7efda2dfcc0cbff0bffc7d6c30081",
|
| 140 |
+
]
|
| 141 |
+
|
| 142 |
+
[[upstreams]]
|
| 143 |
+
id = "protein-ttt"
|
| 144 |
+
path = "vendor/upstream/protein-ttt"
|
| 145 |
+
url = "https://github.com/anton-bushuiev/ProteinTTT.git"
|
| 146 |
+
revision = "fde2817cd84b936167cc76ccabf31e5c0fe49962"
|
| 147 |
+
license = "MIT"
|
| 148 |
+
license_files = ["LICENSE"]
|
| 149 |
+
license_digests = ["LICENSE=sha256:bb01e7d5554f9e2e117172e56551452f68a7818df7bc8e71cd7a776a1d4ba3df"]
|
| 150 |
+
distribution_files = [
|
| 151 |
+
"LICENSE=sha256:bb01e7d5554f9e2e117172e56551452f68a7818df7bc8e71cd7a776a1d4ba3df",
|
| 152 |
+
"PROVENANCE.md=sha256:dc641c37353c2efd50ccbdb316ca4aae495ec02c1563e0e15bac92f75fc482e5",
|
| 153 |
+
]
|
| 154 |
+
|
| 155 |
+
[families.esm2]
|
| 156 |
+
architecture = "ESM2"
|
| 157 |
+
upstreams = ["fair-esm"]
|
| 158 |
+
tokenizer_mode = "tokenizer"
|
| 159 |
+
public_input = "Amino-acid sequences tokenized to residue IDs"
|
| 160 |
+
extra = "core"
|
| 161 |
+
reference_container = "reference-esm2"
|
| 162 |
+
reference_adapter = "tests.parity.support.reference_adapters.esm2"
|
| 163 |
+
attention = ["eager", "sdpa", "flex_attention", "flash_attention_2", "flash_attention_3"]
|
| 164 |
+
dtypes = ["float32", "bfloat16"]
|
| 165 |
+
bf16_execution = "fp32_parameters_autocast"
|
| 166 |
+
precisions = ["default"]
|
| 167 |
+
vram_tier = "sequence"
|
| 168 |
+
checkpoint_license = "MIT"
|
| 169 |
+
hub_license = "mit"
|
| 170 |
+
weights_publication_allowed = true
|
| 171 |
+
state_transform = "esm2_hf_to_fastplms_v1"
|
| 172 |
+
conversion_provenance = "Input: the pinned official ESM2 state dictionary. Transformation: apply the deterministic esm2_hf_to_fastplms_v1 key map while preserving tensor values and materializing the tied input/output embedding values as independent tensors. Output: the pinned Synthyra FastPLMs checkpoint. Validation: release parity compares exact keys and values after the declared non-aliasing transform, tokenizer behavior, and inference. Limitation: any numerical rewrite requires a new transform identifier and exact conversion test."
|
| 173 |
+
representative = "esm2_8m"
|
| 174 |
+
documentation = "docs/models.md#esm2"
|
| 175 |
+
test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
|
| 176 |
+
runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/_esm_rotary.py", "models/esm2", "models/ttt.py"]
|
| 177 |
+
auto_map = { AutoConfig = "fastplms.models.esm2.modeling_fastesm.FastEsmConfig", AutoModel = "fastplms.models.esm2.modeling_fastesm.FastEsmModel", AutoModelForMaskedLM = "fastplms.models.esm2.modeling_fastesm.FastEsmForMaskedLM", AutoModelForSequenceClassification = "fastplms.models.esm2.modeling_fastesm.FastEsmForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.esm2.modeling_fastesm.FastEsmForTokenClassification" }
|
| 178 |
+
|
| 179 |
+
[families.esm_plusplus]
|
| 180 |
+
architecture = "ESMC"
|
| 181 |
+
upstreams = ["biohub-esm", "biohub-transformers"]
|
| 182 |
+
tokenizer_mode = "tokenizer"
|
| 183 |
+
public_input = "Amino-acid sequences tokenized to residue IDs"
|
| 184 |
+
extra = "core"
|
| 185 |
+
reference_container = "reference-biohub-esm"
|
| 186 |
+
reference_adapter = "tests.parity.support.reference_adapters.esm_plusplus"
|
| 187 |
+
attention = ["eager", "sdpa", "flex_attention", "flash_attention_2", "flash_attention_3"]
|
| 188 |
+
dtypes = ["float32", "bfloat16"]
|
| 189 |
+
bf16_execution = "static_parameters"
|
| 190 |
+
precisions = ["default"]
|
| 191 |
+
vram_tier = "sequence"
|
| 192 |
+
checkpoint_license = "MIT"
|
| 193 |
+
hub_license = "mit"
|
| 194 |
+
weights_publication_allowed = true
|
| 195 |
+
state_transform = "esmc_to_fastplms_v1"
|
| 196 |
+
conversion_provenance = "Input: the pinned Biohub ESMC checkpoint. Transformation: apply the deterministic esmc_to_fastplms_v1 parameter map into the FastPLMs ESMC modules. Output: the pinned Synthyra ESMplusplus checkpoint. Validation: release parity compares keys, shapes, dtypes, values, aliases, and live inference. Limitation: runtime attention and precision selection are not serialized weight transforms."
|
| 197 |
+
representative = "esmc_small"
|
| 198 |
+
documentation = "docs/models.md#esm-and-esmc"
|
| 199 |
+
test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
|
| 200 |
+
runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/esm_plusplus", "models/ttt.py"]
|
| 201 |
+
auto_map = { AutoConfig = "fastplms.models.esm_plusplus.modeling_esm_plusplus.ESMplusplusConfig", AutoModel = "fastplms.models.esm_plusplus.modeling_esm_plusplus.ESMplusplusModel", AutoModelForMaskedLM = "fastplms.models.esm_plusplus.modeling_esm_plusplus.ESMplusplusForMaskedLM" }
|
| 202 |
+
|
| 203 |
+
[families.esm3]
|
| 204 |
+
architecture = "ESM3"
|
| 205 |
+
upstreams = ["biohub-esm", "biohub-transformers"]
|
| 206 |
+
tokenizer_mode = "tokenizer"
|
| 207 |
+
public_input = "Sequence, structure, and function tracks prepared through the multimodal helpers"
|
| 208 |
+
extra = "core"
|
| 209 |
+
reference_container = "reference-biohub-esm"
|
| 210 |
+
reference_adapter = "tests.parity.support.reference_adapters.esm3"
|
| 211 |
+
attention = ["eager", "sdpa", "flex_attention"]
|
| 212 |
+
dtypes = ["float32", "bfloat16"]
|
| 213 |
+
bf16_execution = "fp32_parameters_autocast"
|
| 214 |
+
precisions = ["default"]
|
| 215 |
+
vram_tier = "large-sequence"
|
| 216 |
+
checkpoint_license = "MIT"
|
| 217 |
+
hub_license = "mit"
|
| 218 |
+
weights_publication_allowed = true
|
| 219 |
+
state_transform = "esm3_to_fastplms_v1"
|
| 220 |
+
conversion_provenance = "Input: the pinned Biohub ESM3 checkpoint. Transformation: apply the deterministic esm3_to_fastplms_v1 parameter map for the supported sequence and multimodal modules and expand BF16 checkpoint tensors to FP32 storage. Output: the pinned Synthyra ESM3 checkpoint. Validation: release parity compares exact state identity after the declared map and live feature behavior. Limitation: unsupported upstream modalities may not be inferred from this record."
|
| 221 |
+
representative = "esm3_small"
|
| 222 |
+
documentation = "docs/models.md#esm3"
|
| 223 |
+
test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
|
| 224 |
+
runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/esm3", "models/ttt.py"]
|
| 225 |
+
auto_map = { AutoConfig = "fastplms.models.esm3.modeling_esm3.FastESM3Config", AutoModel = "fastplms.models.esm3.modeling_esm3.FastESM3Model" }
|
| 226 |
+
|
| 227 |
+
[families.e1]
|
| 228 |
+
architecture = "E1"
|
| 229 |
+
upstreams = ["e1"]
|
| 230 |
+
tokenizer_mode = "sequence"
|
| 231 |
+
public_input = "Raw amino-acid sequences prepared by the native E1 adapter"
|
| 232 |
+
extra = "core"
|
| 233 |
+
reference_container = "reference-e1"
|
| 234 |
+
reference_adapter = "tests.parity.support.reference_adapters.e1"
|
| 235 |
+
attention = ["sdpa", "flex_attention"]
|
| 236 |
+
dtypes = ["float32", "bfloat16"]
|
| 237 |
+
bf16_execution = "static_parameters"
|
| 238 |
+
precisions = ["default"]
|
| 239 |
+
vram_tier = "sequence"
|
| 240 |
+
checkpoint_license = "Profluent-E1-Agreement"
|
| 241 |
+
hub_license = "other"
|
| 242 |
+
hub_license_name = "Profluent-E1 Clickthrough License Agreement"
|
| 243 |
+
hub_license_link = "https://github.com/Profluent-AI/E1/blob/bfd2620a602248499f3d2583d85a7ecddf0b6e02/LICENSE"
|
| 244 |
+
weights_publication_allowed = true
|
| 245 |
+
state_transform = "e1_to_fastplms_v1"
|
| 246 |
+
conversion_provenance = "Input: the pinned Profluent-E1 checkpoint and tokenizer-free sequence contract. Transformation: apply e1_to_fastplms_v1 to the FastPLMs encoder and official task heads, storing floating tensors in BF16. Output: the pinned Synthyra Profluent-E1 checkpoint. Validation: release parity covers state identity after the declared cast, sequence and RAG preparation, aliases, and inference. Limitation: the FastPLMs scoring extension is not represented as an official E1 head."
|
| 247 |
+
representative = "e1_150m"
|
| 248 |
+
documentation = "docs/models.md#e1"
|
| 249 |
+
test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
|
| 250 |
+
runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/e1", "models/ttt.py"]
|
| 251 |
+
auto_map = { AutoConfig = "fastplms.models.e1.modeling_e1.E1Config", AutoModel = "fastplms.models.e1.modeling_e1.E1Model", AutoModelForMaskedLM = "fastplms.models.e1.modeling_e1.E1ForMaskedLM", AutoModelForSequenceClassification = "fastplms.models.e1.modeling_e1.E1ForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.e1.modeling_e1.E1ForTokenClassification" }
|
| 252 |
+
|
| 253 |
+
[families.dplm]
|
| 254 |
+
architecture = "DPLM"
|
| 255 |
+
upstreams = ["dplm"]
|
| 256 |
+
tokenizer_mode = "tokenizer"
|
| 257 |
+
public_input = "Amino-acid sequences tokenized to masked or partially masked residue IDs"
|
| 258 |
+
extra = "core"
|
| 259 |
+
reference_container = "reference-dplm"
|
| 260 |
+
reference_adapter = "tests.parity.support.reference_adapters.dplm"
|
| 261 |
+
attention = ["eager", "sdpa", "flex_attention", "flash_attention_3"]
|
| 262 |
+
dtypes = ["float32", "bfloat16"]
|
| 263 |
+
bf16_execution = "fp32_parameters_autocast"
|
| 264 |
+
precisions = ["default"]
|
| 265 |
+
vram_tier = "sequence"
|
| 266 |
+
checkpoint_license = "Apache-2.0"
|
| 267 |
+
hub_license = "apache-2.0"
|
| 268 |
+
weights_publication_allowed = true
|
| 269 |
+
state_transform = "dplm_to_fastplms_v1"
|
| 270 |
+
conversion_provenance = "Input: the pinned official DPLM1 checkpoint. Transformation: apply dplm_to_fastplms_v1, omitting the unused absolute-position table for rotary checkpoints and materializing the tied input/output embedding values as independent tensors. Output: the pinned Synthyra DPLM checkpoint. Validation: release parity compares exact state identity after the declared transform, tokenizer behavior, generation, and inference. License basis: the pinned ByteDance DPLM Apache-2.0 LICENSE and README explicitly scope the repository release to the pretrained DPLM1 and DPLM2 weights; immutable evidence is recorded in LICENSES/dplm/PROVENANCE.md. Limitation: redistribution remains subject to Apache-2.0 and the pinned provenance record; no broader rights are inferred."
|
| 271 |
+
representative = "dplm_150m"
|
| 272 |
+
documentation = "docs/models.md#dplm"
|
| 273 |
+
test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
|
| 274 |
+
runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/_diffusion_generation.py", "models/_esm_rotary.py", "models/dplm", "models/ttt.py"]
|
| 275 |
+
auto_map = { AutoConfig = "fastplms.models.dplm.modeling_dplm.DPLMConfig", AutoModel = "fastplms.models.dplm.modeling_dplm.DPLMModel", AutoModelForMaskedLM = "fastplms.models.dplm.modeling_dplm.DPLMForMaskedLM", AutoModelForSequenceClassification = "fastplms.models.dplm.modeling_dplm.DPLMForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.dplm.modeling_dplm.DPLMForTokenClassification" }
|
| 276 |
+
|
| 277 |
+
[families.dplm2]
|
| 278 |
+
architecture = "DPLM2"
|
| 279 |
+
upstreams = ["dplm"]
|
| 280 |
+
tokenizer_mode = "tokenizer"
|
| 281 |
+
public_input = "Tokenized amino-acid and structure tracks with explicit modality boundaries"
|
| 282 |
+
extra = "core"
|
| 283 |
+
reference_container = "reference-dplm"
|
| 284 |
+
reference_adapter = "tests.parity.support.reference_adapters.dplm2"
|
| 285 |
+
attention = ["sdpa"]
|
| 286 |
+
dtypes = ["float32", "bfloat16"]
|
| 287 |
+
bf16_execution = "fp32_parameters_autocast"
|
| 288 |
+
precisions = ["default"]
|
| 289 |
+
vram_tier = "sequence"
|
| 290 |
+
checkpoint_license = "Apache-2.0"
|
| 291 |
+
hub_license = "apache-2.0"
|
| 292 |
+
weights_publication_allowed = true
|
| 293 |
+
state_transform = "dplm2_to_fastplms_v1"
|
| 294 |
+
conversion_provenance = "Input: the pinned official DPLM2 checkpoint. Transformation: apply dplm2_to_fastplms_v1, retaining the independent language-model head and trained encoder contact head while omitting the unused absolute-position table for rotary checkpoints. Output: the pinned Synthyra DPLM2 checkpoint. Validation: release parity compares exact keys and values after the declared omission, non-aliasing, tokenizer behavior, generation, and inference. License basis: the pinned ByteDance DPLM Apache-2.0 LICENSE and README explicitly scope the repository release to the pretrained DPLM1 and DPLM2 weights; immutable evidence is recorded in LICENSES/dplm/PROVENANCE.md. Limitation: no head exception is permitted by this record, and redistribution remains subject to Apache-2.0."
|
| 295 |
+
representative = "dplm2_150m"
|
| 296 |
+
documentation = "docs/models.md#dplm2"
|
| 297 |
+
test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
|
| 298 |
+
runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/_diffusion_generation.py", "models/_esm_rotary.py", "models/dplm2", "models/ttt.py"]
|
| 299 |
+
auto_map = { AutoConfig = "fastplms.models.dplm2.modeling_dplm2.DPLM2Config", AutoModel = "fastplms.models.dplm2.modeling_dplm2.DPLM2Model", AutoModelForMaskedLM = "fastplms.models.dplm2.modeling_dplm2.DPLM2ForMaskedLM", AutoModelForSequenceClassification = "fastplms.models.dplm2.modeling_dplm2.DPLM2ForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.dplm2.modeling_dplm2.DPLM2ForTokenClassification" }
|
| 300 |
+
tokenizer_class = "fastplms.models.dplm2.tokenization_dplm2.DPLM2Tokenizer"
|
| 301 |
+
|
| 302 |
+
[families.ankh]
|
| 303 |
+
architecture = "ANKH"
|
| 304 |
+
upstreams = ["ankh"]
|
| 305 |
+
tokenizer_mode = "tokenizer"
|
| 306 |
+
public_input = "Amino-acid sequences tokenized for encoder or sequence-to-sequence use"
|
| 307 |
+
extra = "core"
|
| 308 |
+
reference_container = "reference-ankh"
|
| 309 |
+
reference_adapter = "tests.parity.support.reference_adapters.ankh"
|
| 310 |
+
attention = ["eager", "sdpa"]
|
| 311 |
+
dtypes = ["float32", "bfloat16"]
|
| 312 |
+
bf16_execution = "static_parameters"
|
| 313 |
+
precisions = ["default"]
|
| 314 |
+
vram_tier = "large-sequence"
|
| 315 |
+
checkpoint_license = "CC-BY-NC-SA-4.0"
|
| 316 |
+
hub_license = "cc-by-nc-sa-4.0"
|
| 317 |
+
weights_publication_allowed = true
|
| 318 |
+
state_transform = "ankh_t5_to_fastplms_v1"
|
| 319 |
+
conversion_provenance = "Input: the pinned official ANKH T5 checkpoint. Transformation: apply ankh_t5_to_fastplms_v1 to the official encoder and sequence-to-sequence heads. Output: the pinned Synthyra ANKH checkpoint. Validation: release parity compares exact mapped state, tokenizer behavior, official heads, and inference. Limitation: the separately named FastPLMs masked-language-model extension is not an official ANKH head."
|
| 320 |
+
representative = "ankh_base"
|
| 321 |
+
documentation = "docs/models.md#ankh"
|
| 322 |
+
test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
|
| 323 |
+
requires_complete_weight_publication = true
|
| 324 |
+
runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/ankh", "models/ttt.py"]
|
| 325 |
+
auto_map = { AutoConfig = "fastplms.models.ankh.modeling_ankh.FastAnkhConfig", AutoModel = "fastplms.models.ankh.modeling_ankh.FastAnkhModel", AutoModelForMaskedLM = "fastplms.models.ankh.modeling_ankh.FastAnkhForMaskedLMExtension", AutoModelForSeq2SeqLM = "fastplms.models.ankh.modeling_ankh.FastAnkhForConditionalGeneration", AutoModelForSequenceClassification = "fastplms.models.ankh.modeling_ankh.FastAnkhForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.ankh.modeling_ankh.FastAnkhForTokenClassification" }
|
| 326 |
+
|
| 327 |
+
[families.boltz2]
|
| 328 |
+
architecture = "Boltz2"
|
| 329 |
+
upstreams = ["boltz"]
|
| 330 |
+
tokenizer_mode = "structure"
|
| 331 |
+
public_input = "Raw amino-acid sequences through the convenience API, or prepared model features"
|
| 332 |
+
extra = "structure"
|
| 333 |
+
reference_container = "reference-boltz2"
|
| 334 |
+
reference_adapter = "tests.parity.support.reference_adapters.boltz"
|
| 335 |
+
attention = ["eager"]
|
| 336 |
+
dtypes = ["float32", "bfloat16"]
|
| 337 |
+
bf16_execution = "fp32_parameters_autocast"
|
| 338 |
+
precisions = ["default"]
|
| 339 |
+
vram_tier = "structure"
|
| 340 |
+
checkpoint_license = "MIT"
|
| 341 |
+
hub_license = "mit"
|
| 342 |
+
weights_publication_allowed = true
|
| 343 |
+
state_transform = "boltz2_inference_core_v1"
|
| 344 |
+
conversion_provenance = "Input: the pinned official Boltz2 checkpoint. Transformation: select and map the supported Boltz2 inference-core parameters with boltz2_inference_core_v1. Output: the pinned Synthyra Boltz2 checkpoint. Validation: release parity covers state identity for the declared subset, feature preparation, seeded inference, and structure outputs. Limitation: this record does not claim support for undeclared upstream training components."
|
| 345 |
+
representative = "boltz2"
|
| 346 |
+
documentation = "docs/models.md#boltz2"
|
| 347 |
+
test_tiers = ["structure", "artifact", "benchmark"]
|
| 348 |
+
runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "models/boltz"]
|
| 349 |
+
auto_map = { AutoConfig = "fastplms.models.boltz.modeling_boltz2.Boltz2Config", AutoModel = "fastplms.models.boltz.modeling_boltz2.Boltz2Model" }
|
| 350 |
+
|
| 351 |
+
[families.esmfold]
|
| 352 |
+
architecture = "ESMFold"
|
| 353 |
+
upstreams = ["fair-esm", "openfold"]
|
| 354 |
+
tokenizer_mode = "structure"
|
| 355 |
+
public_input = "Raw amino-acid sequences through folding helpers, or prepared residue tensors"
|
| 356 |
+
extra = "structure"
|
| 357 |
+
reference_container = "reference-esmfold"
|
| 358 |
+
reference_adapter = "tests.parity.support.reference_adapters.esmfold"
|
| 359 |
+
attention = ["eager", "sdpa", "flex_attention"]
|
| 360 |
+
dtypes = ["float32", "bfloat16"]
|
| 361 |
+
bf16_execution = "fp32_parameters_autocast"
|
| 362 |
+
precisions = ["default"]
|
| 363 |
+
vram_tier = "structure"
|
| 364 |
+
checkpoint_license = "MIT"
|
| 365 |
+
hub_license = "mit"
|
| 366 |
+
weights_publication_allowed = true
|
| 367 |
+
state_transform = "esmfold_meta_to_fastplms_v1"
|
| 368 |
+
conversion_provenance = "Input: the pinned native Meta ESMFold checkpoint plus its pinned ESM2 backbone. Transformation: apply esmfold_meta_to_fastplms_v1 to map native ESM2 names into the structure-only FastPLMs backbone, retain folding tensors, omit five deterministically reconstructed geometry buffers, omit the folding-unused ESM2 masked-LM and contact-regression heads, and remove the obsolete random FastPLMs TTT head from earlier mirrors. Output: canonical FP32 FastPLMs ESMFold state with an explicit CUDA BF16-autocast execution path. Validation: release parity compares exact mapped keys, shapes, dtypes, values, aliases, semantic configuration, FP32 and BF16-compute seeded inference, and structure metrics with pLDDT normalized to (0, 1). Limitation: ESMFold TTT is rejected because the official checkpoint contains no trained masked-language-model head."
|
| 369 |
+
representative = "esmfold"
|
| 370 |
+
documentation = "docs/models.md#esmfold"
|
| 371 |
+
test_tiers = ["check", "compliance", "structure", "feature", "artifact", "benchmark"]
|
| 372 |
+
runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/_esm_rotary.py", "models/esmfold"]
|
| 373 |
+
auto_map = { AutoConfig = "fastplms.models.esmfold.modeling_fast_esmfold.FastEsmFoldConfig", AutoModel = "fastplms.models.esmfold.modeling_fast_esmfold.FastEsmForProteinFolding" }
|
| 374 |
+
|
| 375 |
+
[families.esmfold2]
|
| 376 |
+
architecture = "ESMFold2"
|
| 377 |
+
upstreams = ["biohub-esm", "biohub-transformers", "protein-ttt"]
|
| 378 |
+
backbone_model = "esmc_6b"
|
| 379 |
+
tokenizer_mode = "structure"
|
| 380 |
+
public_input = "Raw amino-acid sequences or typed molecular-complex specifications; low-level forward accepts prepared feature tensors"
|
| 381 |
+
extra = "structure"
|
| 382 |
+
reference_container = "reference-esmfold2"
|
| 383 |
+
reference_adapter = "tests.parity.support.reference_adapters.esmfold2"
|
| 384 |
+
attention = ["eager", "sdpa", "flex_attention"]
|
| 385 |
+
dtypes = ["float32", "bfloat16"]
|
| 386 |
+
bf16_execution = "fp32_parameters_autocast"
|
| 387 |
+
precisions = ["auto", "fp32", "bf16", "fp8"]
|
| 388 |
+
experimental_precisions = ["fp8"]
|
| 389 |
+
vram_tier = "structure-6b"
|
| 390 |
+
checkpoint_license = "MIT"
|
| 391 |
+
hub_license = "mit"
|
| 392 |
+
weights_publication_allowed = true
|
| 393 |
+
state_transform = "identity"
|
| 394 |
+
conversion_provenance = "Input: each pinned Biohub ESMFold2 checkpoint and its separately pinned ESMC checkpoint. Transformation: apply identity to preserve the folding checkpoint exactly, load its parameters in FP32 for CUDA BF16-autocast execution, retain canonical BF16 ESMC weights, and optionally rebuild exactly 80 ESMC attention output projections as transient Transformer Engine linears. Output: the corresponding pinned Synthyra ESMFold2 checkpoint plus its declared ESMC precision policy. Validation: release parity covers exact canonical state, learned projection, prepared features, and seeded BF16 folding; experimental FP8 validation covers strict unavailable-device behavior, all four variants, and three BF16-to-FP8 reload cycles on the standard variant. Limitation: only the four manifest-listed ESMFold2 variants are supported; FP8 is experimental, applies only to inference-time ESMC execution, and requires direct CUDA loading with Transformer Engine availability."
|
| 395 |
+
representative = "esmfold2"
|
| 396 |
+
documentation = "docs/esmfold2.md"
|
| 397 |
+
test_tiers = ["check", "compliance", "structure", "feature", "artifact", "benchmark"]
|
| 398 |
+
runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/esmfold2", "models/esm_plusplus", "models/ttt.py"]
|
| 399 |
+
auto_map = { AutoConfig = "fastplms.models.esmfold2.configuration_esmfold2.ESMFold2Config", AutoModel = "fastplms.models.esmfold2.modeling_esmfold2.ESMFold2Model" }
|
| 400 |
+
|
| 401 |
+
[[models]]
|
| 402 |
+
id = "esm2_8m"
|
| 403 |
+
family = "esm2"
|
| 404 |
+
size_category = "small"
|
| 405 |
+
generation_contract = "not_applicable"
|
| 406 |
+
official_golden = { metadata = "tests/goldens/esm2_8m.json=sha256:6975e86d1d8f27488bf2a676551feaa48cc19254c9d24b6acb09198122745609", tensors = "tests/goldens/esm2_8m.safetensors=sha256:b40217566c33c71988d28869de353be54a3b3ebfc21fdfd29056e88cf7e99f4c" }
|
| 407 |
+
fast_repo = "Synthyra/ESM2-8M"
|
| 408 |
+
fast_revision = "185ecbd45665d050a8dae326d91886d330c5f9d0"
|
| 409 |
+
fast_files = [
|
| 410 |
+
"config.json=git-sha1:46d0a7b517f59123c6ebc6d1011585731cbab259",
|
| 411 |
+
"model.safetensors=sha256:c824e6ded5fb71c72bc5ac05300699947819023cb26cdaf6897665e6b2645e1b",
|
| 412 |
+
"special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
|
| 413 |
+
"tokenizer_config.json=git-sha1:3cfc5db0c6790859a3bc2a4dc053a813acd65295",
|
| 414 |
+
"vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
|
| 415 |
+
]
|
| 416 |
+
official_repo = "facebook/esm2_t6_8M_UR50D"
|
| 417 |
+
official_revision = "c731040fcd8d73dceaa04b0a8e6329b345b0f5df"
|
| 418 |
+
official_files = [
|
| 419 |
+
"config.json=git-sha1:c2c6e65a87d9d20d47699ae236d605b80c741dd3",
|
| 420 |
+
"model.safetensors=sha256:24c5fa474c48f3b754b86efe752d5f189d2bcd88190fa2270fc92b2ef3034189",
|
| 421 |
+
"special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
|
| 422 |
+
"tokenizer_config.json=git-sha1:3f0d47e841e1cb75257aeaf76d156802899a217e",
|
| 423 |
+
"vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
|
| 424 |
+
]
|
| 425 |
+
|
| 426 |
+
[[models.oracle_assets]]
|
| 427 |
+
role = "weights"
|
| 428 |
+
path = "models/esm2_t6_8M_UR50D.pt"
|
| 429 |
+
url = "https://dl.fbaipublicfiles.com/fair-esm/models/esm2_t6_8M_UR50D.pt"
|
| 430 |
+
sha256 = "46f002a9870c9bdecd0ea887acb1f9a38a6b561e8f8bf8a6990b679b9d31b928"
|
| 431 |
+
size = 30099493
|
| 432 |
+
|
| 433 |
+
[[models.oracle_assets]]
|
| 434 |
+
role = "contact_regression"
|
| 435 |
+
path = "regression/esm2_t6_8M_UR50D-contact-regression.pt"
|
| 436 |
+
url = "https://dl.fbaipublicfiles.com/fair-esm/regression/esm2_t6_8M_UR50D-contact-regression.pt"
|
| 437 |
+
sha256 = "8f7a4557d57713b97ba0e484303007efb7230d25299c0ac47a0a1b12a87bbb9d"
|
| 438 |
+
size = 1511
|
| 439 |
+
|
| 440 |
+
[[models]]
|
| 441 |
+
id = "esm2_35m"
|
| 442 |
+
family = "esm2"
|
| 443 |
+
size_category = "small"
|
| 444 |
+
generation_contract = "not_applicable"
|
| 445 |
+
official_golden = { metadata = "tests/goldens/esm2_35m.json=sha256:e919d3ce6d20b6a942d27d92323814ae7594a0129dc9c4de27c5053e96675bcd", tensors = "tests/goldens/esm2_35m.safetensors=sha256:c9b8bb616cf884fb7744521a2fcc6eed23586342d11241e6c9ef16454ec31e17" }
|
| 446 |
+
fast_repo = "Synthyra/ESM2-35M"
|
| 447 |
+
fast_revision = "37ab9f56b41e365b3bd9e25d6fefe9150fd910f0"
|
| 448 |
+
fast_files = [
|
| 449 |
+
"config.json=git-sha1:4d428c9934572f39e2a00db162249971f37c88e4",
|
| 450 |
+
"model.safetensors=sha256:21d95ab6bb9aa91bfec87eff11da61a657b732f2df279cbddbae6a7f1f0bba9c",
|
| 451 |
+
"special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
|
| 452 |
+
"tokenizer_config.json=git-sha1:3cfc5db0c6790859a3bc2a4dc053a813acd65295",
|
| 453 |
+
"vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
|
| 454 |
+
]
|
| 455 |
+
official_repo = "facebook/esm2_t12_35M_UR50D"
|
| 456 |
+
official_revision = "6fbf070e65b0b7291e7bbcd451118c216cff79d8"
|
| 457 |
+
official_files = [
|
| 458 |
+
"config.json=git-sha1:3f64131bb610ed1ce482c4b5421fc358c785278f",
|
| 459 |
+
"model.safetensors=sha256:e35647818e0e064351d4531ed480d225a002567b4b2b93ad3a9246d753150fc0",
|
| 460 |
+
"special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
|
| 461 |
+
"tokenizer_config.json=git-sha1:3f0d47e841e1cb75257aeaf76d156802899a217e",
|
| 462 |
+
"vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
|
| 463 |
+
]
|
| 464 |
+
|
| 465 |
+
[[models.oracle_assets]]
|
| 466 |
+
role = "weights"
|
| 467 |
+
path = "models/esm2_t12_35M_UR50D.pt"
|
| 468 |
+
url = "https://dl.fbaipublicfiles.com/fair-esm/models/esm2_t12_35M_UR50D.pt"
|
| 469 |
+
sha256 = "7f21e80e61d16a71735163ef555d3009afb0c98da74c48e29df08606973cc55e"
|
| 470 |
+
size = 134095705
|
| 471 |
+
|
| 472 |
+
[[models.oracle_assets]]
|
| 473 |
+
role = "contact_regression"
|
| 474 |
+
path = "regression/esm2_t12_35M_UR50D-contact-regression.pt"
|
| 475 |
+
url = "https://dl.fbaipublicfiles.com/fair-esm/regression/esm2_t12_35M_UR50D-contact-regression.pt"
|
| 476 |
+
sha256 = "16641e05d830d0ce863dd152dbb8c2f3ddfa3c3ec2a66080152c8abad01d8585"
|
| 477 |
+
size = 1959
|
| 478 |
+
|
| 479 |
+
[[models]]
|
| 480 |
+
id = "esm2_150m"
|
| 481 |
+
family = "esm2"
|
| 482 |
+
size_category = "medium"
|
| 483 |
+
generation_contract = "not_applicable"
|
| 484 |
+
official_golden = { metadata = "tests/goldens/esm2_150m.json=sha256:c04c93486024ba0fa1c81fbfbe92ee79d1d4c7f1cfcc2c9886728522f752feab", tensors = "tests/goldens/esm2_150m.safetensors=sha256:c03fe9916dba137b452a6bbe944c7dc414db4019a6f0921e87b92d4bb6a8a42f" }
|
| 485 |
+
fast_repo = "Synthyra/ESM2-150M"
|
| 486 |
+
fast_revision = "979e0880dfc9e0c0080839b83d9d2dc05b92786a"
|
| 487 |
+
fast_files = [
|
| 488 |
+
"config.json=git-sha1:efeae2af182b7d34dc35740a45f157661e7acdf4",
|
| 489 |
+
"model.safetensors=sha256:d1f7c60f98c31af328381519a750972b6a31b13b97aa7cca2e71b5ae1b3f8f53",
|
| 490 |
+
"special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
|
| 491 |
+
"tokenizer_config.json=git-sha1:3cfc5db0c6790859a3bc2a4dc053a813acd65295",
|
| 492 |
+
"vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
|
| 493 |
+
]
|
| 494 |
+
official_repo = "facebook/esm2_t30_150M_UR50D"
|
| 495 |
+
official_revision = "a695f6045e2e32885fa60af20c13cb35398ce30c"
|
| 496 |
+
official_files = [
|
| 497 |
+
"config.json=git-sha1:52e04179e6fbad6663a94ea5cc44f09d764c5cd4",
|
| 498 |
+
"model.safetensors=sha256:c3f1da8aea53bddd32c246c86168c23b9fd72341fb9db9a94436f855f5053566",
|
| 499 |
+
"special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
|
| 500 |
+
"tokenizer_config.json=git-sha1:3f0d47e841e1cb75257aeaf76d156802899a217e",
|
| 501 |
+
"vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
|
| 502 |
+
]
|
| 503 |
+
|
| 504 |
+
[[models.oracle_assets]]
|
| 505 |
+
role = "weights"
|
| 506 |
+
path = "models/esm2_t30_150M_UR50D.pt"
|
| 507 |
+
url = "https://dl.fbaipublicfiles.com/fair-esm/models/esm2_t30_150M_UR50D.pt"
|
| 508 |
+
sha256 = "881c7176cf198ef8dec26a3c375d40eb58d0c33df95c22562ca6cc6d3f812c62"
|
| 509 |
+
size = 592774773
|
| 510 |
+
|
| 511 |
+
[[models.oracle_assets]]
|
| 512 |
+
role = "contact_regression"
|
| 513 |
+
path = "regression/esm2_t30_150M_UR50D-contact-regression.pt"
|
| 514 |
+
url = "https://dl.fbaipublicfiles.com/fair-esm/regression/esm2_t30_150M_UR50D-contact-regression.pt"
|
| 515 |
+
sha256 = "6a604b96722ed052eef8a094ad90b275ba2e987d406315dbed0bdc6b3c4238a7"
|
| 516 |
+
size = 3431
|
| 517 |
+
|
| 518 |
+
[[models]]
|
| 519 |
+
id = "esm2_650m"
|
| 520 |
+
family = "esm2"
|
| 521 |
+
size_category = "large"
|
| 522 |
+
generation_contract = "not_applicable"
|
| 523 |
+
official_golden = { metadata = "tests/goldens/esm2_650m.json=sha256:f18332172fcb3abf5dd2485fd55f5b0d193ad3b93a44cc744e0d02817c927477", tensors = "tests/goldens/esm2_650m.safetensors=sha256:c3a66b75add03628e62e238cb63da6a9e4d321f8160e84bdf2a131c096977f86" }
|
| 524 |
+
fast_repo = "Synthyra/ESM2-650M"
|
| 525 |
+
fast_revision = "ca0718a5d52b80d5c60dd76860e55e061a95fb0a"
|
| 526 |
+
fast_files = [
|
| 527 |
+
"config.json=git-sha1:88f6bd240680b29c3244df8292246048401f5caf",
|
| 528 |
+
"model.safetensors=sha256:a15142e94ecf36f0edde9b37796f591e609ebe1694ca411e93640f0ee384994a",
|
| 529 |
+
"special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
|
| 530 |
+
"tokenizer_config.json=git-sha1:3cfc5db0c6790859a3bc2a4dc053a813acd65295",
|
| 531 |
+
"vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
|
| 532 |
+
]
|
| 533 |
+
official_repo = "facebook/esm2_t33_650M_UR50D"
|
| 534 |
+
official_revision = "08e4846e537177426273712802403f7ba8261b6c"
|
| 535 |
+
official_files = [
|
| 536 |
+
"config.json=git-sha1:a956a25d277f30bd870d3760b9a116f19ead885e",
|
| 537 |
+
"model.safetensors=sha256:a08adabb949fa67ad3c14b509d04fd60368b35007b0095e3358f81200c4f4db0",
|
| 538 |
+
"special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
|
| 539 |
+
"tokenizer_config.json=git-sha1:3f0d47e841e1cb75257aeaf76d156802899a217e",
|
| 540 |
+
"vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
|
| 541 |
+
]
|
| 542 |
+
|
| 543 |
+
[[models.oracle_assets]]
|
| 544 |
+
role = "weights"
|
| 545 |
+
path = "models/esm2_t33_650M_UR50D.pt"
|
| 546 |
+
url = "https://dl.fbaipublicfiles.com/fair-esm/models/esm2_t33_650M_UR50D.pt"
|
| 547 |
+
sha256 = "ea9d0522b335a8778dea6535a65301f10208dece28cd5865482b0b1fc446168c"
|
| 548 |
+
size = 2604537549
|
| 549 |
+
|
| 550 |
+
[[models.oracle_assets]]
|
| 551 |
+
role = "contact_regression"
|
| 552 |
+
path = "regression/esm2_t33_650M_UR50D-contact-regression.pt"
|
| 553 |
+
url = "https://dl.fbaipublicfiles.com/fair-esm/regression/esm2_t33_650M_UR50D-contact-regression.pt"
|
| 554 |
+
sha256 = "8ffe6edbd4173dc8d45c2cd5cb27d43aad77ec26b4c768200c58ae1f96693575"
|
| 555 |
+
size = 3687
|
| 556 |
+
|
| 557 |
+
[[models]]
|
| 558 |
+
id = "esm2_3b"
|
| 559 |
+
family = "esm2"
|
| 560 |
+
size_category = "xlarge"
|
| 561 |
+
generation_contract = "not_applicable"
|
| 562 |
+
official_golden = { metadata = "tests/goldens/esm2_3b.json=sha256:5043b2333c57a34d54fac53916722d1acb4b6fd50395b9abafa805435b184a48", tensors = "tests/goldens/esm2_3b.safetensors=sha256:dfd5a8cb05d3e814a080185c4808c8e7ec2277f070f395562fcfbe4376789e4e" }
|
| 563 |
+
notes = "The pinned default SDPA BF16 path uses a checkpoint-specific numeric calibration: relative L2 target/hard limit 0.06/0.07, relative Q99.9 0.15/0.18, first-percentile residue cosine 0.994/0.992, and pooled cosine 0.998/0.997. Exact state identity and the global logits-distribution contract remain required."
|
| 564 |
+
fast_repo = "Synthyra/ESM2-3B"
|
| 565 |
+
fast_revision = "ff89d0180f414ab9c677219a25da79bf09185456"
|
| 566 |
+
fast_files = [
|
| 567 |
+
"config.json=git-sha1:94944ad6cabaa40a3ce1cbe6699cf464fdc1b2c0",
|
| 568 |
+
"model-00001-of-00003.safetensors=sha256:04b57854545c23779b562ee2ae22f10021ba0f4d586ba0ad482ee6eda187d562",
|
| 569 |
+
"model-00002-of-00003.safetensors=sha256:34954aaa05bc91635776ba6672946da5822626753d80db97b38c0538e9525102",
|
| 570 |
+
"model-00003-of-00003.safetensors=sha256:a6b3a55b9e3b2e1778de34c665c3dd17bdfdf6da9d6d5c97730c57168709ccae",
|
| 571 |
+
"special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
|
| 572 |
+
"tokenizer_config.json=git-sha1:3cfc5db0c6790859a3bc2a4dc053a813acd65295",
|
| 573 |
+
"vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
|
| 574 |
+
]
|
| 575 |
+
official_repo = "facebook/esm2_t36_3B_UR50D"
|
| 576 |
+
official_revision = "476b639933c8baad5ad09a60ac1a87f987b656fc"
|
| 577 |
+
official_files = [
|
| 578 |
+
"config.json=git-sha1:69e7563923f87d2d7439bfb83e5a19b44b46d71b",
|
| 579 |
+
"pytorch_model-00001-of-00002.bin=sha256:0f971f11c449d21422aa982b791619c10351972992c735f4c3cd43fe09790412",
|
| 580 |
+
"pytorch_model-00002-of-00002.bin=sha256:7560b46fc383c691fb74b915b7d4bcef40d3df181447f16ba4b298845e308d0c",
|
| 581 |
+
"special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
|
| 582 |
+
"tokenizer_config.json=git-sha1:3f0d47e841e1cb75257aeaf76d156802899a217e",
|
| 583 |
+
"vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
|
| 584 |
+
]
|
| 585 |
+
|
| 586 |
+
[[models.oracle_assets]]
|
| 587 |
+
role = "weights"
|
| 588 |
+
path = "models/esm2_t36_3B_UR50D.pt"
|
| 589 |
+
url = "https://dl.fbaipublicfiles.com/fair-esm/models/esm2_t36_3B_UR50D.pt"
|
| 590 |
+
sha256 = "7de8b4082ba15891959ab368b77ce3886697af1efb16d3c9e9e7b0c5d3f07500"
|
| 591 |
+
size = 5678116398
|
| 592 |
+
|
| 593 |
+
[[models.oracle_assets]]
|
| 594 |
+
role = "contact_regression"
|
| 595 |
+
path = "regression/esm2_t36_3B_UR50D-contact-regression.pt"
|
| 596 |
+
url = "https://dl.fbaipublicfiles.com/fair-esm/regression/esm2_t36_3B_UR50D-contact-regression.pt"
|
| 597 |
+
sha256 = "4da500eab246481dc9c8c95bc7b1d02f2803d761c380b0e95186d4a07d0fc84e"
|
| 598 |
+
size = 6759
|
| 599 |
+
|
| 600 |
+
[[models]]
|
| 601 |
+
id = "esmc_small"
|
| 602 |
+
family = "esm_plusplus"
|
| 603 |
+
size_category = "medium"
|
| 604 |
+
generation_contract = "not_applicable"
|
| 605 |
+
official_golden = { metadata = "tests/goldens/esmc_small.json=sha256:bb02652cf3cc484756b98ffa4ba55ed4c55870d2cea3342adb1d920ba9dfe10a", tensors = "tests/goldens/esmc_small.safetensors=sha256:03378d0f0fdd8161178ebb2c1f0da1b9776a726c8e8d3a10c009808a24de5654" }
|
| 606 |
+
notes = "Release contract: SDPA must match the pinned Biohub implementation bit-for-bit across every hidden state, last hidden state, logits, special token, and padding position. Eager and FlashAttention 2 are release-gated in BF16 against the pinned boundary-length and biological panels with a relative-L2 engineering target of 0.029, hard limit of 0.03, relative-Q99.9 target of 0.049, first-percentile residue-cosine target of 0.997, and Jensen-Shannon target of 0.0004. The global pooled-cosine and top-1 thresholds remain unchanged. Flex Attention and FlashAttention 3 remain selectable as opt-in alternatives, but they are not strict-parity choices: on the locked H100 BF16 generated-boundary panel, ESMC-6B Flex Attention exceeds the 0.03 relative-L2 hard limit and FlashAttention 3 falls below the 0.995 residue-cosine hard limit. The deviation is consistent with backend-specific BF16 kernel arithmetic; it is not a weight-conversion difference or silent fallback. Use SDPA for exact Biohub parity or FlashAttention 2 for release-gated acceleration."
|
| 607 |
+
fast_repo = "Synthyra/ESMplusplus_small"
|
| 608 |
+
fast_revision = "46c5f7d562e47d4c14165b424c71ab7db008e6fb"
|
| 609 |
+
fast_files = [
|
| 610 |
+
"config.json=git-sha1:df2f44187157b0cc371c48c887b77b1783679201",
|
| 611 |
+
"model.safetensors=sha256:d099223765bc4f1ae8d6c7e18561ce41df1d54073fdc5327ef0a229235a8f52a",
|
| 612 |
+
"special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b",
|
| 613 |
+
"tokenizer.json=git-sha1:f49735e56cebab0e791aeaae777757b7fd114f71",
|
| 614 |
+
"tokenizer_config.json=git-sha1:2985ed2b8aa8ecfb1d12f53f47d2b8a44cc21756",
|
| 615 |
+
]
|
| 616 |
+
official_repo = "biohub/ESMC-300M"
|
| 617 |
+
official_revision = "a59b831785f907e96e6a246b1d142bfb76df31ee"
|
| 618 |
+
official_files = [
|
| 619 |
+
"config.json=git-sha1:9a49eacf4e65c39f74381f0f0d240e3b89ef43d7",
|
| 620 |
+
"model.safetensors=sha256:0772d8fe64bb25e14fe6f23b80e3c9a7d215d0da3c6cba5bd356d7c0e0bb22cc",
|
| 621 |
+
"special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b",
|
| 622 |
+
"tokenizer.json=git-sha1:81c797f56768b22dec0301fa771f018b7e43e98c",
|
| 623 |
+
"tokenizer_config.json=git-sha1:2238856624f8d39f03af53a2576c2d9b18c82f61",
|
| 624 |
+
]
|
| 625 |
+
|
| 626 |
+
[[models]]
|
| 627 |
+
id = "esmc_large"
|
| 628 |
+
family = "esm_plusplus"
|
| 629 |
+
size_category = "large"
|
| 630 |
+
generation_contract = "not_applicable"
|
| 631 |
+
official_golden = { metadata = "tests/goldens/esmc_large.json=sha256:7a4d614f67b6fde417f3fd89f61e7ec442ae284769734b2b73e14945a816a8fd", tensors = "tests/goldens/esmc_large.safetensors=sha256:e13302df4cf7e8381552f1043a8fd0f31f3e0d50b2ab6009fb86b7940ae8ff79" }
|
| 632 |
+
notes = "Release contract: SDPA must match the pinned Biohub implementation bit-for-bit across every hidden state, last hidden state, logits, special token, and padding position. Eager and FlashAttention 2 are release-gated in BF16 against the pinned boundary-length and biological panels with a relative-L2 engineering target of 0.029, hard limit of 0.03, relative-Q99.9 target of 0.049, first-percentile residue-cosine target of 0.997, and Jensen-Shannon target of 0.0004. The global pooled-cosine and top-1 thresholds remain unchanged. Flex Attention and FlashAttention 3 remain selectable as opt-in alternatives, but they are not strict-parity choices: on the locked H100 BF16 generated-boundary panel, ESMC-6B Flex Attention exceeds the 0.03 relative-L2 hard limit and FlashAttention 3 falls below the 0.995 residue-cosine hard limit. The deviation is consistent with backend-specific BF16 kernel arithmetic; it is not a weight-conversion difference or silent fallback. Use SDPA for exact Biohub parity or FlashAttention 2 for release-gated acceleration."
|
| 633 |
+
fast_repo = "Synthyra/ESMplusplus_large"
|
| 634 |
+
fast_revision = "f813401638b3fddab09748aec1ad2bf537aa4208"
|
| 635 |
+
fast_files = [
|
| 636 |
+
"config.json=git-sha1:5736371902fe5d04e2859be30ac7dbd31b271b25",
|
| 637 |
+
"model.safetensors=sha256:4aff3f8c5de68c4d3e3824eb2c478e4a47355d3f849f3c745e5c8a5ee6cff851",
|
| 638 |
+
"special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b",
|
| 639 |
+
"tokenizer.json=git-sha1:f49735e56cebab0e791aeaae777757b7fd114f71",
|
| 640 |
+
"tokenizer_config.json=git-sha1:2985ed2b8aa8ecfb1d12f53f47d2b8a44cc21756",
|
| 641 |
+
]
|
| 642 |
+
official_repo = "biohub/ESMC-600M"
|
| 643 |
+
official_revision = "a7e82012c83126b9eedb055fea9fa84b6c02f094"
|
| 644 |
+
official_files = [
|
| 645 |
+
"config.json=git-sha1:71c8241dc28a5fb636248267a0927c0242b264c1",
|
| 646 |
+
"model.safetensors=sha256:e4232c30fd35fe2f57051ec88a703996ac94520580b4b836894207a3d45d9ff8",
|
| 647 |
+
"special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b",
|
| 648 |
+
"tokenizer.json=git-sha1:81c797f56768b22dec0301fa771f018b7e43e98c",
|
| 649 |
+
"tokenizer_config.json=git-sha1:2238856624f8d39f03af53a2576c2d9b18c82f61",
|
| 650 |
+
]
|
| 651 |
+
|
| 652 |
+
[[models]]
|
| 653 |
+
id = "esmc_6b"
|
| 654 |
+
family = "esm_plusplus"
|
| 655 |
+
size_category = "xlarge"
|
| 656 |
+
generation_contract = "not_applicable"
|
| 657 |
+
official_golden = { metadata = "tests/goldens/esmc_6b.json=sha256:e229d938719782f280fab22dfc4c43e86109fdb0cc523631168c5a491afaace3", tensors = "tests/goldens/esmc_6b.safetensors=sha256:a948945e985c7deaca7be8b7eed09c0a9521a2af3f2b10fc2ec7a7d2a0f99ada" }
|
| 658 |
+
notes = "Release contract: SDPA must match the pinned Biohub implementation bit-for-bit across every hidden state, last hidden state, logits, special token, and padding position. Eager and FlashAttention 2 are release-gated in BF16 against the pinned boundary-length and biological panels with a relative-L2 engineering target of 0.029, hard limit of 0.03, relative-Q99.9 target of 0.049, first-percentile residue-cosine target of 0.997, and Jensen-Shannon target of 0.0004. The global pooled-cosine and top-1 thresholds remain unchanged. Flex Attention and FlashAttention 3 remain selectable as opt-in alternatives, but they are not strict-parity choices: on the locked H100 BF16 generated-boundary panel, ESMC-6B Flex Attention exceeds the 0.03 relative-L2 hard limit and FlashAttention 3 falls below the 0.995 residue-cosine hard limit. The deviation is consistent with backend-specific BF16 kernel arithmetic; it is not a weight-conversion difference or silent fallback. Use SDPA for exact Biohub parity or FlashAttention 2 for release-gated acceleration."
|
| 659 |
+
fast_repo = "Synthyra/ESMplusplus_6B"
|
| 660 |
+
fast_revision = "0d579cce3b0f09efa6b3baddf6cc3fd8c9b616c8"
|
| 661 |
+
fast_files = [
|
| 662 |
+
"config.json=git-sha1:e740cbcf211f2511c70c25a1ff6017a757ba7a69",
|
| 663 |
+
"model-00001-of-00006.safetensors=sha256:d30d18703453019f2d2d050866309888720c28eebc9a10307d1ddf3799e85a65",
|
| 664 |
+
"model-00002-of-00006.safetensors=sha256:b3d85378ab5023f4160a96e9c8cbd4cc6f78a771a83c856e88d48112f555bc13",
|
| 665 |
+
"model-00003-of-00006.safetensors=sha256:52595519b59349c5c6e373e6f5ca4a3d48ea6dde345f7e61e24766df5fab0e5b",
|
| 666 |
+
"model-00004-of-00006.safetensors=sha256:e46c6113c89c6f3e9b072c1bef02d763a625c37bcd8f9da2ed9363891c9a0758",
|
| 667 |
+
"model-00005-of-00006.safetensors=sha256:6d92cb2bf9791de644de2ae86f8523d802ac3b4aaabfff0716ab6c2b97f6fb14",
|
| 668 |
+
"model-00006-of-00006.safetensors=sha256:5fc1a8632490bb34162823c35d0d591337b9e4195b22cc0560741397a6e9d0b3",
|
| 669 |
+
"special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b",
|
| 670 |
+
"tokenizer.json=git-sha1:f49735e56cebab0e791aeaae777757b7fd114f71",
|
| 671 |
+
"tokenizer_config.json=git-sha1:2985ed2b8aa8ecfb1d12f53f47d2b8a44cc21756",
|
| 672 |
+
]
|
| 673 |
+
official_repo = "biohub/ESMC-6B"
|
| 674 |
+
official_revision = "45b0fa5d7fb06faefbd5e3b89bdcef35d564e79a"
|
| 675 |
+
official_files = [
|
| 676 |
+
"config.json=git-sha1:19f5fb09e4f630fb5b748a497183c22a87ec5102",
|
| 677 |
+
"model-00001-of-00006.safetensors=sha256:bd90149ff223e6ac1a0cac6147a5ae0df20d3a21df4f65356a1f19cd14f4aa8a",
|
| 678 |
+
"model-00002-of-00006.safetensors=sha256:f75e2144d8269fe2eb4b3e0823fb089b94f176d8024153e85b8fb573a42294fa",
|
| 679 |
+
"model-00003-of-00006.safetensors=sha256:f699f01ecc9691d9c6470492765fe54b8b5d2e9f277c139e89427433ffdfe0b2",
|
| 680 |
+
"model-00004-of-00006.safetensors=sha256:46add1b7be098bbfdc3073884851ba3057f1b33ea23a158b650a37007dabd13d",
|
| 681 |
+
"model-00005-of-00006.safetensors=sha256:1e1cb62f060a34e18f54a31a76683ef888b8cec59e73315f5b31d25d45a1f88c",
|
| 682 |
+
"model-00006-of-00006.safetensors=sha256:56c73e13ae96e777ce65eee99364056069ef93b646470f352f83c5f1037b1b18",
|
| 683 |
+
"special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b",
|
| 684 |
+
"tokenizer.json=git-sha1:81c797f56768b22dec0301fa771f018b7e43e98c",
|
| 685 |
+
"tokenizer_config.json=git-sha1:2238856624f8d39f03af53a2576c2d9b18c82f61",
|
| 686 |
+
]
|
| 687 |
+
|
| 688 |
+
[[models]]
|
| 689 |
+
id = "esm3_small"
|
| 690 |
+
family = "esm3"
|
| 691 |
+
tokenizer_source = "esmc_small"
|
| 692 |
+
size_category = "large"
|
| 693 |
+
generation_contract = "not_applicable"
|
| 694 |
+
official_golden = { metadata = "tests/goldens/esm3_small.json=sha256:5470e8596cbba0e2882647eccbc53c36d8b48b0f3947d1fe0bcea68da1078c32", tensors = "tests/goldens/esm3_small.safetensors=sha256:d957922f810c9ab4c557d80d5aaaf6a3aab79a5a45e4638012a634a4134803b1" }
|
| 695 |
+
fast_repo = "Synthyra/ESM3_small"
|
| 696 |
+
fast_revision = "7ddb5a740f9e5f93933eb6410c0ee8684bc63ec1"
|
| 697 |
+
fast_files = [
|
| 698 |
+
"config.json=git-sha1:60526e2fdd8af9d4fba17f323775458ef5a1a1f9",
|
| 699 |
+
"model-00001-of-00002.safetensors=sha256:a4c9b736c4c59d51180e966005a164859b47d5cd36e1f8ecdea619fbd34a0e92",
|
| 700 |
+
"model-00002-of-00002.safetensors=sha256:bea60e4e91b03bb00b6cedd29b07606b8543f0869fb74454af7b26e216d80d2b",
|
| 701 |
+
"special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b",
|
| 702 |
+
"tokenizer.json=git-sha1:f49735e56cebab0e791aeaae777757b7fd114f71",
|
| 703 |
+
"tokenizer_config.json=git-sha1:2985ed2b8aa8ecfb1d12f53f47d2b8a44cc21756",
|
| 704 |
+
]
|
| 705 |
+
official_repo = "biohub/esm3-sm-open-v1"
|
| 706 |
+
official_revision = "47f0545b2b6daf26a93439a3cd610f4f7f3d5478"
|
| 707 |
+
official_files = [
|
| 708 |
+
"config.json=git-sha1:0967ef424bce6791893e9a57bb952f80fd536e93",
|
| 709 |
+
"data/weights/esm3_function_decoder_v0.pth=sha256:f76d074efcaccfe21365a4fa96f212dadd66798e1e49d809ab7ffbe025d227c9",
|
| 710 |
+
"data/weights/esm3_sm_open_v1.pth=sha256:5ead5a135c658068db6a4f1b933e72d6110992c4668822e1c0e2dcc53e38acd9",
|
| 711 |
+
"data/weights/esm3_structure_decoder_v0.pth=sha256:3b726258a44274792b40ce7ea307e10c5da09936368a4ffa2970264d909da65b",
|
| 712 |
+
"data/weights/esm3_structure_encoder_v0.pth=sha256:467acbaee703ba3ccde6e75241a912a316952e5ff071355f85c1d33c68704f40",
|
| 713 |
+
]
|
| 714 |
+
|
| 715 |
+
[[models]]
|
| 716 |
+
id = "e1_150m"
|
| 717 |
+
family = "e1"
|
| 718 |
+
size_category = "small"
|
| 719 |
+
generation_contract = "not_applicable"
|
| 720 |
+
official_golden = { metadata = "tests/goldens/e1_150m.json=sha256:701a64a6ab1a2fec5a427555b6af96232526c15cb3d5b4dc7fb253ac8f20b922", tensors = "tests/goldens/e1_150m.safetensors=sha256:6558bc8f1a7b20629eaaaa6f72601d0c2cdb859a5dc13595549b1773b6e2de41" }
|
| 721 |
+
fast_repo = "Synthyra/Profluent-E1-150M"
|
| 722 |
+
fast_revision = "7c5f3bbf697226a2e0900db7a100f9201774a907"
|
| 723 |
+
fast_files = [
|
| 724 |
+
"config.json=git-sha1:562ef21e722ca708064fc3d54d25b731d4ac8171",
|
| 725 |
+
"model.safetensors=sha256:d779ed3a4e23799aafc932dc09c9963428d10aa7075999b5f8851b39c76b67f6",
|
| 726 |
+
]
|
| 727 |
+
official_repo = "Profluent-Bio/E1-150m"
|
| 728 |
+
official_revision = "c4dbfe827e4aa6ed7f95eaef50dc1e084f4d77dc"
|
| 729 |
+
official_files = [
|
| 730 |
+
"config.json=git-sha1:485e649199b46fe6ee7456bebf7aae9b3d4baeab",
|
| 731 |
+
"model.safetensors=sha256:ba2656339005e6598642836acfdafde480fecc7e145ce0058eb54adf572c3484",
|
| 732 |
+
]
|
| 733 |
+
|
| 734 |
+
[[models]]
|
| 735 |
+
id = "e1_300m"
|
| 736 |
+
family = "e1"
|
| 737 |
+
size_category = "medium"
|
| 738 |
+
generation_contract = "not_applicable"
|
| 739 |
+
official_golden = { metadata = "tests/goldens/e1_300m.json=sha256:d3478f3f5957a0e0377864074dde0107de890019f96cb63548ee17ffb8f3ec3a", tensors = "tests/goldens/e1_300m.safetensors=sha256:92778b9ef95a803ddc84b3e3ca764c59e045872a94bcff0eb0cd47647732c188" }
|
| 740 |
+
fast_repo = "Synthyra/Profluent-E1-300M"
|
| 741 |
+
fast_revision = "5ef52c0ad2ae2578f40622696b763523810e8e26"
|
| 742 |
+
fast_files = [
|
| 743 |
+
"config.json=git-sha1:f5c91498b76a3e3282a0d716d87738abb1a1b6c1",
|
| 744 |
+
"model.safetensors=sha256:9271c4176a8a2e0905a0bb769570ba1c2978fb999a87da92db4cf2b041224864",
|
| 745 |
+
]
|
| 746 |
+
official_repo = "Profluent-Bio/E1-300m"
|
| 747 |
+
official_revision = "5a2871c587eadbcc9237bc686ea45e5b4d28dfb3"
|
| 748 |
+
official_files = [
|
| 749 |
+
"config.json=git-sha1:918cb09e6e96d4719ed85951f38c693360f9cdb8",
|
| 750 |
+
"model.safetensors=sha256:31e09a2542f45b04e6ce4adafb3b657f21e2d56d12bf68fd2266b1576a80bc9b",
|
| 751 |
+
]
|
| 752 |
+
|
| 753 |
+
[[models]]
|
| 754 |
+
id = "e1_600m"
|
| 755 |
+
family = "e1"
|
| 756 |
+
size_category = "large"
|
| 757 |
+
generation_contract = "not_applicable"
|
| 758 |
+
official_golden = { metadata = "tests/goldens/e1_600m.json=sha256:914be191c28141c1f84535cdb69ead0588a2057bb19d46c5bc7f3891a3d6739e", tensors = "tests/goldens/e1_600m.safetensors=sha256:22ed8417a4651ded255099f6d15c63c2c40552e700d2b0470d1adfde3a39c513" }
|
| 759 |
+
fast_repo = "Synthyra/Profluent-E1-600M"
|
| 760 |
+
fast_revision = "6c8bf0ec83b0e0178677c528b101efffd0677742"
|
| 761 |
+
fast_files = [
|
| 762 |
+
"config.json=git-sha1:1d35c0b35b473259875fd29ee80167487a0d6afe",
|
| 763 |
+
"model.safetensors=sha256:793483b1b3411eab73fe5214b94d1424ca0545992dfac6889cfc0186af472363",
|
| 764 |
+
]
|
| 765 |
+
official_repo = "Profluent-Bio/E1-600m"
|
| 766 |
+
official_revision = "52d959fb87a609d15cf223a485127b29ed5c382a"
|
| 767 |
+
official_files = [
|
| 768 |
+
"config.json=git-sha1:8a0a439ed4201462bc01189c9f8b43523b257b5c",
|
| 769 |
+
"model.safetensors=sha256:cfc108d4b98baaa62932331b40be265eae39dc382595bc3cde4a5ab55db1bf7a",
|
| 770 |
+
]
|
| 771 |
+
|
| 772 |
+
[[models]]
|
| 773 |
+
id = "dplm_150m"
|
| 774 |
+
family = "dplm"
|
| 775 |
+
size_category = "small"
|
| 776 |
+
generation_contract = "required"
|
| 777 |
+
official_golden = { metadata = "tests/goldens/dplm_150m.json=sha256:3228551fe3bed951db9ec97347143ec4462ce7c221ac240b7ce7730948c1dc1f", tensors = "tests/goldens/dplm_150m.safetensors=sha256:392992235195beed97ab8359b90a2e11e52f4326606f99a471447bed81d146bd" }
|
| 778 |
+
fast_repo = "Synthyra/DPLM-150M"
|
| 779 |
+
fast_revision = "90ba742754151a774f3b7ed580170d0a76b3e69d"
|
| 780 |
+
fast_files = [
|
| 781 |
+
"config.json=git-sha1:117ac2c1222152ef378abaad1f605e18c4a18ab0",
|
| 782 |
+
"model.safetensors=sha256:8bac5ac767ceb8deb511b272d32883f811768d56cb25e920cea94ba9b979ca14",
|
| 783 |
+
"special_tokens_map.json=git-sha1:ef5f0f7d7baf4947564eafcf79972d272cd80a15",
|
| 784 |
+
"tokenizer_config.json=git-sha1:80100348e3f2b8ab05b59f3352ea7631685083cd",
|
| 785 |
+
"vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
|
| 786 |
+
]
|
| 787 |
+
official_repo = "airkingbd/dplm_150m"
|
| 788 |
+
official_revision = "49b7125a5d28c6418fcc2f3c4fe799352ac1488b"
|
| 789 |
+
official_files = [
|
| 790 |
+
"config.json=git-sha1:4910cb02f1840e9ac577026f601829604af58c74",
|
| 791 |
+
"pytorch_model.bin=sha256:ea4eaa99536b60ed76f945f71a1a5e604f08447ec3def5104a93ca6001a59961",
|
| 792 |
+
"special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
|
| 793 |
+
"tokenizer_config.json=git-sha1:dbcdd9fb2e742627ee310713615e0d7aeed0c34e",
|
| 794 |
+
"vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
|
| 795 |
+
]
|
| 796 |
+
|
| 797 |
+
[[models]]
|
| 798 |
+
id = "dplm_650m"
|
| 799 |
+
family = "dplm"
|
| 800 |
+
size_category = "large"
|
| 801 |
+
generation_contract = "required"
|
| 802 |
+
official_golden = { metadata = "tests/goldens/dplm_650m.json=sha256:bf58d0ce73aaac7e6fb1923ef3d9adad67122df2a3dd414c3229488ef9587a6d", tensors = "tests/goldens/dplm_650m.safetensors=sha256:073f0a6abea7e48f28c2d921ff8329a28e22627f01979277cb324908a01b3378" }
|
| 803 |
+
fast_repo = "Synthyra/DPLM-650M"
|
| 804 |
+
fast_revision = "05dc16d97c5c028aed924c9ed681cee4ab609760"
|
| 805 |
+
fast_files = [
|
| 806 |
+
"config.json=git-sha1:3537150eb87b213a676d5840548625e220b60e8b",
|
| 807 |
+
"model.safetensors=sha256:e27a47b8ec1c078b3fccb36542210e20f0380c88828db2ca9acf3d8a25048bd8",
|
| 808 |
+
"special_tokens_map.json=git-sha1:ef5f0f7d7baf4947564eafcf79972d272cd80a15",
|
| 809 |
+
"tokenizer_config.json=git-sha1:80100348e3f2b8ab05b59f3352ea7631685083cd",
|
| 810 |
+
"vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
|
| 811 |
+
]
|
| 812 |
+
official_repo = "airkingbd/dplm_650m"
|
| 813 |
+
official_revision = "7a7e651baa667d094aba05e9dc1cf52a3332110a"
|
| 814 |
+
official_files = [
|
| 815 |
+
"config.json=git-sha1:625574d625a4178ca6966e9545fee56026c0b634",
|
| 816 |
+
"pytorch_model.bin=sha256:db4e54343a89e7600f41c3aacbc593db1b0caee82ec28cab25ff2ae090eba39c",
|
| 817 |
+
"special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
|
| 818 |
+
"tokenizer_config.json=git-sha1:dbcdd9fb2e742627ee310713615e0d7aeed0c34e",
|
| 819 |
+
"vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
|
| 820 |
+
]
|
| 821 |
+
|
| 822 |
+
[[models]]
|
| 823 |
+
id = "dplm_3b"
|
| 824 |
+
family = "dplm"
|
| 825 |
+
size_category = "xlarge"
|
| 826 |
+
generation_contract = "required"
|
| 827 |
+
official_golden = { metadata = "tests/goldens/dplm_3b.json=sha256:a5b6df8b9c7b371976892ec1d6c45581a32ad3a6325c6c0a0b3267012848c8ed", tensors = "tests/goldens/dplm_3b.safetensors=sha256:75b0a0854fc391133920b0feaaeb8f69ab7568a88b3759627aca1556c4338c1e" }
|
| 828 |
+
fast_repo = "Synthyra/DPLM-3B"
|
| 829 |
+
fast_revision = "7d764dd3d70ecf1ac0e64693de64a0064aacac65"
|
| 830 |
+
fast_files = [
|
| 831 |
+
"config.json=git-sha1:7f5baf9426be06760c86882948b0f4af2e681e22",
|
| 832 |
+
"model-00001-of-00003.safetensors=sha256:37b54855d087ef3e7d883464ae9d5ea3127ec15a16c6323d91ad16a6b98305c9",
|
| 833 |
+
"model-00002-of-00003.safetensors=sha256:042604fefb05ea8c360a48416ce7ba662a4f90b176b4baf646c5c1814c35e6e8",
|
| 834 |
+
"model-00003-of-00003.safetensors=sha256:b9ae04012665163c3fc9781dd04fcd69738ac20c07e615e98fc4483fd2c4de45",
|
| 835 |
+
"special_tokens_map.json=git-sha1:ef5f0f7d7baf4947564eafcf79972d272cd80a15",
|
| 836 |
+
"tokenizer_config.json=git-sha1:80100348e3f2b8ab05b59f3352ea7631685083cd",
|
| 837 |
+
"vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
|
| 838 |
+
]
|
| 839 |
+
official_repo = "airkingbd/dplm_3b"
|
| 840 |
+
official_revision = "53849d4a7fe944ae0b9cf2bbc0d2cc0054795b51"
|
| 841 |
+
official_files = [
|
| 842 |
+
"config.json=git-sha1:f6206456e8c2f22ebe1d37fce3b5d50fd8073e68",
|
| 843 |
+
"pytorch_model-00001-of-00004.bin=sha256:0bcb86a115fe744ed686756db143f78851304e855e2f83cec58681c6080ced5f",
|
| 844 |
+
"pytorch_model-00002-of-00004.bin=sha256:daf3324f3be949e7dd1c3c84b28da7fec5151b1890cb0904e73427266856a06f",
|
| 845 |
+
"pytorch_model-00003-of-00004.bin=sha256:dbbeb7924a21059854f994931e23590b054aa000b10370a71c052c4aa36e9246",
|
| 846 |
+
"pytorch_model-00004-of-00004.bin=sha256:21c01740d091487db43446489d8a893dea1fcc6f2e1c1991ece13945f7ab4e07",
|
| 847 |
+
"special_tokens_map.json=git-sha1:ba0f9b53dbbf27934f7555e5d31e37bdea9317f1",
|
| 848 |
+
"tokenizer_config.json=git-sha1:dbcdd9fb2e742627ee310713615e0d7aeed0c34e",
|
| 849 |
+
"vocab.txt=git-sha1:6b946952cc35537226f07fd70957ee2f848880d2",
|
| 850 |
+
]
|
| 851 |
+
|
| 852 |
+
[[models]]
|
| 853 |
+
id = "dplm2_150m"
|
| 854 |
+
family = "dplm2"
|
| 855 |
+
size_category = "small"
|
| 856 |
+
generation_contract = "required"
|
| 857 |
+
official_golden = { metadata = "tests/goldens/dplm2_150m.json=sha256:d269de779ea1503de72c77e7b2e6224afc9797bd945b40c571ff6faec782e4aa", tensors = "tests/goldens/dplm2_150m.safetensors=sha256:17fc26600938ba5364b8ecb96750786d33e9f92bcd4ea4df3e12a389340748eb" }
|
| 858 |
+
artifact_source = "official"
|
| 859 |
+
canonical_state_sha256 = "82e1751f59052b8de72b082517557db47947e8d9b4ac2f11278369e6c0cbf001"
|
| 860 |
+
fast_repo = "Synthyra/DPLM2-150M"
|
| 861 |
+
fast_revision = "182745b8dc5661f898481a4fa60a7af9d53385c4"
|
| 862 |
+
fast_files = [
|
| 863 |
+
"config.json=git-sha1:07905a2e4327d27d073cd0390f140aec2976125a",
|
| 864 |
+
"model.safetensors=sha256:0a7751b3113027b1d9c966a5bda2d6ab831855de7aaa047b911731665a7c3cc6",
|
| 865 |
+
"special_tokens_map.json=git-sha1:e6378d20e897b8806734e65fd3ef9cf42a17631b",
|
| 866 |
+
"tokenizer_config.json=git-sha1:f2090783e3368b7323aa877e2b740e09f0862259",
|
| 867 |
+
"vocab.txt=git-sha1:9706a4277a5c39dc9b4ec7b283e8eb130ceaa7f2",
|
| 868 |
+
]
|
| 869 |
+
official_repo = "airkingbd/dplm2_150m"
|
| 870 |
+
official_revision = "3451d984d06497f835ed49634bd68c9dfb54d730"
|
| 871 |
+
official_files = [
|
| 872 |
+
"config.json=git-sha1:20f1e55c64fdc4d1d30f7b1df64b6167fa23dc7c",
|
| 873 |
+
"pytorch_model.bin=sha256:be7f5cf9e421f59fcc437e63ce1c7391099a314a4e9a4f10b8688785fa581238",
|
| 874 |
+
"special_tokens_map.json=git-sha1:eb760e9f49a55145bbe0c64922d4ec2d3de1692a",
|
| 875 |
+
"tokenizer_config.json=git-sha1:fc8c21760dcff173955afb106859e5f015d4f757",
|
| 876 |
+
"vocab.txt=git-sha1:e133a3abd4350ddc3fc62548e162c8df7e62cf37",
|
| 877 |
+
]
|
| 878 |
+
|
| 879 |
+
[[models]]
|
| 880 |
+
id = "dplm2_650m"
|
| 881 |
+
family = "dplm2"
|
| 882 |
+
size_category = "large"
|
| 883 |
+
generation_contract = "required"
|
| 884 |
+
official_golden = { metadata = "tests/goldens/dplm2_650m.json=sha256:d9a7548f9af657a72d441ca70f27379863724fcce8ddd3da4f672104b7bfb772", tensors = "tests/goldens/dplm2_650m.safetensors=sha256:c4e0e467c252c3ac813363d2d4b17a5e3bd99e75fad315e76d97689b4655ddac" }
|
| 885 |
+
artifact_source = "official"
|
| 886 |
+
canonical_state_sha256 = "cba76b6602d2258de9fffff953b608d93cb8ef4a9e89b0bbd27e160c81e78bb4"
|
| 887 |
+
fast_repo = "Synthyra/DPLM2-650M"
|
| 888 |
+
fast_revision = "b9d8527a9473a54954fa2764f590b9ea1b435bb2"
|
| 889 |
+
fast_files = [
|
| 890 |
+
"config.json=git-sha1:3e079579b214d48a09db57f2c60be6a1acea5baf",
|
| 891 |
+
"model.safetensors=sha256:92db08c7dbfd6c5e03fbfeaea3f36b09640ee794dcf5ea8d550527869a9f1d63",
|
| 892 |
+
"special_tokens_map.json=git-sha1:e6378d20e897b8806734e65fd3ef9cf42a17631b",
|
| 893 |
+
"tokenizer_config.json=git-sha1:f2090783e3368b7323aa877e2b740e09f0862259",
|
| 894 |
+
"vocab.txt=git-sha1:9706a4277a5c39dc9b4ec7b283e8eb130ceaa7f2",
|
| 895 |
+
]
|
| 896 |
+
official_repo = "airkingbd/dplm2_650m"
|
| 897 |
+
official_revision = "0bc69b644976c6680ab7e26669854d1979e8876e"
|
| 898 |
+
official_files = [
|
| 899 |
+
"config.json=git-sha1:4cce8d9dc212cdace0e20e89169790bcf199c158",
|
| 900 |
+
"pytorch_model.bin=sha256:8d6e08cc05e4858064a714013c74cc88c9caa2cc8b12c34605a3c24bcd877cfb",
|
| 901 |
+
"special_tokens_map.json=git-sha1:eb760e9f49a55145bbe0c64922d4ec2d3de1692a",
|
| 902 |
+
"tokenizer_config.json=git-sha1:fc8c21760dcff173955afb106859e5f015d4f757",
|
| 903 |
+
"vocab.txt=git-sha1:e133a3abd4350ddc3fc62548e162c8df7e62cf37",
|
| 904 |
+
]
|
| 905 |
+
|
| 906 |
+
[[models]]
|
| 907 |
+
id = "dplm2_3b"
|
| 908 |
+
family = "dplm2"
|
| 909 |
+
size_category = "xlarge"
|
| 910 |
+
# The pinned public sampler fails before generation because cls_token_id is None.
|
| 911 |
+
# State, tokenizer, and inference parity remain required for this checkpoint.
|
| 912 |
+
generation_contract = "official_unavailable"
|
| 913 |
+
official_golden = { metadata = "tests/goldens/dplm2_3b.json=sha256:d6e0e02af53b13cb129192f06e264758aa21c9ebf4ee82411cf67037082d2329", tensors = "tests/goldens/dplm2_3b.safetensors=sha256:838b11824d08f83bcb0c0b3268e579f3a87dbfb965370cfe5c3f8793b96b1964" }
|
| 914 |
+
notes = "The pinned official DPLM2-3B sampler fails before generation, so live generation equivalence cannot be established for this checkpoint. State, tokenizer, and inference parity remain required."
|
| 915 |
+
artifact_source = "official"
|
| 916 |
+
canonical_state_sha256 = "8c46ec09115dbe6cbfb91d94ab5e906369d57e27fe620a7741c6f8cb1b6ca890"
|
| 917 |
+
fast_repo = "Synthyra/DPLM2-3B"
|
| 918 |
+
fast_revision = "2a63babe8848abf5233d31bd55891dff8285fc50"
|
| 919 |
+
fast_files = [
|
| 920 |
+
"config.json=git-sha1:5932b1d501fed28b84614e0d2c1ecc4e89f10d6e",
|
| 921 |
+
"model-00001-of-00003.safetensors=sha256:2ff393f6e8df1568ce075d50de69ff4e5e9d9886e5ec47e43d6c24df23459be3",
|
| 922 |
+
"model-00002-of-00003.safetensors=sha256:feb3cea852c2aa849cc30783a984a97f0d076990ade6606cda5e38bf2a5a9621",
|
| 923 |
+
"model-00003-of-00003.safetensors=sha256:9be363ddb98436af20901981ffbed2f1097377424987f6c1baad27d512b62e71",
|
| 924 |
+
"special_tokens_map.json=git-sha1:e6378d20e897b8806734e65fd3ef9cf42a17631b",
|
| 925 |
+
"tokenizer_config.json=git-sha1:f2090783e3368b7323aa877e2b740e09f0862259",
|
| 926 |
+
"vocab.txt=git-sha1:9706a4277a5c39dc9b4ec7b283e8eb130ceaa7f2",
|
| 927 |
+
]
|
| 928 |
+
official_repo = "airkingbd/dplm2_3b"
|
| 929 |
+
official_revision = "9e77567926f98d1b997ea9131a8eeb035b9bf827"
|
| 930 |
+
official_files = [
|
| 931 |
+
"config.json=git-sha1:22d51ce44cd6da8d819e0d00566987bb51d74753",
|
| 932 |
+
"pytorch_model-00001-of-00004.bin=sha256:d8c641eae6bf891581ec64d543169891b093e296f5679ac75c695bcf596b4211",
|
| 933 |
+
"pytorch_model-00002-of-00004.bin=sha256:6478ad86ec5fef3d1d26580493af2d8666009d3ff884f3f88548080c8bbf94b5",
|
| 934 |
+
"pytorch_model-00003-of-00004.bin=sha256:dde8f88dac4a6355488c2fb433ee12cd69f1169950566624fba43684d4d99dc6",
|
| 935 |
+
"pytorch_model-00004-of-00004.bin=sha256:17ec0145152bc10e4dd3b4c2edff337979f6b99ee7c7bfd6cf4e6dbd7262d079",
|
| 936 |
+
"special_tokens_map.json=git-sha1:eb760e9f49a55145bbe0c64922d4ec2d3de1692a",
|
| 937 |
+
"tokenizer_config.json=git-sha1:fc8c21760dcff173955afb106859e5f015d4f757",
|
| 938 |
+
"vocab.txt=git-sha1:e133a3abd4350ddc3fc62548e162c8df7e62cf37",
|
| 939 |
+
]
|
| 940 |
+
|
| 941 |
+
[[models]]
|
| 942 |
+
id = "ankh_base"
|
| 943 |
+
family = "ankh"
|
| 944 |
+
size_category = "medium"
|
| 945 |
+
generation_contract = "required"
|
| 946 |
+
official_golden = { metadata = "tests/goldens/ankh_base.json=sha256:ebce8d7de821827ee995789c9b38d79252d3b2f76888130b0a8a7eedafaefe2b", tensors = "tests/goldens/ankh_base.safetensors=sha256:f0e78aa15d11749e0c64ff57f9e88c51cec6538a0adf8951f839df70cc708b65" }
|
| 947 |
+
notes = "ANKH parity covers the official encoder and sequence-to-sequence heads. AutoModelForMaskedLM exposes the separately named FastPLMs synthesized masked-LM extension and is not an official ANKH head."
|
| 948 |
+
artifact_source = "official"
|
| 949 |
+
canonical_state_sha256 = "cdd8d30d88e5bf41f44e1eef4470d8e46607aba5f7c7c805b06c035b89c8c16f"
|
| 950 |
+
fast_repo = "Synthyra/ANKH_base"
|
| 951 |
+
fast_revision = "7ec329aae8e3e174bf22a1eb9e0e9fcc12b53092"
|
| 952 |
+
fast_files = [
|
| 953 |
+
"config.json=git-sha1:7e1cbce6d08f9bb64eee4410899b1c6b4054f418",
|
| 954 |
+
"model.safetensors=sha256:b0d3473cac1bda90e39cde54f2abe86da1fc84f872c833ca3415672776dccb95",
|
| 955 |
+
"special_tokens_map.json=git-sha1:a2d8d626c31389a935e197fb94072e2414a6e7d1",
|
| 956 |
+
"tokenizer.json=git-sha1:0734d752d12d0f46ac96467fbceb1c4bfbeee0be",
|
| 957 |
+
"tokenizer_config.json=git-sha1:db0b80de72d3b16242b9eda74ed4663e39c65bcf",
|
| 958 |
+
]
|
| 959 |
+
official_repo = "ElnaggarLab/ankh-base"
|
| 960 |
+
official_revision = "d99cb6b966530dfc2ae96bc69d9255c2a07308b0"
|
| 961 |
+
official_files = [
|
| 962 |
+
"config.json=git-sha1:abd44a36b5469e9a7cb019e4059b5ac1392d8422",
|
| 963 |
+
"pytorch_model.bin=sha256:9b2a886374f0ff4a893f4e7a989deed76bb2458c8998bd5202ea8e97d92ddcc3",
|
| 964 |
+
"special_tokens_map.json=git-sha1:55b145827029ae9672e50d4bb368540daacce791",
|
| 965 |
+
"tokenizer.json=git-sha1:212c5ef08819fa2463c6289ba4ef7db30e715c0a",
|
| 966 |
+
"tokenizer_config.json=git-sha1:a8a872ae3441e7cc85ce19210dff1e4c5d2d7bd0",
|
| 967 |
+
]
|
| 968 |
+
|
| 969 |
+
[[models]]
|
| 970 |
+
id = "ankh_large"
|
| 971 |
+
family = "ankh"
|
| 972 |
+
size_category = "large"
|
| 973 |
+
generation_contract = "required"
|
| 974 |
+
official_golden = { metadata = "tests/goldens/ankh_large.json=sha256:59492518b021de5cfaea87d672c9448c8558e99a3443ba2cc7ab544963196ecb", tensors = "tests/goldens/ankh_large.safetensors=sha256:3fb8d3ac27716d15a9ea92aeef6acf2b977bcc887d9b535000539e523673459b" }
|
| 975 |
+
notes = "ANKH parity covers the official encoder and sequence-to-sequence heads. AutoModelForMaskedLM exposes the separately named FastPLMs synthesized masked-LM extension and is not an official ANKH head."
|
| 976 |
+
artifact_source = "official"
|
| 977 |
+
canonical_state_sha256 = "e498a2e9aea76ef784cbe3e596c6b3f5e9a40e209ad837f7e3207099e4d74483"
|
| 978 |
+
fast_repo = "Synthyra/ANKH_large"
|
| 979 |
+
fast_revision = "3be3df34140f49dc4e65bd1f247e3ce819e7fc59"
|
| 980 |
+
fast_files = [
|
| 981 |
+
"config.json=git-sha1:272509deedb527e5c2c95b0c269194a44148fdcc",
|
| 982 |
+
"model.safetensors=sha256:e70b8f9755ac6bfe95d18359060ae9fe38fac63b12a89a886c83349d1adbaa53",
|
| 983 |
+
"special_tokens_map.json=git-sha1:a2d8d626c31389a935e197fb94072e2414a6e7d1",
|
| 984 |
+
"tokenizer.json=git-sha1:0734d752d12d0f46ac96467fbceb1c4bfbeee0be",
|
| 985 |
+
"tokenizer_config.json=git-sha1:2bcaff2567826f5f51188b00600d2c6e7bcea56e",
|
| 986 |
+
]
|
| 987 |
+
official_repo = "ElnaggarLab/ankh-large"
|
| 988 |
+
official_revision = "74b371dbfa3ee0a05d32ae74df0c2e0b82d6b9a6"
|
| 989 |
+
official_files = [
|
| 990 |
+
"config.json=git-sha1:1abf33e52ee3d6be67d780ec57d32ac2b27b5306",
|
| 991 |
+
"pytorch_model.bin=sha256:517b6e8b279dedcb477af240b35c46bd6eb3307723eb281e60d4b2c8a87b889b",
|
| 992 |
+
"special_tokens_map.json=git-sha1:55b145827029ae9672e50d4bb368540daacce791",
|
| 993 |
+
"tokenizer.json=git-sha1:212c5ef08819fa2463c6289ba4ef7db30e715c0a",
|
| 994 |
+
"tokenizer_config.json=git-sha1:d7fe02ba6f2b18d9ccfa19ac129c9fdc9ec24d09",
|
| 995 |
+
]
|
| 996 |
+
|
| 997 |
+
[[models]]
|
| 998 |
+
id = "ankh2_large"
|
| 999 |
+
family = "ankh"
|
| 1000 |
+
size_category = "large"
|
| 1001 |
+
generation_contract = "required"
|
| 1002 |
+
official_golden = { metadata = "tests/goldens/ankh2_large.json=sha256:e8df38994ca1a1e0c598ace34a0b257b264937e4fdbb01bc41544985116b02a4", tensors = "tests/goldens/ankh2_large.safetensors=sha256:25fe1569f55c635fab8fa49c1d62a889a35a2a738bad921f5764a85b58fd4b5d" }
|
| 1003 |
+
notes = "ANKH parity covers the official encoder and sequence-to-sequence heads. AutoModelForMaskedLM exposes the separately named FastPLMs synthesized masked-LM extension and is not an official ANKH head."
|
| 1004 |
+
artifact_source = "official"
|
| 1005 |
+
canonical_state_sha256 = "597c4fe2fa8711f11a25317905f1d62fa92905e55fdd5c0a79614cd9c9d2bca3"
|
| 1006 |
+
fast_repo = "Synthyra/ANKH2_large"
|
| 1007 |
+
fast_revision = "392de5ed52bbfd73b45f545e378aaebcff096d0e"
|
| 1008 |
+
fast_files = [
|
| 1009 |
+
"config.json=git-sha1:66b6adc7215743a98a3229958bbd1c9c42b6108b",
|
| 1010 |
+
"model.safetensors=sha256:be8e6242388d93b51cd9719a0e32cfc17a2e804786570c795ba332197eccb915",
|
| 1011 |
+
"special_tokens_map.json=git-sha1:a2d8d626c31389a935e197fb94072e2414a6e7d1",
|
| 1012 |
+
"tokenizer.json=git-sha1:0734d752d12d0f46ac96467fbceb1c4bfbeee0be",
|
| 1013 |
+
"tokenizer_config.json=git-sha1:db0b80de72d3b16242b9eda74ed4663e39c65bcf",
|
| 1014 |
+
]
|
| 1015 |
+
official_repo = "ElnaggarLab/ankh2-ext2"
|
| 1016 |
+
official_revision = "aa9b9fa72288c47d9f618ce80c011e24b54e17a8"
|
| 1017 |
+
official_files = [
|
| 1018 |
+
"config.json=git-sha1:9286bed4ecbc4f7113024919d16ec9719b0c0748",
|
| 1019 |
+
"generation_config.json=git-sha1:91f792e452403d46e170e206f9e50be5ddef9b9a",
|
| 1020 |
+
"pytorch_model.bin=sha256:2df583f28f111276ee22a7b76007f4297e9a69766d60bccd9c8d7169c06ac606",
|
| 1021 |
+
"special_tokens_map.json=git-sha1:55b145827029ae9672e50d4bb368540daacce791",
|
| 1022 |
+
"tokenizer.json=git-sha1:212c5ef08819fa2463c6289ba4ef7db30e715c0a",
|
| 1023 |
+
"tokenizer_config.json=git-sha1:854e5db75dae8b1e9dd39c5bae80dae5508b3e25",
|
| 1024 |
+
]
|
| 1025 |
+
|
| 1026 |
+
[[models]]
|
| 1027 |
+
id = "ankh3_large"
|
| 1028 |
+
family = "ankh"
|
| 1029 |
+
size_category = "large"
|
| 1030 |
+
generation_contract = "required"
|
| 1031 |
+
official_golden = { metadata = "tests/goldens/ankh3_large.json=sha256:2e5bb05b3baa5baa78f61fef7d2a2c669b0da5dbfaf6b50b12abd3e17253a961", tensors = "tests/goldens/ankh3_large.safetensors=sha256:e5c494ac418e0a2fe7bdad1376676d48960d58ec9e044d19bfffccb8c3288513" }
|
| 1032 |
+
notes = "ANKH parity covers the official encoder and sequence-to-sequence heads. AutoModelForMaskedLM exposes the separately named FastPLMs synthesized masked-LM extension and is not an official ANKH head."
|
| 1033 |
+
artifact_source = "official"
|
| 1034 |
+
canonical_state_sha256 = "60acb7ef86e85dc0c51fc1edf4c8e69a0480049723b6b2c95e6e9faa720c112a"
|
| 1035 |
+
fast_repo = "Synthyra/ANKH3_large"
|
| 1036 |
+
fast_revision = "53600f175f328f986f43e55ca8ceb14935d337a4"
|
| 1037 |
+
fast_files = [
|
| 1038 |
+
"config.json=git-sha1:432b09625d44a2eeab679fddb7495d42b560b7f9",
|
| 1039 |
+
"model.safetensors=sha256:9f50f58cf5b3a537a0a41aa918695c3a26d7985dd0b2266642d6f86324c9e7a1",
|
| 1040 |
+
"special_tokens_map.json=git-sha1:1fc3a4d6d4282e5201cd7c30d5c0a6a8bfa04f82",
|
| 1041 |
+
"tokenizer.json=git-sha1:3d14291df2d6db3a183c5c4fe133afb330cc44cf",
|
| 1042 |
+
"tokenizer_config.json=git-sha1:2005fec00a7ae9a49e248a1ecefbbd81c56674d6",
|
| 1043 |
+
]
|
| 1044 |
+
official_repo = "ElnaggarLab/ankh3-large"
|
| 1045 |
+
official_revision = "2be091622e8a393f0ef21735070084123c874b6e"
|
| 1046 |
+
official_files = [
|
| 1047 |
+
"config.json=git-sha1:f5278f77d158cdd8a173df888e3ed365e84a80a3",
|
| 1048 |
+
"generation_config.json=git-sha1:5767cc0cacebfd06884eb27ae1c796d3ca829fd2",
|
| 1049 |
+
"pytorch_model.bin=sha256:26321a345e07a25b21c6c41b651c4db91b420892e52c0dcbc55bd7a8f510f95b",
|
| 1050 |
+
"special_tokens_map.json=git-sha1:d596919b7fa2a197edd441ec3ec4685ecacd2de4",
|
| 1051 |
+
"spiece.model=sha256:f2b5e1bbd110b71ca9b2878e1fcd3265610076ecc97bd696e8a745c9bacc54e0",
|
| 1052 |
+
"tokenizer.json=git-sha1:90f0c94b43c81496b3ca81e3ec1c092ef2dd7fca",
|
| 1053 |
+
"tokenizer_config.json=git-sha1:0e699eebfa778698473b4faf1e66ef363b93fb21",
|
| 1054 |
+
]
|
| 1055 |
+
|
| 1056 |
+
[[models]]
|
| 1057 |
+
id = "ankh3_xl"
|
| 1058 |
+
family = "ankh"
|
| 1059 |
+
size_category = "xlarge"
|
| 1060 |
+
generation_contract = "required"
|
| 1061 |
+
official_golden = { metadata = "tests/goldens/ankh3_xl.json=sha256:66bb12e033e4163be225d636108a479393228a4f5061015c8af114e766c3c486", tensors = "tests/goldens/ankh3_xl.safetensors=sha256:72d34567d0228cb6f1ee701c578ed4039fead4346e3f161a52e0e74df28dc8ae" }
|
| 1062 |
+
notes = "ANKH parity covers the official encoder and sequence-to-sequence heads. AutoModelForMaskedLM exposes the separately named FastPLMs synthesized masked-LM extension and is not an official ANKH head. The official PyTorch shard index is deliberately excluded: the builder verifies every declared source shard directly and writes a new canonical safetensors index."
|
| 1063 |
+
artifact_source = "official"
|
| 1064 |
+
canonical_state_sha256 = "dd2188e0d2ca65232135714eef6de394239734d843ddae4928c7398685d858e7"
|
| 1065 |
+
fast_repo = "Synthyra/ANKH3_xl"
|
| 1066 |
+
fast_revision = "3cbf2c22c4f7d67bf0bfcbdcd500f41723e91d29"
|
| 1067 |
+
fast_files = [
|
| 1068 |
+
"config.json=git-sha1:23f6d78ddcb3a031b88f876eaaf04c2fafaea46f",
|
| 1069 |
+
"model-00001-of-00003.safetensors=sha256:39bd8f75cf98a67cf04055399f9fc401198f6fc2896b112aba9fd9ec9df52ab9",
|
| 1070 |
+
"model-00002-of-00003.safetensors=sha256:9ff73233b39d2c200abb78e66b320c014ec61431bd6e1af36fb188a3cfa24c34",
|
| 1071 |
+
"model-00003-of-00003.safetensors=sha256:c13125c02dbcd7f07bd412e9e085f2bca6624d2f1f45fedc95fb777f53161cbe",
|
| 1072 |
+
"special_tokens_map.json=git-sha1:1fc3a4d6d4282e5201cd7c30d5c0a6a8bfa04f82",
|
| 1073 |
+
"tokenizer.json=git-sha1:3d14291df2d6db3a183c5c4fe133afb330cc44cf",
|
| 1074 |
+
"tokenizer_config.json=git-sha1:2005fec00a7ae9a49e248a1ecefbbd81c56674d6",
|
| 1075 |
+
]
|
| 1076 |
+
official_repo = "ElnaggarLab/ankh3-xl"
|
| 1077 |
+
official_revision = "e00113df5c95ef71df7ea3f5a73d56bd00e473a4"
|
| 1078 |
+
official_files = [
|
| 1079 |
+
"config.json=git-sha1:f8997040e8913df75fd2eebe71a2a8eb750ed0d0",
|
| 1080 |
+
"generation_config.json=git-sha1:91f792e452403d46e170e206f9e50be5ddef9b9a",
|
| 1081 |
+
"pytorch_model-00001-of-00003.bin=sha256:2c9793cbee16697cd4149debe07d3a27143e280f6e970fa46042aae820fea981",
|
| 1082 |
+
"pytorch_model-00002-of-00003.bin=sha256:31c5a860e414513c829ae52affb0970d7cef2c0545df2d6e1338b6806ab7174b",
|
| 1083 |
+
"pytorch_model-00003-of-00003.bin=sha256:055a853bdd3623db95a637935aa299427e837cd8ea69fc04708b0262508bec75",
|
| 1084 |
+
"special_tokens_map.json=git-sha1:d596919b7fa2a197edd441ec3ec4685ecacd2de4",
|
| 1085 |
+
"spiece.model=sha256:f2b5e1bbd110b71ca9b2878e1fcd3265610076ecc97bd696e8a745c9bacc54e0",
|
| 1086 |
+
"tokenizer.json=git-sha1:90f0c94b43c81496b3ca81e3ec1c092ef2dd7fca",
|
| 1087 |
+
"tokenizer_config.json=git-sha1:0e699eebfa778698473b4faf1e66ef363b93fb21",
|
| 1088 |
+
]
|
| 1089 |
+
|
| 1090 |
+
[[models]]
|
| 1091 |
+
id = "boltz2"
|
| 1092 |
+
family = "boltz2"
|
| 1093 |
+
size_category = "structure"
|
| 1094 |
+
generation_contract = "not_applicable"
|
| 1095 |
+
notes = "Boltz2 is provisional in FastPLMs 1.0. Exact configuration, the declared inference-core state, feature preparation, and seeded execution remain tested, but native-environment BF16 end-to-end inference currently exceeds the fixed numerical-equivalence limits. FastPLMs therefore does not claim official inference equivalence for this checkpoint yet. Work on that numerical gap continues independently of the ESM++ and ESMFold2 release gates."
|
| 1096 |
+
fast_repo = "Synthyra/Boltz2"
|
| 1097 |
+
fast_revision = "3b148fc5efea109c065ec82ba8683d024de7134e"
|
| 1098 |
+
fast_files = [
|
| 1099 |
+
"config.json=git-sha1:8682ccb12e177e73bc7a351ff7e3af484bfb6fac",
|
| 1100 |
+
"model.safetensors=sha256:5c863fd200a1613a0e311071e2ad73ab350635e3fd336e6822cf45c52cb960e5",
|
| 1101 |
+
]
|
| 1102 |
+
official_repo = "boltz-community/boltz-2"
|
| 1103 |
+
official_revision = "6fdef46d763fee7fbb83ca5501ccceff43b85607"
|
| 1104 |
+
official_files = [
|
| 1105 |
+
"boltz2_conf.ckpt=sha256:090e82ac8c92f5e943fa1b39e7410a44027bea7243c0bbb3caa67a77fc1428e1",
|
| 1106 |
+
"mols.tar=sha256:39e076d96dbec6b4e86982bbda16f3a53a2a60c9bdc17828d88f6f9a0c7d1fd7",
|
| 1107 |
+
]
|
| 1108 |
+
|
| 1109 |
+
[[models]]
|
| 1110 |
+
id = "esmfold"
|
| 1111 |
+
family = "esmfold"
|
| 1112 |
+
size_category = "structure"
|
| 1113 |
+
generation_contract = "not_applicable"
|
| 1114 |
+
official_golden = { metadata = "tests/goldens/esmfold.json=sha256:380b9a96168410717d1f698feaabb826b1606444cbdeec86c2ea06d9ffe8f186", tensors = "tests/goldens/esmfold.safetensors=sha256:873b1b325a43d8e0f35f355c8914a2a9fe611cc48763875e9e6a22e09ec9ebcb" }
|
| 1115 |
+
fast_repo = "Synthyra/FastESMFold"
|
| 1116 |
+
fast_revision = "b88c8cb50d19b2cf7ab4fee4b0a61f5e02da7823"
|
| 1117 |
+
fast_files = [
|
| 1118 |
+
"config.json=git-sha1:18e0091dcbf6140bf68924d53c4c8917b9cd90b1",
|
| 1119 |
+
"model-00001-of-00003.safetensors=sha256:36fab9e5c96d409b2a34a8b4f1273acac8c07f119c32c4fcfa7d47bbcd55b83c",
|
| 1120 |
+
"model-00002-of-00003.safetensors=sha256:34954aaa05bc91635776ba6672946da5822626753d80db97b38c0538e9525102",
|
| 1121 |
+
"model-00003-of-00003.safetensors=sha256:2f1178cda0e6cff3b1e158e1acc59c83e3f4fc46e246388a5127bc56b8d9c4f2",
|
| 1122 |
+
"special_tokens_map.json=git-sha1:53cd95604a28eb7e23da763c8da23f5006ab2179",
|
| 1123 |
+
"tokenizer_config.json=git-sha1:10213f69b51b4b38876a29271b8f908e853a5800",
|
| 1124 |
+
"vocab.txt=git-sha1:eee0a1fc93c82568f78f086550fbd7c591cf423a",
|
| 1125 |
+
]
|
| 1126 |
+
official_repo = "facebook/esmfold_v1"
|
| 1127 |
+
official_revision = "75a3841ee059df2bf4d56688166c8fb459ddd97a"
|
| 1128 |
+
official_files = [
|
| 1129 |
+
"config.json=git-sha1:1232d0aee4be551021d8e70e66ed2b062df917bf",
|
| 1130 |
+
"pytorch_model.bin=sha256:2ee07356b125d1e3e57503c204111fd7323347fc4735d41d3caac57c2a78e116",
|
| 1131 |
+
"special_tokens_map.json=git-sha1:121c8d54f8ea66cdf678f48b3cb37c05b4de5c0d",
|
| 1132 |
+
"tokenizer_config.json=git-sha1:aad24fba9f1bad2d74ed79d414ddcd60e6b0f812",
|
| 1133 |
+
"vocab.txt=git-sha1:9abfdf5472c0ed970648b683b86ab131256b3e42",
|
| 1134 |
+
]
|
| 1135 |
+
|
| 1136 |
+
[[models.oracle_assets]]
|
| 1137 |
+
role = "weights"
|
| 1138 |
+
path = "models/esmfold_3B_v1.pt"
|
| 1139 |
+
url = "https://dl.fbaipublicfiles.com/fair-esm/models/esmfold_3B_v1.pt"
|
| 1140 |
+
sha256 = "e9a52579027e77d2d2e0a18218e755821f395730e86624cab9413dc117f5ca62"
|
| 1141 |
+
size = 2771653574
|
| 1142 |
+
|
| 1143 |
+
[[models]]
|
| 1144 |
+
id = "esmfold2"
|
| 1145 |
+
family = "esmfold2"
|
| 1146 |
+
size_category = "structure"
|
| 1147 |
+
generation_contract = "not_applicable"
|
| 1148 |
+
msa_conditioning = true
|
| 1149 |
+
official_golden = { metadata = "tests/goldens/esmfold2.json=sha256:f6e0ed1ec400b9a0fcc817db51774be968dc454b7a32645a07c479e42423ab20", tensors = "tests/goldens/esmfold2.safetensors=sha256:e4d6be4344c528e26b13f79a9303549e3de7e582da195c0078db3ce957fad420" }
|
| 1150 |
+
fast_repo = "Synthyra/ESMFold2"
|
| 1151 |
+
fast_revision = "cd5a0927cec585a778d983b99a8db23d2e9b281e"
|
| 1152 |
+
fast_files = [
|
| 1153 |
+
"config.json=git-sha1:67e81ff571f393f0b630cd5a22398bd84979c030",
|
| 1154 |
+
"model.safetensors=sha256:138fd4350d6892b81ce6be7ff9bf5a93ae9d4d3751f46a27438a3f9f0dcefa0e",
|
| 1155 |
+
]
|
| 1156 |
+
official_repo = "biohub/ESMFold2"
|
| 1157 |
+
official_revision = "1ebf0e3481a5184eb6171d40615c79e384b48796"
|
| 1158 |
+
official_files = [
|
| 1159 |
+
"config.json=git-sha1:0300c084b990b2bd600efd9f538aa5de27109fea",
|
| 1160 |
+
"model.safetensors=sha256:138fd4350d6892b81ce6be7ff9bf5a93ae9d4d3751f46a27438a3f9f0dcefa0e",
|
| 1161 |
+
]
|
| 1162 |
+
|
| 1163 |
+
[[models]]
|
| 1164 |
+
id = "esmfold2_fast"
|
| 1165 |
+
family = "esmfold2"
|
| 1166 |
+
size_category = "structure"
|
| 1167 |
+
generation_contract = "not_applicable"
|
| 1168 |
+
msa_conditioning = false
|
| 1169 |
+
official_golden = { metadata = "tests/goldens/esmfold2_fast.json=sha256:091b004c0b330217b59c12acd6da3d6edaf91e48d95f6d5f40fc20399cef9478", tensors = "tests/goldens/esmfold2_fast.safetensors=sha256:6e2e1cd07401538b4d9df994f82abe7a5b38a01e8d1ee26681e1216d44a81990" }
|
| 1170 |
+
fast_repo = "Synthyra/ESMFold2-Fast"
|
| 1171 |
+
fast_revision = "407875bfcaa42552bfcb25acd67ee1888b790170"
|
| 1172 |
+
fast_files = [
|
| 1173 |
+
"config.json=git-sha1:62ccca15a416a5dcbd02cd6ce161f432c7b4de58",
|
| 1174 |
+
"model.safetensors=sha256:60ca19f2898188beba92944365f7b909efd9c99212f5018af75cc47cd9a6184a",
|
| 1175 |
+
]
|
| 1176 |
+
official_repo = "biohub/ESMFold2-Fast"
|
| 1177 |
+
official_revision = "b28d8ace5e05e61e5bec1e6820cfd3e221819d12"
|
| 1178 |
+
official_files = [
|
| 1179 |
+
"config.json=git-sha1:c0ca526090fa7f8342ee4666d56e7fe3a4b8cbb2",
|
| 1180 |
+
"model.safetensors=sha256:60ca19f2898188beba92944365f7b909efd9c99212f5018af75cc47cd9a6184a",
|
| 1181 |
+
]
|
| 1182 |
+
|
| 1183 |
+
[[models]]
|
| 1184 |
+
id = "esmfold2_experimental_cutoff2025"
|
| 1185 |
+
family = "esmfold2"
|
| 1186 |
+
size_category = "structure"
|
| 1187 |
+
generation_contract = "not_applicable"
|
| 1188 |
+
msa_conditioning = true
|
| 1189 |
+
official_golden = { metadata = "tests/goldens/esmfold2_experimental_cutoff2025.json=sha256:cfd0e35b2bc468a0dc4f614d3acfa2fce004f96e9ae2433256ed095b829d55cc", tensors = "tests/goldens/esmfold2_experimental_cutoff2025.safetensors=sha256:9347466bbe803b6f5dc82e3356ca6cbbf2c2edd8765f9fd273385bda255019f6" }
|
| 1190 |
+
fast_repo = "Synthyra/ESMFold2-Experimental-Cutoff2025"
|
| 1191 |
+
fast_revision = "632ff4a9e68f1de78ee956a613267bdcdb5b354d"
|
| 1192 |
+
fast_files = [
|
| 1193 |
+
"config.json=git-sha1:41119745d38bc5503a0212ad923e75211dec565f",
|
| 1194 |
+
"model.safetensors=sha256:01358c317428d38535e3db513cab177336fc0f7fab0d84002e64b7741d5181b3",
|
| 1195 |
+
]
|
| 1196 |
+
official_repo = "biohub/ESMFold2-Experimental-Cutoff2025"
|
| 1197 |
+
official_revision = "56f94f5c1069ecde17512c96928850518340d287"
|
| 1198 |
+
official_files = [
|
| 1199 |
+
"config.json=git-sha1:79ed0dc0f867b8f09bfa004d6f77397c2ab9b38d",
|
| 1200 |
+
"model.safetensors=sha256:01358c317428d38535e3db513cab177336fc0f7fab0d84002e64b7741d5181b3",
|
| 1201 |
+
]
|
| 1202 |
+
auto_map = { AutoConfig = "fastplms.models.esmfold2.configuration_esmfold2.ESMFold2Config", AutoModel = "fastplms.models.esmfold2.modeling_esmfold2_experimental.ESMFold2ExperimentalModel" }
|
| 1203 |
+
|
| 1204 |
+
[[models]]
|
| 1205 |
+
id = "esmfold2_experimental_fast_cutoff2025"
|
| 1206 |
+
family = "esmfold2"
|
| 1207 |
+
size_category = "structure"
|
| 1208 |
+
generation_contract = "not_applicable"
|
| 1209 |
+
msa_conditioning = false
|
| 1210 |
+
official_golden = { metadata = "tests/goldens/esmfold2_experimental_fast_cutoff2025.json=sha256:1d0b2da4f1579243f37ae04bd4b834b747005cd8e8e7665e00d088123c43afd9", tensors = "tests/goldens/esmfold2_experimental_fast_cutoff2025.safetensors=sha256:516e216d05d7e6bee59e77126d3e595e2bb7821929433f00c259c5d5241964bb" }
|
| 1211 |
+
fast_repo = "Synthyra/ESMFold2-Experimental-Fast-Cutoff2025"
|
| 1212 |
+
fast_revision = "8f022c2514a6c32692aaca078a8391d6bc6c4bac"
|
| 1213 |
+
fast_files = [
|
| 1214 |
+
"config.json=git-sha1:b9d39e941050179ca51faaed58cbbd77778c1143",
|
| 1215 |
+
"model.safetensors=sha256:4e903b740ad6ad704ec60881bfd593e0d6c874a630ffa0f0838276e0b665088f",
|
| 1216 |
+
]
|
| 1217 |
+
official_repo = "biohub/ESMFold2-Experimental-Fast-Cutoff2025"
|
| 1218 |
+
official_revision = "74b88548bf19688b8727432db0d698cb2e1d8783"
|
| 1219 |
+
official_files = [
|
| 1220 |
+
"config.json=git-sha1:0333d68ddb12ed2f066741dcb801142f466c0a2c",
|
| 1221 |
+
"model.safetensors=sha256:4e903b740ad6ad704ec60881bfd593e0d6c874a630ffa0f0838276e0b665088f",
|
| 1222 |
+
]
|
| 1223 |
+
auto_map = { AutoConfig = "fastplms.models.esmfold2.configuration_esmfold2.ESMFold2Config", AutoModel = "fastplms.models.esmfold2.modeling_esmfold2_experimental.ESMFold2ExperimentalModel" }
|
fastplms/models/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lazy model-family namespace for FastPLMs.
|
| 2 |
+
|
| 3 |
+
Model classes are resolved through Transformers AutoClasses and the typed
|
| 4 |
+
registry. Importing this package therefore does not load checkpoints, create
|
| 5 |
+
tokenizers, compile kernels, or initialize an accelerator runtime.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
__all__: tuple[str, ...] = ()
|
fastplms/models/esm_plusplus/__init__.py
ADDED
|
File without changes
|
fastplms/models/esm_plusplus/modeling_esm_plusplus.py
ADDED
|
@@ -0,0 +1,1552 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hugging Face-compatible ESMC models implemented by FastPLMs."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import math
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from functools import partial
|
| 8 |
+
from typing import ClassVar
|
| 9 |
+
|
| 10 |
+
import torch
|
| 11 |
+
import torch.nn as nn
|
| 12 |
+
import torch.nn.functional as F
|
| 13 |
+
from einops import rearrange
|
| 14 |
+
from tokenizers import Tokenizer
|
| 15 |
+
from tokenizers.models import BPE
|
| 16 |
+
from tokenizers.processors import TemplateProcessing
|
| 17 |
+
from transformers import PretrainedConfig, PreTrainedModel, PreTrainedTokenizerFast
|
| 18 |
+
from transformers.modeling_outputs import (
|
| 19 |
+
MaskedLMOutput,
|
| 20 |
+
ModelOutput,
|
| 21 |
+
SequenceClassifierOutput,
|
| 22 |
+
TokenClassifierOutput,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
from fastplms.attention import (
|
| 27 |
+
AttentionBackend,
|
| 28 |
+
BlockMask,
|
| 29 |
+
FastPLMsAttentionMixin,
|
| 30 |
+
_get_flex_attention_fn,
|
| 31 |
+
_get_flex_block_mask,
|
| 32 |
+
flex_attention,
|
| 33 |
+
get_attention_mask,
|
| 34 |
+
kernels_flash_attention_func,
|
| 35 |
+
resolve_attention_backend,
|
| 36 |
+
resolve_attention_backend_for_call,
|
| 37 |
+
)
|
| 38 |
+
from fastplms.embeddings import EmbeddingMixin, Pooler, select_hidden_state_embeddings
|
| 39 |
+
from fastplms.models.ttt import FastPLMTestTimeTrainingMixin
|
| 40 |
+
except ModuleNotFoundError as error:
|
| 41 |
+
_COMPOSITE_REQUIRED_NAMES = (
|
| 42 |
+
"AttentionBackend",
|
| 43 |
+
"BlockMask",
|
| 44 |
+
"EmbeddingMixin",
|
| 45 |
+
"FastPLMsAttentionMixin",
|
| 46 |
+
"FastPLMTestTimeTrainingMixin",
|
| 47 |
+
"Pooler",
|
| 48 |
+
"_get_flex_attention_fn",
|
| 49 |
+
"_get_flex_block_mask",
|
| 50 |
+
"flex_attention",
|
| 51 |
+
"get_attention_mask",
|
| 52 |
+
"kernels_flash_attention_func",
|
| 53 |
+
"resolve_attention_backend",
|
| 54 |
+
"resolve_attention_backend_for_call",
|
| 55 |
+
"select_hidden_state_embeddings",
|
| 56 |
+
)
|
| 57 |
+
if error.name != "fastplms" or any(
|
| 58 |
+
name not in globals() for name in _COMPOSITE_REQUIRED_NAMES
|
| 59 |
+
):
|
| 60 |
+
raise
|
| 61 |
+
# Legacy flat Hub composites define every shared symbol above this block.
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class ESMplusplusConfig(PretrainedConfig):
|
| 65 |
+
"""Configuration class for ESM++ model.
|
| 66 |
+
|
| 67 |
+
Args:
|
| 68 |
+
vocab_size: Size of the vocabulary
|
| 69 |
+
hidden_size: Dimension of hidden layers
|
| 70 |
+
num_attention_heads: Number of attention heads
|
| 71 |
+
num_hidden_layers: Number of transformer layers
|
| 72 |
+
num_labels: Number of output labels for classification
|
| 73 |
+
problem_type: Type of problem - regression, single/multi label classification
|
| 74 |
+
"""
|
| 75 |
+
|
| 76 |
+
model_type = "ESMplusplus"
|
| 77 |
+
|
| 78 |
+
def __init__(
|
| 79 |
+
self,
|
| 80 |
+
vocab_size: int = 64,
|
| 81 |
+
hidden_size: int = 960,
|
| 82 |
+
num_attention_heads: int = 15,
|
| 83 |
+
num_hidden_layers: int = 30,
|
| 84 |
+
num_labels: int | None = None,
|
| 85 |
+
problem_type: str | None = None,
|
| 86 |
+
dropout: float = 0.0,
|
| 87 |
+
initializer_range: float = 0.02,
|
| 88 |
+
classifier_dropout: float = 0.1,
|
| 89 |
+
classifier_pooling_types: list[str] | None = None,
|
| 90 |
+
attn_backend: str | None = None,
|
| 91 |
+
pad_token_id: int = 1,
|
| 92 |
+
mask_token_id: int = 32,
|
| 93 |
+
**kwargs,
|
| 94 |
+
):
|
| 95 |
+
if num_labels is None:
|
| 96 |
+
configured_labels = kwargs.get("id2label")
|
| 97 |
+
num_labels = len(configured_labels) if configured_labels else 2
|
| 98 |
+
super().__init__(
|
| 99 |
+
pad_token_id=pad_token_id,
|
| 100 |
+
mask_token_id=mask_token_id,
|
| 101 |
+
num_labels=num_labels,
|
| 102 |
+
**kwargs,
|
| 103 |
+
)
|
| 104 |
+
self.vocab_size = vocab_size
|
| 105 |
+
self.hidden_size = hidden_size
|
| 106 |
+
self.num_attention_heads = num_attention_heads
|
| 107 |
+
self.num_hidden_layers = num_hidden_layers
|
| 108 |
+
self.problem_type = problem_type
|
| 109 |
+
self.dropout = dropout
|
| 110 |
+
self.initializer_range = initializer_range
|
| 111 |
+
self.classifier_dropout = classifier_dropout
|
| 112 |
+
self.classifier_pooling_types = (
|
| 113 |
+
list(classifier_pooling_types) if classifier_pooling_types is not None else None
|
| 114 |
+
)
|
| 115 |
+
self.tie_word_embeddings = False
|
| 116 |
+
self.attn_backend = attn_backend
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
### Rotary Embeddings
|
| 120 |
+
def rotate_half(x: torch.Tensor, interleaved: bool = False) -> torch.Tensor:
|
| 121 |
+
"""Rotate the final axis of X by 90 degrees in each two-dimensional plane."""
|
| 122 |
+
if interleaved:
|
| 123 |
+
paired = x.unflatten(-1, (-1, 2))
|
| 124 |
+
return torch.stack((-paired[..., 1], paired[..., 0]), dim=-1).flatten(-2)
|
| 125 |
+
|
| 126 |
+
# torch.chunk assigns an odd remainder to the first half. Express the same
|
| 127 |
+
# public behavior explicitly while keeping the ESMC path branch-free.
|
| 128 |
+
midpoint = (x.shape[-1] + 1) // 2
|
| 129 |
+
return torch.cat((-x[..., midpoint:], x[..., :midpoint]), dim=-1)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def apply_rotary_emb_torch(
|
| 133 |
+
x: torch.Tensor,
|
| 134 |
+
cos: torch.Tensor,
|
| 135 |
+
sin: torch.Tensor,
|
| 136 |
+
interleaved: bool = False,
|
| 137 |
+
_inplace: bool = False,
|
| 138 |
+
) -> torch.Tensor:
|
| 139 |
+
"""Apply cached rotary angles to X while preserving any unrotated features."""
|
| 140 |
+
del _inplace # Kept in the signature for checkpoint remote-code compatibility.
|
| 141 |
+
rotary_width = 2 * cos.shape[-1]
|
| 142 |
+
if rotary_width > x.shape[-1]:
|
| 143 |
+
raise AssertionError("rotary width exceeds the attention head dimension")
|
| 144 |
+
|
| 145 |
+
token_count = x.shape[1]
|
| 146 |
+
cos_full = torch.cat((cos[:token_count], cos[:token_count]), dim=-1).unsqueeze(1)
|
| 147 |
+
sin_full = torch.cat((sin[:token_count], sin[:token_count]), dim=-1).unsqueeze(1)
|
| 148 |
+
x_rotary = x[..., :rotary_width]
|
| 149 |
+
y_rotary = x_rotary * cos_full + rotate_half(x_rotary, interleaved) * sin_full
|
| 150 |
+
if rotary_width == x.shape[-1]:
|
| 151 |
+
return y_rotary
|
| 152 |
+
return torch.cat((y_rotary, x[..., rotary_width:]), dim=-1)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
class RotaryEmbedding(torch.nn.Module):
|
| 156 |
+
"""Rotary position embeddings.
|
| 157 |
+
|
| 158 |
+
Based on the paper "RoFormer: Enhanced Transformer with Rotary Position Embedding"
|
| 159 |
+
|
| 160 |
+
Args:
|
| 161 |
+
dim: Dimension of the embedding
|
| 162 |
+
base: Base for computing angular frequencies
|
| 163 |
+
interleaved: Whether to use interleaved rotations
|
| 164 |
+
scale_base: Base for scaling
|
| 165 |
+
scaling_factor: Factor for scaling positions
|
| 166 |
+
pos_idx_in_fp32: Whether to compute position indices in fp32
|
| 167 |
+
device: Computation device
|
| 168 |
+
"""
|
| 169 |
+
|
| 170 |
+
def __init__(
|
| 171 |
+
self,
|
| 172 |
+
dim: int,
|
| 173 |
+
base: float = 10000.0,
|
| 174 |
+
interleaved: bool = False,
|
| 175 |
+
scale_base: float | None = None,
|
| 176 |
+
scaling_factor: float = 1.0,
|
| 177 |
+
pos_idx_in_fp32: bool = True,
|
| 178 |
+
device: torch.device | None = None,
|
| 179 |
+
):
|
| 180 |
+
super().__init__()
|
| 181 |
+
self.dim, self.base = dim, float(base)
|
| 182 |
+
self.interleaved, self.scale_base = interleaved, scale_base
|
| 183 |
+
self.scaling_factor, self.pos_idx_in_fp32 = scaling_factor, pos_idx_in_fp32
|
| 184 |
+
self.device = device
|
| 185 |
+
self._clear_cache()
|
| 186 |
+
self.reset_parameters()
|
| 187 |
+
|
| 188 |
+
def _clear_cache(self) -> None:
|
| 189 |
+
self._seq_len_cached = 0
|
| 190 |
+
self._cos_cached: torch.Tensor | None = None
|
| 191 |
+
self._sin_cached: torch.Tensor | None = None
|
| 192 |
+
self._cos_k_cached: torch.Tensor | None = None
|
| 193 |
+
self._sin_k_cached: torch.Tensor | None = None
|
| 194 |
+
|
| 195 |
+
def reset_parameters(self, device: torch.device | str | None = None):
|
| 196 |
+
"""Rebuild the non-persistent frequency buffers on ``device``."""
|
| 197 |
+
if device is not None:
|
| 198 |
+
buffer_device = torch.device(device)
|
| 199 |
+
elif "inv_freq" in self._buffers and isinstance(self._buffers["inv_freq"], torch.Tensor):
|
| 200 |
+
buffer_device = self._buffers["inv_freq"].device
|
| 201 |
+
else:
|
| 202 |
+
buffer_device = self.device
|
| 203 |
+
inv_freq = self._compute_inv_freq(buffer_device)
|
| 204 |
+
self._clear_cache()
|
| 205 |
+
self.register_buffer("inv_freq", inv_freq, persistent=False)
|
| 206 |
+
arange = torch.arange(0, self.dim, 2, device=buffer_device, dtype=torch.float32)
|
| 207 |
+
scale = (
|
| 208 |
+
(arange + 0.4 * self.dim) / (1.4 * self.dim) if self.scale_base is not None else None
|
| 209 |
+
)
|
| 210 |
+
self.register_buffer("scale", scale)
|
| 211 |
+
|
| 212 |
+
def _compute_inv_freq(self, device: torch.device | None = None) -> torch.Tensor:
|
| 213 |
+
"""Compute inverse frequency bands on their execution device."""
|
| 214 |
+
return 1 / (
|
| 215 |
+
self.base
|
| 216 |
+
** (torch.arange(0, self.dim, 2, device=device, dtype=torch.float32) / self.dim)
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
def _apply(self, fn, recurse: bool = True):
|
| 220 |
+
"""Move the module, then regenerate device-specific RoPE frequencies."""
|
| 221 |
+
if self.inv_freq.is_meta:
|
| 222 |
+
self.reset_parameters(device="cpu")
|
| 223 |
+
result = super()._apply(fn, recurse=recurse)
|
| 224 |
+
self.register_buffer(
|
| 225 |
+
"inv_freq",
|
| 226 |
+
self._compute_inv_freq(self.inv_freq.device),
|
| 227 |
+
persistent=False,
|
| 228 |
+
)
|
| 229 |
+
self._clear_cache()
|
| 230 |
+
return result
|
| 231 |
+
|
| 232 |
+
def _cache_is_current(
|
| 233 |
+
self,
|
| 234 |
+
token_count: int,
|
| 235 |
+
device: torch.device | None,
|
| 236 |
+
dtype: torch.dtype | None,
|
| 237 |
+
) -> bool:
|
| 238 |
+
cached = self._cos_cached
|
| 239 |
+
return (
|
| 240 |
+
cached is not None
|
| 241 |
+
and self._seq_len_cached >= token_count
|
| 242 |
+
and cached.device == device
|
| 243 |
+
and cached.dtype == dtype
|
| 244 |
+
and not (self.training and cached.is_inference())
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
def _rotary_angles(
|
| 248 |
+
self,
|
| 249 |
+
token_count: int,
|
| 250 |
+
device: torch.device | None,
|
| 251 |
+
) -> torch.Tensor:
|
| 252 |
+
position_dtype = torch.float32 if self.pos_idx_in_fp32 else self.inv_freq.dtype
|
| 253 |
+
positions = torch.arange(token_count, device=device, dtype=position_dtype)
|
| 254 |
+
positions.div_(self.scaling_factor)
|
| 255 |
+
frequencies = (
|
| 256 |
+
self.inv_freq.to(torch.float32)
|
| 257 |
+
if self.pos_idx_in_fp32 and self.inv_freq.dtype != torch.float32
|
| 258 |
+
else self.inv_freq
|
| 259 |
+
)
|
| 260 |
+
return torch.outer(positions, frequencies)
|
| 261 |
+
|
| 262 |
+
def _update_cos_sin_cache(
|
| 263 |
+
self, seqlen: int, device: torch.device | None = None, dtype: torch.dtype | None = None
|
| 264 |
+
) -> None:
|
| 265 |
+
"""Build angle tables when the requested cache identity has changed."""
|
| 266 |
+
if self._cache_is_current(seqlen, device, dtype):
|
| 267 |
+
return
|
| 268 |
+
|
| 269 |
+
self._seq_len_cached = seqlen
|
| 270 |
+
angles = self._rotary_angles(seqlen, device)
|
| 271 |
+
cos_angles = torch.cos(angles)
|
| 272 |
+
sin_angles = torch.sin(angles)
|
| 273 |
+
if self.scale is None:
|
| 274 |
+
self._cos_cached = cos_angles.to(dtype)
|
| 275 |
+
self._sin_cached = sin_angles.to(dtype)
|
| 276 |
+
return
|
| 277 |
+
|
| 278 |
+
centered_positions = (
|
| 279 |
+
torch.arange(seqlen, dtype=self.scale.dtype, device=self.scale.device) - seqlen // 2
|
| 280 |
+
) / self.scale_base
|
| 281 |
+
scale = self.scale ** centered_positions.unsqueeze(-1)
|
| 282 |
+
self._cos_cached = (cos_angles * scale).to(dtype)
|
| 283 |
+
self._sin_cached = (sin_angles * scale).to(dtype)
|
| 284 |
+
self._cos_k_cached = (cos_angles / scale).to(dtype)
|
| 285 |
+
self._sin_k_cached = (sin_angles / scale).to(dtype)
|
| 286 |
+
|
| 287 |
+
def forward(self, q: torch.Tensor, k: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
| 288 |
+
"""Apply rotary embeddings to queries and keys.
|
| 289 |
+
|
| 290 |
+
Args:
|
| 291 |
+
q: Query tensor Q with shape (b, l, h, d).
|
| 292 |
+
k: Key tensor K with shape (b, l, h, d).
|
| 293 |
+
|
| 294 |
+
Returns:
|
| 295 |
+
Tuple of rotated query and key tensors
|
| 296 |
+
"""
|
| 297 |
+
# The pinned Biohub Transformers oracle recomputes inverse frequencies
|
| 298 |
+
# on the execution device. CPU and CUDA differ by about one FP32 ULP in
|
| 299 |
+
# some bands, which is immaterial in BF16 but accumulates measurably in
|
| 300 |
+
# deep FP32 execution.
|
| 301 |
+
self._update_cos_sin_cache(q.shape[1], device=q.device, dtype=q.dtype)
|
| 302 |
+
if self._cos_cached is None or self._sin_cached is None:
|
| 303 |
+
raise RuntimeError(
|
| 304 |
+
"Rotary cache initialization did not produce cosine and sine values."
|
| 305 |
+
)
|
| 306 |
+
if self.scale is not None:
|
| 307 |
+
raise AssertionError("Scaled rotary embeddings are unsupported for ESMC.")
|
| 308 |
+
|
| 309 |
+
cos_angles = self._cos_cached
|
| 310 |
+
sin_angles = self._sin_cached
|
| 311 |
+
return (
|
| 312 |
+
apply_rotary_emb_torch(q, cos_angles, sin_angles, self.interleaved, True),
|
| 313 |
+
apply_rotary_emb_torch(k, cos_angles, sin_angles, self.interleaved, True),
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
### Feedforward Network Components
|
| 318 |
+
def swiglu_correction_fn(expansion_ratio: float, d_model: int) -> int:
|
| 319 |
+
"""Compute corrected dimension for SwiGLU."""
|
| 320 |
+
return int(((expansion_ratio * d_model) + 255) // 256 * 256)
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
class SwiGLU(nn.Module):
|
| 324 |
+
"""SwiGLU activation function."""
|
| 325 |
+
|
| 326 |
+
def __init__(self):
|
| 327 |
+
super().__init__()
|
| 328 |
+
|
| 329 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 330 |
+
x1, x2 = x.chunk(2, dim=-1)
|
| 331 |
+
return F.silu(x1) * x2
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def swiglu_ln_ffn(d_model: int, expansion_ratio: float) -> nn.Sequential:
|
| 335 |
+
"""Create SwiGLU feedforward network with layer normalization."""
|
| 336 |
+
return nn.Sequential(
|
| 337 |
+
nn.LayerNorm(d_model),
|
| 338 |
+
nn.Linear(d_model, swiglu_correction_fn(expansion_ratio, d_model) * 2, bias=False),
|
| 339 |
+
SwiGLU(),
|
| 340 |
+
nn.Linear(swiglu_correction_fn(expansion_ratio, d_model), d_model, bias=False),
|
| 341 |
+
)
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
### Attention
|
| 345 |
+
class MultiHeadAttention(nn.Module):
|
| 346 |
+
"""Multi-head attention with rotary embeddings and configurable backend.
|
| 347 |
+
|
| 348 |
+
Args:
|
| 349 |
+
d_model: Model dimension
|
| 350 |
+
n_heads: Number of attention heads
|
| 351 |
+
attn_backend: One of "eager", "sdpa", or "flex_attention".
|
| 352 |
+
"""
|
| 353 |
+
|
| 354 |
+
def __init__(
|
| 355 |
+
self,
|
| 356 |
+
d_model: int,
|
| 357 |
+
n_heads: int,
|
| 358 |
+
attn_backend: str = "sdpa",
|
| 359 |
+
):
|
| 360 |
+
super().__init__()
|
| 361 |
+
self.d_model = d_model
|
| 362 |
+
self.n_heads = n_heads
|
| 363 |
+
self.d_head = self.d_model // self.n_heads
|
| 364 |
+
self.scale = 1.0 / math.sqrt(self.d_head)
|
| 365 |
+
self.attn_backend = resolve_attention_backend(attn_backend)
|
| 366 |
+
self.layernorm_qkv = nn.Sequential(
|
| 367 |
+
nn.LayerNorm(d_model), nn.Linear(d_model, d_model * 3, bias=False)
|
| 368 |
+
)
|
| 369 |
+
self.out_proj = nn.Linear(d_model, d_model, bias=False)
|
| 370 |
+
self.q_ln = nn.LayerNorm(d_model, bias=False)
|
| 371 |
+
self.k_ln = nn.LayerNorm(d_model, bias=False)
|
| 372 |
+
self.reshaper = partial(rearrange, pattern="b s (h d) -> b h s d", h=n_heads)
|
| 373 |
+
self.rotary = RotaryEmbedding(d_model // n_heads)
|
| 374 |
+
|
| 375 |
+
def _apply_rotary(self, q: torch.Tensor, k: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
| 376 |
+
q = q.unflatten(-1, (self.n_heads, self.d_head))
|
| 377 |
+
k = k.unflatten(-1, (self.n_heads, self.d_head))
|
| 378 |
+
q, k = self.rotary(q, k)
|
| 379 |
+
q = q.flatten(-2, -1)
|
| 380 |
+
k = k.flatten(-2, -1)
|
| 381 |
+
return q, k
|
| 382 |
+
|
| 383 |
+
def forward(
|
| 384 |
+
self,
|
| 385 |
+
x: torch.Tensor,
|
| 386 |
+
attention_mask_2d: torch.Tensor | None = None,
|
| 387 |
+
attention_mask_4d: torch.Tensor | None = None,
|
| 388 |
+
flex_block_mask: BlockMask | None = None,
|
| 389 |
+
output_attentions: bool = False,
|
| 390 |
+
output_s_max: bool = False,
|
| 391 |
+
) -> tuple[torch.Tensor, torch.Tensor | None, list[torch.Tensor] | None]:
|
| 392 |
+
qkv = self.layernorm_qkv(x)
|
| 393 |
+
query_sequence, key_sequence, value_sequence = torch.chunk(qkv, 3, dim=-1)
|
| 394 |
+
query_sequence, key_sequence = (
|
| 395 |
+
self.q_ln(query_sequence).to(query_sequence.dtype),
|
| 396 |
+
self.k_ln(key_sequence).to(query_sequence.dtype),
|
| 397 |
+
)
|
| 398 |
+
query_sequence, key_sequence = self._apply_rotary(query_sequence, key_sequence)
|
| 399 |
+
query_heads, key_heads, value_heads = map(
|
| 400 |
+
self.reshaper, (query_sequence, key_sequence, value_sequence)
|
| 401 |
+
)
|
| 402 |
+
|
| 403 |
+
attn_output, attn_weights, s_max = self._attn(
|
| 404 |
+
query_heads,
|
| 405 |
+
key_heads,
|
| 406 |
+
value_heads,
|
| 407 |
+
attention_mask_2d=attention_mask_2d,
|
| 408 |
+
attention_mask_4d=attention_mask_4d,
|
| 409 |
+
flex_block_mask=flex_block_mask,
|
| 410 |
+
output_attentions=output_attentions,
|
| 411 |
+
output_s_max=output_s_max,
|
| 412 |
+
)
|
| 413 |
+
|
| 414 |
+
output = self.out_proj(attn_output)
|
| 415 |
+
return output, attn_weights, s_max
|
| 416 |
+
|
| 417 |
+
def _attn(
|
| 418 |
+
self,
|
| 419 |
+
query_heads: torch.Tensor,
|
| 420 |
+
key_heads: torch.Tensor,
|
| 421 |
+
value_heads: torch.Tensor,
|
| 422 |
+
attention_mask_2d: torch.Tensor | None = None,
|
| 423 |
+
attention_mask_4d: torch.Tensor | None = None,
|
| 424 |
+
flex_block_mask: BlockMask | None = None,
|
| 425 |
+
output_attentions: bool = False,
|
| 426 |
+
output_s_max: bool = False,
|
| 427 |
+
) -> tuple[torch.Tensor, torch.Tensor | None, list[torch.Tensor] | None]:
|
| 428 |
+
if output_attentions:
|
| 429 |
+
return self._manual_attn(
|
| 430 |
+
query_heads, key_heads, value_heads, attention_mask_4d, output_s_max
|
| 431 |
+
)
|
| 432 |
+
|
| 433 |
+
if self.attn_backend == AttentionBackend.EAGER:
|
| 434 |
+
attn_output, _, s_max = self._manual_attn(
|
| 435 |
+
query_heads, key_heads, value_heads, attention_mask_4d, output_s_max
|
| 436 |
+
)
|
| 437 |
+
return attn_output, None, s_max
|
| 438 |
+
if self.attn_backend.is_flash:
|
| 439 |
+
attn_output, attn_weights = self._kernels_flash_attn(
|
| 440 |
+
query_heads, key_heads, value_heads, attention_mask_2d
|
| 441 |
+
)
|
| 442 |
+
elif self.attn_backend == AttentionBackend.FLEX:
|
| 443 |
+
attn_output, attn_weights = self._flex_attn(
|
| 444 |
+
query_heads,
|
| 445 |
+
key_heads,
|
| 446 |
+
value_heads,
|
| 447 |
+
flex_block_mask,
|
| 448 |
+
attention_mask_2d,
|
| 449 |
+
)
|
| 450 |
+
elif self.attn_backend == AttentionBackend.SDPA:
|
| 451 |
+
attn_output, attn_weights = self._sdpa_attn(
|
| 452 |
+
query_heads, key_heads, value_heads, attention_mask_4d
|
| 453 |
+
)
|
| 454 |
+
else:
|
| 455 |
+
raise AssertionError(f"Unsupported resolved backend: {self.attn_backend}")
|
| 456 |
+
|
| 457 |
+
s_max = self._compute_s_max(query_heads, key_heads) if output_s_max else None
|
| 458 |
+
return attn_output, attn_weights, s_max
|
| 459 |
+
|
| 460 |
+
@torch.no_grad()
|
| 461 |
+
def _compute_s_max(
|
| 462 |
+
self, query_heads: torch.Tensor, key_heads: torch.Tensor
|
| 463 |
+
) -> list[torch.Tensor]:
|
| 464 |
+
q_norm = torch.linalg.vector_norm(query_heads, dim=-1)
|
| 465 |
+
k_norm = torch.linalg.vector_norm(key_heads, dim=-1)
|
| 466 |
+
s_max_bound = (q_norm.max(dim=-1).values * k_norm.max(dim=-1).values).max(
|
| 467 |
+
dim=0
|
| 468 |
+
).values * self.scale
|
| 469 |
+
return [s_max_bound[h] for h in range(self.n_heads)]
|
| 470 |
+
|
| 471 |
+
def _manual_attn(
|
| 472 |
+
self,
|
| 473 |
+
query_heads: torch.Tensor,
|
| 474 |
+
key_heads: torch.Tensor,
|
| 475 |
+
value_heads: torch.Tensor,
|
| 476 |
+
attention_mask_4d: torch.Tensor | None = None,
|
| 477 |
+
output_s_max: bool = False,
|
| 478 |
+
) -> tuple[torch.Tensor, torch.Tensor, list[torch.Tensor] | None]:
|
| 479 |
+
attn_weights = torch.matmul(query_heads, key_heads.transpose(-2, -1)) * self.scale
|
| 480 |
+
if attention_mask_4d is not None:
|
| 481 |
+
attn_weights = attn_weights.masked_fill(attention_mask_4d.logical_not(), float("-inf"))
|
| 482 |
+
attn_weights = F.softmax(attn_weights, dim=-1)
|
| 483 |
+
context_heads = torch.matmul(attn_weights, value_heads)
|
| 484 |
+
attn_output = rearrange(context_heads, "b h s d -> b s (h d)")
|
| 485 |
+
s_max = self._compute_s_max(query_heads, key_heads) if output_s_max else None
|
| 486 |
+
return attn_output, attn_weights, s_max
|
| 487 |
+
|
| 488 |
+
def _kernels_flash_attn(
|
| 489 |
+
self,
|
| 490 |
+
query_heads: torch.Tensor,
|
| 491 |
+
key_heads: torch.Tensor,
|
| 492 |
+
value_heads: torch.Tensor,
|
| 493 |
+
attention_mask_2d: torch.Tensor | None = None,
|
| 494 |
+
) -> tuple[torch.Tensor, None]:
|
| 495 |
+
query_tokens = query_heads.transpose(1, 2).contiguous()
|
| 496 |
+
key_tokens = key_heads.transpose(1, 2).contiguous()
|
| 497 |
+
value_tokens = value_heads.transpose(1, 2).contiguous()
|
| 498 |
+
attn_output = kernels_flash_attention_func(
|
| 499 |
+
query_states=query_tokens,
|
| 500 |
+
key_states=key_tokens,
|
| 501 |
+
value_states=value_tokens,
|
| 502 |
+
attention_mask_2d=attention_mask_2d,
|
| 503 |
+
causal=False,
|
| 504 |
+
implementation=self.attn_backend.value,
|
| 505 |
+
)
|
| 506 |
+
return rearrange(attn_output, "b s h d -> b s (h d)"), None
|
| 507 |
+
|
| 508 |
+
def _flex_attn(
|
| 509 |
+
self,
|
| 510 |
+
query_heads: torch.Tensor,
|
| 511 |
+
key_heads: torch.Tensor,
|
| 512 |
+
value_heads: torch.Tensor,
|
| 513 |
+
flex_block_mask: BlockMask | None = None,
|
| 514 |
+
attention_mask_2d: torch.Tensor | None = None,
|
| 515 |
+
) -> tuple[torch.Tensor, None]:
|
| 516 |
+
if flex_attention is None:
|
| 517 |
+
raise RuntimeError("Flex attention is not available in this environment.")
|
| 518 |
+
fn = _get_flex_attention_fn(
|
| 519 |
+
device=query_heads.device,
|
| 520 |
+
dtype=query_heads.dtype,
|
| 521 |
+
shape=tuple(query_heads.shape),
|
| 522 |
+
mask_semantics="padding",
|
| 523 |
+
)
|
| 524 |
+
context_heads = fn(
|
| 525 |
+
query_heads,
|
| 526 |
+
key_heads,
|
| 527 |
+
value_heads,
|
| 528 |
+
block_mask=flex_block_mask,
|
| 529 |
+
scale=self.scale,
|
| 530 |
+
kernel_options={"PRESCALE_QK": True, "BLOCK_N": 32},
|
| 531 |
+
)
|
| 532 |
+
return rearrange(context_heads, "b h s d -> b s (h d)"), None
|
| 533 |
+
|
| 534 |
+
def _sdpa_attn(
|
| 535 |
+
self,
|
| 536 |
+
query_heads: torch.Tensor,
|
| 537 |
+
key_heads: torch.Tensor,
|
| 538 |
+
value_heads: torch.Tensor,
|
| 539 |
+
attention_mask_4d: torch.Tensor | None = None,
|
| 540 |
+
) -> tuple[torch.Tensor, None]:
|
| 541 |
+
context_heads = F.scaled_dot_product_attention(
|
| 542 |
+
query_heads,
|
| 543 |
+
key_heads,
|
| 544 |
+
value_heads,
|
| 545 |
+
attn_mask=attention_mask_4d,
|
| 546 |
+
scale=self.scale,
|
| 547 |
+
)
|
| 548 |
+
return rearrange(context_heads, "b h s d -> b s (h d)"), None
|
| 549 |
+
|
| 550 |
+
|
| 551 |
+
### Regression Head
|
| 552 |
+
def RegressionHead(d_model: int, output_dim: int, hidden_dim: int | None = None) -> nn.Module:
|
| 553 |
+
"""Create a regression head with optional hidden dimension.
|
| 554 |
+
|
| 555 |
+
Args:
|
| 556 |
+
d_model: Input dimension
|
| 557 |
+
output_dim: Output dimension
|
| 558 |
+
hidden_dim: Optional hidden dimension (defaults to d_model)
|
| 559 |
+
"""
|
| 560 |
+
hidden_dim = hidden_dim if hidden_dim is not None else d_model
|
| 561 |
+
return nn.Sequential(
|
| 562 |
+
nn.Linear(d_model, hidden_dim),
|
| 563 |
+
nn.GELU(),
|
| 564 |
+
nn.LayerNorm(hidden_dim),
|
| 565 |
+
nn.Linear(hidden_dim, output_dim),
|
| 566 |
+
)
|
| 567 |
+
|
| 568 |
+
|
| 569 |
+
### Transformer Block
|
| 570 |
+
class UnifiedTransformerBlock(nn.Module):
|
| 571 |
+
"""Transformer block with attention and feedforward layers."""
|
| 572 |
+
|
| 573 |
+
def __init__(
|
| 574 |
+
self,
|
| 575 |
+
d_model: int,
|
| 576 |
+
n_heads: int,
|
| 577 |
+
residue_scaling_factor: float = 1,
|
| 578 |
+
expansion_ratio: float = 8 / 3,
|
| 579 |
+
dropout: float = 0.0,
|
| 580 |
+
attn_backend: str = "sdpa",
|
| 581 |
+
):
|
| 582 |
+
super().__init__()
|
| 583 |
+
self.attn = MultiHeadAttention(d_model=d_model, n_heads=n_heads, attn_backend=attn_backend)
|
| 584 |
+
self.ffn = swiglu_ln_ffn(d_model, expansion_ratio)
|
| 585 |
+
self.scaling_factor = residue_scaling_factor
|
| 586 |
+
self.dropout = nn.Dropout(dropout)
|
| 587 |
+
|
| 588 |
+
def forward(
|
| 589 |
+
self,
|
| 590 |
+
x: torch.Tensor,
|
| 591 |
+
attention_mask_2d: torch.Tensor | None = None,
|
| 592 |
+
attention_mask_4d: torch.Tensor | None = None,
|
| 593 |
+
flex_block_mask: BlockMask | None = None,
|
| 594 |
+
output_attentions: bool = False,
|
| 595 |
+
output_s_max: bool = False,
|
| 596 |
+
) -> tuple[torch.Tensor, torch.Tensor | None, list[torch.Tensor] | None]:
|
| 597 |
+
attn_output, attn_weights, s_max = self.attn(
|
| 598 |
+
x,
|
| 599 |
+
attention_mask_2d=attention_mask_2d,
|
| 600 |
+
attention_mask_4d=attention_mask_4d,
|
| 601 |
+
flex_block_mask=flex_block_mask,
|
| 602 |
+
output_attentions=output_attentions,
|
| 603 |
+
output_s_max=output_s_max,
|
| 604 |
+
)
|
| 605 |
+
x = x + self.dropout(attn_output) / self.scaling_factor
|
| 606 |
+
x = x + self.dropout(self.ffn(x)) / self.scaling_factor
|
| 607 |
+
return x, attn_weights, s_max
|
| 608 |
+
|
| 609 |
+
|
| 610 |
+
### Model Outputs
|
| 611 |
+
@dataclass
|
| 612 |
+
class TransformerOutput(ModelOutput):
|
| 613 |
+
"""Output type for transformer encoder."""
|
| 614 |
+
|
| 615 |
+
last_hidden_state: torch.Tensor | None = None
|
| 616 |
+
hidden_states: tuple[torch.Tensor] | None = None
|
| 617 |
+
attentions: tuple[torch.Tensor] | None = None
|
| 618 |
+
s_max: tuple[list[torch.Tensor], ...] | None = None
|
| 619 |
+
|
| 620 |
+
|
| 621 |
+
@dataclass
|
| 622 |
+
class ESMplusplusOutput(MaskedLMOutput):
|
| 623 |
+
"""Masked-LM output with FastPLMs fields after the HF contract."""
|
| 624 |
+
|
| 625 |
+
s_max: tuple[list[torch.Tensor], ...] | None = None
|
| 626 |
+
last_hidden_state: torch.Tensor | None = None
|
| 627 |
+
|
| 628 |
+
|
| 629 |
+
@dataclass
|
| 630 |
+
class ESMplusplusSequenceClassifierOutput(SequenceClassifierOutput):
|
| 631 |
+
"""Sequence-classification output with optional attention diagnostics."""
|
| 632 |
+
|
| 633 |
+
s_max: tuple[list[torch.Tensor], ...] | None = None
|
| 634 |
+
|
| 635 |
+
|
| 636 |
+
@dataclass
|
| 637 |
+
class ESMplusplusTokenClassifierOutput(TokenClassifierOutput):
|
| 638 |
+
"""Token-classification output with optional attention diagnostics."""
|
| 639 |
+
|
| 640 |
+
s_max: tuple[list[torch.Tensor], ...] | None = None
|
| 641 |
+
|
| 642 |
+
|
| 643 |
+
### Transformer Stack
|
| 644 |
+
class TransformerStack(nn.Module):
|
| 645 |
+
"""Stack of transformer blocks."""
|
| 646 |
+
|
| 647 |
+
def __init__(
|
| 648 |
+
self,
|
| 649 |
+
d_model: int,
|
| 650 |
+
n_heads: int,
|
| 651 |
+
n_layers: int,
|
| 652 |
+
dropout: float = 0.0,
|
| 653 |
+
attn_backend: str = "sdpa",
|
| 654 |
+
):
|
| 655 |
+
super().__init__()
|
| 656 |
+
self.attention_backend = resolve_attention_backend(attn_backend)
|
| 657 |
+
self.blocks = nn.ModuleList(
|
| 658 |
+
[
|
| 659 |
+
UnifiedTransformerBlock(
|
| 660 |
+
d_model,
|
| 661 |
+
n_heads,
|
| 662 |
+
residue_scaling_factor=math.sqrt(n_layers / 36),
|
| 663 |
+
dropout=dropout,
|
| 664 |
+
attn_backend=attn_backend,
|
| 665 |
+
)
|
| 666 |
+
for i in range(n_layers)
|
| 667 |
+
]
|
| 668 |
+
)
|
| 669 |
+
self.norm = nn.LayerNorm(d_model, bias=False)
|
| 670 |
+
self.gradient_checkpointing = False
|
| 671 |
+
|
| 672 |
+
@property
|
| 673 |
+
def attn_backend(self) -> AttentionBackend:
|
| 674 |
+
return self.attention_backend
|
| 675 |
+
|
| 676 |
+
@attn_backend.setter
|
| 677 |
+
def attn_backend(self, backend: str) -> None:
|
| 678 |
+
resolved = resolve_attention_backend(backend)
|
| 679 |
+
self.attention_backend = resolved
|
| 680 |
+
for block in self.blocks:
|
| 681 |
+
block.attn.attn_backend = resolved
|
| 682 |
+
|
| 683 |
+
def forward(
|
| 684 |
+
self,
|
| 685 |
+
x: torch.Tensor,
|
| 686 |
+
attention_mask: torch.Tensor | None = None,
|
| 687 |
+
sequence_id: torch.Tensor | None = None,
|
| 688 |
+
output_hidden_states: bool | None = False,
|
| 689 |
+
output_attentions: bool | None = False,
|
| 690 |
+
output_s_max: bool | None = False,
|
| 691 |
+
esmfold2_hidden_states: bool = False,
|
| 692 |
+
) -> TransformerOutput:
|
| 693 |
+
hidden_states = () if output_hidden_states else None
|
| 694 |
+
attentions = () if output_attentions else None
|
| 695 |
+
full_s_max = () if output_s_max else None
|
| 696 |
+
# Match the pinned Biohub Transformers contract: a supplied sequence_id
|
| 697 |
+
# is authoritative and must encode padding as -1. attention_mask is
|
| 698 |
+
# ignored in that mode rather than intersected with the chain mask.
|
| 699 |
+
if sequence_id is None and attention_mask is not None:
|
| 700 |
+
expected_shape = (x.shape[0], x.shape[1])
|
| 701 |
+
if attention_mask.ndim != 2 or tuple(attention_mask.shape) != expected_shape:
|
| 702 |
+
raise ValueError(
|
| 703 |
+
f"attention_mask must have shape {expected_shape}; "
|
| 704 |
+
f"received {tuple(attention_mask.shape)}."
|
| 705 |
+
)
|
| 706 |
+
attention_mask = attention_mask.to(device=x.device, dtype=torch.bool)
|
| 707 |
+
if not bool(attention_mask.any(dim=1).all()):
|
| 708 |
+
raise ValueError("attention_mask must keep at least one valid key per batch row.")
|
| 709 |
+
effective_backend = resolve_attention_backend_for_call(
|
| 710 |
+
self.attention_backend,
|
| 711 |
+
output_attentions=bool(output_attentions),
|
| 712 |
+
)
|
| 713 |
+
|
| 714 |
+
if sequence_id is None and attention_mask is not None:
|
| 715 |
+
attention_mask_2d, attention_mask_4d, flex_block_mask = (
|
| 716 |
+
self._sequence_id_attention_masks(
|
| 717 |
+
sequence_id=attention_mask.to(device=x.device, dtype=torch.bool),
|
| 718 |
+
batch_size=x.shape[0],
|
| 719 |
+
seq_len=x.shape[1],
|
| 720 |
+
device=x.device,
|
| 721 |
+
dtype=x.dtype,
|
| 722 |
+
effective_backend=effective_backend,
|
| 723 |
+
)
|
| 724 |
+
)
|
| 725 |
+
elif sequence_id is None:
|
| 726 |
+
attention_mask_2d, attention_mask_4d, flex_block_mask = get_attention_mask(
|
| 727 |
+
effective_backend=effective_backend,
|
| 728 |
+
batch_size=x.shape[0],
|
| 729 |
+
seq_len=x.shape[1],
|
| 730 |
+
device=x.device,
|
| 731 |
+
attention_mask=attention_mask,
|
| 732 |
+
dtype=x.dtype,
|
| 733 |
+
mask_semantics="padding",
|
| 734 |
+
)
|
| 735 |
+
else:
|
| 736 |
+
attention_mask_2d, attention_mask_4d, flex_block_mask = (
|
| 737 |
+
self._sequence_id_attention_masks(
|
| 738 |
+
sequence_id=sequence_id,
|
| 739 |
+
batch_size=x.shape[0],
|
| 740 |
+
seq_len=x.shape[1],
|
| 741 |
+
device=x.device,
|
| 742 |
+
dtype=x.dtype,
|
| 743 |
+
effective_backend=effective_backend,
|
| 744 |
+
)
|
| 745 |
+
)
|
| 746 |
+
|
| 747 |
+
for block in self.blocks:
|
| 748 |
+
if output_hidden_states:
|
| 749 |
+
if hidden_states is None:
|
| 750 |
+
raise RuntimeError(
|
| 751 |
+
"Hidden-state collection was not initialized for an enabled request."
|
| 752 |
+
)
|
| 753 |
+
# Biohub Transformers records the input to each block followed
|
| 754 |
+
# by the final normalized state. This gives n_layers + 1 states
|
| 755 |
+
# and, for ESMC-6B, the 81-state order consumed by ESMFold2.
|
| 756 |
+
hidden_states += (x,)
|
| 757 |
+
if self.gradient_checkpointing and self.training:
|
| 758 |
+
x, attn_weights, s_max = self._gradient_checkpointing_func(
|
| 759 |
+
block.__call__,
|
| 760 |
+
x=x,
|
| 761 |
+
attention_mask_2d=attention_mask_2d,
|
| 762 |
+
attention_mask_4d=attention_mask_4d,
|
| 763 |
+
flex_block_mask=flex_block_mask,
|
| 764 |
+
output_attentions=output_attentions,
|
| 765 |
+
output_s_max=output_s_max,
|
| 766 |
+
)
|
| 767 |
+
else:
|
| 768 |
+
x, attn_weights, s_max = block(
|
| 769 |
+
x=x,
|
| 770 |
+
attention_mask_2d=attention_mask_2d,
|
| 771 |
+
attention_mask_4d=attention_mask_4d,
|
| 772 |
+
flex_block_mask=flex_block_mask,
|
| 773 |
+
output_attentions=output_attentions,
|
| 774 |
+
output_s_max=output_s_max,
|
| 775 |
+
)
|
| 776 |
+
|
| 777 |
+
if attentions is not None:
|
| 778 |
+
attentions += (attn_weights,)
|
| 779 |
+
if full_s_max is not None:
|
| 780 |
+
full_s_max += (s_max,)
|
| 781 |
+
|
| 782 |
+
last_hidden_state = self.norm(x)
|
| 783 |
+
if output_hidden_states:
|
| 784 |
+
hidden_states += (last_hidden_state,)
|
| 785 |
+
|
| 786 |
+
return TransformerOutput(
|
| 787 |
+
last_hidden_state=last_hidden_state,
|
| 788 |
+
hidden_states=hidden_states,
|
| 789 |
+
attentions=attentions,
|
| 790 |
+
s_max=full_s_max,
|
| 791 |
+
)
|
| 792 |
+
|
| 793 |
+
def _sequence_id_attention_masks(
|
| 794 |
+
self,
|
| 795 |
+
sequence_id: torch.Tensor,
|
| 796 |
+
batch_size: int,
|
| 797 |
+
seq_len: int,
|
| 798 |
+
device: torch.device,
|
| 799 |
+
dtype: torch.dtype | None = None,
|
| 800 |
+
effective_backend: AttentionBackend | None = None,
|
| 801 |
+
) -> tuple[torch.Tensor | None, torch.Tensor | None, BlockMask | None]:
|
| 802 |
+
expected_shape = (batch_size, seq_len)
|
| 803 |
+
if sequence_id.ndim != 2 or tuple(sequence_id.shape) != expected_shape:
|
| 804 |
+
raise ValueError(
|
| 805 |
+
f"sequence_id must have shape {expected_shape}; "
|
| 806 |
+
f"received {tuple(sequence_id.shape)}."
|
| 807 |
+
)
|
| 808 |
+
if sequence_id.device != device:
|
| 809 |
+
sequence_id = sequence_id.to(device=device)
|
| 810 |
+
backend = (
|
| 811 |
+
self.attention_backend
|
| 812 |
+
if effective_backend is None
|
| 813 |
+
else resolve_attention_backend(effective_backend)
|
| 814 |
+
)
|
| 815 |
+
if sequence_id.dtype == torch.bool:
|
| 816 |
+
attention_mask_2d = sequence_id
|
| 817 |
+
# Biohub's boolean single-chain form groups biological positions
|
| 818 |
+
# together and padding positions together. Padding queries remain
|
| 819 |
+
# finite without allowing their states to enter residue attention.
|
| 820 |
+
attention_mask_4d = sequence_id[:, None, :, None] == sequence_id[:, None, None, :]
|
| 821 |
+
else:
|
| 822 |
+
attention_mask_2d = sequence_id != -1
|
| 823 |
+
attention_mask_4d = (sequence_id.unsqueeze(-1) == sequence_id.unsqueeze(-2)).unsqueeze(
|
| 824 |
+
1
|
| 825 |
+
)
|
| 826 |
+
if not bool(attention_mask_2d.any(dim=1).all()):
|
| 827 |
+
raise ValueError("attention_mask must keep at least one valid key per batch row.")
|
| 828 |
+
|
| 829 |
+
if backend.is_flash:
|
| 830 |
+
if sequence_id.dtype != torch.bool:
|
| 831 |
+
raise ValueError(
|
| 832 |
+
"ESM++ FlashAttention only supports boolean sequence_id padding masks. "
|
| 833 |
+
"Use eager, sdpa, or flex_attention for chain-aware integer sequence_id "
|
| 834 |
+
"masks."
|
| 835 |
+
)
|
| 836 |
+
return attention_mask_2d, attention_mask_4d, None
|
| 837 |
+
|
| 838 |
+
if backend == AttentionBackend.FLEX:
|
| 839 |
+
if sequence_id.dtype == torch.bool:
|
| 840 |
+
|
| 841 |
+
def mask_mod(batch_idx, head_idx, q_idx, kv_idx):
|
| 842 |
+
del head_idx
|
| 843 |
+
return sequence_id[batch_idx, q_idx] == sequence_id[batch_idx, kv_idx]
|
| 844 |
+
|
| 845 |
+
else:
|
| 846 |
+
|
| 847 |
+
def mask_mod(batch_idx, head_idx, q_idx, kv_idx):
|
| 848 |
+
del head_idx
|
| 849 |
+
q_id = sequence_id[batch_idx, q_idx]
|
| 850 |
+
kv_id = sequence_id[batch_idx, kv_idx]
|
| 851 |
+
return q_id == kv_id
|
| 852 |
+
|
| 853 |
+
flex_block_mask = _get_flex_block_mask(
|
| 854 |
+
mask_pattern=sequence_id,
|
| 855 |
+
batch_size=batch_size,
|
| 856 |
+
query_length=seq_len,
|
| 857 |
+
key_value_length=seq_len,
|
| 858 |
+
device=device,
|
| 859 |
+
dtype=dtype,
|
| 860 |
+
mask_semantics=(
|
| 861 |
+
"boolean_sequence_id"
|
| 862 |
+
if sequence_id.dtype == torch.bool
|
| 863 |
+
else "integer_sequence_id"
|
| 864 |
+
),
|
| 865 |
+
mask_mod=mask_mod,
|
| 866 |
+
)
|
| 867 |
+
return attention_mask_2d, attention_mask_4d, flex_block_mask
|
| 868 |
+
|
| 869 |
+
return attention_mask_2d, attention_mask_4d, None
|
| 870 |
+
|
| 871 |
+
|
| 872 |
+
class PreTrainedESMplusplusModel(FastPLMsAttentionMixin, PreTrainedModel):
|
| 873 |
+
"""
|
| 874 |
+
init weights for ESM++ models
|
| 875 |
+
"""
|
| 876 |
+
|
| 877 |
+
config_class = ESMplusplusConfig
|
| 878 |
+
base_model_prefix = "esm++"
|
| 879 |
+
supports_gradient_checkpointing = True
|
| 880 |
+
all_tied_weights_keys: ClassVar[dict[str, str]] = {}
|
| 881 |
+
_supports_flash_attn = True
|
| 882 |
+
_supports_flash_attn_2 = True
|
| 883 |
+
_supports_flash_attn_3 = True
|
| 884 |
+
_fastplms_attention_implementations = (
|
| 885 |
+
"eager",
|
| 886 |
+
"sdpa",
|
| 887 |
+
"flex_attention",
|
| 888 |
+
"flash_attention_2",
|
| 889 |
+
"flash_attention_3",
|
| 890 |
+
)
|
| 891 |
+
|
| 892 |
+
@property
|
| 893 |
+
def tokenizer(self) -> EsmSequenceTokenizer:
|
| 894 |
+
"""Construct the sequence tokenizer only when a raw-sequence API needs it."""
|
| 895 |
+
|
| 896 |
+
tokenizer = self.__dict__.get("_fastplms_tokenizer")
|
| 897 |
+
if tokenizer is None:
|
| 898 |
+
tokenizer = EsmSequenceTokenizer()
|
| 899 |
+
self.__dict__["_fastplms_tokenizer"] = tokenizer
|
| 900 |
+
return tokenizer
|
| 901 |
+
|
| 902 |
+
@tokenizer.setter
|
| 903 |
+
def tokenizer(self, value: EsmSequenceTokenizer | None) -> None:
|
| 904 |
+
self.__dict__["_fastplms_tokenizer"] = value
|
| 905 |
+
|
| 906 |
+
def _init_weights(self, module):
|
| 907 |
+
"""Initialize the weights"""
|
| 908 |
+
# HF from_pretrained marks loaded parameters with `_is_hf_initialized`.
|
| 909 |
+
# Skip this module if any local parameter is already marked as loaded.
|
| 910 |
+
for parameter in module.parameters(recurse=False):
|
| 911 |
+
if parameter.__dict__.get("_is_hf_initialized"):
|
| 912 |
+
return
|
| 913 |
+
|
| 914 |
+
if isinstance(module, nn.Linear):
|
| 915 |
+
nn.init.normal_(module.weight, mean=0.0, std=self.config.initializer_range)
|
| 916 |
+
if module.bias is not None:
|
| 917 |
+
nn.init.zeros_(module.bias)
|
| 918 |
+
elif isinstance(module, nn.Embedding):
|
| 919 |
+
nn.init.normal_(module.weight, mean=0.0, std=self.config.initializer_range)
|
| 920 |
+
if module.padding_idx is not None:
|
| 921 |
+
with torch.no_grad():
|
| 922 |
+
module.weight[module.padding_idx].zero_()
|
| 923 |
+
elif isinstance(module, nn.LayerNorm):
|
| 924 |
+
if module.bias is not None:
|
| 925 |
+
nn.init.zeros_(module.bias)
|
| 926 |
+
nn.init.ones_(module.weight)
|
| 927 |
+
|
| 928 |
+
@property
|
| 929 |
+
def attn_backend(self) -> str:
|
| 930 |
+
return self.config.attn_backend
|
| 931 |
+
|
| 932 |
+
@attn_backend.setter
|
| 933 |
+
def attn_backend(self, backend: str) -> None:
|
| 934 |
+
if backend not in self._fastplms_attention_implementations:
|
| 935 |
+
raise ValueError(
|
| 936 |
+
f"{type(self).__name__} does not support {backend!r}; expected one of "
|
| 937 |
+
f"{self._fastplms_attention_implementations}."
|
| 938 |
+
)
|
| 939 |
+
self.set_attn_implementation(backend)
|
| 940 |
+
|
| 941 |
+
def _reset_rotary_embeddings(self):
|
| 942 |
+
"""Refresh non-persistent rotary buffers after checkpoint loading."""
|
| 943 |
+
for module in self.modules():
|
| 944 |
+
if isinstance(module, RotaryEmbedding):
|
| 945 |
+
module.reset_parameters()
|
| 946 |
+
|
| 947 |
+
@classmethod
|
| 948 |
+
def from_pretrained(cls, pretrained_model_name_or_path, *model_args, **kwargs):
|
| 949 |
+
output_loading_info = (
|
| 950 |
+
bool(kwargs["output_loading_info"]) if "output_loading_info" in kwargs else False
|
| 951 |
+
)
|
| 952 |
+
loaded = super().from_pretrained(pretrained_model_name_or_path, *model_args, **kwargs)
|
| 953 |
+
if output_loading_info:
|
| 954 |
+
model, loading_info = loaded
|
| 955 |
+
model._reset_rotary_embeddings()
|
| 956 |
+
return model, loading_info
|
| 957 |
+
loaded._reset_rotary_embeddings()
|
| 958 |
+
return loaded
|
| 959 |
+
|
| 960 |
+
|
| 961 |
+
### ESM++ Models
|
| 962 |
+
class ESMplusplusModel(PreTrainedESMplusplusModel, EmbeddingMixin):
|
| 963 |
+
"""
|
| 964 |
+
ESM++ transformer backbone.
|
| 965 |
+
|
| 966 |
+
Official ESM++ checkpoints contain the sequence head even when loaded through
|
| 967 |
+
``AutoModel``. Keep that module in the base class so the checkpoint has one
|
| 968 |
+
exact state-dict contract across ``AutoModel`` and ``AutoModelForMaskedLM``;
|
| 969 |
+
the base forward path intentionally does not compute or return logits.
|
| 970 |
+
"""
|
| 971 |
+
|
| 972 |
+
config_class = ESMplusplusConfig
|
| 973 |
+
|
| 974 |
+
def __init__(self, config: ESMplusplusConfig, **kwargs):
|
| 975 |
+
PreTrainedESMplusplusModel.__init__(self, config, **kwargs)
|
| 976 |
+
self.config = config
|
| 977 |
+
self.vocab_size = config.vocab_size
|
| 978 |
+
self.embed = nn.Embedding(self.vocab_size, config.hidden_size)
|
| 979 |
+
self.transformer = TransformerStack(
|
| 980 |
+
d_model=config.hidden_size,
|
| 981 |
+
n_heads=config.num_attention_heads,
|
| 982 |
+
n_layers=config.num_hidden_layers,
|
| 983 |
+
dropout=config.dropout,
|
| 984 |
+
attn_backend=config.attn_backend,
|
| 985 |
+
)
|
| 986 |
+
self.sequence_head = RegressionHead(config.hidden_size, self.vocab_size)
|
| 987 |
+
self.init_weights()
|
| 988 |
+
|
| 989 |
+
def get_input_embeddings(self):
|
| 990 |
+
return self.embed
|
| 991 |
+
|
| 992 |
+
def set_input_embeddings(self, value):
|
| 993 |
+
self.embed = value
|
| 994 |
+
|
| 995 |
+
def get_output_embeddings(self):
|
| 996 |
+
return self.sequence_head[-1]
|
| 997 |
+
|
| 998 |
+
def set_output_embeddings(self, new_embeddings):
|
| 999 |
+
self.sequence_head[-1] = new_embeddings
|
| 1000 |
+
|
| 1001 |
+
def _embed(
|
| 1002 |
+
self,
|
| 1003 |
+
input_ids: torch.Tensor,
|
| 1004 |
+
attention_mask: torch.Tensor | None = None,
|
| 1005 |
+
hidden_state_index: int = -1,
|
| 1006 |
+
store_all_hidden_states: bool = False,
|
| 1007 |
+
) -> torch.Tensor:
|
| 1008 |
+
if attention_mask is None:
|
| 1009 |
+
attention_mask = input_ids.ne(self.config.pad_token_id)
|
| 1010 |
+
x = self.embed(input_ids)
|
| 1011 |
+
output_hidden_states = store_all_hidden_states or hidden_state_index != -1
|
| 1012 |
+
output = self.transformer(
|
| 1013 |
+
x=x,
|
| 1014 |
+
attention_mask=attention_mask,
|
| 1015 |
+
output_hidden_states=output_hidden_states,
|
| 1016 |
+
output_attentions=False,
|
| 1017 |
+
)
|
| 1018 |
+
return select_hidden_state_embeddings(
|
| 1019 |
+
output.last_hidden_state,
|
| 1020 |
+
output.hidden_states,
|
| 1021 |
+
hidden_state_index=hidden_state_index,
|
| 1022 |
+
store_all_hidden_states=store_all_hidden_states,
|
| 1023 |
+
)
|
| 1024 |
+
|
| 1025 |
+
def forward(
|
| 1026 |
+
self,
|
| 1027 |
+
input_ids: torch.Tensor | None = None,
|
| 1028 |
+
attention_mask: torch.Tensor | None = None,
|
| 1029 |
+
sequence_id: torch.Tensor | None = None,
|
| 1030 |
+
inputs_embeds: torch.Tensor | None = None,
|
| 1031 |
+
output_attentions: bool | None = None,
|
| 1032 |
+
output_hidden_states: bool | None = None,
|
| 1033 |
+
output_s_max: bool | None = False,
|
| 1034 |
+
esmfold2_hidden_states: bool = False,
|
| 1035 |
+
return_dict: bool | None = None,
|
| 1036 |
+
) -> TransformerOutput | tuple[torch.Tensor, ...]:
|
| 1037 |
+
"""Run ESMC inference with the pinned Biohub mask precedence.
|
| 1038 |
+
|
| 1039 |
+
``sequence_id`` is authoritative when supplied: non-negative integers
|
| 1040 |
+
identify chains and ``-1`` identifies padding. In that mode
|
| 1041 |
+
``attention_mask`` is ignored, matching the official implementation.
|
| 1042 |
+
Without ``sequence_id``, ``attention_mask`` is the ordinary padding
|
| 1043 |
+
mask and defaults to ``input_ids != pad_token_id``.
|
| 1044 |
+
"""
|
| 1045 |
+
if input_ids is None and inputs_embeds is None:
|
| 1046 |
+
raise ValueError("You have to specify either input_ids or inputs_embeds")
|
| 1047 |
+
if input_ids is not None and inputs_embeds is not None:
|
| 1048 |
+
raise ValueError("You cannot specify both input_ids and inputs_embeds at the same time")
|
| 1049 |
+
output_attentions = (
|
| 1050 |
+
output_attentions if output_attentions is not None else self.config.output_attentions
|
| 1051 |
+
)
|
| 1052 |
+
output_hidden_states = (
|
| 1053 |
+
output_hidden_states
|
| 1054 |
+
if output_hidden_states is not None
|
| 1055 |
+
else self.config.output_hidden_states
|
| 1056 |
+
)
|
| 1057 |
+
return_dict = return_dict if return_dict is not None else self.config.use_return_dict
|
| 1058 |
+
|
| 1059 |
+
if attention_mask is None and sequence_id is None and input_ids is not None:
|
| 1060 |
+
attention_mask = input_ids.ne(self.config.pad_token_id)
|
| 1061 |
+
|
| 1062 |
+
x = self.embed(input_ids) if inputs_embeds is None else inputs_embeds
|
| 1063 |
+
|
| 1064 |
+
transformer_output = self.transformer(
|
| 1065 |
+
x=x,
|
| 1066 |
+
attention_mask=attention_mask,
|
| 1067 |
+
sequence_id=sequence_id,
|
| 1068 |
+
output_hidden_states=output_hidden_states,
|
| 1069 |
+
output_attentions=output_attentions,
|
| 1070 |
+
output_s_max=output_s_max,
|
| 1071 |
+
esmfold2_hidden_states=esmfold2_hidden_states,
|
| 1072 |
+
)
|
| 1073 |
+
result = TransformerOutput(
|
| 1074 |
+
last_hidden_state=transformer_output.last_hidden_state,
|
| 1075 |
+
hidden_states=transformer_output.hidden_states,
|
| 1076 |
+
attentions=transformer_output.attentions,
|
| 1077 |
+
s_max=transformer_output.s_max,
|
| 1078 |
+
)
|
| 1079 |
+
return result if return_dict else result.to_tuple()
|
| 1080 |
+
|
| 1081 |
+
|
| 1082 |
+
class ESMplusplusForMaskedLM(
|
| 1083 |
+
FastPLMTestTimeTrainingMixin, PreTrainedESMplusplusModel, EmbeddingMixin
|
| 1084 |
+
):
|
| 1085 |
+
"""
|
| 1086 |
+
ESM++ model for masked language modeling.
|
| 1087 |
+
Implements the base ESM++ architecture with a masked language modeling head.
|
| 1088 |
+
"""
|
| 1089 |
+
|
| 1090 |
+
config_class = ESMplusplusConfig
|
| 1091 |
+
|
| 1092 |
+
def __init__(self, config: ESMplusplusConfig, **kwargs):
|
| 1093 |
+
PreTrainedESMplusplusModel.__init__(self, config, **kwargs)
|
| 1094 |
+
self.config = config
|
| 1095 |
+
self.vocab_size = config.vocab_size
|
| 1096 |
+
self.embed = nn.Embedding(self.vocab_size, config.hidden_size)
|
| 1097 |
+
self.transformer = TransformerStack(
|
| 1098 |
+
d_model=config.hidden_size,
|
| 1099 |
+
n_heads=config.num_attention_heads,
|
| 1100 |
+
n_layers=config.num_hidden_layers,
|
| 1101 |
+
dropout=config.dropout,
|
| 1102 |
+
attn_backend=config.attn_backend,
|
| 1103 |
+
)
|
| 1104 |
+
self.sequence_head = RegressionHead(config.hidden_size, self.vocab_size)
|
| 1105 |
+
self.ce_loss = nn.CrossEntropyLoss()
|
| 1106 |
+
self.init_weights()
|
| 1107 |
+
self.init_ttt({"lora_target_replace_module": "MultiHeadAttention"})
|
| 1108 |
+
|
| 1109 |
+
def get_input_embeddings(self):
|
| 1110 |
+
return self.embed
|
| 1111 |
+
|
| 1112 |
+
def set_input_embeddings(self, value):
|
| 1113 |
+
self.embed = value
|
| 1114 |
+
|
| 1115 |
+
def get_output_embeddings(self):
|
| 1116 |
+
return self.sequence_head[-1]
|
| 1117 |
+
|
| 1118 |
+
def set_output_embeddings(self, new_embeddings):
|
| 1119 |
+
self.sequence_head[-1] = new_embeddings
|
| 1120 |
+
|
| 1121 |
+
def _embed(
|
| 1122 |
+
self,
|
| 1123 |
+
input_ids: torch.Tensor,
|
| 1124 |
+
attention_mask: torch.Tensor | None = None,
|
| 1125 |
+
hidden_state_index: int = -1,
|
| 1126 |
+
store_all_hidden_states: bool = False,
|
| 1127 |
+
) -> torch.Tensor:
|
| 1128 |
+
if attention_mask is None:
|
| 1129 |
+
attention_mask = input_ids.ne(self.config.pad_token_id)
|
| 1130 |
+
x = self.embed(input_ids)
|
| 1131 |
+
output_hidden_states = store_all_hidden_states or hidden_state_index != -1
|
| 1132 |
+
output = self.transformer(
|
| 1133 |
+
x=x,
|
| 1134 |
+
attention_mask=attention_mask,
|
| 1135 |
+
output_hidden_states=output_hidden_states,
|
| 1136 |
+
output_attentions=False,
|
| 1137 |
+
)
|
| 1138 |
+
return select_hidden_state_embeddings(
|
| 1139 |
+
output.last_hidden_state,
|
| 1140 |
+
output.hidden_states,
|
| 1141 |
+
hidden_state_index=hidden_state_index,
|
| 1142 |
+
store_all_hidden_states=store_all_hidden_states,
|
| 1143 |
+
)
|
| 1144 |
+
|
| 1145 |
+
def _ttt_get_trainable_modules(self) -> list[nn.Module]:
|
| 1146 |
+
return [self.transformer]
|
| 1147 |
+
|
| 1148 |
+
def forward(
|
| 1149 |
+
self,
|
| 1150 |
+
input_ids: torch.Tensor | None = None,
|
| 1151 |
+
attention_mask: torch.Tensor | None = None,
|
| 1152 |
+
sequence_id: torch.Tensor | None = None,
|
| 1153 |
+
inputs_embeds: torch.Tensor | None = None,
|
| 1154 |
+
labels: torch.Tensor | None = None,
|
| 1155 |
+
output_attentions: bool | None = None,
|
| 1156 |
+
output_hidden_states: bool | None = None,
|
| 1157 |
+
output_s_max: bool | None = False,
|
| 1158 |
+
esmfold2_hidden_states: bool = False,
|
| 1159 |
+
return_dict: bool | None = None,
|
| 1160 |
+
compute_logits: bool = True,
|
| 1161 |
+
) -> ESMplusplusOutput | tuple[torch.Tensor, ...]:
|
| 1162 |
+
if input_ids is None and inputs_embeds is None:
|
| 1163 |
+
raise ValueError("You have to specify either input_ids or inputs_embeds")
|
| 1164 |
+
if input_ids is not None and inputs_embeds is not None:
|
| 1165 |
+
raise ValueError("You cannot specify both input_ids and inputs_embeds at the same time")
|
| 1166 |
+
if labels is not None and not compute_logits:
|
| 1167 |
+
raise ValueError("labels require compute_logits=True.")
|
| 1168 |
+
output_attentions = (
|
| 1169 |
+
output_attentions if output_attentions is not None else self.config.output_attentions
|
| 1170 |
+
)
|
| 1171 |
+
output_hidden_states = (
|
| 1172 |
+
output_hidden_states
|
| 1173 |
+
if output_hidden_states is not None
|
| 1174 |
+
else self.config.output_hidden_states
|
| 1175 |
+
)
|
| 1176 |
+
return_dict = return_dict if return_dict is not None else self.config.use_return_dict
|
| 1177 |
+
if attention_mask is None and sequence_id is None and input_ids is not None:
|
| 1178 |
+
attention_mask = input_ids.ne(self.config.pad_token_id)
|
| 1179 |
+
|
| 1180 |
+
x = self.embed(input_ids) if inputs_embeds is None else inputs_embeds
|
| 1181 |
+
|
| 1182 |
+
output = self.transformer(
|
| 1183 |
+
x=x,
|
| 1184 |
+
attention_mask=attention_mask,
|
| 1185 |
+
sequence_id=sequence_id,
|
| 1186 |
+
output_hidden_states=output_hidden_states,
|
| 1187 |
+
output_attentions=output_attentions,
|
| 1188 |
+
output_s_max=output_s_max,
|
| 1189 |
+
esmfold2_hidden_states=esmfold2_hidden_states,
|
| 1190 |
+
)
|
| 1191 |
+
|
| 1192 |
+
last_hidden_state = output.last_hidden_state
|
| 1193 |
+
logits = self.sequence_head(last_hidden_state) if compute_logits else None
|
| 1194 |
+
loss = None
|
| 1195 |
+
if labels is not None:
|
| 1196 |
+
if logits is None:
|
| 1197 |
+
raise ValueError("labels require compute_logits=True.")
|
| 1198 |
+
labels = labels.to(logits.device)
|
| 1199 |
+
loss = self.ce_loss(logits.view(-1, self.vocab_size), labels.view(-1))
|
| 1200 |
+
|
| 1201 |
+
result = ESMplusplusOutput(
|
| 1202 |
+
loss=loss,
|
| 1203 |
+
logits=logits,
|
| 1204 |
+
hidden_states=output.hidden_states,
|
| 1205 |
+
attentions=output.attentions,
|
| 1206 |
+
s_max=output.s_max,
|
| 1207 |
+
last_hidden_state=last_hidden_state,
|
| 1208 |
+
)
|
| 1209 |
+
return result if return_dict else result.to_tuple()
|
| 1210 |
+
|
| 1211 |
+
|
| 1212 |
+
class ESMplusplusForSequenceClassification(ESMplusplusForMaskedLM, EmbeddingMixin):
|
| 1213 |
+
"""
|
| 1214 |
+
ESM++ model for sequence classification.
|
| 1215 |
+
Extends the base ESM++ model with a classification head.
|
| 1216 |
+
"""
|
| 1217 |
+
|
| 1218 |
+
def __init__(self, config: ESMplusplusConfig, **kwargs):
|
| 1219 |
+
pooling_types = kwargs.pop("pooling_types", None)
|
| 1220 |
+
if pooling_types is None:
|
| 1221 |
+
pooling_types = config.classifier_pooling_types or ["mean", "var"]
|
| 1222 |
+
elif not isinstance(pooling_types, list):
|
| 1223 |
+
raise TypeError("pooling_types must be a non-empty list of strings.")
|
| 1224 |
+
elif not pooling_types:
|
| 1225 |
+
raise ValueError("pooling_types must contain at least one pooling operation.")
|
| 1226 |
+
elif not all(isinstance(pooling_type, str) for pooling_type in pooling_types):
|
| 1227 |
+
raise TypeError("pooling_types must be a non-empty list of strings.")
|
| 1228 |
+
if "parti" in pooling_types:
|
| 1229 |
+
raise ValueError(
|
| 1230 |
+
"pooling_types cannot contain 'parti' for sequence classification "
|
| 1231 |
+
"because the classifier does not expose layer attentions to its pooler."
|
| 1232 |
+
)
|
| 1233 |
+
config.classifier_pooling_types = list(pooling_types)
|
| 1234 |
+
|
| 1235 |
+
ESMplusplusForMaskedLM.__init__(self, config, **kwargs)
|
| 1236 |
+
self.config = config
|
| 1237 |
+
self.num_labels = config.num_labels
|
| 1238 |
+
self.classifier = RegressionHead(
|
| 1239 |
+
config.hidden_size * len(pooling_types),
|
| 1240 |
+
config.num_labels,
|
| 1241 |
+
config.hidden_size * 4,
|
| 1242 |
+
)
|
| 1243 |
+
# Large intermediate projections help with sequence classification tasks (*4)
|
| 1244 |
+
self.mse = nn.MSELoss()
|
| 1245 |
+
self.ce = nn.CrossEntropyLoss()
|
| 1246 |
+
self.bce = nn.BCEWithLogitsLoss()
|
| 1247 |
+
self.pooler = Pooler(pooling_types)
|
| 1248 |
+
self.init_weights()
|
| 1249 |
+
|
| 1250 |
+
def _embed(
|
| 1251 |
+
self,
|
| 1252 |
+
input_ids: torch.Tensor,
|
| 1253 |
+
attention_mask: torch.Tensor | None = None,
|
| 1254 |
+
hidden_state_index: int = -1,
|
| 1255 |
+
store_all_hidden_states: bool = False,
|
| 1256 |
+
) -> torch.Tensor:
|
| 1257 |
+
x = self.embed(input_ids)
|
| 1258 |
+
output_hidden_states = store_all_hidden_states or hidden_state_index != -1
|
| 1259 |
+
output = self.transformer(
|
| 1260 |
+
x=x,
|
| 1261 |
+
attention_mask=attention_mask,
|
| 1262 |
+
output_hidden_states=output_hidden_states,
|
| 1263 |
+
output_attentions=False,
|
| 1264 |
+
)
|
| 1265 |
+
return select_hidden_state_embeddings(
|
| 1266 |
+
output.last_hidden_state,
|
| 1267 |
+
output.hidden_states,
|
| 1268 |
+
hidden_state_index=hidden_state_index,
|
| 1269 |
+
store_all_hidden_states=store_all_hidden_states,
|
| 1270 |
+
)
|
| 1271 |
+
|
| 1272 |
+
def forward(
|
| 1273 |
+
self,
|
| 1274 |
+
input_ids: torch.Tensor | None = None,
|
| 1275 |
+
attention_mask: torch.Tensor | None = None,
|
| 1276 |
+
sequence_id: torch.Tensor | None = None,
|
| 1277 |
+
inputs_embeds: torch.Tensor | None = None,
|
| 1278 |
+
labels: torch.Tensor | None = None,
|
| 1279 |
+
output_attentions: bool | None = None,
|
| 1280 |
+
output_hidden_states: bool | None = None,
|
| 1281 |
+
output_s_max: bool | None = False,
|
| 1282 |
+
return_dict: bool | None = None,
|
| 1283 |
+
) -> ESMplusplusSequenceClassifierOutput | tuple[torch.Tensor, ...]:
|
| 1284 |
+
return_dict = return_dict if return_dict is not None else self.config.use_return_dict
|
| 1285 |
+
pooling_mask = attention_mask
|
| 1286 |
+
if pooling_mask is None:
|
| 1287 |
+
if sequence_id is not None:
|
| 1288 |
+
pooling_mask = (
|
| 1289 |
+
sequence_id if sequence_id.dtype == torch.bool else sequence_id.ne(-1)
|
| 1290 |
+
)
|
| 1291 |
+
elif input_ids is not None:
|
| 1292 |
+
pooling_mask = input_ids.ne(self.config.pad_token_id)
|
| 1293 |
+
else:
|
| 1294 |
+
if inputs_embeds is None:
|
| 1295 |
+
raise ValueError("You have to specify either input_ids or inputs_embeds")
|
| 1296 |
+
pooling_mask = torch.ones(
|
| 1297 |
+
inputs_embeds.shape[:2],
|
| 1298 |
+
dtype=torch.bool,
|
| 1299 |
+
device=inputs_embeds.device,
|
| 1300 |
+
)
|
| 1301 |
+
|
| 1302 |
+
output = super().forward(
|
| 1303 |
+
input_ids=input_ids,
|
| 1304 |
+
attention_mask=attention_mask,
|
| 1305 |
+
sequence_id=sequence_id,
|
| 1306 |
+
inputs_embeds=inputs_embeds,
|
| 1307 |
+
labels=None,
|
| 1308 |
+
output_attentions=output_attentions,
|
| 1309 |
+
output_hidden_states=output_hidden_states,
|
| 1310 |
+
output_s_max=output_s_max,
|
| 1311 |
+
return_dict=True,
|
| 1312 |
+
compute_logits=False,
|
| 1313 |
+
)
|
| 1314 |
+
|
| 1315 |
+
last_hidden_state = output.last_hidden_state
|
| 1316 |
+
features = self.pooler(last_hidden_state, pooling_mask)
|
| 1317 |
+
logits = self.classifier(features)
|
| 1318 |
+
|
| 1319 |
+
loss = None
|
| 1320 |
+
if labels is not None:
|
| 1321 |
+
labels = labels.to(logits.device)
|
| 1322 |
+
if self.config.problem_type is None:
|
| 1323 |
+
if self.num_labels == 1:
|
| 1324 |
+
self.config.problem_type = "regression"
|
| 1325 |
+
elif self.num_labels > 1 and (
|
| 1326 |
+
labels.dtype == torch.long or labels.dtype == torch.int
|
| 1327 |
+
):
|
| 1328 |
+
self.config.problem_type = "single_label_classification"
|
| 1329 |
+
else:
|
| 1330 |
+
self.config.problem_type = "multi_label_classification"
|
| 1331 |
+
|
| 1332 |
+
if self.config.problem_type == "regression":
|
| 1333 |
+
if self.num_labels == 1:
|
| 1334 |
+
loss = self.mse(logits.flatten(), labels.flatten())
|
| 1335 |
+
else:
|
| 1336 |
+
loss = self.mse(logits, labels)
|
| 1337 |
+
elif self.config.problem_type == "single_label_classification":
|
| 1338 |
+
loss = self.ce(logits.view(-1, self.num_labels), labels.view(-1))
|
| 1339 |
+
elif self.config.problem_type == "multi_label_classification":
|
| 1340 |
+
loss = self.bce(logits, labels)
|
| 1341 |
+
|
| 1342 |
+
result = ESMplusplusSequenceClassifierOutput(
|
| 1343 |
+
loss=loss,
|
| 1344 |
+
logits=logits,
|
| 1345 |
+
hidden_states=output.hidden_states,
|
| 1346 |
+
attentions=output.attentions,
|
| 1347 |
+
s_max=output.s_max,
|
| 1348 |
+
)
|
| 1349 |
+
return result if return_dict else result.to_tuple()
|
| 1350 |
+
|
| 1351 |
+
|
| 1352 |
+
class ESMplusplusForTokenClassification(ESMplusplusForMaskedLM, EmbeddingMixin):
|
| 1353 |
+
"""
|
| 1354 |
+
ESM++ model for token classification.
|
| 1355 |
+
Extends the base ESM++ model with a token classification head.
|
| 1356 |
+
"""
|
| 1357 |
+
|
| 1358 |
+
def __init__(self, config: ESMplusplusConfig, **kwargs):
|
| 1359 |
+
ESMplusplusForMaskedLM.__init__(self, config, **kwargs)
|
| 1360 |
+
self.config = config
|
| 1361 |
+
self.num_labels = config.num_labels
|
| 1362 |
+
self.classifier = RegressionHead(
|
| 1363 |
+
config.hidden_size, config.num_labels, config.hidden_size * 4
|
| 1364 |
+
)
|
| 1365 |
+
# Large intermediate projections help with sequence classification tasks (*4)
|
| 1366 |
+
self.loss_fct = nn.CrossEntropyLoss()
|
| 1367 |
+
self.init_weights()
|
| 1368 |
+
|
| 1369 |
+
def _embed(
|
| 1370 |
+
self,
|
| 1371 |
+
input_ids: torch.Tensor,
|
| 1372 |
+
attention_mask: torch.Tensor | None = None,
|
| 1373 |
+
hidden_state_index: int = -1,
|
| 1374 |
+
store_all_hidden_states: bool = False,
|
| 1375 |
+
) -> torch.Tensor:
|
| 1376 |
+
x = self.embed(input_ids)
|
| 1377 |
+
output_hidden_states = store_all_hidden_states or hidden_state_index != -1
|
| 1378 |
+
output = self.transformer(
|
| 1379 |
+
x,
|
| 1380 |
+
attention_mask,
|
| 1381 |
+
output_hidden_states=output_hidden_states,
|
| 1382 |
+
output_attentions=False,
|
| 1383 |
+
)
|
| 1384 |
+
return select_hidden_state_embeddings(
|
| 1385 |
+
output.last_hidden_state,
|
| 1386 |
+
output.hidden_states,
|
| 1387 |
+
hidden_state_index=hidden_state_index,
|
| 1388 |
+
store_all_hidden_states=store_all_hidden_states,
|
| 1389 |
+
)
|
| 1390 |
+
|
| 1391 |
+
def forward(
|
| 1392 |
+
self,
|
| 1393 |
+
input_ids: torch.Tensor | None = None,
|
| 1394 |
+
attention_mask: torch.Tensor | None = None,
|
| 1395 |
+
sequence_id: torch.Tensor | None = None,
|
| 1396 |
+
inputs_embeds: torch.Tensor | None = None,
|
| 1397 |
+
labels: torch.Tensor | None = None,
|
| 1398 |
+
output_attentions: bool | None = None,
|
| 1399 |
+
output_hidden_states: bool | None = None,
|
| 1400 |
+
output_s_max: bool | None = False,
|
| 1401 |
+
return_dict: bool | None = None,
|
| 1402 |
+
) -> ESMplusplusTokenClassifierOutput | tuple[torch.Tensor, ...]:
|
| 1403 |
+
return_dict = return_dict if return_dict is not None else self.config.use_return_dict
|
| 1404 |
+
output = super().forward(
|
| 1405 |
+
input_ids=input_ids,
|
| 1406 |
+
attention_mask=attention_mask,
|
| 1407 |
+
sequence_id=sequence_id,
|
| 1408 |
+
inputs_embeds=inputs_embeds,
|
| 1409 |
+
labels=None,
|
| 1410 |
+
output_attentions=output_attentions,
|
| 1411 |
+
output_hidden_states=output_hidden_states,
|
| 1412 |
+
output_s_max=output_s_max,
|
| 1413 |
+
return_dict=True,
|
| 1414 |
+
compute_logits=False,
|
| 1415 |
+
)
|
| 1416 |
+
|
| 1417 |
+
last_hidden_state = output.last_hidden_state
|
| 1418 |
+
logits = self.classifier(last_hidden_state)
|
| 1419 |
+
loss = None
|
| 1420 |
+
if labels is not None:
|
| 1421 |
+
labels = labels.to(logits.device)
|
| 1422 |
+
loss = self.loss_fct(logits.view(-1, self.num_labels), labels.view(-1))
|
| 1423 |
+
|
| 1424 |
+
result = ESMplusplusTokenClassifierOutput(
|
| 1425 |
+
loss=loss,
|
| 1426 |
+
logits=logits,
|
| 1427 |
+
hidden_states=output.hidden_states,
|
| 1428 |
+
attentions=output.attentions,
|
| 1429 |
+
s_max=output.s_max,
|
| 1430 |
+
)
|
| 1431 |
+
return result if return_dict else result.to_tuple()
|
| 1432 |
+
|
| 1433 |
+
|
| 1434 |
+
### Tokenization
|
| 1435 |
+
SEQUENCE_VOCAB = [
|
| 1436 |
+
"<cls>",
|
| 1437 |
+
"<pad>",
|
| 1438 |
+
"<eos>",
|
| 1439 |
+
"<unk>",
|
| 1440 |
+
"L",
|
| 1441 |
+
"A",
|
| 1442 |
+
"G",
|
| 1443 |
+
"V",
|
| 1444 |
+
"S",
|
| 1445 |
+
"E",
|
| 1446 |
+
"R",
|
| 1447 |
+
"T",
|
| 1448 |
+
"I",
|
| 1449 |
+
"D",
|
| 1450 |
+
"P",
|
| 1451 |
+
"K",
|
| 1452 |
+
"Q",
|
| 1453 |
+
"N",
|
| 1454 |
+
"F",
|
| 1455 |
+
"Y",
|
| 1456 |
+
"M",
|
| 1457 |
+
"H",
|
| 1458 |
+
"W",
|
| 1459 |
+
"C",
|
| 1460 |
+
"X",
|
| 1461 |
+
"B",
|
| 1462 |
+
"U",
|
| 1463 |
+
"Z",
|
| 1464 |
+
"O",
|
| 1465 |
+
".",
|
| 1466 |
+
"-",
|
| 1467 |
+
"|",
|
| 1468 |
+
"<mask>",
|
| 1469 |
+
]
|
| 1470 |
+
|
| 1471 |
+
|
| 1472 |
+
def _build_sequence_tokenizer_backend(
|
| 1473 |
+
*,
|
| 1474 |
+
unk_token: str,
|
| 1475 |
+
cls_token: str,
|
| 1476 |
+
pad_token: str,
|
| 1477 |
+
mask_token: str,
|
| 1478 |
+
eos_token: str,
|
| 1479 |
+
chain_break_token: str,
|
| 1480 |
+
) -> Tokenizer:
|
| 1481 |
+
"""Build the fixed ESMC character vocabulary and boundary-token policy."""
|
| 1482 |
+
vocabulary = dict(zip(SEQUENCE_VOCAB, range(len(SEQUENCE_VOCAB)), strict=True))
|
| 1483 |
+
backend = Tokenizer(BPE(vocabulary, merges=[], unk_token=unk_token))
|
| 1484 |
+
backend.add_special_tokens([cls_token, pad_token, mask_token, eos_token, chain_break_token])
|
| 1485 |
+
backend.post_processor = TemplateProcessing(
|
| 1486 |
+
single="<cls> $A <eos>",
|
| 1487 |
+
pair="<cls>:0 $A:0 <eos>:0 $B:1 <eos>:1",
|
| 1488 |
+
special_tokens=[
|
| 1489 |
+
("<cls>", backend.token_to_id("<cls>")),
|
| 1490 |
+
("<eos>", backend.token_to_id("<eos>")),
|
| 1491 |
+
],
|
| 1492 |
+
)
|
| 1493 |
+
return backend
|
| 1494 |
+
|
| 1495 |
+
|
| 1496 |
+
class EsmSequenceTokenizer(PreTrainedTokenizerFast):
|
| 1497 |
+
model_input_names: ClassVar[list[str]] = ["input_ids", "attention_mask"]
|
| 1498 |
+
|
| 1499 |
+
def __init__(
|
| 1500 |
+
self,
|
| 1501 |
+
unk_token="<unk>",
|
| 1502 |
+
cls_token="<cls>",
|
| 1503 |
+
pad_token="<pad>",
|
| 1504 |
+
mask_token="<mask>",
|
| 1505 |
+
eos_token="<eos>",
|
| 1506 |
+
chain_break_token="|",
|
| 1507 |
+
**kwargs,
|
| 1508 |
+
):
|
| 1509 |
+
backend = _build_sequence_tokenizer_backend(
|
| 1510 |
+
unk_token=unk_token,
|
| 1511 |
+
cls_token=cls_token,
|
| 1512 |
+
pad_token=pad_token,
|
| 1513 |
+
mask_token=mask_token,
|
| 1514 |
+
eos_token=eos_token,
|
| 1515 |
+
chain_break_token=chain_break_token,
|
| 1516 |
+
)
|
| 1517 |
+
self.cb_token = chain_break_token
|
| 1518 |
+
super().__init__(
|
| 1519 |
+
tokenizer_object=backend,
|
| 1520 |
+
unk_token=unk_token,
|
| 1521 |
+
cls_token=cls_token,
|
| 1522 |
+
pad_token=pad_token,
|
| 1523 |
+
mask_token=mask_token,
|
| 1524 |
+
eos_token=eos_token,
|
| 1525 |
+
additional_special_tokens=[chain_break_token],
|
| 1526 |
+
**kwargs,
|
| 1527 |
+
)
|
| 1528 |
+
|
| 1529 |
+
# These are a footgun, we never use the `bos` token anywhere so we're just overriding it here.
|
| 1530 |
+
@property
|
| 1531 |
+
def bos_token(self):
|
| 1532 |
+
return self.cls_token
|
| 1533 |
+
|
| 1534 |
+
@property
|
| 1535 |
+
def bos_token_id(self):
|
| 1536 |
+
return self.cls_token_id
|
| 1537 |
+
|
| 1538 |
+
@property
|
| 1539 |
+
def chain_break_token(self):
|
| 1540 |
+
return self.cb_token
|
| 1541 |
+
|
| 1542 |
+
@property
|
| 1543 |
+
def chain_break_token_id(self):
|
| 1544 |
+
return self.convert_tokens_to_ids(self.chain_break_token)
|
| 1545 |
+
|
| 1546 |
+
@property
|
| 1547 |
+
def all_token_ids(self):
|
| 1548 |
+
return list(range(self.vocab_size))
|
| 1549 |
+
|
| 1550 |
+
@property
|
| 1551 |
+
def special_token_ids(self):
|
| 1552 |
+
return self.all_special_ids
|
fastplms/models/esmfold2/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ESMFold2 public classes, imported lazily to keep optional extras isolated."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from importlib import import_module
|
| 6 |
+
from typing import TYPE_CHECKING, Any
|
| 7 |
+
|
| 8 |
+
if TYPE_CHECKING:
|
| 9 |
+
from .configuration_esmfold2 import ESMFold2Config as ESMFold2Config
|
| 10 |
+
from .modeling_esmfold2 import ESMFold2Model as ESMFold2Model
|
| 11 |
+
from .modeling_esmfold2 import ESMFold2Output as ESMFold2Output
|
| 12 |
+
from .modeling_esmfold2_experimental import (
|
| 13 |
+
ESMFold2ExperimentalModel as ESMFold2ExperimentalModel,
|
| 14 |
+
)
|
| 15 |
+
from .reproducibility import seed_context as seed_context
|
| 16 |
+
|
| 17 |
+
_EXPORT_MODULES = {
|
| 18 |
+
"ESMFold2Config": ".configuration_esmfold2",
|
| 19 |
+
"ESMFold2ExperimentalModel": ".modeling_esmfold2_experimental",
|
| 20 |
+
"ESMFold2Model": ".modeling_esmfold2",
|
| 21 |
+
"ESMFold2Output": ".modeling_esmfold2",
|
| 22 |
+
"seed_context": ".reproducibility",
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def __getattr__(name: str) -> Any:
|
| 27 |
+
module_name = _EXPORT_MODULES.get(name)
|
| 28 |
+
if module_name is None:
|
| 29 |
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
| 30 |
+
value = getattr(import_module(module_name, __name__), name)
|
| 31 |
+
globals()[name] = value
|
| 32 |
+
return value
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def __dir__() -> list[str]:
|
| 36 |
+
return sorted(set(globals()) | set(_EXPORT_MODULES))
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
__all__ = list(_EXPORT_MODULES)
|
fastplms/models/esmfold2/attention.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Transformers-compatible attention selection for ESMFold2's ESMC backbone."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from collections.abc import Mapping
|
| 6 |
+
|
| 7 |
+
from ...attention import FastPLMsAttentionMixin, get_attn_implementation
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class ESMFold2AttentionMixin(FastPLMsAttentionMixin):
|
| 11 |
+
"""Route the outer Transformers attention API into the loaded ESMC model."""
|
| 12 |
+
|
| 13 |
+
_supports_attention_backend = True
|
| 14 |
+
_supports_sdpa = True
|
| 15 |
+
_supports_flex_attn = True
|
| 16 |
+
_supports_flash_attn_2 = False
|
| 17 |
+
_supports_flash_attn_3 = False
|
| 18 |
+
_fastplms_attention_implementations = (
|
| 19 |
+
"eager",
|
| 20 |
+
"sdpa",
|
| 21 |
+
"flex_attention",
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
def __init__(self, config, *args, **kwargs) -> None:
|
| 25 |
+
super().__init__(config, *args, **kwargs)
|
| 26 |
+
config.esmc_attn_backend = get_attn_implementation(config)
|
| 27 |
+
|
| 28 |
+
def set_attn_implementation(
|
| 29 |
+
self,
|
| 30 |
+
attn_implementation: str | Mapping[str, str],
|
| 31 |
+
allow_all_kernels: bool = False,
|
| 32 |
+
) -> None:
|
| 33 |
+
"""Set one canonical backend on ESMFold2 and its loaded ESMC model."""
|
| 34 |
+
|
| 35 |
+
if allow_all_kernels:
|
| 36 |
+
raise ValueError(
|
| 37 |
+
"ESMFold2 accepts only its declared built-in attention backends; "
|
| 38 |
+
"external attention kernels are not supported."
|
| 39 |
+
)
|
| 40 |
+
super().set_attn_implementation(attn_implementation)
|
| 41 |
+
resolved = get_attn_implementation(self.config)
|
| 42 |
+
self.config.esmc_attn_backend = resolved
|
| 43 |
+
esmc = getattr(self, "_esmc", None)
|
| 44 |
+
if esmc is not None:
|
| 45 |
+
esmc.set_attn_implementation(resolved)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
__all__ = ["ESMFold2AttentionMixin"]
|
fastplms/models/esmfold2/configuration_esmfold2.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copyright 2026 Biohub. All rights reserved.
|
| 2 |
+
#
|
| 3 |
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 4 |
+
# you may not use this file except in compliance with the License.
|
| 5 |
+
# You may obtain a copy of the License at
|
| 6 |
+
#
|
| 7 |
+
# http://www.apache.org/licenses/LICENSE-2.0
|
| 8 |
+
#
|
| 9 |
+
# Unless required by applicable law or agreed to in writing, software
|
| 10 |
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 11 |
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 12 |
+
# See the License for the specific language governing permissions and
|
| 13 |
+
# limitations under the License.
|
| 14 |
+
|
| 15 |
+
"""Configuration schema for release and experimental ESMFold2 checkpoints."""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
from dataclasses import asdict, dataclass, field
|
| 20 |
+
from typing import Any, TypeVar, cast
|
| 21 |
+
|
| 22 |
+
from transformers.configuration_utils import PretrainedConfig
|
| 23 |
+
|
| 24 |
+
_ESMC_ATTENTION_IMPLEMENTATIONS = frozenset({"eager", "flex_attention", "sdpa"})
|
| 25 |
+
_ESMC_PRECISIONS = frozenset({"auto", "bf16", "fp32", "fp8"})
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _esmc_backbone_checkpoint_ids() -> tuple[str, str]:
|
| 29 |
+
"""Return the manifest-pinned official and FastPLMs ESMC repositories."""
|
| 30 |
+
|
| 31 |
+
from fastplms.registry import RegistryError, get_model_registry
|
| 32 |
+
|
| 33 |
+
registry = get_model_registry()
|
| 34 |
+
family = registry.families["esmfold2"]
|
| 35 |
+
if family.backbone_model is None:
|
| 36 |
+
raise RegistryError("families.esmfold2 must declare backbone_model.")
|
| 37 |
+
backbone = registry[family.backbone_model]
|
| 38 |
+
return backbone.official.repo_id, backbone.fast.repo_id
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def normalize_esmc_id(esmc_id: str) -> str:
|
| 42 |
+
"""Resolve an official ESMC identifier to its FastPLMs checkpoint mirror."""
|
| 43 |
+
|
| 44 |
+
official_repo, fast_repo = _esmc_backbone_checkpoint_ids()
|
| 45 |
+
return fast_repo if esmc_id == official_repo else esmc_id
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def normalize_esmc_attention_implementation(
|
| 49 |
+
implementation: str | dict[str, str] | None,
|
| 50 |
+
) -> str | None:
|
| 51 |
+
"""Validate the ESMC backend and translate the historical ``flex`` name."""
|
| 52 |
+
|
| 53 |
+
if isinstance(implementation, dict):
|
| 54 |
+
if tuple(implementation) != ("",):
|
| 55 |
+
raise ValueError(
|
| 56 |
+
"ESMFold2 has one ESMC attention backbone; use a string or {'': implementation}."
|
| 57 |
+
)
|
| 58 |
+
implementation = implementation[""]
|
| 59 |
+
canonical = "flex_attention" if implementation == "flex" else implementation
|
| 60 |
+
if canonical is not None and canonical not in _ESMC_ATTENTION_IMPLEMENTATIONS:
|
| 61 |
+
expected = sorted(_ESMC_ATTENTION_IMPLEMENTATIONS)
|
| 62 |
+
raise ValueError(
|
| 63 |
+
f"Unsupported ESMFold2 attention implementation {canonical!r}; "
|
| 64 |
+
f"expected one of {expected}."
|
| 65 |
+
)
|
| 66 |
+
return canonical
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
NestedConfig = TypeVar("NestedConfig")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _nested_config(value: Any, config_type: type[NestedConfig]) -> NestedConfig:
|
| 73 |
+
if isinstance(value, config_type):
|
| 74 |
+
return value
|
| 75 |
+
return config_type(**value) if isinstance(value, dict) else config_type()
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _coerce_nested_field(
|
| 79 |
+
value: NestedConfig | dict[str, Any], config_type: type[NestedConfig]
|
| 80 |
+
) -> NestedConfig:
|
| 81 |
+
"""Convert serialized nested dictionaries while retaining supplied objects."""
|
| 82 |
+
|
| 83 |
+
return config_type(**value) if isinstance(value, dict) else value
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@dataclass
|
| 87 |
+
class AtomAttentionConfig:
|
| 88 |
+
"""Sliding-window atom attention and three-dimensional RoPE settings."""
|
| 89 |
+
|
| 90 |
+
d_atom: int = field(default=128)
|
| 91 |
+
d_token: int = field(default=768)
|
| 92 |
+
n_blocks: int = field(default=3)
|
| 93 |
+
n_heads: int = field(default=4)
|
| 94 |
+
swa_window_size: int = field(default=128)
|
| 95 |
+
expansion_ratio: int = field(default=2)
|
| 96 |
+
spatial_rope_base_frequency: float = field(default=20.0)
|
| 97 |
+
n_spatial_rope_pairs_per_axis: int = field(default=2)
|
| 98 |
+
n_uid_rope_pairs: int = field(default=10)
|
| 99 |
+
uid_rope_base_frequency: float = field(default=10000.0)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@dataclass
|
| 103 |
+
class DiffusionModuleConfig:
|
| 104 |
+
"""Dimensions and depth of the coordinate diffusion network."""
|
| 105 |
+
|
| 106 |
+
sigma_data: float = field(default=16.0)
|
| 107 |
+
c_atom: int = field(default=128)
|
| 108 |
+
c_token: int = field(default=768)
|
| 109 |
+
c_z: int = field(default=256)
|
| 110 |
+
c_s_inputs: int = field(default=451)
|
| 111 |
+
fourier_dim: int = field(default=256)
|
| 112 |
+
relpos_r_max: int = field(default=32)
|
| 113 |
+
relpos_s_max: int = field(default=2)
|
| 114 |
+
atom_num_blocks: int = field(default=3)
|
| 115 |
+
atom_num_heads: int = field(default=4)
|
| 116 |
+
token_num_blocks: int = field(default=12)
|
| 117 |
+
token_num_heads: int = field(default=16)
|
| 118 |
+
transition_multiplier: int = field(default=2)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
@dataclass
|
| 122 |
+
class FoldingTrunkConfig:
|
| 123 |
+
"""Iterative pair/single trunk dimensions."""
|
| 124 |
+
|
| 125 |
+
n_layers: int = field(default=24)
|
| 126 |
+
n_heads: int = field(default=8)
|
| 127 |
+
dropout: float = field(default=0.0)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
@dataclass
|
| 131 |
+
class InputsEmbedderConfig:
|
| 132 |
+
"""Input feature width and atom encoder settings."""
|
| 133 |
+
|
| 134 |
+
d_inputs: int = field(default=451)
|
| 135 |
+
atom_encoder: AtomAttentionConfig = field(default_factory=AtomAttentionConfig)
|
| 136 |
+
|
| 137 |
+
def __post_init__(self) -> None:
|
| 138 |
+
self.atom_encoder = _coerce_nested_field(self.atom_encoder, AtomAttentionConfig)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
@dataclass
|
| 142 |
+
class DiffusionStructureHeadConfig:
|
| 143 |
+
"""Training and inference schedules for coordinate denoising."""
|
| 144 |
+
|
| 145 |
+
diffusion_module: DiffusionModuleConfig = field(default_factory=DiffusionModuleConfig)
|
| 146 |
+
distogram_bins: int = field(default=128)
|
| 147 |
+
train_noise_log_mean: float = field(default=-1.2)
|
| 148 |
+
train_noise_log_std: float = field(default=1.5)
|
| 149 |
+
gamma_0: float = field(default=0.605)
|
| 150 |
+
gamma_min: float = field(default=1.107)
|
| 151 |
+
noise_scale: float = field(default=0.0)
|
| 152 |
+
step_scale: float = field(default=1.0)
|
| 153 |
+
inference_s_max: float = field(default=160.0)
|
| 154 |
+
inference_s_min: float = field(default=4e-4)
|
| 155 |
+
inference_p: float = field(default=8.0)
|
| 156 |
+
inference_num_steps: int = field(default=68)
|
| 157 |
+
|
| 158 |
+
def __post_init__(self) -> None:
|
| 159 |
+
self.diffusion_module = _coerce_nested_field(self.diffusion_module, DiffusionModuleConfig)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
@dataclass
|
| 163 |
+
class ConfidenceHeadConfig:
|
| 164 |
+
"""Confidence-bin definitions and the compact confidence trunk."""
|
| 165 |
+
|
| 166 |
+
enabled: bool = field(default=True)
|
| 167 |
+
num_plddt_bins: int = field(default=50)
|
| 168 |
+
num_pde_bins: int = field(default=64)
|
| 169 |
+
num_pae_bins: int = field(default=64)
|
| 170 |
+
min_dist: float = field(default=2.0)
|
| 171 |
+
max_dist: float = field(default=52.0)
|
| 172 |
+
distogram_bins: int = field(default=128)
|
| 173 |
+
folding_trunk: FoldingTrunkConfig = field(
|
| 174 |
+
default_factory=lambda: FoldingTrunkConfig(n_layers=4)
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
def __post_init__(self) -> None:
|
| 178 |
+
self.folding_trunk = _coerce_nested_field(self.folding_trunk, FoldingTrunkConfig)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
@dataclass
|
| 182 |
+
class MSAEncoderConfig:
|
| 183 |
+
"""Optional multiple-sequence-alignment encoder settings."""
|
| 184 |
+
|
| 185 |
+
enabled: bool = field(default=False)
|
| 186 |
+
d_msa: int = field(default=128)
|
| 187 |
+
d_hidden: int = field(default=32)
|
| 188 |
+
n_layers: int = field(default=4)
|
| 189 |
+
n_heads_msa: int = field(default=8)
|
| 190 |
+
msa_head_width: int = field(default=32)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
@dataclass
|
| 194 |
+
class LMEncoderConfig:
|
| 195 |
+
"""Release-model pair encoder derived from language-model states."""
|
| 196 |
+
|
| 197 |
+
enabled: bool = field(default=True)
|
| 198 |
+
n_layers: int = field(default=4)
|
| 199 |
+
lm_dropout: float = field(default=0.25)
|
| 200 |
+
per_loop_lm_dropout: bool = field(default=True)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
@dataclass
|
| 204 |
+
class ParcaeConfig:
|
| 205 |
+
"""Release-model diffusion-loop scheduler settings."""
|
| 206 |
+
|
| 207 |
+
enabled: bool = field(default=True)
|
| 208 |
+
poisson_mean: float = field(default=3.0)
|
| 209 |
+
min_steps: int = field(default=1)
|
| 210 |
+
max_steps: int | None = field(default=6)
|
| 211 |
+
coda_n_layers: int = field(default=2)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
_SCALAR_DEFAULTS: tuple[tuple[str, Any], ...] = (
|
| 215 |
+
("d_single", 384),
|
| 216 |
+
("d_pair", 256),
|
| 217 |
+
("n_relative_residx_bins", 32),
|
| 218 |
+
("n_relative_chain_bins", 2),
|
| 219 |
+
("num_loops", 10),
|
| 220 |
+
("num_diffusion_samples", 8),
|
| 221 |
+
("disable_msa_features", False),
|
| 222 |
+
("lm_dropout", 0.0),
|
| 223 |
+
("force_lm_dropout_during_inference", False),
|
| 224 |
+
("lm_mask_pct", 0.0),
|
| 225 |
+
("lm_d_model", 2560),
|
| 226 |
+
("lm_num_layers", 80),
|
| 227 |
+
)
|
| 228 |
+
_NESTED_CONFIGS = (
|
| 229 |
+
("inputs", InputsEmbedderConfig),
|
| 230 |
+
("folding_trunk", FoldingTrunkConfig),
|
| 231 |
+
("structure_head", DiffusionStructureHeadConfig),
|
| 232 |
+
("confidence_head", ConfidenceHeadConfig),
|
| 233 |
+
("msa_encoder", MSAEncoderConfig),
|
| 234 |
+
("parcae", ParcaeConfig),
|
| 235 |
+
("lm_encoder", LMEncoderConfig),
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
class ESMFold2Config(PretrainedConfig):
|
| 240 |
+
"""Serializable ESMFold2 architecture, runtime, and precision settings."""
|
| 241 |
+
|
| 242 |
+
model_type = "esmfold2"
|
| 243 |
+
has_no_defaults_at_init = True
|
| 244 |
+
|
| 245 |
+
def __init__(self, **kwargs: Any) -> None:
|
| 246 |
+
legacy_backend = normalize_esmc_attention_implementation(kwargs.get("esmc_attn_backend"))
|
| 247 |
+
requested_backend = normalize_esmc_attention_implementation(
|
| 248 |
+
kwargs.get("attn_implementation")
|
| 249 |
+
)
|
| 250 |
+
resolved_backend = requested_backend or legacy_backend
|
| 251 |
+
kwargs["attn_implementation"] = resolved_backend
|
| 252 |
+
super().__init__(**kwargs)
|
| 253 |
+
|
| 254 |
+
self.type = kwargs.get("type", "release")
|
| 255 |
+
if self.type not in {"experimental", "release"}:
|
| 256 |
+
raise ValueError(
|
| 257 |
+
f"ESMFold2Config.type must be 'release' or 'experimental', got {self.type!r}"
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
for name, default in _SCALAR_DEFAULTS:
|
| 261 |
+
setattr(self, name, kwargs.get(name, default))
|
| 262 |
+
|
| 263 |
+
_official_esmc_repo, default_esmc_repo = _esmc_backbone_checkpoint_ids()
|
| 264 |
+
self.esmc_id = normalize_esmc_id(kwargs.get("esmc_id", default_esmc_repo))
|
| 265 |
+
self.esmc_attn_backend = resolved_backend
|
| 266 |
+
self.esmc_precision = str(kwargs.get("esmc_precision", "auto"))
|
| 267 |
+
if self.esmc_precision not in _ESMC_PRECISIONS:
|
| 268 |
+
raise ValueError(
|
| 269 |
+
"esmc_precision must be 'auto', 'bf16', 'fp32', or 'fp8', "
|
| 270 |
+
f"got {self.esmc_precision!r}."
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
for name, config_type in _NESTED_CONFIGS:
|
| 274 |
+
setattr(self, name, _nested_config(kwargs.get(name), config_type))
|
| 275 |
+
if not isinstance(self.msa_encoder.enabled, bool):
|
| 276 |
+
raise TypeError("msa_encoder.enabled must be a boolean.")
|
| 277 |
+
declared_msa_conditioning = kwargs.get("msa_conditioning")
|
| 278 |
+
if "msa_conditioning" in kwargs and not isinstance(declared_msa_conditioning, bool):
|
| 279 |
+
raise TypeError("msa_conditioning must be a boolean when provided.")
|
| 280 |
+
self.msa_conditioning = (
|
| 281 |
+
self.msa_encoder.enabled
|
| 282 |
+
if "msa_conditioning" not in kwargs
|
| 283 |
+
else declared_msa_conditioning
|
| 284 |
+
)
|
| 285 |
+
if self.msa_conditioning != self.msa_encoder.enabled:
|
| 286 |
+
raise ValueError(
|
| 287 |
+
"msa_conditioning must match msa_encoder.enabled; received "
|
| 288 |
+
f"{self.msa_conditioning!r} and {self.msa_encoder.enabled!r}."
|
| 289 |
+
)
|
| 290 |
+
self.msa_encoder_overwrite = bool(kwargs.get("msa_encoder_overwrite", True))
|
| 291 |
+
|
| 292 |
+
def to_dict(self) -> dict[str, Any]:
|
| 293 |
+
output = cast(dict[str, Any], super().to_dict())
|
| 294 |
+
for name, _config_type in _NESTED_CONFIGS:
|
| 295 |
+
output[name] = asdict(getattr(self, name))
|
| 296 |
+
return output
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
__all__ = [
|
| 300 |
+
"ESMFold2Config",
|
| 301 |
+
"LMEncoderConfig",
|
| 302 |
+
"MSAEncoderConfig",
|
| 303 |
+
"ParcaeConfig",
|
| 304 |
+
"normalize_esmc_attention_implementation",
|
| 305 |
+
"normalize_esmc_id",
|
| 306 |
+
]
|
fastplms/models/esmfold2/embedding.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ESMFold2 integration for the shared FastPLMs embedding API."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Any, ClassVar
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
from torch import Tensor
|
| 9 |
+
|
| 10 |
+
from ...embeddings import EmbeddingBatch, EmbeddingResult, embed_dataset
|
| 11 |
+
from .esmfold2_constants_esm3 import SEQUENCE_PAD_TOKEN, SEQUENCE_VOCAB
|
| 12 |
+
|
| 13 |
+
_TOKEN_TO_ID = {token: index for index, token in enumerate(SEQUENCE_VOCAB)}
|
| 14 |
+
_VALID_RESIDUES = frozenset(SEQUENCE_VOCAB[4:31]) - {".", "-", "|"}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _encode_single_chain(sequence: str) -> list[int]:
|
| 18 |
+
normalized = sequence.upper()
|
| 19 |
+
if not normalized:
|
| 20 |
+
raise ValueError("ESMFold2 dataset embedding requires at least one protein residue.")
|
| 21 |
+
invalid = sorted(set(normalized) - _VALID_RESIDUES)
|
| 22 |
+
if invalid:
|
| 23 |
+
raise ValueError(
|
| 24 |
+
"ESMFold2 dataset embedding accepts one ungapped protein chain; "
|
| 25 |
+
f"invalid symbols: {invalid}."
|
| 26 |
+
)
|
| 27 |
+
return [_TOKEN_TO_ID[residue] for residue in normalized]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class ESMFold2EmbeddingMixin:
|
| 31 |
+
"""Learned ESMC sequence summaries for ESMFold2 models."""
|
| 32 |
+
|
| 33 |
+
embedding_unsupported_pooling = frozenset({"cls", "parti"})
|
| 34 |
+
embedding_layer = "all_81_esmc_states"
|
| 35 |
+
embedding_projection = "esmfold2_learned_sequence_summary"
|
| 36 |
+
embedding_token_policy: ClassVar[dict[str, object]] = {
|
| 37 |
+
"unit": "residue",
|
| 38 |
+
"normalization": "uppercase",
|
| 39 |
+
"include": ["single-chain protein residues"],
|
| 40 |
+
"exclude": [
|
| 41 |
+
"BOS",
|
| 42 |
+
"EOS",
|
| 43 |
+
"padding",
|
| 44 |
+
"chain delimiters",
|
| 45 |
+
"non-protein tokens",
|
| 46 |
+
],
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
def project_esmc_hidden_states(
|
| 50 |
+
self,
|
| 51 |
+
hidden_states: Tensor,
|
| 52 |
+
residue_mask: Tensor | None = None,
|
| 53 |
+
) -> Tensor:
|
| 54 |
+
"""Project H from ``(b, l, 81, 2560)`` to Z with shape ``(b, l, 256)``."""
|
| 55 |
+
|
| 56 |
+
if hidden_states.ndim != 4 or hidden_states.shape[-2] != 81:
|
| 57 |
+
raise ValueError(
|
| 58 |
+
"ESMFold2 projection requires the official ordered 81-state "
|
| 59 |
+
"ESMC tensor H with shape (b, l, 81, d_model)."
|
| 60 |
+
)
|
| 61 |
+
return self.language_model.project_sequence(hidden_states, residue_mask)
|
| 62 |
+
|
| 63 |
+
def _embedding_batch(self, sequences: list[str], **kwargs: Any) -> EmbeddingBatch:
|
| 64 |
+
if kwargs:
|
| 65 |
+
raise TypeError(f"Unexpected ESMFold2 embedding options: {', '.join(sorted(kwargs))}.")
|
| 66 |
+
if self._esmc is None:
|
| 67 |
+
raise RuntimeError("ESMFold2 embeddings require load_esmc=True.")
|
| 68 |
+
encoded = [_encode_single_chain(sequence) for sequence in sequences]
|
| 69 |
+
sequence_length = max(map(len, encoded))
|
| 70 |
+
b = len(encoded)
|
| 71 |
+
device = self.device
|
| 72 |
+
input_ids = torch.full(
|
| 73 |
+
(b, sequence_length),
|
| 74 |
+
SEQUENCE_PAD_TOKEN,
|
| 75 |
+
dtype=torch.long,
|
| 76 |
+
device=device,
|
| 77 |
+
)
|
| 78 |
+
residue_mask = torch.zeros((b, sequence_length), dtype=torch.bool, device=device)
|
| 79 |
+
for batch_index, token_ids in enumerate(encoded):
|
| 80 |
+
length = len(token_ids)
|
| 81 |
+
input_ids[batch_index, :length] = torch.tensor(
|
| 82 |
+
token_ids, dtype=torch.long, device=device
|
| 83 |
+
)
|
| 84 |
+
residue_mask[batch_index, :length] = True
|
| 85 |
+
|
| 86 |
+
residue_index = torch.arange(sequence_length, device=device).expand(b, -1)
|
| 87 |
+
asym_id = torch.zeros_like(input_ids)
|
| 88 |
+
mol_type = torch.zeros_like(input_ids)
|
| 89 |
+
hidden_states = self._compute_lm_hidden_states(
|
| 90 |
+
input_ids,
|
| 91 |
+
asym_id,
|
| 92 |
+
residue_index,
|
| 93 |
+
mol_type,
|
| 94 |
+
residue_mask,
|
| 95 |
+
)
|
| 96 |
+
projected = self.project_esmc_hidden_states(hidden_states, residue_mask)
|
| 97 |
+
return EmbeddingBatch(X=projected, residue_mask=residue_mask)
|
| 98 |
+
|
| 99 |
+
def embed_dataset(self, inputs: Any, **kwargs: Any) -> EmbeddingResult:
|
| 100 |
+
"""Embed single-chain proteins using the learned 256-wide ESMFold2 summary."""
|
| 101 |
+
|
| 102 |
+
return embed_dataset(self, inputs, **kwargs)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
__all__ = ["ESMFold2EmbeddingMixin"]
|
fastplms/models/esmfold2/esmfold2_affine3d.py
ADDED
|
@@ -0,0 +1,605 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Differentiable rigid rotations and affine transforms for ESMFold2."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from typing import Any, Self
|
| 7 |
+
|
| 8 |
+
import torch
|
| 9 |
+
from torch.nn import functional as F
|
| 10 |
+
|
| 11 |
+
from .esmfold2_misc import fp32_autocast_context
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _index_tuple(index: Any) -> tuple[Any, ...]:
|
| 15 |
+
if isinstance(index, int) or index is None:
|
| 16 |
+
return (index,)
|
| 17 |
+
return tuple(index)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _sqrt_subgradient(values: torch.Tensor) -> torch.Tensor:
|
| 21 |
+
"""Square root with a zero subgradient for non-positive inputs."""
|
| 22 |
+
|
| 23 |
+
result = torch.zeros_like(values)
|
| 24 |
+
positive = values > 0
|
| 25 |
+
result[positive] = torch.sqrt(values[positive])
|
| 26 |
+
return result
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _quat_invert(quaternion: torch.Tensor) -> torch.Tensor:
|
| 30 |
+
conjugate_sign = torch.tensor([1, -1, -1, -1], device=quaternion.device)
|
| 31 |
+
return quaternion * conjugate_sign
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _quat_mult(left: torch.Tensor, right: torch.Tensor) -> torch.Tensor:
|
| 35 |
+
"""Hamilton product for real-first quaternion tensors."""
|
| 36 |
+
|
| 37 |
+
aw, ax, ay, az = torch.unbind(left, -1)
|
| 38 |
+
bw, bx, by, bz = torch.unbind(right, -1)
|
| 39 |
+
return torch.stack(
|
| 40 |
+
(
|
| 41 |
+
aw * bw - ax * bx - ay * by - az * bz,
|
| 42 |
+
aw * bx + ax * bw + ay * bz - az * by,
|
| 43 |
+
aw * by - ax * bz + ay * bw + az * bx,
|
| 44 |
+
aw * bz + ax * by - ay * bx + az * bw,
|
| 45 |
+
),
|
| 46 |
+
-1,
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _quat_rotation(
|
| 51 |
+
quaternion: torch.Tensor,
|
| 52 |
+
points: torch.Tensor,
|
| 53 |
+
) -> torch.Tensor:
|
| 54 |
+
"""Rotate points using normalized real-first quaternions."""
|
| 55 |
+
|
| 56 |
+
aw, ax, ay, az = torch.unbind(quaternion, -1)
|
| 57 |
+
bx, by, bz = torch.unbind(points, -1)
|
| 58 |
+
product = torch.stack(
|
| 59 |
+
(
|
| 60 |
+
-ax * bx - ay * by - az * bz,
|
| 61 |
+
aw * bx + ay * bz - az * by,
|
| 62 |
+
aw * by - ax * bz + az * bx,
|
| 63 |
+
aw * bz + ax * by - ay * bx,
|
| 64 |
+
),
|
| 65 |
+
-1,
|
| 66 |
+
)
|
| 67 |
+
return _quat_mult(product, _quat_invert(quaternion))[..., 1:]
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _graham_schmidt(
|
| 71 |
+
x_axis: torch.Tensor,
|
| 72 |
+
xy_plane: torch.Tensor,
|
| 73 |
+
eps: float = 1e-12,
|
| 74 |
+
) -> torch.Tensor:
|
| 75 |
+
"""Construct a right-handed orthonormal frame from two directions."""
|
| 76 |
+
|
| 77 |
+
with fp32_autocast_context(x_axis.device.type):
|
| 78 |
+
e1 = xy_plane
|
| 79 |
+
denominator = torch.sqrt((x_axis**2).sum(dim=-1, keepdim=True) + eps)
|
| 80 |
+
x_axis = x_axis / denominator
|
| 81 |
+
projection = (x_axis * e1).sum(dim=-1, keepdim=True)
|
| 82 |
+
e1 = e1 - x_axis * projection
|
| 83 |
+
denominator = torch.sqrt((e1**2).sum(dim=-1, keepdim=True) + eps)
|
| 84 |
+
e1 = e1 / denominator
|
| 85 |
+
e2 = torch.cross(x_axis, e1, dim=-1)
|
| 86 |
+
return torch.stack([x_axis, e1, e2], dim=-1)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class Rotation:
|
| 90 |
+
"""Common interface for matrix-backed and quaternion-backed rotations."""
|
| 91 |
+
|
| 92 |
+
@classmethod
|
| 93 |
+
def identity(cls, shape: tuple[int, ...], **tensor_kwargs) -> Self: ...
|
| 94 |
+
|
| 95 |
+
@classmethod
|
| 96 |
+
def random(cls, shape: tuple[int, ...], **tensor_kwargs) -> Self: ...
|
| 97 |
+
|
| 98 |
+
def __getitem__(self, idx: Any) -> Self: ...
|
| 99 |
+
|
| 100 |
+
@property
|
| 101 |
+
def tensor(self) -> torch.Tensor: ...
|
| 102 |
+
|
| 103 |
+
@property
|
| 104 |
+
def shape(self) -> torch.Size: ...
|
| 105 |
+
|
| 106 |
+
def as_matrix(self) -> RotationMatrix: ...
|
| 107 |
+
|
| 108 |
+
def as_quat(self, normalize: bool = False) -> RotationQuat: ...
|
| 109 |
+
|
| 110 |
+
def compose(self, other: Self) -> Self: ...
|
| 111 |
+
|
| 112 |
+
def convert_compose(self, other: Self) -> Self: ...
|
| 113 |
+
|
| 114 |
+
def apply(self, points: torch.Tensor) -> torch.Tensor: ...
|
| 115 |
+
|
| 116 |
+
def invert(self) -> Self: ...
|
| 117 |
+
|
| 118 |
+
@property
|
| 119 |
+
def dtype(self) -> torch.dtype:
|
| 120 |
+
return self.tensor.dtype
|
| 121 |
+
|
| 122 |
+
@property
|
| 123 |
+
def device(self) -> torch.device:
|
| 124 |
+
return self.tensor.device
|
| 125 |
+
|
| 126 |
+
@property
|
| 127 |
+
def requires_grad(self) -> bool:
|
| 128 |
+
return self.tensor.requires_grad
|
| 129 |
+
|
| 130 |
+
@classmethod
|
| 131 |
+
def _from_tensor(cls, tensor: torch.Tensor) -> Self:
|
| 132 |
+
return cls(tensor) # type: ignore[call-arg]
|
| 133 |
+
|
| 134 |
+
def to(self, **kwargs) -> Self:
|
| 135 |
+
return self._from_tensor(self.tensor.to(**kwargs))
|
| 136 |
+
|
| 137 |
+
def detach(self, *args, **kwargs) -> Self:
|
| 138 |
+
return self._from_tensor(self.tensor.detach(**kwargs))
|
| 139 |
+
|
| 140 |
+
def tensor_apply(self, func) -> Self:
|
| 141 |
+
transformed = [func(component) for component in self.tensor.unbind(dim=-1)]
|
| 142 |
+
return self._from_tensor(torch.stack(transformed, dim=-1))
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
class RotationQuat(Rotation):
|
| 146 |
+
"""A rotation represented by a real-first quaternion."""
|
| 147 |
+
|
| 148 |
+
def __init__(self, quats: torch.Tensor, normalized: bool = False):
|
| 149 |
+
if not isinstance(quats, torch.Tensor):
|
| 150 |
+
raise TypeError("quats must be a Torch tensor.")
|
| 151 |
+
if quats.ndim == 0 or quats.shape[-1] != 4:
|
| 152 |
+
raise ValueError(
|
| 153 |
+
f"quats must have trailing dimension 4, got shape {tuple(quats.shape)}."
|
| 154 |
+
)
|
| 155 |
+
if not isinstance(normalized, bool):
|
| 156 |
+
raise TypeError("normalized must be a boolean.")
|
| 157 |
+
self._normalized = normalized
|
| 158 |
+
if normalized:
|
| 159 |
+
quats = F.normalize(quats.to(torch.float32), dim=-1)
|
| 160 |
+
self._quats = quats.where(quats[..., :1] >= 0, -quats)
|
| 161 |
+
else:
|
| 162 |
+
self._quats = quats.to(torch.float32)
|
| 163 |
+
|
| 164 |
+
@property
|
| 165 |
+
def tensor(self) -> torch.Tensor:
|
| 166 |
+
return self._quats
|
| 167 |
+
|
| 168 |
+
@property
|
| 169 |
+
def shape(self) -> torch.Size:
|
| 170 |
+
return self._quats.shape[:-1]
|
| 171 |
+
|
| 172 |
+
@classmethod
|
| 173 |
+
def identity(cls, shape, **tensor_kwargs) -> RotationQuat:
|
| 174 |
+
quaternions = torch.ones((*shape, 4), **tensor_kwargs)
|
| 175 |
+
selector = torch.tensor([1, 0, 0, 0], device=quaternions.device)
|
| 176 |
+
return cls(quaternions * selector)
|
| 177 |
+
|
| 178 |
+
@classmethod
|
| 179 |
+
def random(cls, shape, **tensor_kwargs) -> RotationQuat:
|
| 180 |
+
return cls(torch.randn((*shape, 4), **tensor_kwargs), normalized=True)
|
| 181 |
+
|
| 182 |
+
def __getitem__(self, idx: Any) -> RotationQuat:
|
| 183 |
+
indices = _index_tuple(idx)
|
| 184 |
+
return RotationQuat(self._quats[(*indices, slice(None))])
|
| 185 |
+
|
| 186 |
+
def normalized(self) -> RotationQuat:
|
| 187 |
+
if self._normalized:
|
| 188 |
+
return self
|
| 189 |
+
return RotationQuat(self._quats, normalized=True)
|
| 190 |
+
|
| 191 |
+
def as_quat(self, normalize: bool = False) -> RotationQuat:
|
| 192 |
+
return self
|
| 193 |
+
|
| 194 |
+
def as_matrix(self) -> RotationMatrix:
|
| 195 |
+
quaternion = self.normalized().tensor
|
| 196 |
+
r, i, j, k = torch.unbind(quaternion, -1)
|
| 197 |
+
scale = 2.0 / torch.linalg.norm(quaternion, dim=-1)
|
| 198 |
+
elements = torch.stack(
|
| 199 |
+
(
|
| 200 |
+
1 - scale * (j * j + k * k),
|
| 201 |
+
scale * (i * j - k * r),
|
| 202 |
+
scale * (i * k + j * r),
|
| 203 |
+
scale * (i * j + k * r),
|
| 204 |
+
1 - scale * (i * i + k * k),
|
| 205 |
+
scale * (j * k - i * r),
|
| 206 |
+
scale * (i * k - j * r),
|
| 207 |
+
scale * (j * k + i * r),
|
| 208 |
+
1 - scale * (i * i + j * j),
|
| 209 |
+
),
|
| 210 |
+
-1,
|
| 211 |
+
)
|
| 212 |
+
return RotationMatrix(elements.reshape((*quaternion.shape[:-1], 3, 3)))
|
| 213 |
+
|
| 214 |
+
def compose(self, other: RotationQuat) -> RotationQuat:
|
| 215 |
+
with fp32_autocast_context(self.device.type):
|
| 216 |
+
return RotationQuat(_quat_mult(self._quats, other._quats))
|
| 217 |
+
|
| 218 |
+
def convert_compose(self, other: Rotation) -> RotationQuat:
|
| 219 |
+
return self.compose(other.as_quat())
|
| 220 |
+
|
| 221 |
+
def apply(self, points: torch.Tensor) -> torch.Tensor:
|
| 222 |
+
return _quat_rotation(self.normalized()._quats, points)
|
| 223 |
+
|
| 224 |
+
def invert(self) -> RotationQuat:
|
| 225 |
+
return RotationQuat(_quat_invert(self._quats))
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
class RotationMatrix(Rotation):
|
| 229 |
+
"""A rotation represented by a dense FP32 matrix."""
|
| 230 |
+
|
| 231 |
+
def __init__(self, rots: torch.Tensor):
|
| 232 |
+
if not isinstance(rots, torch.Tensor):
|
| 233 |
+
raise TypeError("rots must be a Torch tensor.")
|
| 234 |
+
if rots.ndim > 0 and rots.shape[-1] == 9:
|
| 235 |
+
rots = rots.unflatten(-1, (3, 3))
|
| 236 |
+
if rots.ndim < 2 or rots.shape[-2:] != (3, 3):
|
| 237 |
+
raise ValueError(
|
| 238 |
+
"rots must have trailing shape (3, 3) or flattened width 9, got "
|
| 239 |
+
f"shape {tuple(rots.shape)}."
|
| 240 |
+
)
|
| 241 |
+
self._rots = rots.to(torch.float32)
|
| 242 |
+
|
| 243 |
+
@property
|
| 244 |
+
def tensor(self) -> torch.Tensor:
|
| 245 |
+
return self._rots.flatten(-2)
|
| 246 |
+
|
| 247 |
+
@property
|
| 248 |
+
def shape(self) -> torch.Size:
|
| 249 |
+
return self._rots.shape[:-2]
|
| 250 |
+
|
| 251 |
+
@classmethod
|
| 252 |
+
def identity(cls, shape, **tensor_kwargs) -> RotationMatrix:
|
| 253 |
+
matrix = torch.eye(3, **tensor_kwargs)
|
| 254 |
+
matrix = matrix.view(*(1 for _ in shape), 3, 3)
|
| 255 |
+
return cls(matrix.expand(*shape, -1, -1))
|
| 256 |
+
|
| 257 |
+
@classmethod
|
| 258 |
+
def random(cls, shape, **tensor_kwargs) -> RotationMatrix:
|
| 259 |
+
return RotationQuat.random(shape, **tensor_kwargs).as_matrix()
|
| 260 |
+
|
| 261 |
+
@staticmethod
|
| 262 |
+
def from_graham_schmidt(
|
| 263 |
+
x_axis: torch.Tensor,
|
| 264 |
+
xy_plane: torch.Tensor,
|
| 265 |
+
eps: float = 1e-12,
|
| 266 |
+
) -> RotationMatrix:
|
| 267 |
+
return RotationMatrix(_graham_schmidt(x_axis, xy_plane, eps))
|
| 268 |
+
|
| 269 |
+
def __getitem__(self, idx: Any) -> RotationMatrix:
|
| 270 |
+
indices = _index_tuple(idx)
|
| 271 |
+
return RotationMatrix(self._rots[(*indices, slice(None), slice(None))])
|
| 272 |
+
|
| 273 |
+
def as_matrix(self) -> RotationMatrix:
|
| 274 |
+
return self
|
| 275 |
+
|
| 276 |
+
def to_3x3(self) -> torch.Tensor:
|
| 277 |
+
return self._rots
|
| 278 |
+
|
| 279 |
+
def as_quat(self, normalize: bool = False) -> RotationQuat:
|
| 280 |
+
m00, m01, m02, m10, m11, m12, m20, m21, m22 = torch.unbind(
|
| 281 |
+
self._rots.flatten(-2),
|
| 282 |
+
dim=-1,
|
| 283 |
+
)
|
| 284 |
+
q_abs = _sqrt_subgradient(
|
| 285 |
+
torch.stack(
|
| 286 |
+
(
|
| 287 |
+
1.0 + m00 + m11 + m22,
|
| 288 |
+
1.0 + m00 - m11 - m22,
|
| 289 |
+
1.0 - m00 + m11 - m22,
|
| 290 |
+
1.0 - m00 - m11 + m22,
|
| 291 |
+
),
|
| 292 |
+
dim=-1,
|
| 293 |
+
)
|
| 294 |
+
)
|
| 295 |
+
products = torch.stack(
|
| 296 |
+
(
|
| 297 |
+
q_abs[..., 0] ** 2,
|
| 298 |
+
m21 - m12,
|
| 299 |
+
m02 - m20,
|
| 300 |
+
m10 - m01,
|
| 301 |
+
m21 - m12,
|
| 302 |
+
q_abs[..., 1] ** 2,
|
| 303 |
+
m10 + m01,
|
| 304 |
+
m02 + m20,
|
| 305 |
+
m02 - m20,
|
| 306 |
+
m10 + m01,
|
| 307 |
+
q_abs[..., 2] ** 2,
|
| 308 |
+
m12 + m21,
|
| 309 |
+
m10 - m01,
|
| 310 |
+
m20 + m02,
|
| 311 |
+
m21 + m12,
|
| 312 |
+
q_abs[..., 3] ** 2,
|
| 313 |
+
),
|
| 314 |
+
dim=-1,
|
| 315 |
+
).unflatten(-1, (4, 4))
|
| 316 |
+
floor = torch.tensor(0.1).to(dtype=q_abs.dtype, device=q_abs.device)
|
| 317 |
+
candidates = products / (2.0 * q_abs[..., None].max(floor))
|
| 318 |
+
best = torch.zeros_like(q_abs, dtype=torch.bool)
|
| 319 |
+
best.scatter_(-1, q_abs.argmax(dim=-1, keepdim=True), True)
|
| 320 |
+
quaternion = candidates[best, :].reshape(q_abs.shape)
|
| 321 |
+
return RotationQuat(quaternion)
|
| 322 |
+
|
| 323 |
+
def compose(self, other: RotationMatrix) -> RotationMatrix:
|
| 324 |
+
with fp32_autocast_context(self.device.type):
|
| 325 |
+
return RotationMatrix(self._rots @ other._rots)
|
| 326 |
+
|
| 327 |
+
def convert_compose(self, other: Rotation) -> RotationMatrix:
|
| 328 |
+
return self.compose(other.as_matrix())
|
| 329 |
+
|
| 330 |
+
def apply(self, points: torch.Tensor) -> torch.Tensor:
|
| 331 |
+
with fp32_autocast_context(self.device.type):
|
| 332 |
+
if self._rots.shape[-3] == 1:
|
| 333 |
+
return points @ self._rots.transpose(-1, -2).squeeze(-3)
|
| 334 |
+
return torch.einsum("...ij,...j", self._rots, points)
|
| 335 |
+
|
| 336 |
+
def invert(self) -> RotationMatrix:
|
| 337 |
+
return RotationMatrix(self._rots.transpose(-1, -2))
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
@dataclass(frozen=True)
|
| 341 |
+
class Affine3D:
|
| 342 |
+
"""A rigid transform with translation and rotation components."""
|
| 343 |
+
|
| 344 |
+
trans: torch.Tensor
|
| 345 |
+
rot: Rotation
|
| 346 |
+
|
| 347 |
+
def __post_init__(self) -> None:
|
| 348 |
+
if not isinstance(self.trans, torch.Tensor):
|
| 349 |
+
raise TypeError("trans must be a Torch tensor.")
|
| 350 |
+
if not isinstance(self.rot, Rotation):
|
| 351 |
+
raise TypeError("rot must implement the ESMFold2 Rotation interface.")
|
| 352 |
+
if self.trans.ndim == 0 or self.trans.shape[-1] != 3:
|
| 353 |
+
raise ValueError(
|
| 354 |
+
"trans must have trailing dimension 3, got "
|
| 355 |
+
f"shape {tuple(self.trans.shape)}."
|
| 356 |
+
)
|
| 357 |
+
if self.trans.shape[:-1] != self.rot.shape:
|
| 358 |
+
raise ValueError(
|
| 359 |
+
"translation and rotation batch shapes must match, got "
|
| 360 |
+
f"{tuple(self.trans.shape[:-1])} and {tuple(self.rot.shape)}."
|
| 361 |
+
)
|
| 362 |
+
|
| 363 |
+
@property
|
| 364 |
+
def shape(self) -> torch.Size:
|
| 365 |
+
return self.trans.shape[:-1]
|
| 366 |
+
|
| 367 |
+
@property
|
| 368 |
+
def dtype(self) -> torch.dtype:
|
| 369 |
+
return self.trans.dtype
|
| 370 |
+
|
| 371 |
+
@property
|
| 372 |
+
def device(self) -> torch.device:
|
| 373 |
+
return self.trans.device
|
| 374 |
+
|
| 375 |
+
@property
|
| 376 |
+
def requires_grad(self) -> bool:
|
| 377 |
+
return self.trans.requires_grad
|
| 378 |
+
|
| 379 |
+
@property
|
| 380 |
+
def tensor(self) -> torch.Tensor:
|
| 381 |
+
return torch.cat((self.rot.tensor, self.trans), dim=-1)
|
| 382 |
+
|
| 383 |
+
@staticmethod
|
| 384 |
+
def identity(
|
| 385 |
+
shape_or_affine: tuple[int, ...] | Affine3D,
|
| 386 |
+
rotation_type: type[Rotation] = RotationMatrix,
|
| 387 |
+
**tensor_kwargs,
|
| 388 |
+
) -> Affine3D:
|
| 389 |
+
if isinstance(shape_or_affine, Affine3D):
|
| 390 |
+
kwargs = {
|
| 391 |
+
"dtype": shape_or_affine.dtype,
|
| 392 |
+
"device": shape_or_affine.device,
|
| 393 |
+
}
|
| 394 |
+
kwargs.update(tensor_kwargs)
|
| 395 |
+
shape = shape_or_affine.shape
|
| 396 |
+
rotation_type = type(shape_or_affine.rot)
|
| 397 |
+
else:
|
| 398 |
+
kwargs = tensor_kwargs
|
| 399 |
+
shape = shape_or_affine
|
| 400 |
+
return Affine3D(
|
| 401 |
+
torch.zeros((*shape, 3), **kwargs),
|
| 402 |
+
rotation_type.identity(shape, **kwargs),
|
| 403 |
+
)
|
| 404 |
+
|
| 405 |
+
@staticmethod
|
| 406 |
+
def random(
|
| 407 |
+
shape: tuple[int, ...],
|
| 408 |
+
std: float = 1,
|
| 409 |
+
rotation_type: type[Rotation] = RotationMatrix,
|
| 410 |
+
**tensor_kwargs,
|
| 411 |
+
) -> Affine3D:
|
| 412 |
+
translation = torch.randn((*shape, 3), **tensor_kwargs).mul(std)
|
| 413 |
+
rotation = rotation_type.random(shape, **tensor_kwargs)
|
| 414 |
+
return Affine3D(trans=translation, rot=rotation)
|
| 415 |
+
|
| 416 |
+
@staticmethod
|
| 417 |
+
def from_tensor(tensor: torch.Tensor) -> Affine3D:
|
| 418 |
+
if not isinstance(tensor, torch.Tensor):
|
| 419 |
+
raise TypeError("tensor must be a Torch tensor.")
|
| 420 |
+
if tensor.ndim == 0:
|
| 421 |
+
raise ValueError("tensor must have at least one dimension.")
|
| 422 |
+
width = tensor.shape[-1]
|
| 423 |
+
if width == 4:
|
| 424 |
+
if tensor.ndim < 2 or tensor.shape[-2] not in (3, 4):
|
| 425 |
+
raise ValueError(
|
| 426 |
+
"matrix-form affine tensors must have trailing shape (3, 4) or "
|
| 427 |
+
f"(4, 4), got {tuple(tensor.shape)}."
|
| 428 |
+
)
|
| 429 |
+
translation = tensor[..., :3, 3]
|
| 430 |
+
rotation: Rotation = RotationMatrix(tensor[..., :3, :3])
|
| 431 |
+
elif width == 6:
|
| 432 |
+
translation = tensor[..., -3:]
|
| 433 |
+
rotation = RotationQuat(F.pad(tensor[..., :3], (1, 0), value=1))
|
| 434 |
+
elif width == 7:
|
| 435 |
+
translation = tensor[..., -3:]
|
| 436 |
+
rotation = RotationQuat(tensor[..., :4])
|
| 437 |
+
elif width == 12:
|
| 438 |
+
translation = tensor[..., -3:]
|
| 439 |
+
rotation = RotationMatrix(tensor[..., :-3].unflatten(-1, (3, 3)))
|
| 440 |
+
else:
|
| 441 |
+
raise RuntimeError(
|
| 442 |
+
f"Cannot detect rotation format from {tensor.shape[-1] - 3}-d flat vector"
|
| 443 |
+
)
|
| 444 |
+
return Affine3D(translation, rotation)
|
| 445 |
+
|
| 446 |
+
@staticmethod
|
| 447 |
+
def from_tensor_pair(
|
| 448 |
+
translation: torch.Tensor,
|
| 449 |
+
rotation: torch.Tensor,
|
| 450 |
+
) -> Affine3D:
|
| 451 |
+
return Affine3D(translation, RotationMatrix(rotation))
|
| 452 |
+
|
| 453 |
+
@staticmethod
|
| 454 |
+
def from_graham_schmidt(
|
| 455 |
+
neg_x_axis: torch.Tensor,
|
| 456 |
+
origin: torch.Tensor,
|
| 457 |
+
xy_plane: torch.Tensor,
|
| 458 |
+
eps: float = 1e-10,
|
| 459 |
+
) -> Affine3D:
|
| 460 |
+
x_axis = origin - neg_x_axis
|
| 461 |
+
plane_direction = xy_plane - origin
|
| 462 |
+
rotation = RotationMatrix.from_graham_schmidt(
|
| 463 |
+
x_axis,
|
| 464 |
+
plane_direction,
|
| 465 |
+
eps,
|
| 466 |
+
)
|
| 467 |
+
return Affine3D(trans=origin, rot=rotation)
|
| 468 |
+
|
| 469 |
+
@staticmethod
|
| 470 |
+
def cat(affines: list[Affine3D], dim: int = 0) -> Affine3D:
|
| 471 |
+
if not affines:
|
| 472 |
+
raise ValueError("affines must contain at least one transform.")
|
| 473 |
+
if any(not isinstance(affine, Affine3D) for affine in affines):
|
| 474 |
+
raise TypeError("affines must contain only Affine3D instances.")
|
| 475 |
+
if dim < 0:
|
| 476 |
+
dim = len(affines[0].shape) + dim
|
| 477 |
+
return Affine3D.from_tensor(torch.cat([affine.tensor for affine in affines], dim=dim))
|
| 478 |
+
|
| 479 |
+
def __getitem__(self, idx: Any) -> Affine3D:
|
| 480 |
+
indices = _index_tuple(idx)
|
| 481 |
+
translation = self.trans[(*indices, slice(None))]
|
| 482 |
+
return Affine3D(trans=translation, rot=self.rot[idx])
|
| 483 |
+
|
| 484 |
+
def to(self, **kwargs) -> Affine3D:
|
| 485 |
+
return Affine3D(self.trans.to(**kwargs), self.rot.to(**kwargs))
|
| 486 |
+
|
| 487 |
+
def detach(self, *args, **kwargs) -> Affine3D:
|
| 488 |
+
return Affine3D(
|
| 489 |
+
self.trans.detach(**kwargs),
|
| 490 |
+
self.rot.detach(**kwargs),
|
| 491 |
+
)
|
| 492 |
+
|
| 493 |
+
def tensor_apply(self, func) -> Affine3D:
|
| 494 |
+
components = [func(value) for value in self.tensor.unbind(dim=-1)]
|
| 495 |
+
return Affine3D.from_tensor(torch.stack(components, dim=-1))
|
| 496 |
+
|
| 497 |
+
def as_matrix(self) -> Affine3D:
|
| 498 |
+
return Affine3D(trans=self.trans, rot=self.rot.as_matrix())
|
| 499 |
+
|
| 500 |
+
def as_quat(self, normalize: bool = False) -> Affine3D:
|
| 501 |
+
return Affine3D(
|
| 502 |
+
trans=self.trans,
|
| 503 |
+
rot=self.rot.as_quat(normalize),
|
| 504 |
+
)
|
| 505 |
+
|
| 506 |
+
def compose(
|
| 507 |
+
self,
|
| 508 |
+
other: Affine3D,
|
| 509 |
+
autoconvert: bool = False,
|
| 510 |
+
) -> Affine3D:
|
| 511 |
+
compose_rotation = self.rot.convert_compose if autoconvert else self.rot.compose
|
| 512 |
+
rotation = compose_rotation(other.rot)
|
| 513 |
+
translation = self.rot.apply(other.trans) + self.trans
|
| 514 |
+
return Affine3D(trans=translation, rot=rotation)
|
| 515 |
+
|
| 516 |
+
def compose_rotation(
|
| 517 |
+
self,
|
| 518 |
+
other: Rotation,
|
| 519 |
+
autoconvert: bool = False,
|
| 520 |
+
) -> Affine3D:
|
| 521 |
+
compose = self.rot.convert_compose if autoconvert else self.rot.compose
|
| 522 |
+
return Affine3D(trans=self.trans, rot=compose(other))
|
| 523 |
+
|
| 524 |
+
def scale(self, value: torch.Tensor | float) -> Affine3D:
|
| 525 |
+
return Affine3D(self.trans * value, self.rot)
|
| 526 |
+
|
| 527 |
+
def mask(self, mask: torch.Tensor, with_zero: bool = False) -> Affine3D:
|
| 528 |
+
if with_zero:
|
| 529 |
+
masked = torch.zeros_like(self.tensor).where(
|
| 530 |
+
mask[..., None],
|
| 531 |
+
self.tensor,
|
| 532 |
+
)
|
| 533 |
+
return Affine3D.from_tensor(masked)
|
| 534 |
+
identity = self.identity(
|
| 535 |
+
self.shape,
|
| 536 |
+
rotation_type=type(self.rot),
|
| 537 |
+
device=self.device,
|
| 538 |
+
dtype=self.dtype,
|
| 539 |
+
).tensor
|
| 540 |
+
return Affine3D.from_tensor(identity.where(mask[..., None], self.tensor))
|
| 541 |
+
|
| 542 |
+
def apply(self, points: torch.Tensor) -> torch.Tensor:
|
| 543 |
+
return self.rot.apply(points) + self.trans
|
| 544 |
+
|
| 545 |
+
def invert(self) -> Affine3D:
|
| 546 |
+
rotation = self.rot.invert()
|
| 547 |
+
return Affine3D(trans=-rotation.apply(self.trans), rot=rotation)
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
def build_affine3d_from_coordinates(
|
| 551 |
+
coords: torch.Tensor,
|
| 552 |
+
) -> tuple[Affine3D, torch.Tensor]:
|
| 553 |
+
"""Build residue frames from X with shape (b, l, 3, 3)."""
|
| 554 |
+
|
| 555 |
+
if not isinstance(coords, torch.Tensor):
|
| 556 |
+
raise TypeError("coords must be a Torch tensor.")
|
| 557 |
+
if coords.ndim != 4 or coords.shape[-2:] != (3, 3):
|
| 558 |
+
raise ValueError(
|
| 559 |
+
"coords must have shape (batch, length, 3, 3), got "
|
| 560 |
+
f"{tuple(coords.shape)}."
|
| 561 |
+
)
|
| 562 |
+
|
| 563 |
+
maximum_distance = 1e6
|
| 564 |
+
coord_mask = torch.all(
|
| 565 |
+
torch.all(
|
| 566 |
+
torch.isfinite(coords) & (coords < maximum_distance),
|
| 567 |
+
dim=-1,
|
| 568 |
+
),
|
| 569 |
+
dim=-1,
|
| 570 |
+
)
|
| 571 |
+
|
| 572 |
+
def backbone_affine(positions: torch.Tensor) -> Affine3D:
|
| 573 |
+
n, ca, c = positions.unbind(dim=-2)
|
| 574 |
+
return Affine3D.from_graham_schmidt(c, ca, n)
|
| 575 |
+
|
| 576 |
+
coords = coords.clone().float()
|
| 577 |
+
coords[~coord_mask] = 0
|
| 578 |
+
average = coords.masked_fill(~coord_mask[..., None, None], 0).sum(1) / (
|
| 579 |
+
coord_mask.sum(-1)[..., None, None] + 1e-8
|
| 580 |
+
)
|
| 581 |
+
average_affine = backbone_affine(average.float()).as_matrix()
|
| 582 |
+
|
| 583 |
+
b, length, _, _ = coords.shape
|
| 584 |
+
rotation = average_affine.rot.tensor[..., None, :].expand(b, length, 9)
|
| 585 |
+
translation = average_affine.trans[..., None, :].expand(b, length, 3)
|
| 586 |
+
identity = RotationMatrix.identity(
|
| 587 |
+
(b, length),
|
| 588 |
+
dtype=torch.float32,
|
| 589 |
+
device=coords.device,
|
| 590 |
+
requires_grad=False,
|
| 591 |
+
)
|
| 592 |
+
rotation = rotation.where(
|
| 593 |
+
coord_mask.any(-1)[..., None, None],
|
| 594 |
+
identity.tensor,
|
| 595 |
+
)
|
| 596 |
+
missing_frame = Affine3D(translation, RotationMatrix(rotation))
|
| 597 |
+
|
| 598 |
+
residue_frame = backbone_affine(coords.float())
|
| 599 |
+
residue_frame = Affine3D.from_tensor(
|
| 600 |
+
residue_frame.tensor.where(
|
| 601 |
+
coord_mask[..., None],
|
| 602 |
+
missing_frame.tensor,
|
| 603 |
+
)
|
| 604 |
+
)
|
| 605 |
+
return residue_frame, coord_mask
|
fastplms/models/esmfold2/esmfold2_aligner.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rigid alignment for structure dataclasses."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import Field, replace
|
| 6 |
+
from typing import Any, ClassVar, Protocol, TypeVar
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import torch
|
| 10 |
+
from torch import Tensor
|
| 11 |
+
|
| 12 |
+
from .esmfold2_protein_structure import compute_affine_and_rmsd
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class Alignable(Protocol):
|
| 16 |
+
"""Minimum structure interface accepted by :class:`Aligner`."""
|
| 17 |
+
|
| 18 |
+
__dataclass_fields__: ClassVar[dict[str, Field[Any]]]
|
| 19 |
+
|
| 20 |
+
@property
|
| 21 |
+
def atom37_positions(self) -> np.ndarray: ...
|
| 22 |
+
|
| 23 |
+
@property
|
| 24 |
+
def atom37_mask(self) -> np.ndarray: ...
|
| 25 |
+
|
| 26 |
+
def __len__(self) -> int: ...
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
AlignableT = TypeVar("AlignableT", bound=Alignable)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _coordinate_batch(structure: Alignable) -> Tensor:
|
| 33 |
+
return torch.as_tensor(structure.atom37_positions, dtype=torch.double).unsqueeze(0)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _shared_atom_mask(mobile: Alignable, target: Alignable, backbone_only: bool) -> Tensor:
|
| 37 |
+
shared = np.asarray(mobile.atom37_mask, dtype=bool) & np.asarray(
|
| 38 |
+
target.atom37_mask,
|
| 39 |
+
dtype=bool,
|
| 40 |
+
)
|
| 41 |
+
if backbone_only:
|
| 42 |
+
shared = shared.copy()
|
| 43 |
+
shared[:, 3:] = False
|
| 44 |
+
return torch.from_numpy(shared).unsqueeze(0)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class Aligner:
|
| 48 |
+
"""Fit a mobile structure onto a target with masked Kabsch alignment."""
|
| 49 |
+
|
| 50 |
+
def __init__(
|
| 51 |
+
self,
|
| 52 |
+
mobile: Alignable,
|
| 53 |
+
target: Alignable,
|
| 54 |
+
only_use_backbone: bool = False,
|
| 55 |
+
use_reflection: bool = False,
|
| 56 |
+
) -> None:
|
| 57 |
+
if len(mobile) != len(target):
|
| 58 |
+
raise AssertionError("mobile and target must contain the same residue count")
|
| 59 |
+
|
| 60 |
+
mobile_coordinates = _coordinate_batch(mobile)
|
| 61 |
+
target_coordinates = _coordinate_batch(target)
|
| 62 |
+
if use_reflection:
|
| 63 |
+
target_coordinates = -target_coordinates
|
| 64 |
+
atom_mask = _shared_atom_mask(mobile, target, only_use_backbone)
|
| 65 |
+
self._affine3D, rmsd = compute_affine_and_rmsd(
|
| 66 |
+
mobile_coordinates,
|
| 67 |
+
target_coordinates,
|
| 68 |
+
atom_exists_mask=atom_mask,
|
| 69 |
+
)
|
| 70 |
+
self._rmsd = rmsd.item()
|
| 71 |
+
|
| 72 |
+
@property
|
| 73 |
+
def rmsd(self) -> float:
|
| 74 |
+
return self._rmsd
|
| 75 |
+
|
| 76 |
+
def apply(self, mobile: AlignableT) -> AlignableT:
|
| 77 |
+
"""Return a dataclass copy with all present atom coordinates aligned."""
|
| 78 |
+
|
| 79 |
+
present = np.asarray(mobile.atom37_mask, dtype=bool)
|
| 80 |
+
packed = torch.as_tensor(
|
| 81 |
+
mobile.atom37_positions[present],
|
| 82 |
+
dtype=torch.float32,
|
| 83 |
+
).unsqueeze(0)
|
| 84 |
+
aligned = self._affine3D.apply(packed).squeeze(0).cpu().numpy()
|
| 85 |
+
atom37_positions = np.full_like(mobile.atom37_positions, np.nan)
|
| 86 |
+
atom37_positions[present] = aligned
|
| 87 |
+
return replace(mobile, atom37_positions=atom37_positions)
|
fastplms/models/esmfold2/esmfold2_atom_indexer.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Name-based views into an atom-axis property."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from operator import attrgetter
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
from .esmfold2_protein_structure import index_by_atom_name
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class AtomIndexer:
|
| 14 |
+
"""Select named atoms from one property of a structure-like object.
|
| 15 |
+
|
| 16 |
+
The wrapper intentionally remains small because ``ProteinChain.atom37`` and
|
| 17 |
+
related public properties expose it directly.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
__slots__ = ("_get_property", "dim", "property", "structure")
|
| 21 |
+
|
| 22 |
+
def __init__(self, structure: Any, property: str, dim: int):
|
| 23 |
+
self.structure = structure
|
| 24 |
+
self.property = property
|
| 25 |
+
self.dim = dim
|
| 26 |
+
self._get_property = attrgetter(property)
|
| 27 |
+
|
| 28 |
+
def __getitem__(self, atom_names: str | list[str]) -> np.ndarray:
|
| 29 |
+
values = self._get_property(self.structure)
|
| 30 |
+
return index_by_atom_name(values, atom_names, dim=self.dim)
|
fastplms/models/esmfold2/esmfold2_conformers.py
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lazy access to Chemical Component Dictionary conformers.
|
| 2 |
+
|
| 3 |
+
The feature pipeline depends on atom names, formal charges, bonds, leaving-atom
|
| 4 |
+
flags, and one preferred reference conformer. Asset resolution is explicit at
|
| 5 |
+
``load_ccd`` time; importing this module performs no download or file access.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
import pickle
|
| 12 |
+
import stat
|
| 13 |
+
import tempfile
|
| 14 |
+
from collections.abc import Iterator
|
| 15 |
+
from contextlib import contextmanager
|
| 16 |
+
from dataclasses import dataclass
|
| 17 |
+
from hashlib import file_digest
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Any, BinaryIO
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
from huggingface_hub import hf_hub_download
|
| 23 |
+
from huggingface_hub.constants import HF_HUB_CACHE
|
| 24 |
+
|
| 25 |
+
from fastplms.registry import RuntimeAsset, get_model_registry
|
| 26 |
+
|
| 27 |
+
from .esmfold2_constants import RES_TYPE_TO_CCD
|
| 28 |
+
|
| 29 |
+
_CCD_ENVIRONMENT_VARIABLE = "ESMCFOLD_CCD_PATH"
|
| 30 |
+
_CCD_ASSET_ID = "esmfold2_ccd"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass(frozen=True)
|
| 34 |
+
class _ResolvedAsset:
|
| 35 |
+
path: Path
|
| 36 |
+
trusted_hub_cache_root: Path | None = None
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _asset_contract() -> RuntimeAsset:
|
| 40 |
+
"""Return the manifest-owned identity of the trusted CCD pickle."""
|
| 41 |
+
|
| 42 |
+
try:
|
| 43 |
+
asset = get_model_registry().runtime_assets[_CCD_ASSET_ID]
|
| 44 |
+
except KeyError as error:
|
| 45 |
+
raise RuntimeError(
|
| 46 |
+
f"The package manifest does not declare runtime asset {_CCD_ASSET_ID!r}."
|
| 47 |
+
) from error
|
| 48 |
+
if asset.trust_kind != "hash_pinned_pickle":
|
| 49 |
+
raise RuntimeError(
|
| 50 |
+
f"Runtime asset {_CCD_ASSET_ID!r} must use the hash_pinned_pickle trust policy."
|
| 51 |
+
)
|
| 52 |
+
return asset
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@contextmanager
|
| 56 |
+
def _open_verified_asset(
|
| 57 |
+
asset_path: Path,
|
| 58 |
+
contract: RuntimeAsset,
|
| 59 |
+
*,
|
| 60 |
+
trusted_hub_cache_root: Path | None = None,
|
| 61 |
+
) -> Iterator[BinaryIO]:
|
| 62 |
+
"""Yield a private snapshot containing exactly the verified pickle bytes."""
|
| 63 |
+
|
| 64 |
+
try:
|
| 65 |
+
path_state = asset_path.lstat()
|
| 66 |
+
except FileNotFoundError as error:
|
| 67 |
+
raise FileNotFoundError(f"CCD asset does not exist: {asset_path}") from error
|
| 68 |
+
opened_path = asset_path
|
| 69 |
+
if stat.S_ISLNK(path_state.st_mode):
|
| 70 |
+
if trusted_hub_cache_root is None:
|
| 71 |
+
raise ValueError(f"CCD asset must not be a symlink: {asset_path}")
|
| 72 |
+
opened_path = _resolve_trusted_hub_snapshot_link(
|
| 73 |
+
asset_path,
|
| 74 |
+
contract,
|
| 75 |
+
trusted_hub_cache_root,
|
| 76 |
+
)
|
| 77 |
+
path_state = opened_path.lstat()
|
| 78 |
+
if not stat.S_ISREG(path_state.st_mode):
|
| 79 |
+
raise ValueError(f"CCD asset must be a regular file: {asset_path}")
|
| 80 |
+
|
| 81 |
+
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_CLOEXEC", 0)
|
| 82 |
+
flags |= getattr(os, "O_NOFOLLOW", 0)
|
| 83 |
+
descriptor: int | None = None
|
| 84 |
+
try:
|
| 85 |
+
descriptor = os.open(opened_path, flags)
|
| 86 |
+
opened_state = os.fstat(descriptor)
|
| 87 |
+
if not stat.S_ISREG(opened_state.st_mode):
|
| 88 |
+
raise ValueError(f"CCD asset must be a regular file: {asset_path}")
|
| 89 |
+
if (path_state.st_dev, path_state.st_ino) != (
|
| 90 |
+
opened_state.st_dev,
|
| 91 |
+
opened_state.st_ino,
|
| 92 |
+
):
|
| 93 |
+
raise ValueError(f"CCD asset changed while it was being opened: {asset_path}")
|
| 94 |
+
|
| 95 |
+
source = os.fdopen(descriptor, "rb")
|
| 96 |
+
descriptor = None
|
| 97 |
+
with source, tempfile.TemporaryFile(mode="w+b") as snapshot:
|
| 98 |
+
actual_size = opened_state.st_size
|
| 99 |
+
if actual_size != contract.size:
|
| 100 |
+
raise ValueError(
|
| 101 |
+
"CCD asset size mismatch: "
|
| 102 |
+
f"expected {contract.size} bytes, received {actual_size}."
|
| 103 |
+
)
|
| 104 |
+
# Copy into a loader-owned OS temporary file. Hashing and
|
| 105 |
+
# deserialization then consume the same immutable snapshot, so a
|
| 106 |
+
# path replacement or in-place source write cannot substitute
|
| 107 |
+
# unverified pickle bytes after validation.
|
| 108 |
+
remaining = contract.size
|
| 109 |
+
while remaining:
|
| 110 |
+
chunk = source.read(min(1024 * 1024, remaining))
|
| 111 |
+
if not chunk:
|
| 112 |
+
break
|
| 113 |
+
snapshot.write(chunk)
|
| 114 |
+
remaining -= len(chunk)
|
| 115 |
+
copied_size = snapshot.tell()
|
| 116 |
+
extra_byte = source.read(1)
|
| 117 |
+
if remaining or extra_byte:
|
| 118 |
+
observed_size = copied_size if remaining else copied_size + len(extra_byte)
|
| 119 |
+
raise ValueError(
|
| 120 |
+
"CCD asset size changed while it was being copied: "
|
| 121 |
+
f"expected {contract.size} bytes, received at least {observed_size}."
|
| 122 |
+
)
|
| 123 |
+
snapshot.flush()
|
| 124 |
+
snapshot.seek(0)
|
| 125 |
+
actual_hash = file_digest(snapshot, "sha256").hexdigest()
|
| 126 |
+
if actual_hash != contract.sha256:
|
| 127 |
+
raise ValueError(
|
| 128 |
+
"CCD asset SHA256 mismatch; refusing to cross the "
|
| 129 |
+
"trusted-pickle boundary."
|
| 130 |
+
)
|
| 131 |
+
snapshot.seek(0)
|
| 132 |
+
yield snapshot
|
| 133 |
+
finally:
|
| 134 |
+
if descriptor is not None:
|
| 135 |
+
os.close(descriptor)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _resolve_trusted_hub_snapshot_link(
|
| 139 |
+
asset_path: Path,
|
| 140 |
+
contract: RuntimeAsset,
|
| 141 |
+
cache_root: Path,
|
| 142 |
+
) -> Path:
|
| 143 |
+
"""Resolve only the immutable Hub snapshot link declared by the manifest."""
|
| 144 |
+
|
| 145 |
+
root = cache_root.expanduser().resolve(strict=True)
|
| 146 |
+
if len(contract.revision) != 40 or any(
|
| 147 |
+
character not in "0123456789abcdef" for character in contract.revision.lower()
|
| 148 |
+
):
|
| 149 |
+
raise ValueError("CCD Hub asset revision must be an immutable 40-character commit.")
|
| 150 |
+
relative_asset = Path(contract.path)
|
| 151 |
+
if relative_asset.is_absolute() or ".." in relative_asset.parts:
|
| 152 |
+
raise ValueError(f"CCD Hub asset path is unsafe: {contract.path!r}")
|
| 153 |
+
repository_cache = root / f"models--{contract.repository.replace('/', '--')}"
|
| 154 |
+
try:
|
| 155 |
+
repository_cache.resolve(strict=True).relative_to(root)
|
| 156 |
+
except (FileNotFoundError, ValueError) as error:
|
| 157 |
+
raise ValueError(
|
| 158 |
+
f"CCD Hub repository cache escapes the effective Hub cache root: {repository_cache}"
|
| 159 |
+
) from error
|
| 160 |
+
snapshot_root = repository_cache / "snapshots" / contract.revision
|
| 161 |
+
expected_path = snapshot_root / relative_asset
|
| 162 |
+
lexical_path = Path(os.path.abspath(asset_path))
|
| 163 |
+
if lexical_path != Path(os.path.abspath(expected_path)):
|
| 164 |
+
raise ValueError(
|
| 165 |
+
"CCD Hub symlink is not the manifest-owned immutable snapshot path: "
|
| 166 |
+
f"{asset_path}"
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
try:
|
| 170 |
+
asset_path.parent.resolve(strict=True).relative_to(root)
|
| 171 |
+
except (FileNotFoundError, ValueError) as error:
|
| 172 |
+
raise ValueError(
|
| 173 |
+
f"CCD Hub snapshot path escapes the effective Hub cache root: {asset_path}"
|
| 174 |
+
) from error
|
| 175 |
+
|
| 176 |
+
resolved = asset_path.resolve(strict=True)
|
| 177 |
+
blob_root = (repository_cache / "blobs").resolve(strict=True)
|
| 178 |
+
try:
|
| 179 |
+
blob_root.relative_to(root)
|
| 180 |
+
resolved.relative_to(blob_root)
|
| 181 |
+
except ValueError as error:
|
| 182 |
+
raise ValueError(
|
| 183 |
+
f"CCD Hub snapshot link escapes its repository blob cache: {asset_path}"
|
| 184 |
+
) from error
|
| 185 |
+
if not resolved.is_file() or resolved.is_symlink():
|
| 186 |
+
raise ValueError(f"CCD Hub snapshot target must be a regular file: {resolved}")
|
| 187 |
+
return resolved
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
class _ChemicalComponentStore:
|
| 191 |
+
def __init__(self) -> None:
|
| 192 |
+
self.molecules: dict[str, Any] | None = None
|
| 193 |
+
self.conformers: dict[str, dict[str, np.ndarray]] = {}
|
| 194 |
+
self.atoms: dict[str, list[tuple[str, str, int]]] = {}
|
| 195 |
+
self.bonds: dict[str, list[tuple[str, str]]] = {}
|
| 196 |
+
self.leaving_atoms: dict[str, set[str]] = {}
|
| 197 |
+
self.standard_positions: dict[tuple[int, str], np.ndarray | None] = {}
|
| 198 |
+
self.ligand_positions: dict[tuple[str, str], np.ndarray | None] = {}
|
| 199 |
+
|
| 200 |
+
def load(self, cache_dir: Path | str | None = None) -> dict[str, Any]:
|
| 201 |
+
if self.molecules is not None:
|
| 202 |
+
return self.molecules
|
| 203 |
+
contract = _asset_contract()
|
| 204 |
+
resolved = self._resolve_asset_location(cache_dir, contract)
|
| 205 |
+
asset = resolved.path
|
| 206 |
+
try:
|
| 207 |
+
# SECURITY: the private snapshot is both hash-validated and
|
| 208 |
+
# deserialized, closing path-replacement and in-place-write races.
|
| 209 |
+
with _open_verified_asset(
|
| 210 |
+
asset,
|
| 211 |
+
contract,
|
| 212 |
+
trusted_hub_cache_root=resolved.trusted_hub_cache_root,
|
| 213 |
+
) as handle:
|
| 214 |
+
loaded = pickle.load(handle)
|
| 215 |
+
except FileNotFoundError:
|
| 216 |
+
raise
|
| 217 |
+
except Exception as error:
|
| 218 |
+
raise ValueError(f"Could not read the CCD asset at {asset}: {error}") from error
|
| 219 |
+
if loaded is not None and not isinstance(loaded, dict):
|
| 220 |
+
raise TypeError("The CCD asset must contain a component dictionary.")
|
| 221 |
+
self.molecules = loaded or {}
|
| 222 |
+
return self.molecules
|
| 223 |
+
|
| 224 |
+
@staticmethod
|
| 225 |
+
def _resolve_asset(cache_dir: Path | str | None) -> Path:
|
| 226 |
+
contract = _asset_contract()
|
| 227 |
+
return _ChemicalComponentStore._resolve_asset_location(cache_dir, contract).path
|
| 228 |
+
|
| 229 |
+
@staticmethod
|
| 230 |
+
def _resolve_asset_location(
|
| 231 |
+
cache_dir: Path | str | None,
|
| 232 |
+
contract: RuntimeAsset,
|
| 233 |
+
) -> _ResolvedAsset:
|
| 234 |
+
configured = os.environ.get(_CCD_ENVIRONMENT_VARIABLE)
|
| 235 |
+
if configured:
|
| 236 |
+
asset = Path(configured).expanduser()
|
| 237 |
+
elif cache_dir is not None:
|
| 238 |
+
asset = Path(cache_dir).expanduser() / contract.path
|
| 239 |
+
else:
|
| 240 |
+
try:
|
| 241 |
+
asset = Path(
|
| 242 |
+
hf_hub_download(
|
| 243 |
+
repo_id=contract.repository,
|
| 244 |
+
filename=contract.path,
|
| 245 |
+
revision=contract.revision,
|
| 246 |
+
)
|
| 247 |
+
)
|
| 248 |
+
except Exception as error:
|
| 249 |
+
raise FileNotFoundError(
|
| 250 |
+
"Could not resolve the ESMFold2 CCD asset. Set "
|
| 251 |
+
f"{_CCD_ENVIRONMENT_VARIABLE} or populate the Hugging Face cache."
|
| 252 |
+
) from error
|
| 253 |
+
return _ResolvedAsset(
|
| 254 |
+
path=asset,
|
| 255 |
+
trusted_hub_cache_root=Path(HF_HUB_CACHE),
|
| 256 |
+
)
|
| 257 |
+
return _ResolvedAsset(path=asset)
|
| 258 |
+
|
| 259 |
+
def _component_with_conformer(self, component_id: str):
|
| 260 |
+
molecule = self.load().get(component_id)
|
| 261 |
+
if molecule is None or molecule.GetNumConformers() == 0:
|
| 262 |
+
return None, None
|
| 263 |
+
|
| 264 |
+
conformers = list(molecule.GetConformers())
|
| 265 |
+
priority = {"Computed": 0, "Ideal": 1}
|
| 266 |
+
selected_index = min(
|
| 267 |
+
range(len(conformers)),
|
| 268 |
+
key=lambda index: priority.get(conformers[index].GetPropsAsDict().get("name"), 2),
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
from rdkit import Chem
|
| 272 |
+
|
| 273 |
+
heavy_molecule = Chem.RemoveHs(molecule, sanitize=False)
|
| 274 |
+
if heavy_molecule.GetNumConformers() == 0:
|
| 275 |
+
return None, None
|
| 276 |
+
conformer_index = min(selected_index, heavy_molecule.GetNumConformers() - 1)
|
| 277 |
+
return heavy_molecule, heavy_molecule.GetConformer(conformer_index)
|
| 278 |
+
|
| 279 |
+
def conformer(self, component_id: str) -> dict[str, np.ndarray] | None:
|
| 280 |
+
if component_id not in self.conformers:
|
| 281 |
+
molecule, conformer = self._component_with_conformer(component_id)
|
| 282 |
+
positions: dict[str, np.ndarray] = {}
|
| 283 |
+
if molecule is not None and conformer is not None:
|
| 284 |
+
for atom in molecule.GetAtoms():
|
| 285 |
+
atom_name = atom.GetPropsAsDict().get("name")
|
| 286 |
+
if not isinstance(atom_name, str) or not atom_name:
|
| 287 |
+
continue
|
| 288 |
+
point = conformer.GetAtomPosition(atom.GetIdx())
|
| 289 |
+
positions[atom_name] = np.asarray((point.x, point.y, point.z), dtype=np.float32)
|
| 290 |
+
self.conformers[component_id] = positions
|
| 291 |
+
result = self.conformers[component_id]
|
| 292 |
+
return result or None
|
| 293 |
+
|
| 294 |
+
def atom_records(self, component_id: str) -> list[tuple[str, str, int]] | None:
|
| 295 |
+
if component_id not in self.atoms:
|
| 296 |
+
molecule, _conformer = self._component_with_conformer(component_id)
|
| 297 |
+
records: list[tuple[str, str, int]] = []
|
| 298 |
+
if molecule is not None:
|
| 299 |
+
for atom in molecule.GetAtoms():
|
| 300 |
+
atom_name = atom.GetPropsAsDict().get("name")
|
| 301 |
+
if isinstance(atom_name, str) and atom_name:
|
| 302 |
+
records.append((atom_name, atom.GetSymbol(), atom.GetFormalCharge()))
|
| 303 |
+
self.atoms[component_id] = records
|
| 304 |
+
result = self.atoms[component_id]
|
| 305 |
+
return result or None
|
| 306 |
+
|
| 307 |
+
def bond_records(self, component_id: str) -> list[tuple[str, str]] | None:
|
| 308 |
+
if component_id not in self.bonds:
|
| 309 |
+
molecule, _conformer = self._component_with_conformer(component_id)
|
| 310 |
+
records: list[tuple[str, str]] = []
|
| 311 |
+
if molecule is not None:
|
| 312 |
+
names = {
|
| 313 |
+
atom.GetIdx(): atom.GetPropsAsDict().get("name") for atom in molecule.GetAtoms()
|
| 314 |
+
}
|
| 315 |
+
for bond in molecule.GetBonds():
|
| 316 |
+
first = names.get(bond.GetBeginAtomIdx())
|
| 317 |
+
second = names.get(bond.GetEndAtomIdx())
|
| 318 |
+
if isinstance(first, str) and first and isinstance(second, str) and second:
|
| 319 |
+
records.append((first, second))
|
| 320 |
+
self.bonds[component_id] = records
|
| 321 |
+
result = self.bonds[component_id]
|
| 322 |
+
return result or None
|
| 323 |
+
|
| 324 |
+
def component_leaving_atoms(self, component_id: str) -> set[str]:
|
| 325 |
+
if component_id not in self.leaving_atoms:
|
| 326 |
+
molecule = self.load().get(component_id)
|
| 327 |
+
names: set[str] = set()
|
| 328 |
+
if molecule is not None:
|
| 329 |
+
for atom in molecule.GetAtoms():
|
| 330 |
+
if atom.HasProp("leaving_atom") and atom.GetProp("leaving_atom") == "1":
|
| 331 |
+
name = atom.GetProp("name") if atom.HasProp("name") else ""
|
| 332 |
+
if name:
|
| 333 |
+
names.add(name)
|
| 334 |
+
self.leaving_atoms[component_id] = names
|
| 335 |
+
return self.leaving_atoms[component_id]
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
_STORE = _ChemicalComponentStore()
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def load_ccd(cache_dir: Path | str | None = None) -> dict[str, Any]:
|
| 342 |
+
"""Load and cache the CCD asset, resolving it only when called."""
|
| 343 |
+
|
| 344 |
+
return _STORE.load(cache_dir)
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
def get_ccd_conformer(component_id: str) -> dict[str, np.ndarray] | None:
|
| 348 |
+
"""Return the preferred heavy-atom conformer by atom name."""
|
| 349 |
+
|
| 350 |
+
return _STORE.conformer(component_id)
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def get_idealized_atom_pos(res_type: int, atom_name: str) -> np.ndarray | None:
|
| 354 |
+
"""Return one standard-residue atom position from the preferred conformer."""
|
| 355 |
+
|
| 356 |
+
key = (res_type, atom_name)
|
| 357 |
+
if key not in _STORE.standard_positions:
|
| 358 |
+
component_id = RES_TYPE_TO_CCD.get(res_type)
|
| 359 |
+
conformer = _STORE.conformer(component_id) if component_id is not None else None
|
| 360 |
+
_STORE.standard_positions[key] = None if conformer is None else conformer.get(atom_name)
|
| 361 |
+
return _STORE.standard_positions[key]
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def get_ligand_idealized_atom_pos(residue_name: str, atom_name: str) -> np.ndarray | None:
|
| 365 |
+
"""Return one ligand atom position from the preferred conformer."""
|
| 366 |
+
|
| 367 |
+
key = (residue_name, atom_name)
|
| 368 |
+
if key not in _STORE.ligand_positions:
|
| 369 |
+
conformer = _STORE.conformer(residue_name)
|
| 370 |
+
_STORE.ligand_positions[key] = None if conformer is None else conformer.get(atom_name)
|
| 371 |
+
return _STORE.ligand_positions[key]
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def get_ligand_ccd_atoms_with_charges(
|
| 375 |
+
component_id: str,
|
| 376 |
+
) -> list[tuple[str, str, int]] | None:
|
| 377 |
+
"""Return heavy-atom name, element, and formal-charge records."""
|
| 378 |
+
|
| 379 |
+
return _STORE.atom_records(component_id)
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
def get_ligand_ccd_bonds(component_id: str) -> list[tuple[str, str]] | None:
|
| 383 |
+
"""Return bonds as component atom-name pairs."""
|
| 384 |
+
|
| 385 |
+
return _STORE.bond_records(component_id)
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
def get_ccd_leaving_atoms(component_id: str) -> set[str]:
|
| 389 |
+
"""Return atoms removed when a CCD component is polymerized."""
|
| 390 |
+
|
| 391 |
+
return _STORE.component_leaving_atoms(component_id)
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
__all__ = [
|
| 395 |
+
"get_ccd_conformer",
|
| 396 |
+
"get_ccd_leaving_atoms",
|
| 397 |
+
"get_idealized_atom_pos",
|
| 398 |
+
"get_ligand_ccd_atoms_with_charges",
|
| 399 |
+
"get_ligand_ccd_bonds",
|
| 400 |
+
"get_ligand_idealized_atom_pos",
|
| 401 |
+
"load_ccd",
|
| 402 |
+
]
|
fastplms/models/esmfold2/esmfold2_constants.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Declarative molecular schema for ESMFold2 feature preparation.
|
| 2 |
+
|
| 3 |
+
The package manifest owns the upstream revision and license provenance. This
|
| 4 |
+
module expresses the corresponding checkpoint-facing integer schema as compact
|
| 5 |
+
ordered records, then derives lookup tables from those records. The generated
|
| 6 |
+
tables are validated at import without reading files, downloading assets, or
|
| 7 |
+
mutating process state.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
SCHEMA_PROVENANCE = {
|
| 13 |
+
"manifest_family": "esmfold2",
|
| 14 |
+
"contract": "biohub_esmfold2_input_v1",
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _words(value: str) -> list[str]:
|
| 19 |
+
return value.split()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
MOL_TYPE_PROTEIN = 0
|
| 23 |
+
MOL_TYPE_DNA = 1
|
| 24 |
+
MOL_TYPE_RNA = 2
|
| 25 |
+
MOL_TYPE_NONPOLYMER = 3
|
| 26 |
+
|
| 27 |
+
# The record order is part of the checkpoint input contract. Residue indices
|
| 28 |
+
# start at two because zero and one are reserved by the model feature schema.
|
| 29 |
+
_PROTEIN_SCHEMA = tuple(
|
| 30 |
+
tuple(record.split(":"))
|
| 31 |
+
for record in (
|
| 32 |
+
"ALA:A:N CA C O CB",
|
| 33 |
+
"ARG:R:N CA C O CB CG CD NE CZ NH1 NH2",
|
| 34 |
+
"ASN:N:N CA C O CB CG OD1 ND2",
|
| 35 |
+
"ASP:D:N CA C O CB CG OD1 OD2",
|
| 36 |
+
"CYS:C:N CA C O CB SG",
|
| 37 |
+
"GLN:Q:N CA C O CB CG CD OE1 NE2",
|
| 38 |
+
"GLU:E:N CA C O CB CG CD OE1 OE2",
|
| 39 |
+
"GLY:G:N CA C O",
|
| 40 |
+
"HIS:H:N CA C O CB CG ND1 CD2 CE1 NE2",
|
| 41 |
+
"ILE:I:N CA C O CB CG1 CG2 CD1",
|
| 42 |
+
"LEU:L:N CA C O CB CG CD1 CD2",
|
| 43 |
+
"LYS:K:N CA C O CB CG CD CE NZ",
|
| 44 |
+
"MET:M:N CA C O CB CG SD CE",
|
| 45 |
+
"PHE:F:N CA C O CB CG CD1 CD2 CE1 CE2 CZ",
|
| 46 |
+
"PRO:P:N CA C O CB CG CD",
|
| 47 |
+
"SER:S:N CA C O CB OG",
|
| 48 |
+
"THR:T:N CA C O CB OG1 CG2",
|
| 49 |
+
"TRP:W:N CA C O CB CG CD1 CD2 NE1 CE2 CE3 CZ2 CZ3 CH2",
|
| 50 |
+
"TYR:Y:N CA C O CB CG CD1 CD2 CE1 CE2 CZ OH",
|
| 51 |
+
"VAL:V:N CA C O CB CG1 CG2",
|
| 52 |
+
)
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
PROTEIN_RESIDUE_TO_RES_TYPE = {
|
| 56 |
+
residue: index for index, (residue, _letter, _atoms) in enumerate(_PROTEIN_SCHEMA, 2)
|
| 57 |
+
}
|
| 58 |
+
PROTEIN_RESIDUE_TO_RES_TYPE["MSE"] = PROTEIN_RESIDUE_TO_RES_TYPE["MET"]
|
| 59 |
+
PROTEIN_UNK_RES_TYPE = 22
|
| 60 |
+
|
| 61 |
+
RNA_RESIDUE_TO_RES_TYPE = dict(zip("AGCU", range(23, 27), strict=True))
|
| 62 |
+
RNA_UNK_RES_TYPE = 27
|
| 63 |
+
DNA_RESIDUE_TO_RES_TYPE = dict(zip(("DA", "DG", "DC", "DT"), range(28, 32), strict=True))
|
| 64 |
+
DNA_UNK_RES_TYPE = 32
|
| 65 |
+
GAP_RES_TYPE = DNA_UNK_RES_TYPE
|
| 66 |
+
|
| 67 |
+
PROTEIN_3TO1 = {residue: letter for residue, letter, _atoms in _PROTEIN_SCHEMA}
|
| 68 |
+
PROTEIN_3TO1["MSE"] = "M"
|
| 69 |
+
PROTEIN_1TO3 = {letter: residue for residue, letter, _atoms in _PROTEIN_SCHEMA}
|
| 70 |
+
PROTEIN_1TO3["X"] = "UNK"
|
| 71 |
+
DNA_1TO3 = dict(zip("ATCG", ("DA", "DT", "DC", "DG"), strict=True))
|
| 72 |
+
RNA_1TO3 = {letter: letter for letter in "AUCG"}
|
| 73 |
+
|
| 74 |
+
_ESM_RESIDUE_ORDER = "LAGVSERTIDPKQNFYM HWC".replace(" ", "")
|
| 75 |
+
ESM_PROTEIN_VOCAB = {residue: token_id for token_id, residue in enumerate(_ESM_RESIDUE_ORDER, 4)}
|
| 76 |
+
ESM_PROTEIN_VOCAB["X"] = 3
|
| 77 |
+
DNA_RNA_LIGAND_INPUT_ID = 24
|
| 78 |
+
MSA_PAD_TOKEN_ID = 0
|
| 79 |
+
MSA_GAP_TOKEN_ID = 1
|
| 80 |
+
|
| 81 |
+
RES_TYPE_TO_CCD = {
|
| 82 |
+
**{
|
| 83 |
+
index: residue for residue, index in PROTEIN_RESIDUE_TO_RES_TYPE.items() if residue != "MSE"
|
| 84 |
+
},
|
| 85 |
+
22: "UNK",
|
| 86 |
+
**dict(zip(range(23, 28), ("A", "G", "C", "U", "N"), strict=True)),
|
| 87 |
+
**dict(zip(range(28, 33), ("DA", "DG", "DC", "DT", "DN"), strict=True)),
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
_CHARGE_SCHEMA = _words(
|
| 91 |
+
"LYS:NZ:1 ARG:NH2:1 HIS:ND1:1 PO4:O2:-1 PO4:O3:-1 PO4:O4:-1 "
|
| 92 |
+
"SO4:O3:-1 SO4:O4:-1 MG:MG:2 ZN:ZN:2 CA:CA:2 FE2:FE:2 MN:MN:2 "
|
| 93 |
+
"CO:CO:2 NCO:CO:3 CU:CU:2 NI:NI:2 K:K:1 NA:NA:1 CD:CD:2 CL:CL:-1 "
|
| 94 |
+
"ACT:OXT:-1 NAD:O2N:-1 NAD:N1N:1 NAP:O2N:-1 NAP:N1N:1 IMD:N3:1 "
|
| 95 |
+
"SAM:SD:1 FE:FE:3 A1BH3:N3:1"
|
| 96 |
+
)
|
| 97 |
+
CHARGED_ATOMS = {
|
| 98 |
+
(component, atom): int(charge)
|
| 99 |
+
for component, atom, charge in (record.split(":") for record in _CHARGE_SCHEMA)
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
_PERIODIC_SYMBOLS = _words(
|
| 103 |
+
"H HE LI BE B C N O F NE NA MG AL SI P S CL AR K CA SC TI V CR MN FE CO NI CU ZN "
|
| 104 |
+
"GA GE AS SE BR KR RB SR Y ZR NB MO TC RU RH PD AG CD IN SN SB TE I XE CS BA LA CE "
|
| 105 |
+
"PR ND PM SM EU GD TB DY HO ER TM YB LU HF TA W RE OS IR PT AU HG TL PB BI PO AT RN "
|
| 106 |
+
"FR RA AC TH PA U"
|
| 107 |
+
)
|
| 108 |
+
ELEMENT_TO_ATOMIC_NUM = {
|
| 109 |
+
symbol: atomic_number
|
| 110 |
+
for atomic_number, symbol in enumerate(_PERIODIC_SYMBOLS, 1)
|
| 111 |
+
if symbol != "HE"
|
| 112 |
+
}
|
| 113 |
+
ELEMENT_NUMBER_TO_SYMBOL = {
|
| 114 |
+
atomic_number: symbol for symbol, atomic_number in ELEMENT_TO_ATOMIC_NUM.items()
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
PROTEIN_HEAVY_ATOMS = {
|
| 118 |
+
residue: atom_string.split() for residue, _letter, atom_string in _PROTEIN_SCHEMA
|
| 119 |
+
}
|
| 120 |
+
PROTEIN_HEAVY_ATOMS["MSE"] = PROTEIN_HEAVY_ATOMS["MET"].copy()
|
| 121 |
+
PROTEIN_HEAVY_ATOMS["UNK"] = _words("N CA C O")
|
| 122 |
+
|
| 123 |
+
DNA_BACKBONE_ATOMS = _words("P OP1 OP2 O5' C5' C4' O4' C3' O3' C2' C1'")
|
| 124 |
+
RNA_BACKBONE_ATOMS = _words("P OP1 OP2 O5' C5' C4' O4' C3' O3' C2' O2' C1'")
|
| 125 |
+
_NUCLEOBASE_ATOMS = {
|
| 126 |
+
"A": _words("N9 C8 N7 C5 C6 N6 N1 C2 N3 C4"),
|
| 127 |
+
"G": _words("N9 C8 N7 C5 C6 O6 N1 C2 N2 N3 C4"),
|
| 128 |
+
"C": _words("N1 C2 O2 N3 C4 N4 C5 C6"),
|
| 129 |
+
"U": _words("N1 C2 O2 N3 C4 O4 C5 C6"),
|
| 130 |
+
"T": _words("N1 C2 O2 N3 C4 O4 C5 C7 C6"),
|
| 131 |
+
}
|
| 132 |
+
DNA_HEAVY_ATOMS = {
|
| 133 |
+
"DA": DNA_BACKBONE_ATOMS + _NUCLEOBASE_ATOMS["A"],
|
| 134 |
+
"DG": DNA_BACKBONE_ATOMS + _NUCLEOBASE_ATOMS["G"],
|
| 135 |
+
"DC": DNA_BACKBONE_ATOMS + _NUCLEOBASE_ATOMS["C"],
|
| 136 |
+
"DT": DNA_BACKBONE_ATOMS + _NUCLEOBASE_ATOMS["T"],
|
| 137 |
+
}
|
| 138 |
+
RNA_HEAVY_ATOMS = {residue: RNA_BACKBONE_ATOMS + _NUCLEOBASE_ATOMS[residue] for residue in "AGCU"}
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _validate_schema() -> None:
|
| 142 |
+
if sorted(set(PROTEIN_RESIDUE_TO_RES_TYPE.values())) != list(range(2, 22)):
|
| 143 |
+
raise RuntimeError("Protein residue indices must cover the checkpoint interval 2..21.")
|
| 144 |
+
if len(_ESM_RESIDUE_ORDER) != 20 or len(set(_ESM_RESIDUE_ORDER)) != 20:
|
| 145 |
+
raise RuntimeError("The ESM residue vocabulary must contain 20 canonical residues.")
|
| 146 |
+
if RES_TYPE_TO_CCD[14] != "MET" or PROTEIN_RESIDUE_TO_RES_TYPE["MSE"] != 14:
|
| 147 |
+
raise RuntimeError("Selenomethionine must share the methionine residue index.")
|
| 148 |
+
if ELEMENT_TO_ATOMIC_NUM.get("U") != 92 or 2 in ELEMENT_NUMBER_TO_SYMBOL:
|
| 149 |
+
raise RuntimeError("The element schema must preserve the training-time atomic-number map.")
|
| 150 |
+
if set(DNA_HEAVY_ATOMS) != {"DA", "DG", "DC", "DT"}:
|
| 151 |
+
raise RuntimeError("The DNA atom schema is incomplete.")
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
_validate_schema()
|
| 155 |
+
|
| 156 |
+
__all__ = [name for name in globals() if name.isupper()]
|
fastplms/models/esmfold2/esmfold2_constants_esm3.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Token schemas needed by the ESMC encoder inside ESMFold2.
|
| 2 |
+
|
| 3 |
+
The values implement the published Biohub ESM sequence-token contract pinned by
|
| 4 |
+
``models.toml``. They are generated from ordered schemas so token position and
|
| 5 |
+
special-token relationships are explicit and independently testable. This
|
| 6 |
+
module performs no downloads and resolves no model assets at import time.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from types import MappingProxyType
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _words(value: str) -> list[str]:
|
| 15 |
+
return value.split()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
SEQUENCE_VOCAB = _words(
|
| 19 |
+
"<cls> <pad> <eos> <unk> L A G V S E R T I D P K Q N F Y M H W C X B U Z O . - | <mask>"
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
_sequence_token_ids = MappingProxyType({token: index for index, token in enumerate(SEQUENCE_VOCAB)})
|
| 23 |
+
SEQUENCE_BOS_TOKEN = _sequence_token_ids["<cls>"]
|
| 24 |
+
SEQUENCE_PAD_TOKEN = _sequence_token_ids["<pad>"]
|
| 25 |
+
SEQUENCE_EOS_TOKEN = _sequence_token_ids["<eos>"]
|
| 26 |
+
SEQUENCE_CHAINBREAK_TOKEN = _sequence_token_ids["|"]
|
| 27 |
+
SEQUENCE_MASK_TOKEN = _sequence_token_ids["<mask>"]
|
| 28 |
+
SEQUENCE_STANDARD_AA_MIN_TOKEN = _sequence_token_ids["L"]
|
| 29 |
+
SEQUENCE_STANDARD_AA_MAX_TOKEN = _sequence_token_ids["X"]
|
| 30 |
+
|
| 31 |
+
VQVAE_CODEBOOK_SIZE = 4096
|
| 32 |
+
VQVAE_SPECIAL_TOKENS = {
|
| 33 |
+
name: VQVAE_CODEBOOK_SIZE + offset
|
| 34 |
+
for offset, name in enumerate(("MASK", "EOS", "BOS", "PAD", "CHAINBREAK"))
|
| 35 |
+
}
|
| 36 |
+
VQVAE_DIRECTION_LOSS_BINS = 16
|
| 37 |
+
VQVAE_PAE_BINS = 64
|
| 38 |
+
VQVAE_MAX_PAE_BIN = 31.0
|
| 39 |
+
VQVAE_PLDDT_BINS = 50
|
| 40 |
+
|
| 41 |
+
STRUCTURE_MASK_TOKEN = VQVAE_SPECIAL_TOKENS["MASK"]
|
| 42 |
+
STRUCTURE_EOS_TOKEN = VQVAE_SPECIAL_TOKENS["EOS"]
|
| 43 |
+
STRUCTURE_BOS_TOKEN = VQVAE_SPECIAL_TOKENS["BOS"]
|
| 44 |
+
STRUCTURE_PAD_TOKEN = VQVAE_SPECIAL_TOKENS["PAD"]
|
| 45 |
+
STRUCTURE_CHAINBREAK_TOKEN = VQVAE_SPECIAL_TOKENS["CHAINBREAK"]
|
| 46 |
+
STRUCTURE_UNDEFINED_TOKEN = 955
|
| 47 |
+
|
| 48 |
+
SASA_PAD_TOKEN = 0
|
| 49 |
+
SS8_PAD_TOKEN = 0
|
| 50 |
+
INTERPRO_PAD_TOKEN = 0
|
| 51 |
+
RESIDUE_PAD_TOKEN = 0
|
| 52 |
+
|
| 53 |
+
CHAIN_BREAK_STR = "|"
|
| 54 |
+
SEQUENCE_BOS_STR = "<cls>"
|
| 55 |
+
SEQUENCE_EOS_STR = "<eos>"
|
| 56 |
+
MASK_STR_SHORT = "_"
|
| 57 |
+
SEQUENCE_MASK_STR = "<mask>"
|
| 58 |
+
SASA_MASK_STR = "<unk>"
|
| 59 |
+
SS8_MASK_STR = "<unk>"
|
| 60 |
+
|
| 61 |
+
SSE_8CLASS_VOCAB = "GHITEBSC"
|
| 62 |
+
SSE_3CLASS_VOCAB = "HEC"
|
| 63 |
+
SSE_8CLASS_TO_3CLASS_MAP = dict(zip(SSE_8CLASS_VOCAB, "HHHCEECC", strict=True))
|
| 64 |
+
|
| 65 |
+
SASA_DISCRETIZATION_BOUNDARIES = [
|
| 66 |
+
0.8,
|
| 67 |
+
4.0,
|
| 68 |
+
9.6,
|
| 69 |
+
16.4,
|
| 70 |
+
24.5,
|
| 71 |
+
32.9,
|
| 72 |
+
42.0,
|
| 73 |
+
51.5,
|
| 74 |
+
61.2,
|
| 75 |
+
70.9,
|
| 76 |
+
81.6,
|
| 77 |
+
93.3,
|
| 78 |
+
107.2,
|
| 79 |
+
125.4,
|
| 80 |
+
151.4,
|
| 81 |
+
]
|
| 82 |
+
MAX_RESIDUE_ANNOTATIONS = 16
|
| 83 |
+
TFIDF_VECTOR_SIZE = 58_641
|
| 84 |
+
FUNCTION_TOKENS_DEPTH = 8
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _validate_schema() -> None:
|
| 88 |
+
if len(SEQUENCE_VOCAB) != len(set(SEQUENCE_VOCAB)):
|
| 89 |
+
raise RuntimeError("The ESM sequence vocabulary contains duplicate tokens.")
|
| 90 |
+
if SEQUENCE_STANDARD_AA_MAX_TOKEN - SEQUENCE_STANDARD_AA_MIN_TOKEN != 20:
|
| 91 |
+
raise RuntimeError("The canonical residue interval must contain 20 tokens.")
|
| 92 |
+
if tuple(VQVAE_SPECIAL_TOKENS.values()) != tuple(range(4096, 4101)):
|
| 93 |
+
raise RuntimeError("The structure special-token interval is not contiguous.")
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
_validate_schema()
|
| 97 |
+
|
| 98 |
+
__all__ = [name for name in globals() if name.isupper()]
|
fastplms/models/esmfold2/esmfold2_input_builder.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Typed, JSON-safe inputs for ESMFold2 feature preparation."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from collections.abc import Sequence
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from typing import Any, TypeAlias
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
from .esmfold2_msa import MSA
|
| 12 |
+
|
| 13 |
+
MSAInput: TypeAlias = MSA | None
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class Modification:
|
| 18 |
+
"""A zero-indexed residue substitution using a CCD component."""
|
| 19 |
+
|
| 20 |
+
position: int
|
| 21 |
+
ccd: str
|
| 22 |
+
smiles: str | None = None
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@dataclass
|
| 26 |
+
class ProteinInput:
|
| 27 |
+
id: str | list[str]
|
| 28 |
+
sequence: str
|
| 29 |
+
modifications: list[Modification] | None = None
|
| 30 |
+
msa: MSAInput = None
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass
|
| 34 |
+
class RNAInput:
|
| 35 |
+
id: str | list[str]
|
| 36 |
+
sequence: str
|
| 37 |
+
modifications: list[Modification] | None = None
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass
|
| 41 |
+
class DNAInput:
|
| 42 |
+
id: str | list[str]
|
| 43 |
+
sequence: str
|
| 44 |
+
modifications: list[Modification] | None = None
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@dataclass
|
| 48 |
+
class LigandInput:
|
| 49 |
+
id: str | list[str]
|
| 50 |
+
smiles: str | None = None
|
| 51 |
+
ccd: list[str] | None = None
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@dataclass
|
| 55 |
+
class DistogramConditioning:
|
| 56 |
+
chain_id: str
|
| 57 |
+
distogram: np.ndarray
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@dataclass
|
| 61 |
+
class PocketConditioning:
|
| 62 |
+
binder_chain_id: str
|
| 63 |
+
contacts: list[tuple[str, int]]
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@dataclass
|
| 67 |
+
class CovalentBond:
|
| 68 |
+
chain_id1: str
|
| 69 |
+
res_idx1: int
|
| 70 |
+
atom_idx1: int
|
| 71 |
+
chain_id2: str
|
| 72 |
+
res_idx2: int
|
| 73 |
+
atom_idx2: int
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
SequenceInput: TypeAlias = ProteinInput | RNAInput | DNAInput | LigandInput
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@dataclass
|
| 80 |
+
class StructurePredictionInput:
|
| 81 |
+
sequences: Sequence[SequenceInput]
|
| 82 |
+
pocket: PocketConditioning | None = None
|
| 83 |
+
distogram_conditioning: list[DistogramConditioning] | None = None
|
| 84 |
+
covalent_bonds: list[CovalentBond] | None = None
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
_CHAIN_TYPE = {
|
| 88 |
+
ProteinInput: "protein",
|
| 89 |
+
RNAInput: "rna",
|
| 90 |
+
DNAInput: "dna",
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _serialize_modifications(
|
| 95 |
+
modifications: list[Modification] | None,
|
| 96 |
+
) -> list[dict[str, Any]] | None:
|
| 97 |
+
if not modifications:
|
| 98 |
+
return None
|
| 99 |
+
return [{"position": item.position, "ccd": item.ccd} for item in modifications]
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _serialize_chain(chain: SequenceInput) -> dict[str, Any]:
|
| 103 |
+
if isinstance(chain, LigandInput):
|
| 104 |
+
return {
|
| 105 |
+
"smiles": chain.smiles,
|
| 106 |
+
"id": chain.id,
|
| 107 |
+
"ccd": chain.ccd,
|
| 108 |
+
"type": "ligand",
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
chain_type = _CHAIN_TYPE.get(type(chain))
|
| 112 |
+
if chain_type is None:
|
| 113 |
+
raise ValueError(f"Unsupported sequence input type: {type(chain)}")
|
| 114 |
+
serialized: dict[str, Any] = {
|
| 115 |
+
"sequence": chain.sequence,
|
| 116 |
+
"id": chain.id,
|
| 117 |
+
"type": chain_type,
|
| 118 |
+
}
|
| 119 |
+
if modifications := _serialize_modifications(chain.modifications):
|
| 120 |
+
serialized["modifications"] = modifications
|
| 121 |
+
if isinstance(chain, ProteinInput):
|
| 122 |
+
if chain.msa is not None and not isinstance(chain.msa, MSA):
|
| 123 |
+
raise AttributeError(f"MSA must be None or MSA. Got {chain.msa} instead.")
|
| 124 |
+
serialized["msa"] = None if chain.msa is None else {"sequences": chain.msa.sequences}
|
| 125 |
+
return serialized
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def serialize_structure_prediction_input(
|
| 129 |
+
structure_input: StructurePredictionInput,
|
| 130 |
+
) -> dict[str, Any]:
|
| 131 |
+
"""Convert an input object to a JSON-safe mapping."""
|
| 132 |
+
|
| 133 |
+
serialized: dict[str, Any] = {
|
| 134 |
+
"sequences": [_serialize_chain(chain) for chain in structure_input.sequences]
|
| 135 |
+
}
|
| 136 |
+
if structure_input.covalent_bonds is not None:
|
| 137 |
+
serialized["covalent_bonds"] = [
|
| 138 |
+
vars(bond).copy() for bond in structure_input.covalent_bonds
|
| 139 |
+
]
|
| 140 |
+
if structure_input.pocket is not None:
|
| 141 |
+
serialized["pocket"] = {
|
| 142 |
+
"binder_chain_id": structure_input.pocket.binder_chain_id,
|
| 143 |
+
"contacts": structure_input.pocket.contacts,
|
| 144 |
+
}
|
| 145 |
+
if structure_input.distogram_conditioning is not None:
|
| 146 |
+
serialized["distogram_conditioning"] = [
|
| 147 |
+
{"chain_id": item.chain_id, "distogram": item.distogram.tolist()}
|
| 148 |
+
for item in structure_input.distogram_conditioning
|
| 149 |
+
]
|
| 150 |
+
return serialized
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def _deserialize_modifications(chain: dict[str, Any]) -> list[Modification] | None:
|
| 154 |
+
raw = chain.get("modifications")
|
| 155 |
+
if not raw:
|
| 156 |
+
return None
|
| 157 |
+
return [Modification(position=item["position"], ccd=item["ccd"]) for item in raw]
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _deserialize_msa(chain: dict[str, Any]) -> MSAInput:
|
| 161 |
+
raw = chain.get("msa")
|
| 162 |
+
if raw is None:
|
| 163 |
+
return None
|
| 164 |
+
if not isinstance(raw, dict) or not isinstance(raw.get("sequences"), list):
|
| 165 |
+
raise ValueError(f"Unexpected MSA value: {raw!r}")
|
| 166 |
+
return MSA.from_sequences(raw["sequences"])
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def _deserialize_chain(chain: dict[str, Any]) -> SequenceInput:
|
| 170 |
+
chain_type = chain.get("type")
|
| 171 |
+
common = {"id": chain["id"]}
|
| 172 |
+
if chain_type == "protein":
|
| 173 |
+
return ProteinInput(
|
| 174 |
+
**common,
|
| 175 |
+
sequence=chain["sequence"],
|
| 176 |
+
modifications=_deserialize_modifications(chain),
|
| 177 |
+
msa=_deserialize_msa(chain),
|
| 178 |
+
)
|
| 179 |
+
if chain_type == "rna":
|
| 180 |
+
return RNAInput(
|
| 181 |
+
**common,
|
| 182 |
+
sequence=chain["sequence"],
|
| 183 |
+
modifications=_deserialize_modifications(chain),
|
| 184 |
+
)
|
| 185 |
+
if chain_type == "dna":
|
| 186 |
+
return DNAInput(
|
| 187 |
+
**common,
|
| 188 |
+
sequence=chain["sequence"],
|
| 189 |
+
modifications=_deserialize_modifications(chain),
|
| 190 |
+
)
|
| 191 |
+
if chain_type == "ligand":
|
| 192 |
+
return LigandInput(**common, smiles=chain.get("smiles"), ccd=chain.get("ccd"))
|
| 193 |
+
raise ValueError(f"Unsupported sequence type: {chain_type!r}")
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def deserialize_structure_prediction_input(data: dict[str, Any]) -> StructurePredictionInput:
|
| 197 |
+
"""Reconstruct the typed input represented by a serialized mapping."""
|
| 198 |
+
|
| 199 |
+
pocket_data = data.get("pocket")
|
| 200 |
+
pocket = None
|
| 201 |
+
if pocket_data is not None:
|
| 202 |
+
pocket = PocketConditioning(
|
| 203 |
+
binder_chain_id=pocket_data["binder_chain_id"],
|
| 204 |
+
contacts=[tuple(contact) for contact in pocket_data["contacts"]],
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
distogram_data = data.get("distogram_conditioning")
|
| 208 |
+
distograms = None
|
| 209 |
+
if distogram_data is not None:
|
| 210 |
+
distograms = [
|
| 211 |
+
DistogramConditioning(
|
| 212 |
+
chain_id=item["chain_id"], distogram=np.asarray(item["distogram"])
|
| 213 |
+
)
|
| 214 |
+
for item in distogram_data
|
| 215 |
+
]
|
| 216 |
+
|
| 217 |
+
bond_data = data.get("covalent_bonds")
|
| 218 |
+
bonds = None
|
| 219 |
+
if bond_data is not None:
|
| 220 |
+
bonds = [CovalentBond(**item) for item in bond_data]
|
| 221 |
+
|
| 222 |
+
return StructurePredictionInput(
|
| 223 |
+
sequences=[_deserialize_chain(chain) for chain in data["sequences"]],
|
| 224 |
+
pocket=pocket,
|
| 225 |
+
distogram_conditioning=distograms,
|
| 226 |
+
covalent_bonds=bonds,
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
__all__ = [
|
| 231 |
+
"CovalentBond",
|
| 232 |
+
"DNAInput",
|
| 233 |
+
"DistogramConditioning",
|
| 234 |
+
"LigandInput",
|
| 235 |
+
"MSAInput",
|
| 236 |
+
"Modification",
|
| 237 |
+
"PocketConditioning",
|
| 238 |
+
"ProteinInput",
|
| 239 |
+
"RNAInput",
|
| 240 |
+
"SequenceInput",
|
| 241 |
+
"StructurePredictionInput",
|
| 242 |
+
"deserialize_structure_prediction_input",
|
| 243 |
+
"serialize_structure_prediction_input",
|
| 244 |
+
]
|
fastplms/models/esmfold2/esmfold2_metrics.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Contact, lDDT, RMSD, and GDT-TS metrics for structure validation."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import torch
|
| 7 |
+
import torch.nn.functional as F
|
| 8 |
+
from torch import Tensor
|
| 9 |
+
from torch.amp import autocast # type: ignore
|
| 10 |
+
|
| 11 |
+
from . import esmfold2_residue_constants as residue_constants
|
| 12 |
+
from .esmfold2_misc import binpack, unbinpack
|
| 13 |
+
from .esmfold2_protein_structure import (
|
| 14 |
+
compute_alignment_tensors,
|
| 15 |
+
compute_gdt_ts_no_alignment,
|
| 16 |
+
compute_rmsd_no_alignment,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _distance_matrix(positions: Tensor, eps: float) -> Tensor:
|
| 21 |
+
displacement = positions[..., None, :] - positions[..., None, :, :]
|
| 22 |
+
return torch.sqrt(eps + torch.sum(displacement**2, dim=-1))
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def compute_lddt_from_dmat(
|
| 26 |
+
dmat_pred: Tensor,
|
| 27 |
+
dmat_true: Tensor,
|
| 28 |
+
pairwise_mask: Tensor,
|
| 29 |
+
cutoff: float | Tensor = 15.0,
|
| 30 |
+
eps: float = 1e-10,
|
| 31 |
+
per_residue: bool = True,
|
| 32 |
+
) -> Tensor:
|
| 33 |
+
"""Score distance matrices ``D_pred`` and ``D_true`` with shape (..., l, l)."""
|
| 34 |
+
|
| 35 |
+
sequence_length = dmat_true.size(-1)
|
| 36 |
+
identity = torch.eye(sequence_length, device=dmat_true.device)
|
| 37 |
+
scored_pairs = (dmat_true < cutoff) * pairwise_mask * (1.0 - identity)
|
| 38 |
+
absolute_error = torch.abs(dmat_true - dmat_pred)
|
| 39 |
+
score = (
|
| 40 |
+
(absolute_error < 0.5).type(absolute_error.dtype)
|
| 41 |
+
+ (absolute_error < 1.0).type(absolute_error.dtype)
|
| 42 |
+
+ (absolute_error < 2.0).type(absolute_error.dtype)
|
| 43 |
+
+ (absolute_error < 4.0).type(absolute_error.dtype)
|
| 44 |
+
) * 0.25
|
| 45 |
+
dimensions = (-1,) if per_residue else (-2, -1)
|
| 46 |
+
normalization = 1.0 / (eps + scored_pairs.sum(dim=dimensions))
|
| 47 |
+
return normalization * (eps + (scored_pairs * score).sum(dim=dimensions))
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def compute_lddt(
|
| 51 |
+
all_atom_pred_pos: Tensor,
|
| 52 |
+
all_atom_positions: Tensor,
|
| 53 |
+
all_atom_mask: Tensor,
|
| 54 |
+
pairwise_all_atom_mask: Tensor | None = None,
|
| 55 |
+
cutoff: float | Tensor = 15.0,
|
| 56 |
+
eps: float = 1e-10,
|
| 57 |
+
per_residue: bool = True,
|
| 58 |
+
sequence_id: Tensor | None = None,
|
| 59 |
+
) -> Tensor:
|
| 60 |
+
"""Compute lDDT from coordinate tensors and atom masks."""
|
| 61 |
+
|
| 62 |
+
expanded_mask = all_atom_mask[..., None]
|
| 63 |
+
true_distances = _distance_matrix(all_atom_positions, eps)
|
| 64 |
+
predicted_distances = _distance_matrix(all_atom_pred_pos, eps)
|
| 65 |
+
pair_mask = expanded_mask * expanded_mask.transpose(-2, -1)
|
| 66 |
+
if pairwise_all_atom_mask is not None:
|
| 67 |
+
pair_mask = pair_mask * pairwise_all_atom_mask
|
| 68 |
+
if sequence_id is not None:
|
| 69 |
+
same_sequence = sequence_id[..., None] == sequence_id[..., None, :]
|
| 70 |
+
pair_mask = pair_mask * same_sequence.type_as(pair_mask)
|
| 71 |
+
return compute_lddt_from_dmat(
|
| 72 |
+
predicted_distances,
|
| 73 |
+
true_distances,
|
| 74 |
+
pair_mask,
|
| 75 |
+
cutoff=cutoff,
|
| 76 |
+
eps=eps,
|
| 77 |
+
per_residue=per_residue,
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def compute_lddt_ca(
|
| 82 |
+
all_atom_pred_pos: Tensor,
|
| 83 |
+
all_atom_positions: Tensor,
|
| 84 |
+
all_atom_mask: Tensor,
|
| 85 |
+
cutoff: float = 15.0,
|
| 86 |
+
eps: float = 1e-10,
|
| 87 |
+
per_residue: bool = True,
|
| 88 |
+
sequence_id: Tensor | None = None,
|
| 89 |
+
) -> Tensor:
|
| 90 |
+
"""Compute lDDT using only C-alpha coordinates."""
|
| 91 |
+
|
| 92 |
+
ca_index = residue_constants.atom_order["CA"]
|
| 93 |
+
predicted_ca = (
|
| 94 |
+
all_atom_pred_pos if all_atom_pred_pos.dim() == 3 else all_atom_pred_pos[..., ca_index, :]
|
| 95 |
+
)
|
| 96 |
+
return compute_lddt(
|
| 97 |
+
predicted_ca,
|
| 98 |
+
all_atom_positions[..., ca_index, :],
|
| 99 |
+
all_atom_mask[..., ca_index],
|
| 100 |
+
cutoff=cutoff,
|
| 101 |
+
eps=eps,
|
| 102 |
+
per_residue=per_residue,
|
| 103 |
+
sequence_id=sequence_id,
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@torch.no_grad()
|
| 108 |
+
@autocast("cuda", enabled=False)
|
| 109 |
+
def compute_rmsd(
|
| 110 |
+
mobile: Tensor,
|
| 111 |
+
target: Tensor,
|
| 112 |
+
atom_exists_mask: Tensor | None = None,
|
| 113 |
+
sequence_id: Tensor | None = None,
|
| 114 |
+
reduction: str = "batch",
|
| 115 |
+
) -> Tensor:
|
| 116 |
+
"""Align ``X`` to ``Y`` and compute RMSD."""
|
| 117 |
+
|
| 118 |
+
centered_mobile, _, centered_target, _, rotation, counts = compute_alignment_tensors(
|
| 119 |
+
mobile,
|
| 120 |
+
target,
|
| 121 |
+
atom_exists_mask,
|
| 122 |
+
sequence_id,
|
| 123 |
+
)
|
| 124 |
+
rmsd = compute_rmsd_no_alignment(
|
| 125 |
+
torch.matmul(centered_mobile, rotation),
|
| 126 |
+
centered_target,
|
| 127 |
+
counts,
|
| 128 |
+
reduction=reduction,
|
| 129 |
+
)
|
| 130 |
+
if reduction == "per_residue" and sequence_id is not None:
|
| 131 |
+
return binpack(rmsd, sequence_id, pad_value=0)
|
| 132 |
+
return rmsd
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def compute_gdt_ts(
|
| 136 |
+
mobile: Tensor,
|
| 137 |
+
target: Tensor,
|
| 138 |
+
atom_exists_mask: Tensor | None = None,
|
| 139 |
+
sequence_id: Tensor | None = None,
|
| 140 |
+
reduction: str = "per_sample",
|
| 141 |
+
) -> Tensor:
|
| 142 |
+
"""Align ``X`` to ``Y`` and compute GDT-TS."""
|
| 143 |
+
|
| 144 |
+
if atom_exists_mask is None:
|
| 145 |
+
atom_exists_mask = torch.isfinite(target).all(dim=-1)
|
| 146 |
+
centered_mobile, _, centered_target, _, rotation, _ = compute_alignment_tensors(
|
| 147 |
+
mobile,
|
| 148 |
+
target,
|
| 149 |
+
atom_exists_mask,
|
| 150 |
+
sequence_id,
|
| 151 |
+
)
|
| 152 |
+
if sequence_id is not None:
|
| 153 |
+
atom_exists_mask = unbinpack(atom_exists_mask, sequence_id, pad_value=False)
|
| 154 |
+
return compute_gdt_ts_no_alignment(
|
| 155 |
+
torch.matmul(centered_mobile, rotation),
|
| 156 |
+
centered_target,
|
| 157 |
+
atom_exists_mask,
|
| 158 |
+
reduction,
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def _batched_contacts(predictions: Tensor, targets: Tensor) -> tuple[Tensor, Tensor]:
|
| 163 |
+
if predictions.dim() == 2:
|
| 164 |
+
predictions = predictions.unsqueeze(0)
|
| 165 |
+
if targets.dim() == 2:
|
| 166 |
+
targets = targets.unsqueeze(0)
|
| 167 |
+
if predictions.size() != targets.size():
|
| 168 |
+
raise ValueError(
|
| 169 |
+
f"Size mismatch. Received predictions of size {predictions.size()}, "
|
| 170 |
+
f"targets of size {targets.size()}"
|
| 171 |
+
)
|
| 172 |
+
return predictions, targets
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def _valid_contact_mask(
|
| 176 |
+
targets: Tensor,
|
| 177 |
+
src_lengths: Tensor,
|
| 178 |
+
minsep: int,
|
| 179 |
+
maxsep: int | None,
|
| 180 |
+
) -> Tensor:
|
| 181 |
+
sequence_length = targets.shape[-1]
|
| 182 |
+
positions = torch.arange(sequence_length, device=targets.device)
|
| 183 |
+
separation = (positions.unsqueeze(0) - positions.unsqueeze(1)).unsqueeze(0)
|
| 184 |
+
valid = (separation >= minsep) & (targets >= 0)
|
| 185 |
+
if maxsep is not None:
|
| 186 |
+
valid &= separation < maxsep
|
| 187 |
+
within_length = positions.unsqueeze(0) < src_lengths.unsqueeze(1)
|
| 188 |
+
return valid & within_length.unsqueeze(1) & within_length.unsqueeze(2)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def contact_precision(
|
| 192 |
+
predictions: Tensor,
|
| 193 |
+
targets: Tensor,
|
| 194 |
+
src_lengths: Tensor | None = None,
|
| 195 |
+
minsep: int = 6,
|
| 196 |
+
maxsep: int | None = None,
|
| 197 |
+
override_length: int | None = None,
|
| 198 |
+
) -> dict[str, Tensor]:
|
| 199 |
+
"""Compute P@L, P@L/5, and binned area for contact probabilities."""
|
| 200 |
+
|
| 201 |
+
predictions, targets = _batched_contacts(predictions, targets)
|
| 202 |
+
batch_size, sequence_length, _ = predictions.shape
|
| 203 |
+
if src_lengths is None:
|
| 204 |
+
src_lengths = torch.full(
|
| 205 |
+
(batch_size,),
|
| 206 |
+
sequence_length,
|
| 207 |
+
dtype=torch.long,
|
| 208 |
+
device=predictions.device,
|
| 209 |
+
)
|
| 210 |
+
valid = _valid_contact_mask(targets, src_lengths, minsep, maxsep)
|
| 211 |
+
masked_predictions = predictions.masked_fill(~valid, float("-inf"))
|
| 212 |
+
row_index, column_index = np.triu_indices(sequence_length, minsep)
|
| 213 |
+
upper_predictions = masked_predictions[:, row_index, column_index]
|
| 214 |
+
upper_targets = targets[:, row_index, column_index]
|
| 215 |
+
|
| 216 |
+
topk = sequence_length if override_length is None else max(sequence_length, override_length)
|
| 217 |
+
ranked_indices = upper_predictions.argsort(dim=-1, descending=True)[:, :topk]
|
| 218 |
+
batch_indices = torch.arange(batch_size, device=ranked_indices.device).unsqueeze(1)
|
| 219 |
+
ranked_targets = upper_targets[batch_indices, ranked_indices]
|
| 220 |
+
if ranked_targets.size(1) < topk:
|
| 221 |
+
ranked_targets = F.pad(ranked_targets, [0, topk - ranked_targets.size(1)])
|
| 222 |
+
cumulative_contacts = ranked_targets.type_as(predictions).cumsum(dim=-1)
|
| 223 |
+
|
| 224 |
+
gather_lengths = src_lengths.unsqueeze(1)
|
| 225 |
+
if override_length is not None:
|
| 226 |
+
gather_lengths = override_length * torch.ones_like(gather_lengths)
|
| 227 |
+
fractions = torch.arange(0.1, 1.1, 0.1, device=predictions.device).unsqueeze(0)
|
| 228 |
+
gather_indices = (fractions * gather_lengths).type(torch.long).sub(1).clamp_min(0)
|
| 229 |
+
cumulative_bins = cumulative_contacts.gather(1, gather_indices)
|
| 230 |
+
precisions = cumulative_bins / (gather_indices + 1).type_as(cumulative_bins)
|
| 231 |
+
return {
|
| 232 |
+
"AUC": precisions.mean(dim=-1),
|
| 233 |
+
"P@L": precisions[:, 9],
|
| 234 |
+
"P@L5": precisions[:, 1],
|
| 235 |
+
}
|
fastplms/models/esmfold2/esmfold2_misc.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Small tensor, sequence, and annotation utilities used by ESMFold2.
|
| 2 |
+
|
| 3 |
+
The helpers in this module are deliberately free of model state. Importing the
|
| 4 |
+
module therefore performs no device selection, compilation, or remote access.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from collections import defaultdict
|
| 10 |
+
from collections.abc import Generator, Iterable, Sequence
|
| 11 |
+
from contextlib import AbstractContextManager, nullcontext
|
| 12 |
+
from dataclasses import is_dataclass
|
| 13 |
+
from io import BytesIO
|
| 14 |
+
from typing import Any, Protocol, TypeVar, runtime_checkable
|
| 15 |
+
from warnings import warn
|
| 16 |
+
|
| 17 |
+
import numpy as np
|
| 18 |
+
import torch
|
| 19 |
+
import zstandard
|
| 20 |
+
|
| 21 |
+
from .esmfold2_constants_esm3 import CHAIN_BREAK_STR
|
| 22 |
+
from .esmfold2_utils_types import FunctionAnnotation
|
| 23 |
+
|
| 24 |
+
MAX_SUPPORTED_DISTANCE = 1e6
|
| 25 |
+
|
| 26 |
+
TSequence = TypeVar("TSequence", bound=Sequence)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@runtime_checkable
|
| 30 |
+
class Concatable(Protocol):
|
| 31 |
+
"""Protocol for sequence-like records with a class-level concatenator."""
|
| 32 |
+
|
| 33 |
+
@classmethod
|
| 34 |
+
def concat(cls, objs: list[Concatable]) -> Concatable: ...
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def fp32_autocast_context(
|
| 38 |
+
device_type: str,
|
| 39 |
+
) -> AbstractContextManager[Any]: # type: ignore
|
| 40 |
+
"""Return a context that keeps numerically sensitive work in FP32."""
|
| 41 |
+
|
| 42 |
+
if device_type == "mps":
|
| 43 |
+
return nullcontext()
|
| 44 |
+
if device_type == "cpu":
|
| 45 |
+
return torch.amp.autocast(device_type, enabled=False) # type: ignore
|
| 46 |
+
if device_type == "cuda":
|
| 47 |
+
return torch.amp.autocast(device_type, dtype=torch.float32) # type: ignore
|
| 48 |
+
raise ValueError(f"Unsupported device type: {device_type}")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def maybe_tensor(value, convert_none_to_nan: bool = False) -> torch.Tensor | None:
|
| 52 |
+
"""Convert an optional array-like value to a tensor."""
|
| 53 |
+
|
| 54 |
+
if value is None:
|
| 55 |
+
return None
|
| 56 |
+
if isinstance(value, torch.Tensor):
|
| 57 |
+
return value
|
| 58 |
+
if isinstance(value, list) and all(isinstance(element, torch.Tensor) for element in value):
|
| 59 |
+
return torch.stack(value)
|
| 60 |
+
if convert_none_to_nan:
|
| 61 |
+
value = np.asarray(value, dtype=np.float32)
|
| 62 |
+
value = np.where(value is None, np.nan, value)
|
| 63 |
+
return torch.tensor(value)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def maybe_list(value, convert_nan_to_none: bool = False) -> list | None:
|
| 67 |
+
"""Convert an optional tensor or NumPy array to nested Python lists."""
|
| 68 |
+
|
| 69 |
+
if value is None:
|
| 70 |
+
return None
|
| 71 |
+
if not convert_nan_to_none:
|
| 72 |
+
return value.tolist()
|
| 73 |
+
if isinstance(value, torch.Tensor):
|
| 74 |
+
nan_mask = torch.isnan(value).cpu().numpy()
|
| 75 |
+
array = value.cpu().numpy().astype(object)
|
| 76 |
+
elif isinstance(value, np.ndarray):
|
| 77 |
+
nan_mask = np.isnan(value)
|
| 78 |
+
array = value.astype(object)
|
| 79 |
+
else:
|
| 80 |
+
raise TypeError("maybe_list can only work with torch.tensor or np.ndarray.")
|
| 81 |
+
array[nan_mask] = None
|
| 82 |
+
return array.tolist()
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def replace_inf(data):
|
| 86 |
+
"""Replace infinite array values by the ESM API sentinel value."""
|
| 87 |
+
|
| 88 |
+
if data is None:
|
| 89 |
+
return None
|
| 90 |
+
array = np.asarray(data, dtype=np.float32)
|
| 91 |
+
return np.where(np.isinf(array), 1000, array).tolist()
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def slice_python_object_as_numpy(
|
| 95 |
+
obj: TSequence,
|
| 96 |
+
idx: int | list[int] | slice | np.ndarray,
|
| 97 |
+
) -> TSequence:
|
| 98 |
+
"""Apply NumPy-style scalar, mask, or index-array slicing to Python data."""
|
| 99 |
+
|
| 100 |
+
normalized_idx: list[int] | slice | np.ndarray = (
|
| 101 |
+
[int(idx)] if np.isscalar(idx) else idx # type: ignore[arg-type]
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
if isinstance(normalized_idx, np.ndarray) and normalized_idx.dtype == bool:
|
| 105 |
+
selected = [obj[position] for position in np.flatnonzero(normalized_idx)]
|
| 106 |
+
elif isinstance(normalized_idx, slice):
|
| 107 |
+
selected = obj[normalized_idx]
|
| 108 |
+
else:
|
| 109 |
+
selected = [obj[position] for position in normalized_idx]
|
| 110 |
+
|
| 111 |
+
if isinstance(obj, str) and isinstance(selected, list):
|
| 112 |
+
return "".join(selected) # type: ignore[return-value]
|
| 113 |
+
return obj.__class__(selected) # type: ignore[call-arg,return-value]
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def slice_any_object(
|
| 117 |
+
obj: TSequence,
|
| 118 |
+
idx: int | list[int] | slice | np.ndarray,
|
| 119 |
+
) -> TSequence:
|
| 120 |
+
"""Slice tensors, arrays, dataclasses, and ordinary Python sequences."""
|
| 121 |
+
|
| 122 |
+
if isinstance(obj, (np.ndarray, torch.Tensor)) or is_dataclass(obj):
|
| 123 |
+
return obj[idx] # type: ignore[index,return-value]
|
| 124 |
+
return slice_python_object_as_numpy(obj, idx)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def join_lists(
|
| 128 |
+
lists: Sequence[Sequence[Any]],
|
| 129 |
+
separator: Sequence[Any] | None = None,
|
| 130 |
+
) -> list[Any]:
|
| 131 |
+
"""Join lists, inserting all elements of ``separator`` between inputs."""
|
| 132 |
+
|
| 133 |
+
if len(lists) == 0:
|
| 134 |
+
return []
|
| 135 |
+
joined = list(lists[0])
|
| 136 |
+
for values in lists[1:]:
|
| 137 |
+
if separator:
|
| 138 |
+
joined.extend(separator)
|
| 139 |
+
joined.extend(values)
|
| 140 |
+
return joined
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def iterate_with_intermediate(
|
| 144 |
+
lists: Iterable,
|
| 145 |
+
intermediate,
|
| 146 |
+
) -> Generator[Any, None, None]:
|
| 147 |
+
"""Yield an intermediate value between consecutive input values."""
|
| 148 |
+
|
| 149 |
+
iterator = iter(lists)
|
| 150 |
+
yield next(iterator)
|
| 151 |
+
for value in iterator:
|
| 152 |
+
yield intermediate
|
| 153 |
+
yield value
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def concat_objects(objs: Sequence[Any], separator: Any | None = None):
|
| 157 |
+
"""Concatenate one supported homogeneous collection."""
|
| 158 |
+
|
| 159 |
+
if not objs:
|
| 160 |
+
raise ValueError("objs must contain at least one value.")
|
| 161 |
+
first = objs[0]
|
| 162 |
+
if isinstance(first, Concatable):
|
| 163 |
+
return first.__class__.concat(objs)
|
| 164 |
+
if isinstance(first, str):
|
| 165 |
+
if not isinstance(separator, str):
|
| 166 |
+
raise TypeError("separator must be a string when joining strings.")
|
| 167 |
+
return separator.join(objs)
|
| 168 |
+
if isinstance(first, list):
|
| 169 |
+
return join_lists(objs, None if separator is None else [separator])
|
| 170 |
+
if isinstance(first, np.ndarray):
|
| 171 |
+
pieces = (
|
| 172 |
+
objs
|
| 173 |
+
if separator is None
|
| 174 |
+
else list(iterate_with_intermediate(objs, np.array([separator])))
|
| 175 |
+
)
|
| 176 |
+
return np.concatenate(pieces)
|
| 177 |
+
if isinstance(first, torch.Tensor):
|
| 178 |
+
pieces = (
|
| 179 |
+
objs
|
| 180 |
+
if separator is None
|
| 181 |
+
else list(iterate_with_intermediate(objs, torch.tensor([separator])))
|
| 182 |
+
)
|
| 183 |
+
return torch.cat(pieces) # type: ignore[arg-type]
|
| 184 |
+
raise TypeError(type(first))
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def rbf(values, v_min, v_max, n_bins=16):
|
| 188 |
+
"""Encode values against evenly spaced radial basis centers."""
|
| 189 |
+
|
| 190 |
+
centers = torch.linspace(
|
| 191 |
+
v_min,
|
| 192 |
+
v_max,
|
| 193 |
+
n_bins,
|
| 194 |
+
dtype=values.dtype,
|
| 195 |
+
device=values.device,
|
| 196 |
+
)
|
| 197 |
+
centers = centers.reshape((1,) * values.ndim + (-1,))
|
| 198 |
+
standardized = (values.unsqueeze(-1) - centers) / ((v_max - v_min) / n_bins)
|
| 199 |
+
return torch.exp(-(standardized**2))
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def batched_gather(data, inds, dim=0, no_batch_dims=0):
|
| 203 |
+
"""Gather along one data dimension while retaining leading batch axes."""
|
| 204 |
+
|
| 205 |
+
batch_indices = []
|
| 206 |
+
index_rank = len(inds.shape)
|
| 207 |
+
for axis, size in enumerate(data.shape[:no_batch_dims]):
|
| 208 |
+
shape = (1,) * axis + (-1,) + (1,) * (index_rank - axis - 1)
|
| 209 |
+
batch_indices.append(torch.arange(size).view(*shape))
|
| 210 |
+
tail = [slice(None)] * (len(data.shape) - no_batch_dims)
|
| 211 |
+
tail[dim - no_batch_dims if dim >= 0 else dim] = inds
|
| 212 |
+
return data[tuple(batch_indices + tail)]
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def node_gather(s: torch.Tensor, edges: torch.Tensor) -> torch.Tensor:
|
| 216 |
+
"""Gather node features for each row of an edge-index tensor."""
|
| 217 |
+
|
| 218 |
+
return batched_gather(
|
| 219 |
+
s.unsqueeze(-3),
|
| 220 |
+
edges,
|
| 221 |
+
-2,
|
| 222 |
+
no_batch_dims=len(s.shape) - 1,
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def knn_graph(
|
| 227 |
+
coords: torch.Tensor,
|
| 228 |
+
coord_mask: torch.Tensor,
|
| 229 |
+
padding_mask: torch.Tensor,
|
| 230 |
+
sequence_id: torch.Tensor,
|
| 231 |
+
*,
|
| 232 |
+
no_knn: int,
|
| 233 |
+
):
|
| 234 |
+
"""Build nearest-neighbor edges, using sequence distance for missing geometry."""
|
| 235 |
+
|
| 236 |
+
length = coords.shape[-2]
|
| 237 |
+
coords = coords.nan_to_num()
|
| 238 |
+
missing_pair = ~(coord_mask[..., None, :] & coord_mask[..., :, None])
|
| 239 |
+
excluded_pair = padding_mask[..., None, :] | padding_mask[..., :, None]
|
| 240 |
+
if sequence_id is not None:
|
| 241 |
+
excluded_pair |= sequence_id.unsqueeze(1) != sequence_id.unsqueeze(2)
|
| 242 |
+
|
| 243 |
+
distances = (coords.unsqueeze(-2) - coords.unsqueeze(-3)).norm(dim=-1)
|
| 244 |
+
residue_index = torch.arange(length, device=coords.device)
|
| 245 |
+
sequence_distance = (residue_index.unsqueeze(-1) - residue_index.unsqueeze(-2)).abs()
|
| 246 |
+
if not (distances[~missing_pair] < MAX_SUPPORTED_DISTANCE).all():
|
| 247 |
+
raise ValueError(
|
| 248 |
+
"Coordinate pairwise distances exceed max supported distance "
|
| 249 |
+
f"({MAX_SUPPORTED_DISTANCE}). "
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
rank_distance = sequence_distance.to(distances.dtype).mul(1e2).add(MAX_SUPPORTED_DISTANCE)
|
| 253 |
+
rank_distance = rank_distance.where(missing_pair, distances)
|
| 254 |
+
rank_distance = rank_distance.masked_fill(excluded_pair, torch.inf)
|
| 255 |
+
sorted_distance, sorted_edge = rank_distance.sort(dim=-1, descending=False)
|
| 256 |
+
width = min(no_knn, length)
|
| 257 |
+
return sorted_edge[..., :width], sorted_distance[..., :width].isfinite()
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def stack_variable_length_tensors(
|
| 261 |
+
sequences: Sequence[torch.Tensor],
|
| 262 |
+
constant_value: int | float = 0,
|
| 263 |
+
dtype: torch.dtype | None = None,
|
| 264 |
+
) -> torch.Tensor:
|
| 265 |
+
"""Pad arbitrary tensor dimensions to their maxima, then stack."""
|
| 266 |
+
|
| 267 |
+
output_shape = [
|
| 268 |
+
len(sequences),
|
| 269 |
+
*np.max([sequence.shape for sequence in sequences], axis=0).tolist(),
|
| 270 |
+
]
|
| 271 |
+
output = torch.full(
|
| 272 |
+
output_shape,
|
| 273 |
+
constant_value,
|
| 274 |
+
dtype=sequences[0].dtype if dtype is None else dtype,
|
| 275 |
+
device=sequences[0].device,
|
| 276 |
+
)
|
| 277 |
+
for destination, source in zip(output, sequences, strict=True):
|
| 278 |
+
destination[tuple(slice(size) for size in source.shape)] = source
|
| 279 |
+
return output
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def binpack(
|
| 283 |
+
tensor: torch.Tensor,
|
| 284 |
+
sequence_id: torch.Tensor | None,
|
| 285 |
+
pad_value: int | float,
|
| 286 |
+
):
|
| 287 |
+
"""Scatter a sequence-major tensor into the packed layout described by IDs."""
|
| 288 |
+
|
| 289 |
+
if sequence_id is None:
|
| 290 |
+
return tensor
|
| 291 |
+
sequence_counts = sequence_id.max(dim=-1).values + 1
|
| 292 |
+
output = torch.full(
|
| 293 |
+
sequence_id.shape + tensor.shape[2:],
|
| 294 |
+
fill_value=pad_value,
|
| 295 |
+
dtype=tensor.dtype,
|
| 296 |
+
device=tensor.device,
|
| 297 |
+
)
|
| 298 |
+
source_index = 0
|
| 299 |
+
for batch_index, (batch_ids, count) in enumerate(
|
| 300 |
+
zip(sequence_id, sequence_counts, strict=True)
|
| 301 |
+
):
|
| 302 |
+
for seqid in range(count):
|
| 303 |
+
selection = batch_ids == seqid
|
| 304 |
+
output[batch_index, selection] = tensor[source_index, : selection.sum()]
|
| 305 |
+
source_index += 1
|
| 306 |
+
return output
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def unbinpack(
|
| 310 |
+
tensor: torch.Tensor,
|
| 311 |
+
sequence_id: torch.Tensor | None,
|
| 312 |
+
pad_value: int | float,
|
| 313 |
+
):
|
| 314 |
+
"""Restore sequence-major rows from a packed tensor and its sequence IDs."""
|
| 315 |
+
|
| 316 |
+
if sequence_id is None:
|
| 317 |
+
return tensor
|
| 318 |
+
rows = []
|
| 319 |
+
sequence_counts = sequence_id.max(dim=-1).values + 1
|
| 320 |
+
for batch_index, (batch_ids, count) in enumerate(
|
| 321 |
+
zip(sequence_id, sequence_counts, strict=True)
|
| 322 |
+
):
|
| 323 |
+
for seqid in range(count):
|
| 324 |
+
rows.append(tensor[batch_index, batch_ids == seqid])
|
| 325 |
+
return stack_variable_length_tensors(rows, pad_value)
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def merge_ranges(
|
| 329 |
+
ranges: list[range],
|
| 330 |
+
merge_gap_max: int | None = None,
|
| 331 |
+
) -> list[range]:
|
| 332 |
+
"""Merge overlapping or sufficiently close ranges in positional order."""
|
| 333 |
+
|
| 334 |
+
maximum_gap = 0 if merge_gap_max is None else merge_gap_max
|
| 335 |
+
if not isinstance(maximum_gap, int) or isinstance(maximum_gap, bool):
|
| 336 |
+
raise TypeError("merge_gap_max must be an integer or None.")
|
| 337 |
+
if maximum_gap < 0:
|
| 338 |
+
raise ValueError(f"merge_gap_max must be non-negative, got {maximum_gap}.")
|
| 339 |
+
merged: list[range] = []
|
| 340 |
+
for current in sorted(ranges, key=lambda item: item.start):
|
| 341 |
+
if not merged or merged[-1].stop + maximum_gap < current.start:
|
| 342 |
+
merged.append(current)
|
| 343 |
+
continue
|
| 344 |
+
previous = merged[-1]
|
| 345 |
+
merged[-1] = range(previous.start, max(previous.stop, current.stop))
|
| 346 |
+
return merged
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
def merge_annotations(
|
| 350 |
+
annotations: list[FunctionAnnotation],
|
| 351 |
+
merge_gap_max: int | None = None,
|
| 352 |
+
) -> list[FunctionAnnotation]:
|
| 353 |
+
"""Merge overlapping annotations independently for each label."""
|
| 354 |
+
|
| 355 |
+
grouped: dict[str, list[range]] = defaultdict(list)
|
| 356 |
+
for annotation in annotations:
|
| 357 |
+
grouped[annotation.label].append(range(annotation.start, annotation.end + 1))
|
| 358 |
+
result = []
|
| 359 |
+
for label, spans in grouped.items():
|
| 360 |
+
result.extend(
|
| 361 |
+
FunctionAnnotation(label=label, start=span.start, end=span.stop - 1)
|
| 362 |
+
for span in merge_ranges(spans, merge_gap_max=merge_gap_max)
|
| 363 |
+
)
|
| 364 |
+
return result
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
def get_chainbreak_boundaries_from_sequence(
|
| 368 |
+
sequence: Sequence[str],
|
| 369 |
+
) -> np.ndarray:
|
| 370 |
+
"""Return half-open chain intervals split by chain-break tokens."""
|
| 371 |
+
|
| 372 |
+
boundaries = [0]
|
| 373 |
+
final_index = len(sequence) - 1
|
| 374 |
+
for index, residue in enumerate(sequence):
|
| 375 |
+
if residue != CHAIN_BREAK_STR:
|
| 376 |
+
continue
|
| 377 |
+
if index == final_index:
|
| 378 |
+
raise ValueError(
|
| 379 |
+
"Encountered chain break token at end of sequence, this is unexpected."
|
| 380 |
+
)
|
| 381 |
+
if index == final_index - 1:
|
| 382 |
+
warn(
|
| 383 |
+
"Encountered chain break token at penultimate position, this is unexpected.",
|
| 384 |
+
stacklevel=2,
|
| 385 |
+
)
|
| 386 |
+
boundaries.extend((index, index + 1))
|
| 387 |
+
boundaries.append(len(sequence))
|
| 388 |
+
assert len(boundaries) % 2 == 0
|
| 389 |
+
return np.asarray(boundaries).reshape(-1, 2)
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
def deserialize_tensors(data: bytes) -> Any:
|
| 393 |
+
"""Decompress a tensor-only Torch payload onto CPU."""
|
| 394 |
+
|
| 395 |
+
decompressed = zstandard.ZstdDecompressor().decompress(data)
|
| 396 |
+
return torch.load(
|
| 397 |
+
BytesIO(decompressed),
|
| 398 |
+
map_location="cpu",
|
| 399 |
+
weights_only=True,
|
| 400 |
+
)
|
fastplms/models/esmfold2/esmfold2_mmcif_parsing.py
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Biotite-backed mmCIF parsing used by ESMFold2 structure records."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import functools
|
| 6 |
+
import io
|
| 7 |
+
import os
|
| 8 |
+
from contextlib import suppress
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
|
| 12 |
+
import biotite.structure as bs
|
| 13 |
+
import biotite.structure.io.pdbx as pdbx
|
| 14 |
+
import numpy as np
|
| 15 |
+
from biotite.structure.io.pdbx import CIFColumn, CIFData, CIFFile
|
| 16 |
+
|
| 17 |
+
from . import esmfold2_residue_constants as residue_constants
|
| 18 |
+
|
| 19 |
+
PathOrBuffer = str | os.PathLike | io.StringIO
|
| 20 |
+
|
| 21 |
+
PLDDT_B_FACTOR_SCALE = 100.0
|
| 22 |
+
_MMCIF_COLUMN_DECIMALS = {
|
| 23 |
+
"Cartn_x": 3,
|
| 24 |
+
"Cartn_y": 3,
|
| 25 |
+
"Cartn_z": 3,
|
| 26 |
+
"B_iso_or_equiv": 2,
|
| 27 |
+
}
|
| 28 |
+
_NONPOLYMER_ENTITY_TYPES = frozenset({"NON-POLYMER", "WATER", "BRANCHED"})
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class NoProteinError(Exception):
|
| 32 |
+
"""Raised internally when an mmCIF block contains no model-one atoms."""
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass
|
| 36 |
+
class Residue:
|
| 37 |
+
residue_number: int | None = None
|
| 38 |
+
insertion_code: str = ""
|
| 39 |
+
hetflag: bool = False
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@dataclass
|
| 43 |
+
class MmcifHeader:
|
| 44 |
+
release_date: datetime | None = None
|
| 45 |
+
resolution: float | None = None
|
| 46 |
+
structure_method: str = "UNKNOWN"
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def round_mmcif_columns(cif_file: CIFFile) -> None:
|
| 50 |
+
"""Round coordinate and confidence columns in place for stable exports."""
|
| 51 |
+
|
| 52 |
+
if "atom_site" not in cif_file.block:
|
| 53 |
+
return
|
| 54 |
+
atom_site = cif_file.block["atom_site"]
|
| 55 |
+
for name, decimals in _MMCIF_COLUMN_DECIMALS.items():
|
| 56 |
+
if name not in atom_site:
|
| 57 |
+
continue
|
| 58 |
+
original = atom_site[name]
|
| 59 |
+
values = original.as_array(np.float64)
|
| 60 |
+
strings = np.asarray(
|
| 61 |
+
[f"{value:.{decimals}f}" for value in values],
|
| 62 |
+
dtype=np.str_,
|
| 63 |
+
)
|
| 64 |
+
atom_site[name] = CIFColumn(
|
| 65 |
+
data=CIFData(array=strings, dtype=np.str_),
|
| 66 |
+
mask=original.mask,
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _clean_chain_list(value: str) -> list[str]:
|
| 71 |
+
return [chain.strip() for chain in value.split(",") if chain.strip()]
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _empty_residue() -> Residue:
|
| 75 |
+
return Residue(residue_number=None, insertion_code="", hetflag=False)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _header_from_block(
|
| 79 |
+
block,
|
| 80 |
+
header: MmcifHeader | None = None,
|
| 81 |
+
) -> MmcifHeader:
|
| 82 |
+
header = MmcifHeader() if header is None else header
|
| 83 |
+
try:
|
| 84 |
+
if "pdbx_database_status" in block:
|
| 85 |
+
category = block["pdbx_database_status"]
|
| 86 |
+
if "recvd_initial_deposition_date" in category:
|
| 87 |
+
value = category["recvd_initial_deposition_date"].as_item()
|
| 88 |
+
if value and value != "?":
|
| 89 |
+
with suppress(ValueError):
|
| 90 |
+
header.release_date = datetime.strptime(value, "%Y-%m-%d")
|
| 91 |
+
if "refine" in block:
|
| 92 |
+
category = block["refine"]
|
| 93 |
+
if "ls_d_res_high" in category:
|
| 94 |
+
value = category["ls_d_res_high"].as_item()
|
| 95 |
+
if value and value != "?":
|
| 96 |
+
with suppress(ValueError):
|
| 97 |
+
header.resolution = float(value)
|
| 98 |
+
if "exptl" in block:
|
| 99 |
+
category = block["exptl"]
|
| 100 |
+
if "method" in category:
|
| 101 |
+
value = category["method"].as_item()
|
| 102 |
+
if value and value != "?":
|
| 103 |
+
header.structure_method = value.upper()
|
| 104 |
+
except Exception:
|
| 105 |
+
pass
|
| 106 |
+
return header
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _entities_from_block(
|
| 110 |
+
block,
|
| 111 |
+
entities: dict[int, list[str]] | None = None,
|
| 112 |
+
) -> dict[int, list[str]]:
|
| 113 |
+
entities = {} if entities is None else entities
|
| 114 |
+
if "entity" in block:
|
| 115 |
+
category = block["entity"]
|
| 116 |
+
ids = category["id"].as_array(str)
|
| 117 |
+
types = category["type"].as_array(str)
|
| 118 |
+
for entity_id, _ in zip(ids, types, strict=False):
|
| 119 |
+
entities[int(entity_id)] = []
|
| 120 |
+
if "entity_poly" in block:
|
| 121 |
+
category = block["entity_poly"]
|
| 122 |
+
ids = category["entity_id"].as_array(str)
|
| 123 |
+
chain_lists = category["pdbx_strand_id"].as_array(str)
|
| 124 |
+
for raw_id, raw_chains in zip(ids, chain_lists, strict=False):
|
| 125 |
+
entity_id = int(raw_id)
|
| 126 |
+
if entity_id in entities:
|
| 127 |
+
entities[entity_id] = _clean_chain_list(raw_chains)
|
| 128 |
+
if "struct_asym" in block:
|
| 129 |
+
category = block["struct_asym"]
|
| 130 |
+
asym_ids = category["id"].as_array(str)
|
| 131 |
+
entity_ids = category["entity_id"].as_array(str)
|
| 132 |
+
for asym_id, raw_id in zip(asym_ids, entity_ids, strict=False):
|
| 133 |
+
entity_id = int(raw_id)
|
| 134 |
+
if entity_id in entities and not entities[entity_id]:
|
| 135 |
+
entities[entity_id].append(asym_id)
|
| 136 |
+
return entities
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _polymer_sequences(block) -> dict[str, str]:
|
| 140 |
+
sequences: dict[str, str] = {}
|
| 141 |
+
if "entity_poly" not in block:
|
| 142 |
+
return sequences
|
| 143 |
+
category = block["entity_poly"]
|
| 144 |
+
entity_ids = category["entity_id"].as_array(str)
|
| 145 |
+
raw_sequences = category["pdbx_seq_one_letter_code_can"].as_array(str)
|
| 146 |
+
chain_lists = category["pdbx_strand_id"].as_array(str)
|
| 147 |
+
for _, raw_sequence, raw_chains in zip(
|
| 148 |
+
entity_ids,
|
| 149 |
+
raw_sequences,
|
| 150 |
+
chain_lists,
|
| 151 |
+
strict=False,
|
| 152 |
+
):
|
| 153 |
+
sequence = "".join(raw_sequence.split())
|
| 154 |
+
for chain_id in _clean_chain_list(raw_chains):
|
| 155 |
+
sequences[chain_id] = sequence
|
| 156 |
+
return sequences
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _scheme_columns(category):
|
| 160 |
+
asym_ids = category["asym_id"].as_array(str)
|
| 161 |
+
insertion_codes = (
|
| 162 |
+
category["pdb_ins_code"].as_array(str)
|
| 163 |
+
if "pdb_ins_code" in category
|
| 164 |
+
else [""] * len(asym_ids)
|
| 165 |
+
)
|
| 166 |
+
hetflags = category["hetflag"].as_array(str) if "hetflag" in category else ["N"] * len(asym_ids)
|
| 167 |
+
author_chains = (
|
| 168 |
+
category["pdb_strand_id"].as_array(str) if "pdb_strand_id" in category else asym_ids
|
| 169 |
+
)
|
| 170 |
+
return (
|
| 171 |
+
asym_ids,
|
| 172 |
+
category["seq_id"].as_array(str),
|
| 173 |
+
category["auth_seq_num"].as_array(str),
|
| 174 |
+
insertion_codes,
|
| 175 |
+
hetflags,
|
| 176 |
+
author_chains,
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def _scheme_residue_map(category):
|
| 181 |
+
(
|
| 182 |
+
asym_ids,
|
| 183 |
+
sequence_positions,
|
| 184 |
+
author_numbers,
|
| 185 |
+
insertion_codes,
|
| 186 |
+
hetflags,
|
| 187 |
+
author_chains,
|
| 188 |
+
) = _scheme_columns(category)
|
| 189 |
+
asym_to_author = {
|
| 190 |
+
asym_id: author_id for asym_id, author_id in zip(asym_ids, author_chains, strict=False)
|
| 191 |
+
}
|
| 192 |
+
per_chain: dict[str, dict[int, Residue]] = {}
|
| 193 |
+
for asym_id, raw_position, raw_number, raw_code, raw_hetflag in zip(
|
| 194 |
+
asym_ids,
|
| 195 |
+
sequence_positions,
|
| 196 |
+
author_numbers,
|
| 197 |
+
insertion_codes,
|
| 198 |
+
hetflags,
|
| 199 |
+
strict=False,
|
| 200 |
+
):
|
| 201 |
+
residues = per_chain.setdefault(asym_id, {})
|
| 202 |
+
try:
|
| 203 |
+
position = int(raw_position) - 1
|
| 204 |
+
residue_number = int(raw_number) if raw_number != "?" else None
|
| 205 |
+
except ValueError:
|
| 206 |
+
continue
|
| 207 |
+
if residue_number is None:
|
| 208 |
+
insertion_code = ""
|
| 209 |
+
else:
|
| 210 |
+
insertion_code = "" if raw_code in (".", "?") else raw_code
|
| 211 |
+
residues[position] = Residue(
|
| 212 |
+
residue_number=residue_number,
|
| 213 |
+
insertion_code=insertion_code,
|
| 214 |
+
hetflag=raw_hetflag.upper() == "Y",
|
| 215 |
+
)
|
| 216 |
+
return per_chain, asym_to_author
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def _renumber_duplicate_residues(
|
| 220 |
+
per_chain: dict[str, dict[int, Residue]],
|
| 221 |
+
) -> None:
|
| 222 |
+
for residues in per_chain.values():
|
| 223 |
+
positions_by_number: dict[int, list[int]] = {}
|
| 224 |
+
for position, residue in residues.items():
|
| 225 |
+
if residue.residue_number is not None:
|
| 226 |
+
positions_by_number.setdefault(residue.residue_number, []).append(position)
|
| 227 |
+
for number, positions in positions_by_number.items():
|
| 228 |
+
if len(positions) <= 1:
|
| 229 |
+
continue
|
| 230 |
+
positions.sort()
|
| 231 |
+
for offset, position in enumerate(positions):
|
| 232 |
+
previous = residues[position]
|
| 233 |
+
residues[position] = Residue(
|
| 234 |
+
residue_number=number + offset,
|
| 235 |
+
insertion_code=previous.insertion_code,
|
| 236 |
+
hetflag=previous.hetflag,
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def _ordered_scheme_mapping(
|
| 241 |
+
per_chain: dict[str, dict[int, Residue]],
|
| 242 |
+
asym_to_author: dict[str, str],
|
| 243 |
+
chain_sequences: dict[str, str],
|
| 244 |
+
) -> dict[str, dict[int, Residue]]:
|
| 245 |
+
result: dict[str, dict[int, Residue]] = {}
|
| 246 |
+
for asym_id, residues in per_chain.items():
|
| 247 |
+
author_chain = asym_to_author.get(asym_id, asym_id)
|
| 248 |
+
if author_chain in chain_sequences:
|
| 249 |
+
result[author_chain] = {
|
| 250 |
+
position: residues.get(position, _empty_residue())
|
| 251 |
+
for position in range(len(chain_sequences[author_chain]))
|
| 252 |
+
}
|
| 253 |
+
elif residues:
|
| 254 |
+
result[author_chain] = {
|
| 255 |
+
index: residues[position] for index, position in enumerate(sorted(residues))
|
| 256 |
+
}
|
| 257 |
+
return result
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def _complete_polymer_mappings(
|
| 261 |
+
mappings: dict[str, dict[int, Residue]],
|
| 262 |
+
chain_sequences: dict[str, str],
|
| 263 |
+
) -> None:
|
| 264 |
+
for chain_id, sequence in chain_sequences.items():
|
| 265 |
+
mapping = mappings.setdefault(chain_id, {})
|
| 266 |
+
for position in range(len(sequence)):
|
| 267 |
+
if position not in mapping:
|
| 268 |
+
mapping[position] = _empty_residue()
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def _add_structure_fallbacks(
|
| 272 |
+
mappings: dict[str, dict[int, Residue]],
|
| 273 |
+
structure: bs.AtomArray,
|
| 274 |
+
) -> None:
|
| 275 |
+
if not (
|
| 276 |
+
structure
|
| 277 |
+
and hasattr(structure, "chain_id")
|
| 278 |
+
and structure.chain_id is not None
|
| 279 |
+
and hasattr(structure.chain_id, "__iter__")
|
| 280 |
+
):
|
| 281 |
+
return
|
| 282 |
+
for chain_id in set(structure.chain_id):
|
| 283 |
+
if chain_id in mappings:
|
| 284 |
+
continue
|
| 285 |
+
chain = structure[structure.chain_id == chain_id]
|
| 286 |
+
if not (
|
| 287 |
+
hasattr(chain, "res_id")
|
| 288 |
+
and chain.res_id is not None
|
| 289 |
+
and hasattr(chain.res_id, "__iter__")
|
| 290 |
+
):
|
| 291 |
+
continue
|
| 292 |
+
residue_ids = sorted(set(chain.res_id))
|
| 293 |
+
mappings[chain_id] = {
|
| 294 |
+
index: Residue(
|
| 295 |
+
residue_number=residue_id,
|
| 296 |
+
insertion_code="",
|
| 297 |
+
hetflag=False,
|
| 298 |
+
)
|
| 299 |
+
for index, residue_id in enumerate(residue_ids)
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def _nonpolymer_entity_ids(block) -> set[str]:
|
| 304 |
+
result = set()
|
| 305 |
+
if "entity" not in block:
|
| 306 |
+
return result
|
| 307 |
+
category = block["entity"]
|
| 308 |
+
ids = category["id"].as_array(str)
|
| 309 |
+
types = category["type"].as_array(str)
|
| 310 |
+
for entity_id, entity_type in zip(ids, types, strict=False):
|
| 311 |
+
if entity_type.upper() in _NONPOLYMER_ENTITY_TYPES:
|
| 312 |
+
result.add(entity_id)
|
| 313 |
+
return result
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def _nonpolymer_component_map(block, entity_ids: set[str]) -> dict[str, str]:
|
| 317 |
+
result = {}
|
| 318 |
+
if "pdbx_entity_nonpoly" not in block:
|
| 319 |
+
return result
|
| 320 |
+
category = block["pdbx_entity_nonpoly"]
|
| 321 |
+
ids = category["entity_id"].as_array(str)
|
| 322 |
+
components = category["comp_id"].as_array(str)
|
| 323 |
+
for entity_id, component in zip(ids, components, strict=False):
|
| 324 |
+
if entity_id in entity_ids:
|
| 325 |
+
result[entity_id] = component
|
| 326 |
+
return result
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
class MmcifWrapper:
|
| 330 |
+
"""Parsed model-one structure, metadata, sequences, and residue mappings."""
|
| 331 |
+
|
| 332 |
+
def __init__(self, id: str | None = None):
|
| 333 |
+
self.id = id or ""
|
| 334 |
+
self.raw: pdbx.CIFFile | None = None
|
| 335 |
+
self.structure: bs.AtomArray
|
| 336 |
+
self.header = MmcifHeader()
|
| 337 |
+
self.entities: dict[int, list[str]] = {}
|
| 338 |
+
self.chain_to_seqres: dict[str, str] = {}
|
| 339 |
+
self.seqres_to_structure: dict[str, dict[int, Residue]] = {}
|
| 340 |
+
|
| 341 |
+
@classmethod
|
| 342 |
+
def read(cls, path: PathOrBuffer, id: str | None = None) -> MmcifWrapper:
|
| 343 |
+
wrapper = cls(id=id)
|
| 344 |
+
wrapper._load(path)
|
| 345 |
+
return wrapper
|
| 346 |
+
|
| 347 |
+
def _load(self, path: PathOrBuffer, fileid: str | None = None) -> None:
|
| 348 |
+
self.raw = pdbx.CIFFile.read(path)
|
| 349 |
+
self._parse_structure()
|
| 350 |
+
self._parse_header()
|
| 351 |
+
self._parse_entities()
|
| 352 |
+
self._parse_sequences()
|
| 353 |
+
|
| 354 |
+
def _parse_structure(self) -> None:
|
| 355 |
+
try:
|
| 356 |
+
structure = pdbx.get_structure(self.raw, model=1)
|
| 357 |
+
if structure is None or not isinstance(structure, bs.AtomArray):
|
| 358 |
+
raise NoProteinError("No structure found in mmCIF file")
|
| 359 |
+
if len(structure) == 0:
|
| 360 |
+
raise NoProteinError("Empty structure in mmCIF file")
|
| 361 |
+
self.structure = structure
|
| 362 |
+
except Exception as error:
|
| 363 |
+
raise ValueError(f"Failed to parse structure: {error}") from error
|
| 364 |
+
|
| 365 |
+
def _parse_header(self) -> None:
|
| 366 |
+
if self.raw:
|
| 367 |
+
self.header = _header_from_block(self.raw.block, self.header)
|
| 368 |
+
|
| 369 |
+
def _parse_entities(self) -> None:
|
| 370 |
+
if not self.raw:
|
| 371 |
+
return
|
| 372 |
+
try:
|
| 373 |
+
self.entities = _entities_from_block(self.raw.block, self.entities)
|
| 374 |
+
except Exception:
|
| 375 |
+
if (
|
| 376 |
+
self.structure
|
| 377 |
+
and hasattr(self.structure, "chain_id")
|
| 378 |
+
and self.structure.chain_id is not None
|
| 379 |
+
and hasattr(self.structure.chain_id, "__iter__")
|
| 380 |
+
):
|
| 381 |
+
self.entities = {1: list(set(self.structure.chain_id))}
|
| 382 |
+
|
| 383 |
+
def _parse_sequences(self) -> None:
|
| 384 |
+
if not self.raw:
|
| 385 |
+
return
|
| 386 |
+
block = self.raw.block
|
| 387 |
+
self.chain_to_seqres.update(_polymer_sequences(block))
|
| 388 |
+
if "pdbx_poly_seq_scheme" in block:
|
| 389 |
+
per_chain, asym_to_author = _scheme_residue_map(block["pdbx_poly_seq_scheme"])
|
| 390 |
+
_renumber_duplicate_residues(per_chain)
|
| 391 |
+
self.seqres_to_structure.update(
|
| 392 |
+
_ordered_scheme_mapping(
|
| 393 |
+
per_chain,
|
| 394 |
+
asym_to_author,
|
| 395 |
+
self.chain_to_seqres,
|
| 396 |
+
)
|
| 397 |
+
)
|
| 398 |
+
_complete_polymer_mappings(
|
| 399 |
+
self.seqres_to_structure,
|
| 400 |
+
self.chain_to_seqres,
|
| 401 |
+
)
|
| 402 |
+
_add_structure_fallbacks(self.seqres_to_structure, self.structure)
|
| 403 |
+
|
| 404 |
+
def _parse_nonpoly_from_mmcif(self) -> dict[tuple, bs.AtomArray]:
|
| 405 |
+
assert self.raw is not None
|
| 406 |
+
block = self.raw.block
|
| 407 |
+
entity_ids = _nonpolymer_entity_ids(block)
|
| 408 |
+
_nonpolymer_component_map(block, entity_ids)
|
| 409 |
+
groups: dict[tuple[str, str], list[int]] = {}
|
| 410 |
+
if "atom_site" in block:
|
| 411 |
+
category = block["atom_site"]
|
| 412 |
+
chain_ids = category["label_asym_id"].as_array(str)
|
| 413 |
+
atom_entity_ids = category["label_entity_id"].as_array(str)
|
| 414 |
+
component_ids = category["label_comp_id"].as_array(str)
|
| 415 |
+
for index, (chain_id, entity_id, component_id) in enumerate(
|
| 416 |
+
zip(chain_ids, atom_entity_ids, component_ids, strict=False)
|
| 417 |
+
):
|
| 418 |
+
if entity_id in entity_ids:
|
| 419 |
+
groups.setdefault((component_id, chain_id), []).append(index)
|
| 420 |
+
|
| 421 |
+
coordinates = {}
|
| 422 |
+
for component_id, chain_id in groups:
|
| 423 |
+
selection = (self.structure.chain_id == chain_id) & (
|
| 424 |
+
self.structure.res_name == component_id
|
| 425 |
+
)
|
| 426 |
+
if not selection.any():
|
| 427 |
+
continue
|
| 428 |
+
atoms = self.structure[selection]
|
| 429 |
+
if isinstance(atoms, (bs.AtomArray, bs.AtomArrayStack)) and len(atoms) > 0:
|
| 430 |
+
coordinates[(component_id, chain_id)] = atoms
|
| 431 |
+
return coordinates
|
| 432 |
+
|
| 433 |
+
def _parse_nonpoly_fallback(self) -> dict[tuple, bs.AtomArray]:
|
| 434 |
+
result = {}
|
| 435 |
+
if not (self.structure and hasattr(self.structure, "chain_id")):
|
| 436 |
+
return result
|
| 437 |
+
standard_residues = set(residue_constants.resnames[:-1])
|
| 438 |
+
standard_residues.update({"A", "C", "G", "T", "U"})
|
| 439 |
+
if self.structure.chain_id is None:
|
| 440 |
+
return result
|
| 441 |
+
for chain_id in set(self.structure.chain_id):
|
| 442 |
+
chain = self.structure[self.structure.chain_id == chain_id]
|
| 443 |
+
if not (
|
| 444 |
+
hasattr(chain, "res_name")
|
| 445 |
+
and chain.res_name is not None
|
| 446 |
+
and hasattr(chain.res_name, "__iter__")
|
| 447 |
+
):
|
| 448 |
+
continue
|
| 449 |
+
for residue_name in set(chain.res_name):
|
| 450 |
+
if residue_name in standard_residues:
|
| 451 |
+
continue
|
| 452 |
+
selection = (chain.chain_id == chain_id) & (chain.res_name == residue_name)
|
| 453 |
+
if selection.any() and isinstance(
|
| 454 |
+
chain,
|
| 455 |
+
(bs.AtomArray, bs.AtomArrayStack),
|
| 456 |
+
):
|
| 457 |
+
result[(residue_name, chain_id)] = chain[selection]
|
| 458 |
+
return result
|
| 459 |
+
|
| 460 |
+
@functools.cached_property
|
| 461 |
+
def non_polymer_coords(self) -> dict[tuple, bs.AtomArray]:
|
| 462 |
+
"""Map each non-polymer component and chain to its atoms."""
|
| 463 |
+
|
| 464 |
+
if not self.structure or not self.raw:
|
| 465 |
+
return {}
|
| 466 |
+
try:
|
| 467 |
+
return self._parse_nonpoly_from_mmcif()
|
| 468 |
+
except Exception:
|
| 469 |
+
return self._parse_nonpoly_fallback()
|
fastplms/models/esmfold2/esmfold2_molecular_complex.py
ADDED
|
@@ -0,0 +1,1016 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Flat molecular-complex records used by the ESMFold2 public API.
|
| 2 |
+
|
| 3 |
+
The folding model operates on tokens and a single atom table. This module owns
|
| 4 |
+
that representation, its protein-only bridge, mmCIF I/O, structure metrics, and
|
| 5 |
+
the compact wire format. It deliberately has no dependency on the upstream
|
| 6 |
+
Biohub package; the pinned submodule is used only by differential tests.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import io
|
| 12 |
+
import os
|
| 13 |
+
import re
|
| 14 |
+
from dataclasses import asdict, dataclass
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from subprocess import check_output
|
| 17 |
+
from tempfile import TemporaryDirectory
|
| 18 |
+
from typing import TYPE_CHECKING, Any
|
| 19 |
+
|
| 20 |
+
import biotite.structure as bs
|
| 21 |
+
import biotite.structure.io.pdbx as pdbx
|
| 22 |
+
import brotli
|
| 23 |
+
import msgpack
|
| 24 |
+
import numpy as np
|
| 25 |
+
import torch
|
| 26 |
+
from biotite.structure.io.pdbx import (
|
| 27 |
+
CIFCategory,
|
| 28 |
+
CIFColumn,
|
| 29 |
+
CIFData,
|
| 30 |
+
CIFFile,
|
| 31 |
+
set_structure,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
from . import esmfold2_residue_constants as residue_constants
|
| 35 |
+
from .esmfold2_metrics import compute_lddt, compute_rmsd
|
| 36 |
+
from .esmfold2_mmcif_parsing import PLDDT_B_FACTOR_SCALE, round_mmcif_columns
|
| 37 |
+
from .esmfold2_protein_complex import ProteinComplex, ProteinComplexMetadata
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass
|
| 41 |
+
class MolecularComplexResult:
|
| 42 |
+
"""One folded complex and the optional model outputs associated with it."""
|
| 43 |
+
|
| 44 |
+
complex: MolecularComplex
|
| 45 |
+
plddt: torch.Tensor | None = None
|
| 46 |
+
ptm: float | None = None
|
| 47 |
+
iptm: float | None = None
|
| 48 |
+
pae: torch.Tensor | None = None
|
| 49 |
+
distogram: torch.Tensor | None = None
|
| 50 |
+
pair_chains_iptm: torch.Tensor | None = None
|
| 51 |
+
output_embedding_sequence: torch.Tensor | None = None
|
| 52 |
+
output_embedding_pair_pooled: torch.Tensor | None = None
|
| 53 |
+
residue_index: torch.Tensor | None = None
|
| 54 |
+
entity_id: torch.Tensor | None = None
|
| 55 |
+
sae_features: np.ndarray | None = None # X has shape (l, n_features).
|
| 56 |
+
ttt_metrics: dict[str, Any] | None = None
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@dataclass
|
| 60 |
+
class MolecularComplexMetadata:
|
| 61 |
+
"""Entity and chain labels carried with a molecular complex."""
|
| 62 |
+
|
| 63 |
+
entity_lookup: dict[int, str]
|
| 64 |
+
chain_lookup: dict[int, str]
|
| 65 |
+
assembly_composition: dict[str, list[str]] | None = None
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@dataclass
|
| 69 |
+
class Molecule:
|
| 70 |
+
"""The atom slice represented by one model token."""
|
| 71 |
+
|
| 72 |
+
token: str
|
| 73 |
+
token_idx: int
|
| 74 |
+
atom_positions: np.ndarray # P has shape (n_atoms, 3).
|
| 75 |
+
atom_elements: np.ndarray # E has shape (n_atoms,).
|
| 76 |
+
atom_names: np.ndarray | None = None # N has shape (n_atoms,) when present.
|
| 77 |
+
atom_hetero: np.ndarray | None = None # M has shape (n_atoms,) when present.
|
| 78 |
+
residue_type: int = 0
|
| 79 |
+
molecule_type: int = 0
|
| 80 |
+
confidence: float = 0.0
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
_NUCLEOTIDE_NAMES = frozenset({"A", "T", "G", "C", "U", "DA", "DT", "DG", "DC"})
|
| 84 |
+
_SERIALIZED_ARRAYS = frozenset(
|
| 85 |
+
{
|
| 86 |
+
"atom_positions",
|
| 87 |
+
"atom_elements",
|
| 88 |
+
"atom_names",
|
| 89 |
+
"atom_hetero",
|
| 90 |
+
"token_to_atoms",
|
| 91 |
+
"chain_id",
|
| 92 |
+
"entity_id",
|
| 93 |
+
"sym_id",
|
| 94 |
+
"plddt",
|
| 95 |
+
}
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _assert_table_lengths(complex_value: MolecularComplex) -> None:
|
| 100 |
+
"""Check that token and atom annotations align with their tables."""
|
| 101 |
+
if not isinstance(complex_value.sequence, list) or any(
|
| 102 |
+
not isinstance(token, str) for token in complex_value.sequence
|
| 103 |
+
):
|
| 104 |
+
raise TypeError("sequence must be a list of token strings.")
|
| 105 |
+
n_tokens = len(complex_value.sequence)
|
| 106 |
+
if not isinstance(complex_value.atom_positions, np.ndarray):
|
| 107 |
+
raise TypeError("atom_positions must be a NumPy array.")
|
| 108 |
+
if complex_value.atom_positions.ndim != 2 or complex_value.atom_positions.shape[1:] != (
|
| 109 |
+
3,
|
| 110 |
+
):
|
| 111 |
+
raise ValueError(
|
| 112 |
+
"atom_positions must have shape (n_atoms, 3), got "
|
| 113 |
+
f"{complex_value.atom_positions.shape}."
|
| 114 |
+
)
|
| 115 |
+
if not np.issubdtype(complex_value.atom_positions.dtype, np.number):
|
| 116 |
+
raise TypeError("atom_positions must use a numeric dtype.")
|
| 117 |
+
n_atoms = len(complex_value.atom_positions)
|
| 118 |
+
if not isinstance(complex_value.atom_elements, np.ndarray):
|
| 119 |
+
raise TypeError("atom_elements must be a NumPy array.")
|
| 120 |
+
if complex_value.atom_elements.shape != (n_atoms,):
|
| 121 |
+
raise ValueError(
|
| 122 |
+
f"atom_elements shape {complex_value.atom_elements.shape} != {n_atoms} atoms"
|
| 123 |
+
)
|
| 124 |
+
token_tables = {
|
| 125 |
+
"token_to_atoms": complex_value.token_to_atoms,
|
| 126 |
+
"chain_id": complex_value.chain_id,
|
| 127 |
+
"plddt": complex_value.plddt,
|
| 128 |
+
}
|
| 129 |
+
if complex_value.entity_id is not None:
|
| 130 |
+
token_tables["entity_id"] = complex_value.entity_id
|
| 131 |
+
if complex_value.sym_id is not None:
|
| 132 |
+
token_tables["sym_id"] = complex_value.sym_id
|
| 133 |
+
for label, values in token_tables.items():
|
| 134 |
+
if not isinstance(values, np.ndarray):
|
| 135 |
+
raise TypeError(f"{label} must be a NumPy array, got {type(values).__name__}.")
|
| 136 |
+
if values.ndim == 0 or values.shape[0] != n_tokens:
|
| 137 |
+
raise ValueError(f"{label} shape {values.shape} != {n_tokens} tokens")
|
| 138 |
+
if complex_value.token_to_atoms.shape != (n_tokens, 2):
|
| 139 |
+
raise ValueError(
|
| 140 |
+
"token_to_atoms must have shape "
|
| 141 |
+
f"({n_tokens}, 2), got {complex_value.token_to_atoms.shape}."
|
| 142 |
+
)
|
| 143 |
+
if not np.issubdtype(complex_value.token_to_atoms.dtype, np.integer):
|
| 144 |
+
raise TypeError("token_to_atoms must use an integer dtype.")
|
| 145 |
+
if complex_value.chain_id.shape != (n_tokens,):
|
| 146 |
+
raise ValueError(f"chain_id must have shape ({n_tokens},).")
|
| 147 |
+
for label, values in (
|
| 148 |
+
("chain_id", complex_value.chain_id),
|
| 149 |
+
("entity_id", complex_value.entity_id),
|
| 150 |
+
("sym_id", complex_value.sym_id),
|
| 151 |
+
):
|
| 152 |
+
if values is not None and values.shape != (n_tokens,):
|
| 153 |
+
raise ValueError(f"{label} must have shape ({n_tokens},).")
|
| 154 |
+
if values is not None and not np.issubdtype(values.dtype, np.integer):
|
| 155 |
+
raise TypeError(f"{label} must use an integer dtype.")
|
| 156 |
+
if complex_value.plddt.shape != (n_tokens,):
|
| 157 |
+
raise ValueError(f"plddt must have shape ({n_tokens},).")
|
| 158 |
+
if not np.issubdtype(complex_value.plddt.dtype, np.number):
|
| 159 |
+
raise TypeError("plddt must use a numeric dtype.")
|
| 160 |
+
if n_tokens:
|
| 161 |
+
starts = complex_value.token_to_atoms[:, 0]
|
| 162 |
+
stops = complex_value.token_to_atoms[:, 1]
|
| 163 |
+
if np.any(starts < 0) or np.any(stops < starts) or np.any(stops > n_atoms):
|
| 164 |
+
raise ValueError("token_to_atoms contains an invalid or out-of-bounds atom span.")
|
| 165 |
+
for label, values in (
|
| 166 |
+
("atom_names", complex_value.atom_names),
|
| 167 |
+
("atom_hetero", complex_value.atom_hetero),
|
| 168 |
+
):
|
| 169 |
+
if values is not None and not isinstance(values, np.ndarray):
|
| 170 |
+
raise TypeError(f"{label} must be a NumPy array, got {type(values).__name__}.")
|
| 171 |
+
if isinstance(values, np.ndarray) and values.shape != (n_atoms,):
|
| 172 |
+
raise ValueError(f"{label} shape {values.shape} != {n_atoms} atoms")
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def _flat_protein_atoms(
|
| 176 |
+
protein: ProteinComplex,
|
| 177 |
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
| 178 |
+
"""Flatten the populated atom37 entries of a protein complex."""
|
| 179 |
+
positions: list[np.ndarray] = []
|
| 180 |
+
elements: list[str] = []
|
| 181 |
+
names: list[str] = []
|
| 182 |
+
hetero: list[bool] = []
|
| 183 |
+
spans: list[tuple[int, int]] = []
|
| 184 |
+
|
| 185 |
+
for sequence_index, residue in enumerate(protein.sequence):
|
| 186 |
+
if residue == "|":
|
| 187 |
+
continue
|
| 188 |
+
start = len(positions)
|
| 189 |
+
mask = protein.atom37_mask[sequence_index]
|
| 190 |
+
residue_positions = protein.atom37_positions[sequence_index]
|
| 191 |
+
for atom_index in np.flatnonzero(mask):
|
| 192 |
+
atom_name = residue_constants.atom_types[int(atom_index)]
|
| 193 |
+
positions.append(residue_positions[atom_index])
|
| 194 |
+
elements.append(atom_name[0] if atom_name else "C")
|
| 195 |
+
names.append(atom_name)
|
| 196 |
+
hetero.append(False)
|
| 197 |
+
spans.append((start, len(positions)))
|
| 198 |
+
|
| 199 |
+
return (
|
| 200 |
+
np.asarray(positions, dtype=np.float32),
|
| 201 |
+
np.asarray(elements, dtype=object),
|
| 202 |
+
np.asarray(names, dtype=object),
|
| 203 |
+
np.asarray(hetero, dtype=bool),
|
| 204 |
+
np.asarray(spans, dtype=np.int32),
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def _protein_sequence_and_indices(
|
| 209 |
+
complex_value: MolecularComplex,
|
| 210 |
+
) -> tuple[list[int], str, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
| 211 |
+
protein_indices = [
|
| 212 |
+
index
|
| 213 |
+
for index, token in enumerate(complex_value.sequence)
|
| 214 |
+
if token in residue_constants.restype_3to1
|
| 215 |
+
]
|
| 216 |
+
if not protein_indices:
|
| 217 |
+
raise ValueError("No protein tokens found in MolecularComplex")
|
| 218 |
+
|
| 219 |
+
chain_ids = complex_value.chain_id[protein_indices]
|
| 220 |
+
entity_ids = (
|
| 221 |
+
chain_ids
|
| 222 |
+
if complex_value.entity_id is None
|
| 223 |
+
else complex_value.entity_id[protein_indices]
|
| 224 |
+
)
|
| 225 |
+
sym_ids = (
|
| 226 |
+
np.zeros_like(chain_ids)
|
| 227 |
+
if complex_value.sym_id is None
|
| 228 |
+
else complex_value.sym_id[protein_indices]
|
| 229 |
+
)
|
| 230 |
+
confidences = complex_value.plddt[protein_indices]
|
| 231 |
+
sequence: list[str] = []
|
| 232 |
+
previous_instance: Any = None
|
| 233 |
+
preserve_instances = complex_value.sym_id is not None
|
| 234 |
+
for index, chain_id, sym_id in zip(
|
| 235 |
+
protein_indices, chain_ids, sym_ids, strict=True
|
| 236 |
+
):
|
| 237 |
+
instance = (int(chain_id), int(sym_id)) if preserve_instances else int(chain_id)
|
| 238 |
+
if previous_instance is not None and instance != previous_instance:
|
| 239 |
+
sequence.append("|")
|
| 240 |
+
sequence.append(residue_constants.restype_3to1[complex_value.sequence[index]])
|
| 241 |
+
previous_instance = instance
|
| 242 |
+
return protein_indices, "".join(sequence), chain_ids, entity_ids, sym_ids, confidences
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def _protein_entity_metadata_value(value: int | str) -> int | str:
|
| 246 |
+
"""Restore the numeric entity labels used by ProteinComplex metadata."""
|
| 247 |
+
if isinstance(value, str):
|
| 248 |
+
try:
|
| 249 |
+
return int(value)
|
| 250 |
+
except ValueError:
|
| 251 |
+
pass
|
| 252 |
+
return value
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def _atom37_from_flat(
|
| 256 |
+
complex_value: MolecularComplex, protein_indices: list[int]
|
| 257 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 258 |
+
n_residues = len(protein_indices)
|
| 259 |
+
positions = np.full((n_residues, 37, 3), np.nan, dtype=np.float32)
|
| 260 |
+
mask = np.zeros((n_residues, 37), dtype=bool)
|
| 261 |
+
if complex_value.atom_names is None:
|
| 262 |
+
return positions, mask
|
| 263 |
+
|
| 264 |
+
for residue_index, token_index in enumerate(protein_indices):
|
| 265 |
+
start, stop = complex_value.token_to_atoms[token_index]
|
| 266 |
+
seen: set[str] = set()
|
| 267 |
+
for atom_name, atom_position in zip(
|
| 268 |
+
complex_value.atom_names[start:stop],
|
| 269 |
+
complex_value.atom_positions[start:stop],
|
| 270 |
+
strict=True,
|
| 271 |
+
):
|
| 272 |
+
normalized = str(atom_name).upper().strip()
|
| 273 |
+
if normalized in seen:
|
| 274 |
+
continue
|
| 275 |
+
seen.add(normalized)
|
| 276 |
+
atom37_index = residue_constants.atom_order.get(normalized)
|
| 277 |
+
if atom37_index is not None:
|
| 278 |
+
positions[residue_index, atom37_index] = atom_position
|
| 279 |
+
mask[residue_index, atom37_index] = True
|
| 280 |
+
return positions, mask
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def _expand_protein_rows(
|
| 284 |
+
sequence: str,
|
| 285 |
+
protein_chain_ids: np.ndarray,
|
| 286 |
+
protein_entity_ids: np.ndarray,
|
| 287 |
+
protein_sym_ids: np.ndarray,
|
| 288 |
+
confidences: np.ndarray,
|
| 289 |
+
compact_positions: np.ndarray,
|
| 290 |
+
compact_mask: np.ndarray,
|
| 291 |
+
) -> dict[str, np.ndarray]:
|
| 292 |
+
"""Insert empty rows at chain separators in a protein representation."""
|
| 293 |
+
n_positions = len(sequence)
|
| 294 |
+
expanded = {
|
| 295 |
+
"chain_id": np.full(n_positions, -1, dtype=np.int64),
|
| 296 |
+
"entity_id": np.full(n_positions, -1, dtype=np.int64),
|
| 297 |
+
"sym_id": np.zeros(n_positions, dtype=np.int64),
|
| 298 |
+
"residue_index": np.zeros(n_positions, dtype=np.int64),
|
| 299 |
+
"insertion_code": np.asarray([""] * n_positions, dtype=object),
|
| 300 |
+
"confidence": np.zeros(n_positions, dtype=np.float32),
|
| 301 |
+
"atom37_positions": np.full((n_positions, 37, 3), np.nan, dtype=np.float32),
|
| 302 |
+
"atom37_mask": np.zeros((n_positions, 37), dtype=bool),
|
| 303 |
+
}
|
| 304 |
+
residue_number = 0
|
| 305 |
+
compact_index = 0
|
| 306 |
+
for sequence_index, residue in enumerate(sequence):
|
| 307 |
+
if residue == "|":
|
| 308 |
+
residue_number = 0
|
| 309 |
+
continue
|
| 310 |
+
chain_id = protein_chain_ids[compact_index]
|
| 311 |
+
residue_number += 1
|
| 312 |
+
expanded["chain_id"][sequence_index] = chain_id
|
| 313 |
+
expanded["entity_id"][sequence_index] = protein_entity_ids[compact_index]
|
| 314 |
+
expanded["sym_id"][sequence_index] = protein_sym_ids[compact_index]
|
| 315 |
+
expanded["residue_index"][sequence_index] = residue_number
|
| 316 |
+
expanded["confidence"][sequence_index] = confidences[compact_index]
|
| 317 |
+
expanded["atom37_positions"][sequence_index] = compact_positions[compact_index]
|
| 318 |
+
expanded["atom37_mask"][sequence_index] = compact_mask[compact_index]
|
| 319 |
+
compact_index += 1
|
| 320 |
+
return expanded
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def _read_cif(source: str) -> CIFFile:
|
| 324 |
+
if os.path.exists(source):
|
| 325 |
+
return pdbx.CIFFile.read(source)
|
| 326 |
+
return pdbx.CIFFile.read(io.StringIO(source))
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
def _read_structure(cif_file: CIFFile) -> Any:
|
| 330 |
+
try:
|
| 331 |
+
return pdbx.get_structure(cif_file, model=1, extra_fields=["b_factor"])
|
| 332 |
+
except (KeyError, ValueError):
|
| 333 |
+
try:
|
| 334 |
+
return pdbx.get_structure(cif_file)
|
| 335 |
+
except Exception:
|
| 336 |
+
return pdbx.get_structure(cif_file, model=None)
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def _column_array(category: Any, name: str) -> np.ndarray:
|
| 340 |
+
column = category[name]
|
| 341 |
+
if hasattr(column, "as_array"):
|
| 342 |
+
return column.as_array(str)
|
| 343 |
+
return np.asarray(list(column), dtype=str)
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def _label_asym_ids(cif_file: CIFFile, n_structure_atoms: int) -> list[str] | None:
|
| 347 |
+
"""Return label-asym identifiers after applying Biohub's atom filters."""
|
| 348 |
+
block = cif_file.block
|
| 349 |
+
if "atom_site" not in block or "label_asym_id" not in block["atom_site"]:
|
| 350 |
+
return None
|
| 351 |
+
atom_site = block["atom_site"]
|
| 352 |
+
labels = _column_array(atom_site, "label_asym_id")
|
| 353 |
+
keep = np.ones(len(labels), dtype=bool)
|
| 354 |
+
if "pdbx_PDB_model_num" in atom_site:
|
| 355 |
+
keep &= _column_array(atom_site, "pdbx_PDB_model_num") == "1"
|
| 356 |
+
if "label_alt_id" in atom_site:
|
| 357 |
+
keep &= np.isin(_column_array(atom_site, "label_alt_id"), [".", "?", "", "A"])
|
| 358 |
+
filtered = labels[keep]
|
| 359 |
+
return filtered.tolist() if len(filtered) == n_structure_atoms else None
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def _entity_metadata(cif_file: CIFFile) -> dict[Any, Any]:
|
| 363 |
+
result: dict[Any, Any] = {}
|
| 364 |
+
try:
|
| 365 |
+
category = cif_file.block["entity"]
|
| 366 |
+
if "id" not in category or "type" not in category:
|
| 367 |
+
return result
|
| 368 |
+
for entity_id, entity_type in zip(category["id"], category["type"], strict=False):
|
| 369 |
+
result[entity_id] = entity_type
|
| 370 |
+
except Exception:
|
| 371 |
+
return {}
|
| 372 |
+
return result
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
def _group_structure_atoms(
|
| 376 |
+
structure: Any, labels: list[str] | None
|
| 377 |
+
) -> dict[str, dict[tuple[int, str], dict[str, Any]]]:
|
| 378 |
+
grouped: dict[str, dict[tuple[int, str], dict[str, Any]]] = {}
|
| 379 |
+
for atom_index, atom in enumerate(structure):
|
| 380 |
+
chain = labels[atom_index] if labels is not None else atom.chain_id
|
| 381 |
+
residues = grouped.setdefault(chain, {})
|
| 382 |
+
key = (atom.res_id, atom.res_name)
|
| 383 |
+
record = residues.setdefault(
|
| 384 |
+
key,
|
| 385 |
+
{"atoms": [], "res_name": atom.res_name, "is_hetero": atom.hetero},
|
| 386 |
+
)
|
| 387 |
+
record["atoms"].append(atom)
|
| 388 |
+
return grouped
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
def _flatten_structure_groups(
|
| 392 |
+
grouped: dict[str, dict[tuple[int, str], dict[str, Any]]],
|
| 393 |
+
) -> tuple[
|
| 394 |
+
list[str],
|
| 395 |
+
list[np.ndarray],
|
| 396 |
+
list[str],
|
| 397 |
+
list[str],
|
| 398 |
+
list[bool],
|
| 399 |
+
list[tuple[int, int]],
|
| 400 |
+
list[float],
|
| 401 |
+
list[int],
|
| 402 |
+
dict[str, int],
|
| 403 |
+
]:
|
| 404 |
+
tokens: list[str] = []
|
| 405 |
+
positions: list[np.ndarray] = []
|
| 406 |
+
elements: list[str] = []
|
| 407 |
+
names: list[str] = []
|
| 408 |
+
hetero: list[bool] = []
|
| 409 |
+
spans: list[tuple[int, int]] = []
|
| 410 |
+
confidences: list[float] = []
|
| 411 |
+
token_chains: list[int] = []
|
| 412 |
+
chain_numbers = {chain: index for index, chain in enumerate(sorted(grouped))}
|
| 413 |
+
|
| 414 |
+
for chain in sorted(grouped):
|
| 415 |
+
for residue_key in sorted(grouped[chain]):
|
| 416 |
+
record = grouped[chain][residue_key]
|
| 417 |
+
if record["res_name"] == "HOH":
|
| 418 |
+
continue
|
| 419 |
+
atoms = record["atoms"]
|
| 420 |
+
tokens.append(record["res_name"])
|
| 421 |
+
token_chains.append(chain_numbers[chain])
|
| 422 |
+
start = len(positions)
|
| 423 |
+
positions.extend(atom.coord for atom in atoms)
|
| 424 |
+
elements.extend(atom.element for atom in atoms)
|
| 425 |
+
names.extend(atom.atom_name for atom in atoms)
|
| 426 |
+
hetero.extend(atom.hetero for atom in atoms)
|
| 427 |
+
spans.append((start, len(positions)))
|
| 428 |
+
b_factor = getattr(atoms[0], "b_factor", 50.0) if atoms else 50.0
|
| 429 |
+
confidences.append(min(b_factor / PLDDT_B_FACTOR_SCALE, 1.0))
|
| 430 |
+
return (
|
| 431 |
+
tokens,
|
| 432 |
+
positions,
|
| 433 |
+
elements,
|
| 434 |
+
names,
|
| 435 |
+
hetero,
|
| 436 |
+
spans,
|
| 437 |
+
confidences,
|
| 438 |
+
token_chains,
|
| 439 |
+
chain_numbers,
|
| 440 |
+
)
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
def _chain_entity_maps(
|
| 444 |
+
complex_value: MolecularComplex,
|
| 445 |
+
) -> tuple[dict[str, list[str]], dict[str, int], dict[int, tuple[str, ...]]]:
|
| 446 |
+
chains: dict[str, list[str]] = {}
|
| 447 |
+
for token_index, numeric_chain in enumerate(complex_value.chain_id):
|
| 448 |
+
numeric = int(numeric_chain)
|
| 449 |
+
label = complex_value.metadata.chain_lookup.get(numeric, chr(65 + numeric))
|
| 450 |
+
chains.setdefault(label, []).append(complex_value.sequence[token_index])
|
| 451 |
+
|
| 452 |
+
sequence_entities: dict[tuple[str, ...], int] = {}
|
| 453 |
+
chain_entities: dict[str, int] = {}
|
| 454 |
+
entity_sequences: dict[int, tuple[str, ...]] = {}
|
| 455 |
+
for label, sequence in chains.items():
|
| 456 |
+
key = tuple(sequence)
|
| 457 |
+
entity_id = sequence_entities.get(key)
|
| 458 |
+
if entity_id is None:
|
| 459 |
+
entity_id = len(sequence_entities) + 1
|
| 460 |
+
sequence_entities[key] = entity_id
|
| 461 |
+
entity_sequences[entity_id] = key
|
| 462 |
+
chain_entities[label] = entity_id
|
| 463 |
+
return chains, chain_entities, entity_sequences
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
def _cif_column(values: list[str]) -> CIFColumn:
|
| 467 |
+
return CIFColumn(data=CIFData(array=np.asarray(values), dtype=np.str_))
|
| 468 |
+
|
| 469 |
+
|
| 470 |
+
def _add_entity_categories(
|
| 471 |
+
cif_file: CIFFile,
|
| 472 |
+
complex_value: MolecularComplex,
|
| 473 |
+
entity_sequences: dict[int, tuple[str, ...]],
|
| 474 |
+
) -> None:
|
| 475 |
+
ids: list[str] = []
|
| 476 |
+
types: list[str] = []
|
| 477 |
+
descriptions: list[str] = []
|
| 478 |
+
for entity_id in sorted(entity_sequences):
|
| 479 |
+
sequence = entity_sequences[entity_id]
|
| 480 |
+
protein = any(token in residue_constants.restype_3to1 for token in sequence)
|
| 481 |
+
nucleic = any(token in _NUCLEOTIDE_NAMES for token in sequence)
|
| 482 |
+
ids.append(str(entity_id))
|
| 483 |
+
types.append("polymer" if protein or nucleic else "non-polymer")
|
| 484 |
+
if protein:
|
| 485 |
+
descriptions.append(f"Polymer entity {entity_id} (protein)")
|
| 486 |
+
elif nucleic:
|
| 487 |
+
descriptions.append(f"Polymer entity {entity_id} (nucleic acid)")
|
| 488 |
+
else:
|
| 489 |
+
descriptions.append(f"Non-polymer entity {entity_id}")
|
| 490 |
+
|
| 491 |
+
if ids:
|
| 492 |
+
cif_file.block["entity"] = CIFCategory(
|
| 493 |
+
name="entity",
|
| 494 |
+
columns={
|
| 495 |
+
"id": _cif_column(ids),
|
| 496 |
+
"type": _cif_column(types),
|
| 497 |
+
"pdbx_description": _cif_column(descriptions),
|
| 498 |
+
},
|
| 499 |
+
)
|
| 500 |
+
|
| 501 |
+
_, chain_entities, _ = _chain_entity_maps(complex_value)
|
| 502 |
+
if chain_entities:
|
| 503 |
+
labels = sorted(chain_entities)
|
| 504 |
+
cif_file.block["struct_asym"] = CIFCategory(
|
| 505 |
+
name="struct_asym",
|
| 506 |
+
columns={
|
| 507 |
+
"id": _cif_column(labels),
|
| 508 |
+
"entity_id": _cif_column([str(chain_entities[label]) for label in labels]),
|
| 509 |
+
},
|
| 510 |
+
)
|
| 511 |
+
|
| 512 |
+
entity_chains: dict[int, list[str]] = {}
|
| 513 |
+
for chain, entity_id in chain_entities.items():
|
| 514 |
+
entity_chains.setdefault(entity_id, []).append(chain)
|
| 515 |
+
polymer_rows: list[tuple[str, str, str, str]] = []
|
| 516 |
+
residue_rows: list[tuple[str, str, str, str]] = []
|
| 517 |
+
for entity_id in sorted(entity_sequences):
|
| 518 |
+
sequence = entity_sequences[entity_id]
|
| 519 |
+
protein = any(token in residue_constants.restype_3to1 for token in sequence)
|
| 520 |
+
nucleic = any(token in _NUCLEOTIDE_NAMES for token in sequence)
|
| 521 |
+
if not (protein or nucleic):
|
| 522 |
+
continue
|
| 523 |
+
if protein:
|
| 524 |
+
polymer_type = "polypeptide(L)"
|
| 525 |
+
canonical = "".join(
|
| 526 |
+
residue_constants.restype_3to1.get(token, "(X)") for token in sequence
|
| 527 |
+
)
|
| 528 |
+
else:
|
| 529 |
+
polymer_type = (
|
| 530 |
+
"polyribonucleotide"
|
| 531 |
+
if "U" in sequence
|
| 532 |
+
else (
|
| 533 |
+
"polydeoxyribonucleotide"
|
| 534 |
+
if any(token in {"DA", "DT", "DG", "DC"} for token in sequence)
|
| 535 |
+
else "polyribonucleotide"
|
| 536 |
+
)
|
| 537 |
+
)
|
| 538 |
+
nucleotide_letters = {"DA": "A", "DT": "T", "DG": "G", "DC": "C"}
|
| 539 |
+
canonical = "".join(nucleotide_letters.get(token, token) for token in sequence)
|
| 540 |
+
strand_ids = ",".join(sorted(entity_chains.get(entity_id, []))) or "?"
|
| 541 |
+
polymer_rows.append((str(entity_id), polymer_type, strand_ids, canonical))
|
| 542 |
+
residue_rows.extend(
|
| 543 |
+
(str(entity_id), str(number), token, "n")
|
| 544 |
+
for number, token in enumerate(sequence, start=1)
|
| 545 |
+
)
|
| 546 |
+
|
| 547 |
+
if polymer_rows:
|
| 548 |
+
columns = list(zip(*polymer_rows, strict=True))
|
| 549 |
+
cif_file.block["entity_poly"] = CIFCategory(
|
| 550 |
+
name="entity_poly",
|
| 551 |
+
columns={
|
| 552 |
+
"entity_id": _cif_column(list(columns[0])),
|
| 553 |
+
"type": _cif_column(list(columns[1])),
|
| 554 |
+
"pdbx_strand_id": _cif_column(list(columns[2])),
|
| 555 |
+
"pdbx_seq_one_letter_code_can": _cif_column(list(columns[3])),
|
| 556 |
+
},
|
| 557 |
+
)
|
| 558 |
+
if residue_rows:
|
| 559 |
+
columns = list(zip(*residue_rows, strict=True))
|
| 560 |
+
cif_file.block["entity_poly_seq"] = CIFCategory(
|
| 561 |
+
name="entity_poly_seq",
|
| 562 |
+
columns={
|
| 563 |
+
"entity_id": _cif_column(list(columns[0])),
|
| 564 |
+
"num": _cif_column(list(columns[1])),
|
| 565 |
+
"mon_id": _cif_column(list(columns[2])),
|
| 566 |
+
"hetero": _cif_column(list(columns[3])),
|
| 567 |
+
},
|
| 568 |
+
)
|
| 569 |
+
|
| 570 |
+
|
| 571 |
+
def _fallback_atom_names(token: str, count: int) -> list[str]:
|
| 572 |
+
if token in residue_constants.restype_3to1:
|
| 573 |
+
names = list(residue_constants.residue_atoms.get(token, ["N", "CA", "C", "O"]))[:count]
|
| 574 |
+
names.extend(f"X{index + 1}" for index in range(len(names), count))
|
| 575 |
+
return names
|
| 576 |
+
return [f"C{index + 1}" for index in range(count)]
|
| 577 |
+
|
| 578 |
+
|
| 579 |
+
def _as_atom_array(complex_value: MolecularComplex, chain_entities: dict[str, int]) -> bs.AtomArray:
|
| 580 |
+
n_atoms = len(complex_value.atom_positions)
|
| 581 |
+
atom_array = bs.AtomArray(length=n_atoms)
|
| 582 |
+
atom_array.coord = complex_value.atom_positions
|
| 583 |
+
residue_ids = np.zeros(n_atoms, dtype=np.int32)
|
| 584 |
+
chain_labels = np.empty(n_atoms, dtype=object)
|
| 585 |
+
residue_names = np.empty(n_atoms, dtype=object)
|
| 586 |
+
hetero = np.zeros(n_atoms, dtype=bool)
|
| 587 |
+
b_factors = np.zeros(n_atoms, dtype=np.float32)
|
| 588 |
+
atom_names = np.empty(n_atoms, dtype=object)
|
| 589 |
+
entity_ids = np.zeros(n_atoms, dtype=np.int32)
|
| 590 |
+
next_residue: dict[Any, int] = {}
|
| 591 |
+
|
| 592 |
+
for token_index, (start, stop) in enumerate(complex_value.token_to_atoms):
|
| 593 |
+
token = complex_value.sequence[token_index]
|
| 594 |
+
numeric_chain = complex_value.chain_id[token_index]
|
| 595 |
+
numeric = int(numeric_chain)
|
| 596 |
+
chain = complex_value.metadata.chain_lookup.get(numeric, chr(65 + numeric))
|
| 597 |
+
residue_id = next_residue.get(numeric_chain, 0) + 1
|
| 598 |
+
next_residue[numeric_chain] = residue_id
|
| 599 |
+
count = int(stop - start)
|
| 600 |
+
names = (
|
| 601 |
+
list(complex_value.atom_names[start:stop])
|
| 602 |
+
if complex_value.atom_names is not None
|
| 603 |
+
else _fallback_atom_names(token, count)
|
| 604 |
+
)
|
| 605 |
+
residue_ids[start:stop] = residue_id
|
| 606 |
+
chain_labels[start:stop] = chain
|
| 607 |
+
residue_names[start:stop] = token
|
| 608 |
+
hetero[start:stop] = (
|
| 609 |
+
complex_value.atom_hetero[start:stop]
|
| 610 |
+
if complex_value.atom_hetero is not None
|
| 611 |
+
else token not in residue_constants.restype_3to1
|
| 612 |
+
)
|
| 613 |
+
b_factors[start:stop] = complex_value.plddt[token_index] * PLDDT_B_FACTOR_SCALE
|
| 614 |
+
atom_names[start:stop] = names
|
| 615 |
+
entity_ids[start:stop] = chain_entities.get(chain, 1)
|
| 616 |
+
|
| 617 |
+
atom_array.res_id = residue_ids
|
| 618 |
+
atom_array.chain_id = np.asarray(chain_labels, dtype="U16")
|
| 619 |
+
atom_array.res_name = np.asarray(residue_names, dtype="U8")
|
| 620 |
+
atom_array.hetero = hetero
|
| 621 |
+
atom_array.atom_name = np.asarray(atom_names, dtype="U4")
|
| 622 |
+
atom_array.add_annotation("b_factor", dtype=float)
|
| 623 |
+
atom_array.b_factor = b_factors
|
| 624 |
+
atom_array.add_annotation("occupancy", dtype=float)
|
| 625 |
+
atom_array.occupancy = np.ones(n_atoms, dtype=np.float32)
|
| 626 |
+
atom_array.add_annotation("entity_id", dtype=int)
|
| 627 |
+
atom_array.entity_id = entity_ids
|
| 628 |
+
if complex_value.atom_elements is not None and len(complex_value.atom_elements) == n_atoms:
|
| 629 |
+
atom_array.element = np.asarray(complex_value.atom_elements, dtype="U4")
|
| 630 |
+
else:
|
| 631 |
+
atom_array.element = bs.infer_elements(atom_array)
|
| 632 |
+
return atom_array
|
| 633 |
+
|
| 634 |
+
|
| 635 |
+
def _repair_label_entity_ids(cif_file: CIFFile, chain_entities: dict[str, int]) -> None:
|
| 636 |
+
if "atom_site" not in cif_file.block:
|
| 637 |
+
return
|
| 638 |
+
atom_site = cif_file.block["atom_site"]
|
| 639 |
+
if "label_asym_id" not in atom_site or "label_entity_id" not in atom_site:
|
| 640 |
+
return
|
| 641 |
+
labels = _column_array(atom_site, "label_asym_id").tolist()
|
| 642 |
+
if labels:
|
| 643 |
+
atom_site["label_entity_id"] = _cif_column(
|
| 644 |
+
[str(chain_entities.get(label, 1)) for label in labels]
|
| 645 |
+
)
|
| 646 |
+
|
| 647 |
+
|
| 648 |
+
def _centroid_tensors(
|
| 649 |
+
mobile: MolecularComplex,
|
| 650 |
+
target: MolecularComplex,
|
| 651 |
+
*,
|
| 652 |
+
retain_missing: bool,
|
| 653 |
+
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
| 654 |
+
if len(mobile) != len(target):
|
| 655 |
+
raise ValueError(
|
| 656 |
+
f"Complexes must have the same number of tokens: {len(mobile)} vs {len(target)}"
|
| 657 |
+
)
|
| 658 |
+
mobile_centers: list[np.ndarray] = []
|
| 659 |
+
target_centers: list[np.ndarray] = []
|
| 660 |
+
valid: list[bool] = []
|
| 661 |
+
for token_index in range(len(mobile)):
|
| 662 |
+
mobile_start, mobile_stop = mobile.token_to_atoms[token_index]
|
| 663 |
+
target_start, target_stop = target.token_to_atoms[token_index]
|
| 664 |
+
mobile_atoms = mobile.atom_positions[mobile_start:mobile_stop]
|
| 665 |
+
target_atoms = target.atom_positions[target_start:target_stop]
|
| 666 |
+
present = len(mobile_atoms) > 0 and len(target_atoms) > 0
|
| 667 |
+
if not present and not retain_missing:
|
| 668 |
+
continue
|
| 669 |
+
if present:
|
| 670 |
+
mobile_centers.append(mobile_atoms.mean(axis=0))
|
| 671 |
+
target_centers.append(target_atoms.mean(axis=0))
|
| 672 |
+
else:
|
| 673 |
+
mobile_centers.append(np.full(3, np.nan))
|
| 674 |
+
target_centers.append(np.full(3, np.nan))
|
| 675 |
+
valid.append(present)
|
| 676 |
+
if not any(valid):
|
| 677 |
+
metric = "LDDT" if retain_missing else "RMSD"
|
| 678 |
+
raise ValueError(f"No valid atoms found for {metric} computation")
|
| 679 |
+
return (
|
| 680 |
+
torch.from_numpy(np.stack(mobile_centers)).unsqueeze(0),
|
| 681 |
+
torch.from_numpy(np.stack(target_centers)).unsqueeze(0),
|
| 682 |
+
torch.as_tensor(valid, dtype=torch.bool).unsqueeze(0),
|
| 683 |
+
)
|
| 684 |
+
|
| 685 |
+
|
| 686 |
+
@dataclass(frozen=True)
|
| 687 |
+
class MolecularComplex:
|
| 688 |
+
"""A token sequence backed by one contiguous atom table.
|
| 689 |
+
|
| 690 |
+
P stores atom coordinates with shape (n_atoms, 3). Token span ``i`` is
|
| 691 |
+
``P[token_to_atoms[i, 0]:token_to_atoms[i, 1]]``. ``chain_id`` identifies
|
| 692 |
+
the author chain, while optional ``entity_id`` and ``sym_id`` distinguish
|
| 693 |
+
biological entities and repeated chain instances.
|
| 694 |
+
"""
|
| 695 |
+
|
| 696 |
+
id: str
|
| 697 |
+
sequence: list[str]
|
| 698 |
+
atom_positions: np.ndarray # P has shape (n_atoms, 3).
|
| 699 |
+
atom_elements: np.ndarray # E has shape (n_atoms,).
|
| 700 |
+
token_to_atoms: np.ndarray # I has shape (n_tokens, 2).
|
| 701 |
+
chain_id: np.ndarray # C has shape (n_tokens,).
|
| 702 |
+
plddt: np.ndarray # S has shape (n_tokens,).
|
| 703 |
+
metadata: MolecularComplexMetadata
|
| 704 |
+
atom_names: np.ndarray | None = None # N has shape (n_atoms,) when present.
|
| 705 |
+
atom_hetero: np.ndarray | None = None # M has shape (n_atoms,) when present.
|
| 706 |
+
# These token-aligned IDs are optional for compatibility with older blobs.
|
| 707 |
+
# ProteinComplex adapters populate them so homomers and repeated author-chain
|
| 708 |
+
# labels survive a MolecularComplex round trip.
|
| 709 |
+
entity_id: np.ndarray | None = None
|
| 710 |
+
sym_id: np.ndarray | None = None
|
| 711 |
+
|
| 712 |
+
def __post_init__(self) -> None:
|
| 713 |
+
_assert_table_lengths(self)
|
| 714 |
+
|
| 715 |
+
def __len__(self) -> int:
|
| 716 |
+
return len(self.sequence)
|
| 717 |
+
|
| 718 |
+
def __getitem__(self, idx: int) -> Molecule:
|
| 719 |
+
if idx < 0 or idx >= len(self):
|
| 720 |
+
raise IndexError(f"Token index {idx} out of range for {len(self)} tokens")
|
| 721 |
+
start, stop = self.token_to_atoms[idx]
|
| 722 |
+
return Molecule(
|
| 723 |
+
token=self.sequence[idx],
|
| 724 |
+
token_idx=idx,
|
| 725 |
+
atom_positions=self.atom_positions[start:stop],
|
| 726 |
+
atom_elements=self.atom_elements[start:stop],
|
| 727 |
+
atom_names=None if self.atom_names is None else self.atom_names[start:stop],
|
| 728 |
+
atom_hetero=(None if self.atom_hetero is None else self.atom_hetero[start:stop]),
|
| 729 |
+
residue_type=0,
|
| 730 |
+
molecule_type=0,
|
| 731 |
+
confidence=self.plddt[idx],
|
| 732 |
+
)
|
| 733 |
+
|
| 734 |
+
@property
|
| 735 |
+
def atom_coordinates(self) -> np.ndarray:
|
| 736 |
+
"""Return P, the flat atom-coordinate table with shape (n_atoms, 3)."""
|
| 737 |
+
return self.atom_positions
|
| 738 |
+
|
| 739 |
+
@classmethod
|
| 740 |
+
def from_protein_complex(cls, pc: ProteinComplex) -> MolecularComplex:
|
| 741 |
+
positions, elements, names, hetero, spans = _flat_protein_atoms(pc)
|
| 742 |
+
residue_positions = [index for index, value in enumerate(pc.sequence) if value != "|"]
|
| 743 |
+
metadata = MolecularComplexMetadata(
|
| 744 |
+
entity_lookup={key: str(value) for key, value in pc.metadata.entity_lookup.items()},
|
| 745 |
+
chain_lookup=dict(pc.metadata.chain_lookup),
|
| 746 |
+
assembly_composition=pc.metadata.assembly_composition,
|
| 747 |
+
)
|
| 748 |
+
return cls(
|
| 749 |
+
id=pc.id,
|
| 750 |
+
sequence=[
|
| 751 |
+
residue_constants.restype_1to3.get(pc.sequence[index], "UNK")
|
| 752 |
+
for index in residue_positions
|
| 753 |
+
],
|
| 754 |
+
atom_positions=positions,
|
| 755 |
+
atom_elements=elements,
|
| 756 |
+
token_to_atoms=spans,
|
| 757 |
+
chain_id=np.asarray(pc.chain_id[residue_positions], dtype=np.int64),
|
| 758 |
+
plddt=np.asarray(pc.confidence[residue_positions], dtype=np.float32),
|
| 759 |
+
metadata=metadata,
|
| 760 |
+
atom_names=names,
|
| 761 |
+
atom_hetero=hetero,
|
| 762 |
+
entity_id=np.asarray(pc.entity_id[residue_positions], dtype=np.int64),
|
| 763 |
+
sym_id=np.asarray(pc.sym_id[residue_positions], dtype=np.int64),
|
| 764 |
+
)
|
| 765 |
+
|
| 766 |
+
def to_protein_complex(self) -> ProteinComplex:
|
| 767 |
+
(
|
| 768 |
+
protein_indices,
|
| 769 |
+
sequence,
|
| 770 |
+
chain_ids,
|
| 771 |
+
entity_ids,
|
| 772 |
+
sym_ids,
|
| 773 |
+
confidences,
|
| 774 |
+
) = _protein_sequence_and_indices(self)
|
| 775 |
+
compact_positions, compact_mask = _atom37_from_flat(self, protein_indices)
|
| 776 |
+
arrays = _expand_protein_rows(
|
| 777 |
+
sequence,
|
| 778 |
+
chain_ids,
|
| 779 |
+
entity_ids,
|
| 780 |
+
sym_ids,
|
| 781 |
+
confidences,
|
| 782 |
+
compact_positions,
|
| 783 |
+
compact_mask,
|
| 784 |
+
)
|
| 785 |
+
unique_chains = np.unique(chain_ids)
|
| 786 |
+
unique_entities = np.unique(entity_ids)
|
| 787 |
+
metadata = ProteinComplexMetadata(
|
| 788 |
+
entity_lookup={
|
| 789 |
+
int(entity): _protein_entity_metadata_value(
|
| 790 |
+
self.metadata.entity_lookup.get(int(entity), int(entity))
|
| 791 |
+
)
|
| 792 |
+
for entity in unique_entities
|
| 793 |
+
},
|
| 794 |
+
chain_lookup={
|
| 795 |
+
int(chain): self.metadata.chain_lookup.get(int(chain), chr(65 + int(chain)))
|
| 796 |
+
for chain in unique_chains
|
| 797 |
+
},
|
| 798 |
+
assembly_composition=self.metadata.assembly_composition,
|
| 799 |
+
)
|
| 800 |
+
return ProteinComplex(
|
| 801 |
+
id=self.id,
|
| 802 |
+
sequence=sequence,
|
| 803 |
+
entity_id=arrays["entity_id"],
|
| 804 |
+
chain_id=arrays["chain_id"],
|
| 805 |
+
sym_id=arrays["sym_id"],
|
| 806 |
+
residue_index=arrays["residue_index"],
|
| 807 |
+
insertion_code=arrays["insertion_code"],
|
| 808 |
+
atom37_positions=arrays["atom37_positions"],
|
| 809 |
+
atom37_mask=arrays["atom37_mask"],
|
| 810 |
+
confidence=arrays["confidence"],
|
| 811 |
+
metadata=metadata,
|
| 812 |
+
)
|
| 813 |
+
|
| 814 |
+
@classmethod
|
| 815 |
+
def from_mmcif(cls, inp: str, id: str | None = None) -> MolecularComplex:
|
| 816 |
+
cif_file = _read_cif(inp)
|
| 817 |
+
structure = _read_structure(cif_file)
|
| 818 |
+
if TYPE_CHECKING:
|
| 819 |
+
structure: Any = structure
|
| 820 |
+
labels = _label_asym_ids(cif_file, len(structure))
|
| 821 |
+
grouped = _group_structure_atoms(structure, labels)
|
| 822 |
+
(
|
| 823 |
+
tokens,
|
| 824 |
+
positions,
|
| 825 |
+
elements,
|
| 826 |
+
names,
|
| 827 |
+
hetero,
|
| 828 |
+
spans,
|
| 829 |
+
confidences,
|
| 830 |
+
token_chains,
|
| 831 |
+
chain_numbers,
|
| 832 |
+
) = _flatten_structure_groups(grouped)
|
| 833 |
+
n_tokens = len(tokens)
|
| 834 |
+
if positions:
|
| 835 |
+
position_array = np.asarray(positions, dtype=np.float32)
|
| 836 |
+
element_array = np.asarray(elements, dtype=object)
|
| 837 |
+
name_array = np.asarray(names, dtype=object)
|
| 838 |
+
hetero_array = np.asarray(hetero, dtype=bool)
|
| 839 |
+
span_array = np.asarray(spans, dtype=np.int32)
|
| 840 |
+
chain_array = np.asarray(token_chains, dtype=np.int64)
|
| 841 |
+
else:
|
| 842 |
+
position_array = np.zeros((0, 3), dtype=np.float32)
|
| 843 |
+
element_array = np.zeros(0, dtype=object)
|
| 844 |
+
name_array = np.zeros(0, dtype=object)
|
| 845 |
+
hetero_array = np.zeros(0, dtype=bool)
|
| 846 |
+
span_array = np.zeros((n_tokens, 2), dtype=np.int32)
|
| 847 |
+
chain_array = (
|
| 848 |
+
np.asarray(token_chains, dtype=np.int64)
|
| 849 |
+
if token_chains
|
| 850 |
+
else np.zeros(n_tokens, dtype=np.int64)
|
| 851 |
+
)
|
| 852 |
+
complex_id = id or (Path(inp).stem if os.path.exists(inp) else "complex_from_string")
|
| 853 |
+
return cls(
|
| 854 |
+
id=complex_id,
|
| 855 |
+
sequence=tokens,
|
| 856 |
+
atom_positions=position_array,
|
| 857 |
+
atom_elements=element_array,
|
| 858 |
+
token_to_atoms=span_array,
|
| 859 |
+
chain_id=chain_array,
|
| 860 |
+
plddt=np.asarray(confidences, dtype=np.float32),
|
| 861 |
+
metadata=MolecularComplexMetadata(
|
| 862 |
+
entity_lookup=_entity_metadata(cif_file),
|
| 863 |
+
chain_lookup={number: chain for chain, number in chain_numbers.items()},
|
| 864 |
+
assembly_composition=None,
|
| 865 |
+
),
|
| 866 |
+
atom_names=name_array,
|
| 867 |
+
atom_hetero=hetero_array,
|
| 868 |
+
)
|
| 869 |
+
|
| 870 |
+
def _get_entity_mapping(
|
| 871 |
+
self,
|
| 872 |
+
) -> tuple[dict[str, list[str]], dict[str, int], dict[int, tuple[str, ...]]]:
|
| 873 |
+
return _chain_entity_maps(self)
|
| 874 |
+
|
| 875 |
+
def _add_entity_information(
|
| 876 |
+
self, cif_file: CIFFile, entity_sequences: dict[int, tuple[str, ...]]
|
| 877 |
+
) -> None:
|
| 878 |
+
_add_entity_categories(cif_file, self, entity_sequences)
|
| 879 |
+
|
| 880 |
+
def to_mmcif(self) -> str:
|
| 881 |
+
_, chain_entities, entity_sequences = _chain_entity_maps(self)
|
| 882 |
+
atom_array = _as_atom_array(self, chain_entities)
|
| 883 |
+
cif_file = CIFFile()
|
| 884 |
+
set_structure(cif_file, atom_array, data_block=self.id)
|
| 885 |
+
_repair_label_entity_ids(cif_file, chain_entities)
|
| 886 |
+
_add_entity_categories(cif_file, self, entity_sequences)
|
| 887 |
+
round_mmcif_columns(cif_file)
|
| 888 |
+
output = io.StringIO()
|
| 889 |
+
cif_file.write(output)
|
| 890 |
+
return output.getvalue()
|
| 891 |
+
|
| 892 |
+
def dockq(self, native: MolecularComplex) -> Any:
|
| 893 |
+
try:
|
| 894 |
+
mobile = self.to_protein_complex().normalize_chain_ids_for_pdb()
|
| 895 |
+
target = native.to_protein_complex().normalize_chain_ids_for_pdb()
|
| 896 |
+
except ValueError as error:
|
| 897 |
+
raise ValueError(
|
| 898 |
+
f"Cannot convert MolecularComplex to ProteinComplex for DockQ: {error}"
|
| 899 |
+
) from None
|
| 900 |
+
try:
|
| 901 |
+
return mobile.dockq(target)
|
| 902 |
+
except Exception:
|
| 903 |
+
return self._compute_dockq_manual(native)
|
| 904 |
+
|
| 905 |
+
def _compute_dockq_manual(self, native: MolecularComplex) -> Any:
|
| 906 |
+
try:
|
| 907 |
+
mobile = self.to_protein_complex().normalize_chain_ids_for_pdb()
|
| 908 |
+
target = native.to_protein_complex().normalize_chain_ids_for_pdb()
|
| 909 |
+
except ValueError as error:
|
| 910 |
+
raise ValueError(
|
| 911 |
+
f"Cannot convert MolecularComplex to ProteinComplex for DockQ: {error}"
|
| 912 |
+
) from None
|
| 913 |
+
with TemporaryDirectory() as directory:
|
| 914 |
+
mobile_path = Path(directory) / "self.pdb"
|
| 915 |
+
target_path = Path(directory) / "native.pdb"
|
| 916 |
+
mobile.to_pdb(mobile_path)
|
| 917 |
+
target.to_pdb(target_path)
|
| 918 |
+
try:
|
| 919 |
+
raw_output = check_output(["DockQ", str(mobile_path), str(target_path)])
|
| 920 |
+
output = raw_output.decode()
|
| 921 |
+
score: float | None = None
|
| 922 |
+
for line in output.split("\n"):
|
| 923 |
+
if "Total DockQ" in line:
|
| 924 |
+
match = re.search(r"Total DockQ.*: ([\d.]+)", line)
|
| 925 |
+
if match:
|
| 926 |
+
score = float(match.group(1))
|
| 927 |
+
break
|
| 928 |
+
if score is None:
|
| 929 |
+
for line in output.split("\n"):
|
| 930 |
+
if line.startswith("DockQ") and ":" in line:
|
| 931 |
+
try:
|
| 932 |
+
score = float(line.split(":")[1].strip())
|
| 933 |
+
break
|
| 934 |
+
except (ValueError, IndexError):
|
| 935 |
+
continue
|
| 936 |
+
if score is None:
|
| 937 |
+
raise ValueError("Could not parse DockQ score from output")
|
| 938 |
+
return {"total_dockq": score, "raw_output": output, "aligned": self}
|
| 939 |
+
except FileNotFoundError:
|
| 940 |
+
raise RuntimeError(
|
| 941 |
+
"DockQ is not installed. Please install DockQ to use this method."
|
| 942 |
+
) from None
|
| 943 |
+
except Exception as error:
|
| 944 |
+
raise RuntimeError(f"DockQ computation failed: {error}") from error
|
| 945 |
+
|
| 946 |
+
def rmsd(self, target: MolecularComplex, **kwargs: Any) -> float:
|
| 947 |
+
mobile, reference, mask = _centroid_tensors(self, target, retain_missing=False)
|
| 948 |
+
value = compute_rmsd(
|
| 949 |
+
mobile=mobile,
|
| 950 |
+
target=reference,
|
| 951 |
+
atom_exists_mask=mask,
|
| 952 |
+
reduction="batch",
|
| 953 |
+
**kwargs,
|
| 954 |
+
)
|
| 955 |
+
return float(value)
|
| 956 |
+
|
| 957 |
+
def lddt_ca(self, target: MolecularComplex, **kwargs: Any) -> float:
|
| 958 |
+
mobile, reference, mask = _centroid_tensors(self, target, retain_missing=True)
|
| 959 |
+
value = compute_lddt(
|
| 960 |
+
all_atom_pred_pos=mobile,
|
| 961 |
+
all_atom_positions=reference,
|
| 962 |
+
all_atom_mask=mask,
|
| 963 |
+
per_residue=False,
|
| 964 |
+
**kwargs,
|
| 965 |
+
)
|
| 966 |
+
return float(value)
|
| 967 |
+
|
| 968 |
+
def state_dict(self) -> dict[str, Any]:
|
| 969 |
+
state = dict(vars(self))
|
| 970 |
+
for optional_identity in ("entity_id", "sym_id"):
|
| 971 |
+
if state[optional_identity] is None:
|
| 972 |
+
state.pop(optional_identity)
|
| 973 |
+
for key, value in tuple(state.items()):
|
| 974 |
+
if isinstance(value, MolecularComplexMetadata):
|
| 975 |
+
state[key] = asdict(value)
|
| 976 |
+
elif isinstance(value, np.ndarray):
|
| 977 |
+
if value.dtype == np.int64:
|
| 978 |
+
value = value.astype(np.int32)
|
| 979 |
+
elif value.dtype in (np.dtype(np.float64), np.dtype(np.float32)):
|
| 980 |
+
value = value.astype(np.float16)
|
| 981 |
+
state[key] = value.tolist()
|
| 982 |
+
return state
|
| 983 |
+
|
| 984 |
+
def to_blob(self) -> bytes:
|
| 985 |
+
return brotli.compress(msgpack.dumps(self.state_dict()), quality=5)
|
| 986 |
+
|
| 987 |
+
@classmethod
|
| 988 |
+
def from_state_dict(cls, dct: dict[str, Any]) -> MolecularComplex:
|
| 989 |
+
dct = dict(dct)
|
| 990 |
+
for key, value in tuple(dct.items()):
|
| 991 |
+
if isinstance(value, list) and key in _SERIALIZED_ARRAYS:
|
| 992 |
+
dct[key] = np.asarray(value)
|
| 993 |
+
for key, value in tuple(dct.items()):
|
| 994 |
+
if not isinstance(value, np.ndarray):
|
| 995 |
+
continue
|
| 996 |
+
if key in {"atom_positions", "plddt"}:
|
| 997 |
+
dct[key] = value.astype(np.float32)
|
| 998 |
+
elif key == "token_to_atoms":
|
| 999 |
+
dct[key] = value.astype(np.int32)
|
| 1000 |
+
elif key in {"chain_id", "entity_id", "sym_id"}:
|
| 1001 |
+
dct[key] = value.astype(np.int64)
|
| 1002 |
+
dct["metadata"] = MolecularComplexMetadata(**dct["metadata"])
|
| 1003 |
+
if "chain_id" not in dct:
|
| 1004 |
+
dct["chain_id"] = np.zeros(len(dct["sequence"]), dtype=np.int64)
|
| 1005 |
+
return cls(**dct)
|
| 1006 |
+
|
| 1007 |
+
@classmethod
|
| 1008 |
+
def from_blob(cls, input: Path | str | io.BytesIO | bytes) -> MolecularComplex:
|
| 1009 |
+
if isinstance(input, (Path, str)):
|
| 1010 |
+
payload = Path(input).read_bytes()
|
| 1011 |
+
elif isinstance(input, io.BytesIO):
|
| 1012 |
+
payload = input.getvalue()
|
| 1013 |
+
else:
|
| 1014 |
+
payload = input
|
| 1015 |
+
state = msgpack.loads(brotli.decompress(payload), strict_map_key=False)
|
| 1016 |
+
return cls.from_state_dict(state)
|
fastplms/models/esmfold2/esmfold2_msa.py
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Multiple-sequence-alignment value objects and lossless encodings."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import dataclasses
|
| 6 |
+
import string
|
| 7 |
+
from collections.abc import Sequence
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
from functools import cached_property
|
| 10 |
+
from itertools import islice
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
from Bio import SeqIO
|
| 15 |
+
from scipy.spatial.distance import cdist
|
| 16 |
+
|
| 17 |
+
from .esmfold2_misc import slice_any_object
|
| 18 |
+
from .esmfold2_msa_filter_sequences import greedy_select_indices, hhfilter
|
| 19 |
+
from .esmfold2_parsing import FastaEntry, read_sequences, write_sequences
|
| 20 |
+
from .esmfold2_sequential_dataclass import SequentialDataclass
|
| 21 |
+
from .esmfold2_system import PathOrBuffer
|
| 22 |
+
|
| 23 |
+
_A3M_INSERTION_DELETE_TABLE = str.maketrans(
|
| 24 |
+
dict.fromkeys(string.ascii_lowercase + ".")
|
| 25 |
+
)
|
| 26 |
+
_SERIALIZATION_VERSION = 1
|
| 27 |
+
_UINT32_BYTES = 4
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def is_a3m_insertion(character: str) -> bool:
|
| 31 |
+
"""Return whether a character is an A3M insertion marker."""
|
| 32 |
+
|
| 33 |
+
return character == "." or character.islower()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def remove_insertions_from_sequence(sequence: str) -> str:
|
| 37 |
+
"""Remove lowercase residues and dot insertion markers from an A3M row."""
|
| 38 |
+
|
| 39 |
+
return sequence.translate(_A3M_INSERTION_DELETE_TABLE)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def a3m_deletion_counts(sequence: str) -> np.ndarray:
|
| 43 |
+
"""Count insertions preceding each A3M match column."""
|
| 44 |
+
|
| 45 |
+
codes = np.frombuffer(sequence.encode("ascii"), dtype=np.uint8)
|
| 46 |
+
lowercase = (codes >= ord("a")) & (codes <= ord("z"))
|
| 47 |
+
insertion_mask = lowercase | (codes == ord("."))
|
| 48 |
+
prefix_counts = np.concatenate(([0], np.cumsum(insertion_mask)))
|
| 49 |
+
match_positions = np.flatnonzero(~insertion_mask)
|
| 50 |
+
return np.diff(prefix_counts[match_positions], prepend=0)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _parse_full_payload(data: bytes) -> tuple[np.ndarray, list[str]]:
|
| 54 |
+
version = int.from_bytes(data[:1], "little")
|
| 55 |
+
if version != _SERIALIZATION_VERSION:
|
| 56 |
+
raise ValueError(f"Unsupported version: {version}")
|
| 57 |
+
seqlen = int.from_bytes(data[1:5], "little")
|
| 58 |
+
depth = int.from_bytes(data[5:9], "little")
|
| 59 |
+
body = data[9:]
|
| 60 |
+
split = seqlen * depth
|
| 61 |
+
array = np.frombuffer(body[:split], dtype="|S1").reshape(depth, seqlen)
|
| 62 |
+
headers = [header for header in body[split:].decode().split("\n") if header]
|
| 63 |
+
if not headers and depth > 0:
|
| 64 |
+
headers = [""] * depth
|
| 65 |
+
return array, headers
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _parse_sequence_payload(data: bytes) -> np.ndarray:
|
| 69 |
+
seqlen = int.from_bytes(data[:_UINT32_BYTES], "little")
|
| 70 |
+
return np.frombuffer(data[_UINT32_BYTES:], dtype="|S1").reshape(-1, seqlen)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _full_payload(array: np.ndarray, headers: Sequence[str]) -> bytes:
|
| 74 |
+
depth, seqlen = array.shape
|
| 75 |
+
prefix = b"".join(
|
| 76 |
+
(
|
| 77 |
+
_SERIALIZATION_VERSION.to_bytes(1, "little"),
|
| 78 |
+
seqlen.to_bytes(_UINT32_BYTES, "little"),
|
| 79 |
+
depth.to_bytes(_UINT32_BYTES, "little"),
|
| 80 |
+
)
|
| 81 |
+
)
|
| 82 |
+
return prefix + array.tobytes() + "\n".join(headers).encode()
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _sequence_payload(array: np.ndarray) -> bytes:
|
| 86 |
+
return array.shape[1].to_bytes(_UINT32_BYTES, "little") + array.tobytes()
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _random_row_indices(depth: int, count: int) -> np.ndarray:
|
| 90 |
+
sampled = np.random.choice(depth - 1, count - 1, replace=False) + 1
|
| 91 |
+
return np.sort(np.append(0, sampled))
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@dataclass(frozen=True)
|
| 95 |
+
class FastMSA(SequentialDataclass):
|
| 96 |
+
"""An MSA stored as a two-dimensional NumPy byte array."""
|
| 97 |
+
|
| 98 |
+
array: np.ndarray
|
| 99 |
+
headers: list[str] | None = None
|
| 100 |
+
|
| 101 |
+
def __post_init__(self) -> None:
|
| 102 |
+
if not isinstance(self.array, np.ndarray):
|
| 103 |
+
raise TypeError("FastMSA array must be a NumPy array.")
|
| 104 |
+
if self.array.ndim != 2 or self.array.shape[0] == 0 or self.array.shape[1] == 0:
|
| 105 |
+
raise ValueError(
|
| 106 |
+
f"FastMSA array must have non-empty shape (depth, length), got {self.array.shape}."
|
| 107 |
+
)
|
| 108 |
+
if self.headers is not None and len(self.headers) != self.depth:
|
| 109 |
+
raise ValueError("Number of headers must match depth.")
|
| 110 |
+
|
| 111 |
+
@property
|
| 112 |
+
def depth(self) -> int:
|
| 113 |
+
return self.array.shape[0]
|
| 114 |
+
|
| 115 |
+
@property
|
| 116 |
+
def seqlen(self) -> int:
|
| 117 |
+
return self.array.shape[1]
|
| 118 |
+
|
| 119 |
+
def __len__(self) -> int:
|
| 120 |
+
return self.seqlen
|
| 121 |
+
|
| 122 |
+
@classmethod
|
| 123 |
+
def from_bytes(cls, data: bytes) -> FastMSA:
|
| 124 |
+
array, headers = _parse_full_payload(data)
|
| 125 |
+
return cls(array, headers)
|
| 126 |
+
|
| 127 |
+
@classmethod
|
| 128 |
+
def from_sequence_bytes(cls, data: bytes) -> FastMSA:
|
| 129 |
+
return cls(_parse_sequence_payload(data))
|
| 130 |
+
|
| 131 |
+
def __getitem__(
|
| 132 |
+
self,
|
| 133 |
+
indices: int | list[int] | slice | np.ndarray,
|
| 134 |
+
) -> FastMSA:
|
| 135 |
+
column_indices = [indices] if isinstance(indices, int) else indices
|
| 136 |
+
return dataclasses.replace(self, array=self.array[:, column_indices])
|
| 137 |
+
|
| 138 |
+
def select_sequences(
|
| 139 |
+
self,
|
| 140 |
+
indices: Sequence[int] | np.ndarray,
|
| 141 |
+
) -> FastMSA:
|
| 142 |
+
headers = None
|
| 143 |
+
if self.headers is not None:
|
| 144 |
+
headers = [self.headers[index] for index in indices]
|
| 145 |
+
return dataclasses.replace(
|
| 146 |
+
self,
|
| 147 |
+
array=self.array[indices],
|
| 148 |
+
headers=headers,
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
def select_random_sequences(self, num_seqs: int) -> FastMSA:
|
| 152 |
+
if num_seqs >= self.depth:
|
| 153 |
+
return self
|
| 154 |
+
return self.select_sequences(_random_row_indices(self.depth, num_seqs))
|
| 155 |
+
|
| 156 |
+
def pad_to_depth(self, depth: int) -> FastMSA:
|
| 157 |
+
if depth < self.depth:
|
| 158 |
+
raise ValueError(f"Cannot pad to depth {depth} when depth is {self.depth}")
|
| 159 |
+
if depth == self.depth:
|
| 160 |
+
return self
|
| 161 |
+
row_count = depth - self.depth
|
| 162 |
+
pad_value = ord("-") if self.array.dtype == np.uint8 else b"-"
|
| 163 |
+
array = np.pad(
|
| 164 |
+
self.array,
|
| 165 |
+
((0, row_count), (0, 0)),
|
| 166 |
+
constant_values=pad_value,
|
| 167 |
+
)
|
| 168 |
+
headers = None if self.headers is None else self.headers + [""] * row_count
|
| 169 |
+
return dataclasses.replace(self, array=array, headers=headers)
|
| 170 |
+
|
| 171 |
+
@classmethod
|
| 172 |
+
def concat(
|
| 173 |
+
cls,
|
| 174 |
+
msas: Sequence[FastMSA],
|
| 175 |
+
join_token: str | None = None,
|
| 176 |
+
allow_depth_mismatch: bool = False,
|
| 177 |
+
) -> FastMSA:
|
| 178 |
+
if not msas:
|
| 179 |
+
raise ValueError("Cannot concatenate an empty list of MSAs")
|
| 180 |
+
if join_token not in (None, ""):
|
| 181 |
+
raise NotImplementedError("join_token is not supported for FastMSA")
|
| 182 |
+
depths = [msa.depth for msa in msas]
|
| 183 |
+
if len(set(depths)) != 1:
|
| 184 |
+
if not allow_depth_mismatch:
|
| 185 |
+
raise ValueError("Depth mismatch in concatenating MSAs")
|
| 186 |
+
maximum_depth = max(depths)
|
| 187 |
+
msas = [msa.pad_to_depth(maximum_depth) for msa in msas]
|
| 188 |
+
header_columns = (
|
| 189 |
+
msa.headers if msa.headers is not None else [""] * msa.depth for msa in msas
|
| 190 |
+
)
|
| 191 |
+
headers = [
|
| 192 |
+
"|".join(str(header) for header in row) for row in zip(*header_columns, strict=False)
|
| 193 |
+
]
|
| 194 |
+
return cls(
|
| 195 |
+
np.concatenate([msa.array for msa in msas], axis=1),
|
| 196 |
+
headers,
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
@classmethod
|
| 200 |
+
def stack(
|
| 201 |
+
cls,
|
| 202 |
+
msas: Sequence[FastMSA],
|
| 203 |
+
remove_query_from_later_msas: bool = True,
|
| 204 |
+
) -> FastMSA:
|
| 205 |
+
if not msas:
|
| 206 |
+
raise ValueError("Cannot stack an empty list of MSAs")
|
| 207 |
+
arrays: list[np.ndarray] = []
|
| 208 |
+
headers: list[str] | None = [] if any(msa.headers is not None for msa in msas) else None
|
| 209 |
+
for index, msa in enumerate(msas):
|
| 210 |
+
start = 1 if index > 0 and remove_query_from_later_msas else 0
|
| 211 |
+
arrays.append(msa.array[start:])
|
| 212 |
+
if headers is not None:
|
| 213 |
+
source_headers = msa.headers or [""] * msa.depth
|
| 214 |
+
headers.extend(source_headers[start:])
|
| 215 |
+
return cls(np.concatenate(arrays, axis=0), headers)
|
| 216 |
+
|
| 217 |
+
def to_msa(self) -> MSA:
|
| 218 |
+
headers = self.headers
|
| 219 |
+
if headers is None:
|
| 220 |
+
headers = [f"seq{index}" for index in range(self.depth)]
|
| 221 |
+
entries = [
|
| 222 |
+
FastaEntry(header, b"".join(row).decode())
|
| 223 |
+
for header, row in zip(headers, self.array, strict=False)
|
| 224 |
+
]
|
| 225 |
+
return MSA(entries)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
@dataclass(frozen=True)
|
| 229 |
+
class MSA(SequentialDataclass):
|
| 230 |
+
"""An ordered set of aligned protein sequences and optional A3M metadata."""
|
| 231 |
+
|
| 232 |
+
entries: list[FastaEntry]
|
| 233 |
+
deletions: np.ndarray | None = dataclasses.field(default=None, compare=False)
|
| 234 |
+
|
| 235 |
+
def __post_init__(self) -> None:
|
| 236 |
+
if not isinstance(self.entries, list):
|
| 237 |
+
raise TypeError("MSA entries must be a list of FastaEntry rows.")
|
| 238 |
+
if not self.entries:
|
| 239 |
+
raise ValueError("MSA requires at least one aligned sequence.")
|
| 240 |
+
if any(not isinstance(entry, FastaEntry) for entry in self.entries):
|
| 241 |
+
raise TypeError("Every MSA entry must be a FastaEntry.")
|
| 242 |
+
expected_length = len(self.entries[0].sequence)
|
| 243 |
+
if expected_length == 0:
|
| 244 |
+
raise ValueError("MSA sequences must be non-empty.")
|
| 245 |
+
for row, entry in enumerate(self.entries[1:], start=1):
|
| 246 |
+
if len(entry.sequence) != expected_length:
|
| 247 |
+
raise ValueError(
|
| 248 |
+
"MSA row length mismatch: "
|
| 249 |
+
f"row 0 has {expected_length} columns, row {row} has "
|
| 250 |
+
f"{len(entry.sequence)}."
|
| 251 |
+
)
|
| 252 |
+
deletions = self.deletions
|
| 253 |
+
if deletions is not None and not isinstance(deletions, np.ndarray):
|
| 254 |
+
raise TypeError("MSA deletions must be a NumPy array when provided.")
|
| 255 |
+
if isinstance(deletions, np.ndarray) and deletions.shape != (
|
| 256 |
+
len(self.entries),
|
| 257 |
+
expected_length,
|
| 258 |
+
):
|
| 259 |
+
raise ValueError(
|
| 260 |
+
"MSA deletion matrix must have shape "
|
| 261 |
+
f"({len(self.entries)}, {expected_length}), got {deletions.shape}."
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
@cached_property
|
| 265 |
+
def sequences(self) -> list[str]:
|
| 266 |
+
return [entry.sequence for entry in self.entries]
|
| 267 |
+
|
| 268 |
+
@cached_property
|
| 269 |
+
def headers(self) -> list[str]:
|
| 270 |
+
return [entry.header for entry in self.entries]
|
| 271 |
+
|
| 272 |
+
@property
|
| 273 |
+
def depth(self) -> int:
|
| 274 |
+
return len(self.entries)
|
| 275 |
+
|
| 276 |
+
@property
|
| 277 |
+
def seqlen(self) -> int:
|
| 278 |
+
return len(self.entries[0].sequence)
|
| 279 |
+
|
| 280 |
+
@property
|
| 281 |
+
def query(self) -> str:
|
| 282 |
+
return self.entries[0].sequence
|
| 283 |
+
|
| 284 |
+
@cached_property
|
| 285 |
+
def array(self) -> np.ndarray:
|
| 286 |
+
return np.array([list(sequence) for sequence in self.sequences], dtype="|S1")
|
| 287 |
+
|
| 288 |
+
@cached_property
|
| 289 |
+
def seqid(self) -> np.ndarray:
|
| 290 |
+
byte_array = self.array.view(np.uint8)
|
| 291 |
+
return (1 - cdist(byte_array[0][None], byte_array, "hamming"))[0]
|
| 292 |
+
|
| 293 |
+
def __len__(self) -> int:
|
| 294 |
+
return self.seqlen
|
| 295 |
+
|
| 296 |
+
def __repr__(self) -> str:
|
| 297 |
+
return f"MSA({self.entries[0].header}: Depth={self.depth}, Length={self.seqlen})"
|
| 298 |
+
|
| 299 |
+
@classmethod
|
| 300 |
+
def from_a3m(
|
| 301 |
+
cls,
|
| 302 |
+
path: PathOrBuffer,
|
| 303 |
+
remove_insertions: bool = True,
|
| 304 |
+
max_sequences: int | None = None,
|
| 305 |
+
) -> MSA:
|
| 306 |
+
entries = []
|
| 307 |
+
deletion_rows = []
|
| 308 |
+
for header, raw_sequence in islice(read_sequences(path), max_sequences):
|
| 309 |
+
if remove_insertions:
|
| 310 |
+
deletion_rows.append(a3m_deletion_counts(raw_sequence))
|
| 311 |
+
sequence = (
|
| 312 |
+
remove_insertions_from_sequence(raw_sequence) if remove_insertions else raw_sequence
|
| 313 |
+
)
|
| 314 |
+
if entries:
|
| 315 |
+
expected_length = len(entries[0].sequence)
|
| 316 |
+
if len(sequence) != expected_length:
|
| 317 |
+
raise ValueError(
|
| 318 |
+
"Sequence length mismatch. "
|
| 319 |
+
f"Expected: {expected_length}, Received: {len(sequence)}"
|
| 320 |
+
)
|
| 321 |
+
entries.append(FastaEntry(header, sequence))
|
| 322 |
+
deletions = None
|
| 323 |
+
if remove_insertions and deletion_rows:
|
| 324 |
+
deletions = np.stack(deletion_rows).astype(np.float32)
|
| 325 |
+
return cls(entries, deletions=deletions)
|
| 326 |
+
|
| 327 |
+
@classmethod
|
| 328 |
+
def from_stockholm(
|
| 329 |
+
cls,
|
| 330 |
+
path: PathOrBuffer,
|
| 331 |
+
remove_insertions: bool = True,
|
| 332 |
+
max_sequences: int | None = None,
|
| 333 |
+
) -> MSA:
|
| 334 |
+
entries = []
|
| 335 |
+
for record in islice(SeqIO.parse(path, "stockholm"), max_sequences):
|
| 336 |
+
sequence = str(record.seq)
|
| 337 |
+
if entries:
|
| 338 |
+
expected_length = len(entries[0].sequence)
|
| 339 |
+
if len(sequence) != expected_length:
|
| 340 |
+
raise ValueError(
|
| 341 |
+
"Sequence length mismatch. "
|
| 342 |
+
f"Expected: {expected_length}, Received: {len(sequence)}"
|
| 343 |
+
)
|
| 344 |
+
entries.append(FastaEntry(f"{record.id} {record.description}", sequence))
|
| 345 |
+
msa = cls(entries)
|
| 346 |
+
if remove_insertions:
|
| 347 |
+
msa = msa.select_positions(
|
| 348 |
+
[index for index, residue in enumerate(msa.query) if residue != "-"]
|
| 349 |
+
)
|
| 350 |
+
return msa
|
| 351 |
+
|
| 352 |
+
@classmethod
|
| 353 |
+
def from_sequences(
|
| 354 |
+
cls,
|
| 355 |
+
sequences: list[str],
|
| 356 |
+
remove_insertions: bool = False,
|
| 357 |
+
) -> MSA:
|
| 358 |
+
transform = (
|
| 359 |
+
remove_insertions_from_sequence if remove_insertions else lambda sequence: sequence
|
| 360 |
+
)
|
| 361 |
+
return cls([FastaEntry("", transform(sequence)) for sequence in sequences])
|
| 362 |
+
|
| 363 |
+
@classmethod
|
| 364 |
+
def from_bytes(cls, data: bytes) -> MSA:
|
| 365 |
+
array, headers = _parse_full_payload(data)
|
| 366 |
+
return cls(
|
| 367 |
+
[
|
| 368 |
+
FastaEntry(header, b"".join(row).decode())
|
| 369 |
+
for header, row in zip(headers, array, strict=False)
|
| 370 |
+
]
|
| 371 |
+
)
|
| 372 |
+
|
| 373 |
+
@classmethod
|
| 374 |
+
def from_sequence_bytes(cls, data: bytes) -> MSA:
|
| 375 |
+
array = _parse_sequence_payload(data)
|
| 376 |
+
return cls([FastaEntry("", b"".join(row).decode()) for row in array])
|
| 377 |
+
|
| 378 |
+
@classmethod
|
| 379 |
+
def from_state_dict(cls, dct: dict[str, Any]) -> MSA:
|
| 380 |
+
deletions = dct.get("deletions")
|
| 381 |
+
return cls(
|
| 382 |
+
[FastaEntry("", sequence) for sequence in dct["sequences"]],
|
| 383 |
+
deletions=(None if deletions is None else np.asarray(deletions, dtype=np.float32)),
|
| 384 |
+
)
|
| 385 |
+
|
| 386 |
+
def to_a3m(self, path: PathOrBuffer) -> None:
|
| 387 |
+
write_sequences(self.entries, path)
|
| 388 |
+
|
| 389 |
+
def to_fast_msa(self) -> FastMSA:
|
| 390 |
+
return FastMSA(self.array, self.headers)
|
| 391 |
+
|
| 392 |
+
def to_bytes(self) -> bytes:
|
| 393 |
+
return _full_payload(self.array, self.headers)
|
| 394 |
+
|
| 395 |
+
def to_sequence_bytes(self) -> bytes:
|
| 396 |
+
"""Serialize aligned sequences without their headers."""
|
| 397 |
+
|
| 398 |
+
return _sequence_payload(self.array)
|
| 399 |
+
|
| 400 |
+
def state_dict(self, json_serializable: bool = False) -> dict[str, Any]:
|
| 401 |
+
result: dict[str, Any] = {"sequences": self.sequences}
|
| 402 |
+
if self.deletions is not None:
|
| 403 |
+
result["deletions"] = self.deletions.tolist() if json_serializable else self.deletions
|
| 404 |
+
return result
|
| 405 |
+
|
| 406 |
+
def _aligned_deletions(self) -> np.ndarray | None:
|
| 407 |
+
if self.deletions is None:
|
| 408 |
+
return None
|
| 409 |
+
if self.deletions.shape != (self.depth, self.seqlen):
|
| 410 |
+
return None
|
| 411 |
+
return self.deletions
|
| 412 |
+
|
| 413 |
+
def _select_deletion_columns(self, indices) -> np.ndarray | None:
|
| 414 |
+
if self.deletions is None or self.deletions.shape[1] != self.seqlen:
|
| 415 |
+
return None
|
| 416 |
+
return self.deletions[:, indices]
|
| 417 |
+
|
| 418 |
+
def select_sequences(
|
| 419 |
+
self,
|
| 420 |
+
indices: Sequence[int] | np.ndarray,
|
| 421 |
+
) -> MSA:
|
| 422 |
+
deletions = None if self.deletions is None else self.deletions[np.asarray(indices)]
|
| 423 |
+
return dataclasses.replace(
|
| 424 |
+
self,
|
| 425 |
+
entries=[self.entries[index] for index in indices],
|
| 426 |
+
deletions=deletions,
|
| 427 |
+
)
|
| 428 |
+
|
| 429 |
+
def select_positions(
|
| 430 |
+
self,
|
| 431 |
+
indices: Sequence[int] | np.ndarray,
|
| 432 |
+
) -> MSA:
|
| 433 |
+
entries = [
|
| 434 |
+
FastaEntry(
|
| 435 |
+
entry.header,
|
| 436 |
+
"".join(entry.sequence[index] for index in indices),
|
| 437 |
+
)
|
| 438 |
+
for entry in self.entries
|
| 439 |
+
]
|
| 440 |
+
return dataclasses.replace(
|
| 441 |
+
self,
|
| 442 |
+
entries=entries,
|
| 443 |
+
deletions=self._select_deletion_columns(indices),
|
| 444 |
+
)
|
| 445 |
+
|
| 446 |
+
def __getitem__(
|
| 447 |
+
self,
|
| 448 |
+
indices: int | list[int] | slice | np.ndarray,
|
| 449 |
+
) -> MSA:
|
| 450 |
+
column_indices = [indices] if isinstance(indices, int) else indices
|
| 451 |
+
entries = [
|
| 452 |
+
FastaEntry(
|
| 453 |
+
entry.header,
|
| 454 |
+
slice_any_object(entry.sequence, column_indices),
|
| 455 |
+
)
|
| 456 |
+
for entry in self.entries
|
| 457 |
+
]
|
| 458 |
+
return dataclasses.replace(
|
| 459 |
+
self,
|
| 460 |
+
entries=entries,
|
| 461 |
+
deletions=self._select_deletion_columns(column_indices),
|
| 462 |
+
)
|
| 463 |
+
|
| 464 |
+
def greedy_select(self, num_seqs: int, mode: str = "max") -> MSA:
|
| 465 |
+
if mode not in ("max", "min"):
|
| 466 |
+
raise ValueError(f"Unsupported MSA selection mode: {mode!r}.")
|
| 467 |
+
if self.depth <= num_seqs:
|
| 468 |
+
return self
|
| 469 |
+
return self.select_sequences(greedy_select_indices(self.array, num_seqs, mode))
|
| 470 |
+
|
| 471 |
+
def hhfilter(
|
| 472 |
+
self,
|
| 473 |
+
seqid: int = 90,
|
| 474 |
+
diff: int = 0,
|
| 475 |
+
cov: int = 0,
|
| 476 |
+
qid: int = 0,
|
| 477 |
+
qsc: float = -20.0,
|
| 478 |
+
binary: str = "hhfilter",
|
| 479 |
+
) -> MSA:
|
| 480 |
+
indices = hhfilter(
|
| 481 |
+
self.sequences,
|
| 482 |
+
seqid=seqid,
|
| 483 |
+
diff=diff,
|
| 484 |
+
cov=cov,
|
| 485 |
+
qid=qid,
|
| 486 |
+
qsc=qsc,
|
| 487 |
+
binary=binary,
|
| 488 |
+
)
|
| 489 |
+
return self.select_sequences(indices)
|
| 490 |
+
|
| 491 |
+
def select_random_sequences(self, num_seqs: int) -> MSA:
|
| 492 |
+
if num_seqs >= self.depth:
|
| 493 |
+
return self
|
| 494 |
+
return self.select_sequences(_random_row_indices(self.depth, num_seqs))
|
| 495 |
+
|
| 496 |
+
def select_diverse_sequences(self, num_seqs: int) -> MSA:
|
| 497 |
+
if num_seqs >= self.depth:
|
| 498 |
+
return self
|
| 499 |
+
filtered = self.hhfilter(diff=num_seqs)
|
| 500 |
+
if num_seqs < filtered.depth:
|
| 501 |
+
filtered = filtered.select_random_sequences(num_seqs)
|
| 502 |
+
return filtered
|
| 503 |
+
|
| 504 |
+
def pad_to_depth(self, depth: int) -> MSA:
|
| 505 |
+
if depth < self.depth:
|
| 506 |
+
raise ValueError(f"Cannot pad to depth {depth} when depth is {self.depth}")
|
| 507 |
+
if depth == self.depth:
|
| 508 |
+
return self
|
| 509 |
+
count = depth - self.depth
|
| 510 |
+
extra = [FastaEntry("", "-" * self.seqlen) for _ in range(count)]
|
| 511 |
+
deletions = self._aligned_deletions()
|
| 512 |
+
if deletions is not None:
|
| 513 |
+
zero_rows = np.zeros((count, self.seqlen), dtype=deletions.dtype)
|
| 514 |
+
deletions = np.concatenate((deletions, zero_rows), axis=0)
|
| 515 |
+
return dataclasses.replace(
|
| 516 |
+
self,
|
| 517 |
+
entries=self.entries + extra,
|
| 518 |
+
deletions=deletions,
|
| 519 |
+
)
|
| 520 |
+
|
| 521 |
+
@classmethod
|
| 522 |
+
def stack(
|
| 523 |
+
cls,
|
| 524 |
+
msas: Sequence[MSA],
|
| 525 |
+
remove_query_from_later_msas: bool = True,
|
| 526 |
+
) -> MSA:
|
| 527 |
+
entries = []
|
| 528 |
+
deletion_arrays = []
|
| 529 |
+
for index, msa in enumerate(msas):
|
| 530 |
+
start = 1 if index > 0 and remove_query_from_later_msas else 0
|
| 531 |
+
entries.extend(msa.entries[start:])
|
| 532 |
+
aligned = msa._aligned_deletions()
|
| 533 |
+
if aligned is not None:
|
| 534 |
+
deletion_arrays.append(aligned[start:])
|
| 535 |
+
deletions = None
|
| 536 |
+
if (
|
| 537 |
+
len(deletion_arrays) == len(msas)
|
| 538 |
+
and len({array.shape[1] for array in deletion_arrays}) == 1
|
| 539 |
+
):
|
| 540 |
+
deletions = np.concatenate(deletion_arrays, axis=0)
|
| 541 |
+
return cls(entries=entries, deletions=deletions)
|
| 542 |
+
|
| 543 |
+
@classmethod
|
| 544 |
+
def concat(
|
| 545 |
+
cls,
|
| 546 |
+
msas: Sequence[MSA],
|
| 547 |
+
join_token: str | None = "|",
|
| 548 |
+
allow_depth_mismatch: bool = False,
|
| 549 |
+
) -> MSA:
|
| 550 |
+
if not msas:
|
| 551 |
+
raise ValueError("Cannot concatenate an empty list of MSAs")
|
| 552 |
+
depths = [msa.depth for msa in msas]
|
| 553 |
+
if len(set(depths)) != 1:
|
| 554 |
+
if not allow_depth_mismatch:
|
| 555 |
+
raise ValueError("Depth mismatch in concatenating MSAs")
|
| 556 |
+
maximum_depth = max(depths)
|
| 557 |
+
msas = [msa.pad_to_depth(maximum_depth) for msa in msas]
|
| 558 |
+
headers = [
|
| 559 |
+
"|".join(str(header) for header in row)
|
| 560 |
+
for row in zip(*(msa.headers for msa in msas), strict=False)
|
| 561 |
+
]
|
| 562 |
+
separator = "" if join_token is None else join_token
|
| 563 |
+
sequences = [
|
| 564 |
+
separator.join(row) for row in zip(*(msa.sequences for msa in msas), strict=False)
|
| 565 |
+
]
|
| 566 |
+
deletions = None
|
| 567 |
+
if separator == "":
|
| 568 |
+
arrays = [msa._aligned_deletions() for msa in msas]
|
| 569 |
+
if all(array is not None for array in arrays):
|
| 570 |
+
deletions = np.concatenate(arrays, axis=1) # type: ignore[arg-type]
|
| 571 |
+
return cls(
|
| 572 |
+
[
|
| 573 |
+
FastaEntry(header, sequence)
|
| 574 |
+
for header, sequence in zip(headers, sequences, strict=False)
|
| 575 |
+
],
|
| 576 |
+
deletions=deletions,
|
| 577 |
+
)
|
fastplms/models/esmfold2/esmfold2_msa_filter_sequences.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sequence selection for multiple-sequence alignments."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import tempfile
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
from .esmfold2_system import run_subprocess_with_errorcheck
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _byte_matrix(array: np.ndarray) -> np.ndarray:
|
| 15 |
+
"""Return a two-dimensional byte view used for Hamming comparisons."""
|
| 16 |
+
|
| 17 |
+
matrix = np.asarray(array).view(np.uint8)
|
| 18 |
+
return matrix.reshape(matrix.shape[0], -1)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _hamming_to_all(query: np.ndarray, sequences: np.ndarray) -> np.ndarray:
|
| 22 |
+
return np.not_equal(sequences, query).mean(axis=1, dtype=np.float64)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def greedy_select_indices(array: np.ndarray, num_seqs: int, mode: str = "max") -> list[int]:
|
| 26 |
+
"""Select MSA rows by greedy mean Hamming distance from the query row.
|
| 27 |
+
|
| 28 |
+
Row zero is always retained. At each step the selector chooses the remaining
|
| 29 |
+
row with greatest distance for ``mode="max"`` or least distance for
|
| 30 |
+
``mode="min"``. Returned indices follow source order.
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
if not isinstance(array, np.ndarray):
|
| 34 |
+
raise TypeError("array must be a NumPy array")
|
| 35 |
+
if array.ndim != 2 or array.shape[0] == 0 or array.shape[1] == 0:
|
| 36 |
+
raise ValueError(
|
| 37 |
+
f"array must have non-empty shape (depth, length), got {array.shape}"
|
| 38 |
+
)
|
| 39 |
+
if isinstance(num_seqs, bool) or not isinstance(num_seqs, int):
|
| 40 |
+
raise TypeError("num_seqs must be an integer")
|
| 41 |
+
if num_seqs <= 0:
|
| 42 |
+
raise ValueError("num_seqs must be greater than zero")
|
| 43 |
+
if not isinstance(mode, str):
|
| 44 |
+
raise TypeError("mode must be a string")
|
| 45 |
+
if mode not in {"max", "min"}:
|
| 46 |
+
raise ValueError(f"unsupported selection mode: {mode}")
|
| 47 |
+
depth = array.shape[0]
|
| 48 |
+
if depth <= num_seqs:
|
| 49 |
+
return list(range(depth))
|
| 50 |
+
|
| 51 |
+
sequences = _byte_matrix(array)
|
| 52 |
+
selected = [0]
|
| 53 |
+
available = np.ones(depth, dtype=bool)
|
| 54 |
+
available[0] = False
|
| 55 |
+
distance_sum = _hamming_to_all(sequences[0], sequences)
|
| 56 |
+
choose = np.argmax if mode == "max" else np.argmin
|
| 57 |
+
|
| 58 |
+
while len(selected) < num_seqs:
|
| 59 |
+
candidates = np.flatnonzero(available)
|
| 60 |
+
candidate_scores = distance_sum[candidates] / len(selected)
|
| 61 |
+
next_index = int(candidates[int(choose(candidate_scores))])
|
| 62 |
+
selected.append(next_index)
|
| 63 |
+
available[next_index] = False
|
| 64 |
+
distance_sum += _hamming_to_all(sequences[next_index], sequences)
|
| 65 |
+
return sorted(selected)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _temporary_root() -> str | None:
|
| 69 |
+
shared_memory = Path("/dev/shm")
|
| 70 |
+
return os.fspath(shared_memory) if shared_memory.is_dir() else None
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def hhfilter(
|
| 74 |
+
sequences: list[str],
|
| 75 |
+
seqid: int = 90,
|
| 76 |
+
diff: int = 0,
|
| 77 |
+
cov: int = 0,
|
| 78 |
+
qid: int = 0,
|
| 79 |
+
qsc: float = -20.0,
|
| 80 |
+
binary: str = "hhfilter",
|
| 81 |
+
) -> list[int]:
|
| 82 |
+
"""Run HH-suite filtering and return source indices from its FASTA headers."""
|
| 83 |
+
|
| 84 |
+
with tempfile.TemporaryDirectory(dir=_temporary_root()) as directory:
|
| 85 |
+
work = Path(directory)
|
| 86 |
+
source_path = work / "input.fasta"
|
| 87 |
+
result_path = work / "output.fasta"
|
| 88 |
+
records = (f">{index}\n{sequence}" for index, sequence in enumerate(sequences))
|
| 89 |
+
source_path.write_text("\n".join(records), encoding="utf-8")
|
| 90 |
+
command = [
|
| 91 |
+
binary,
|
| 92 |
+
"-i",
|
| 93 |
+
os.fspath(source_path),
|
| 94 |
+
"-M",
|
| 95 |
+
"a3m",
|
| 96 |
+
"-o",
|
| 97 |
+
os.fspath(result_path),
|
| 98 |
+
"-id",
|
| 99 |
+
str(seqid),
|
| 100 |
+
"-diff",
|
| 101 |
+
str(diff),
|
| 102 |
+
"-cov",
|
| 103 |
+
str(cov),
|
| 104 |
+
"-qid",
|
| 105 |
+
str(qid),
|
| 106 |
+
"-qsc",
|
| 107 |
+
str(qsc),
|
| 108 |
+
]
|
| 109 |
+
run_subprocess_with_errorcheck(command, capture_output=True)
|
| 110 |
+
headers = result_path.read_text(encoding="utf-8").splitlines()
|
| 111 |
+
return [int(line[1:].strip()) for line in headers if line.startswith(">")]
|
fastplms/models/esmfold2/esmfold2_normalize_coordinates.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rigid-frame normalization for atom37 coordinates."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import TypeVar
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import torch
|
| 9 |
+
from torch import Tensor
|
| 10 |
+
|
| 11 |
+
from . import esmfold2_residue_constants as residue_constants
|
| 12 |
+
from .esmfold2_affine3d import Affine3D
|
| 13 |
+
|
| 14 |
+
ArrayOrTensor = TypeVar("ArrayOrTensor", np.ndarray, Tensor)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def atom3_to_backbone_frames(bb_positions: Tensor) -> Affine3D:
|
| 18 |
+
"""Construct a frame from N, C-alpha, and C positions in ``X``."""
|
| 19 |
+
|
| 20 |
+
n_position, ca_position, c_position = bb_positions.unbind(dim=-2)
|
| 21 |
+
return Affine3D.from_graham_schmidt(c_position, ca_position, n_position)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def index_by_atom_name(
|
| 25 |
+
atom37: ArrayOrTensor,
|
| 26 |
+
atom_names: str | list[str],
|
| 27 |
+
dim: int = -2,
|
| 28 |
+
) -> ArrayOrTensor:
|
| 29 |
+
"""Select one or more named atoms along an atom37 axis."""
|
| 30 |
+
|
| 31 |
+
single_atom = isinstance(atom_names, str)
|
| 32 |
+
names = [atom_names] if single_atom else atom_names
|
| 33 |
+
indices = [residue_constants.atom_order[name] for name in names]
|
| 34 |
+
axis = dim % atom37.ndim
|
| 35 |
+
if isinstance(atom37, Tensor):
|
| 36 |
+
index = torch.tensor(indices, dtype=torch.long, device=atom37.device)
|
| 37 |
+
selected = torch.index_select(atom37, axis, index)
|
| 38 |
+
else:
|
| 39 |
+
selected = np.take(atom37, indices, axis=axis)
|
| 40 |
+
return selected.squeeze(axis) if single_atom else selected # type: ignore[return-value]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def get_protein_normalization_frame(coords: Tensor) -> Affine3D:
|
| 44 |
+
"""Build one frame from backbone coordinates ``X`` with shape (l, 37, 3)."""
|
| 45 |
+
|
| 46 |
+
backbone = index_by_atom_name(coords, ["N", "CA", "C"], dim=-2)
|
| 47 |
+
residue_is_valid = torch.isfinite(backbone).all(dim=-1).all(dim=-1)
|
| 48 |
+
weights = residue_is_valid[..., None, None]
|
| 49 |
+
coordinate_sum = backbone.masked_fill(~weights, 0).sum(dim=-3)
|
| 50 |
+
count = residue_is_valid.sum(dim=-1)[..., None, None]
|
| 51 |
+
mean_backbone = coordinate_sum / (count + 1e-8)
|
| 52 |
+
return atom3_to_backbone_frames(mean_backbone.float())
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def apply_frame_to_coords(coords: Tensor, frame: Affine3D) -> Tensor:
|
| 56 |
+
"""Express atom coordinates ``X`` in the inverse of ``frame``."""
|
| 57 |
+
|
| 58 |
+
transformed = frame[..., None, None].invert().apply(coords)
|
| 59 |
+
frame_is_valid = frame.trans.norm(dim=-1) > 0
|
| 60 |
+
normalized = torch.where(frame_is_valid[..., None, None, None], transformed, coords)
|
| 61 |
+
return normalized.masked_fill(torch.isinf(coords), torch.inf)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def normalize_coordinates(coords: Tensor) -> Tensor:
|
| 65 |
+
"""Normalize ``X`` with shape (..., l, 37, 3) to its backbone frame."""
|
| 66 |
+
|
| 67 |
+
return apply_frame_to_coords(coords, get_protein_normalization_frame(coords))
|
fastplms/models/esmfold2/esmfold2_output.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Convert ESMFold2 coordinate tensors into molecular-complex records."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from collections.abc import Iterable
|
| 6 |
+
from dataclasses import dataclass, field
|
| 7 |
+
from itertools import groupby
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
import torch
|
| 12 |
+
|
| 13 |
+
from .esmfold2_constants import ELEMENT_NUMBER_TO_SYMBOL, MOL_TYPE_NONPOLYMER
|
| 14 |
+
from .esmfold2_molecular_complex import MolecularComplex, MolecularComplexMetadata
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def get_element_symbol(atomic_number: int) -> str:
|
| 18 |
+
"""Map a training-time atomic number to a chemical symbol."""
|
| 19 |
+
|
| 20 |
+
return ELEMENT_NUMBER_TO_SYMBOL.get(atomic_number, "X")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _decode_atom_name(encoded_name: Any) -> str:
|
| 24 |
+
values = encoded_name.tolist() if hasattr(encoded_name, "tolist") else encoded_name
|
| 25 |
+
return "".join(chr(int(value) + 32) for value in values if int(value)).strip()
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass
|
| 29 |
+
class _ComplexRecords:
|
| 30 |
+
sequence: list[str] = field(default_factory=list)
|
| 31 |
+
chain_ids: list[int] = field(default_factory=list)
|
| 32 |
+
token_to_atoms: list[list[int]] = field(default_factory=list)
|
| 33 |
+
confidence: list[float] = field(default_factory=list)
|
| 34 |
+
positions: list[list[float]] = field(default_factory=list)
|
| 35 |
+
elements: list[str] = field(default_factory=list)
|
| 36 |
+
atom_names: list[str] = field(default_factory=list)
|
| 37 |
+
atom_hetero: list[bool] = field(default_factory=list)
|
| 38 |
+
chain_lookup: dict[int, str] = field(default_factory=dict)
|
| 39 |
+
entity_lookup: dict[int, str] = field(default_factory=dict)
|
| 40 |
+
|
| 41 |
+
def add_token(
|
| 42 |
+
self,
|
| 43 |
+
*,
|
| 44 |
+
residue_name: str,
|
| 45 |
+
asym_id: int,
|
| 46 |
+
plddt: float,
|
| 47 |
+
atoms: Iterable[tuple[list[float], str, str]],
|
| 48 |
+
hetero: bool,
|
| 49 |
+
) -> None:
|
| 50 |
+
atom_start = len(self.positions)
|
| 51 |
+
for position, element, atom_name in atoms:
|
| 52 |
+
self.positions.append(position)
|
| 53 |
+
self.elements.append(element)
|
| 54 |
+
self.atom_names.append(atom_name)
|
| 55 |
+
self.atom_hetero.append(hetero)
|
| 56 |
+
self.sequence.append(residue_name)
|
| 57 |
+
self.chain_ids.append(asym_id)
|
| 58 |
+
self.confidence.append(plddt)
|
| 59 |
+
self.token_to_atoms.append([atom_start, len(self.positions)])
|
| 60 |
+
|
| 61 |
+
def build(self, complex_id: str) -> MolecularComplex:
|
| 62 |
+
return MolecularComplex(
|
| 63 |
+
id=complex_id,
|
| 64 |
+
sequence=self.sequence,
|
| 65 |
+
atom_positions=np.asarray(self.positions, dtype=np.float32).reshape(-1, 3),
|
| 66 |
+
atom_elements=np.asarray(self.elements, dtype=object),
|
| 67 |
+
token_to_atoms=np.asarray(self.token_to_atoms, dtype=np.int32).reshape(-1, 2),
|
| 68 |
+
chain_id=np.asarray(self.chain_ids, dtype=np.int64),
|
| 69 |
+
plddt=np.asarray(self.confidence, dtype=np.float32),
|
| 70 |
+
atom_names=np.asarray(self.atom_names, dtype=object),
|
| 71 |
+
atom_hetero=np.asarray(self.atom_hetero, dtype=bool),
|
| 72 |
+
metadata=MolecularComplexMetadata(
|
| 73 |
+
entity_lookup=self.entity_lookup,
|
| 74 |
+
chain_lookup=self.chain_lookup,
|
| 75 |
+
assembly_composition=None,
|
| 76 |
+
),
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def build_molecular_complex_from_features(
|
| 81 |
+
coords: torch.Tensor,
|
| 82 |
+
plddt: torch.Tensor,
|
| 83 |
+
atom_mask: torch.Tensor,
|
| 84 |
+
ref_element: torch.Tensor,
|
| 85 |
+
ref_atom_name_chars: torch.Tensor,
|
| 86 |
+
chain_infos: list[Any],
|
| 87 |
+
complex_id: str,
|
| 88 |
+
) -> MolecularComplex:
|
| 89 |
+
"""Decode model features into one complex without intermediate structure files.
|
| 90 |
+
|
| 91 |
+
Protein, DNA, and RNA tokens are grouped by residue index. Ligand atom
|
| 92 |
+
tokens are collapsed into one non-polymer residue per chain.
|
| 93 |
+
"""
|
| 94 |
+
|
| 95 |
+
M = atom_mask.bool().cpu().numpy()
|
| 96 |
+
X = coords.float().cpu().numpy()
|
| 97 |
+
atom_names = ref_atom_name_chars.cpu().numpy()
|
| 98 |
+
elements = ref_element.cpu().numpy()
|
| 99 |
+
confidence = plddt.float().cpu().numpy()
|
| 100 |
+
records = _ComplexRecords()
|
| 101 |
+
|
| 102 |
+
def decode_atoms(tokens: Iterable[Any]):
|
| 103 |
+
for token in tokens:
|
| 104 |
+
for atom_index in range(token.atom_start, token.atom_start + token.atom_count):
|
| 105 |
+
if M[atom_index]:
|
| 106 |
+
yield (
|
| 107 |
+
X[atom_index].tolist(),
|
| 108 |
+
get_element_symbol(int(elements[atom_index])),
|
| 109 |
+
_decode_atom_name(atom_names[atom_index]),
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
for chain in chain_infos:
|
| 113 |
+
is_nonpolymer = chain.mol_type == MOL_TYPE_NONPOLYMER
|
| 114 |
+
records.chain_lookup[chain.asym_id] = chain.chain_id
|
| 115 |
+
records.entity_lookup[chain.entity_id] = "non-polymer" if is_nonpolymer else "polymer"
|
| 116 |
+
|
| 117 |
+
if is_nonpolymer:
|
| 118 |
+
mean_confidence = (
|
| 119 |
+
float(np.mean([confidence[token.token_index] for token in chain.tokens]))
|
| 120 |
+
if chain.tokens
|
| 121 |
+
else 0.0
|
| 122 |
+
)
|
| 123 |
+
records.add_token(
|
| 124 |
+
residue_name=chain.tokens[0].residue_name if chain.tokens else "LIG",
|
| 125 |
+
asym_id=chain.asym_id,
|
| 126 |
+
plddt=mean_confidence,
|
| 127 |
+
atoms=decode_atoms(chain.tokens),
|
| 128 |
+
hetero=True,
|
| 129 |
+
)
|
| 130 |
+
continue
|
| 131 |
+
|
| 132 |
+
residue_groups = groupby(chain.tokens, key=lambda token: token.residue_index)
|
| 133 |
+
for _residue_index, group in residue_groups:
|
| 134 |
+
residue_tokens = list(group)
|
| 135 |
+
records.add_token(
|
| 136 |
+
residue_name=residue_tokens[0].residue_name,
|
| 137 |
+
asym_id=chain.asym_id,
|
| 138 |
+
plddt=float(np.mean([confidence[token.token_index] for token in residue_tokens])),
|
| 139 |
+
atoms=decode_atoms(residue_tokens),
|
| 140 |
+
hetero=False,
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
return records.build(complex_id)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def build_molecular_complex(
|
| 147 |
+
structure: Any,
|
| 148 |
+
coords: torch.Tensor,
|
| 149 |
+
plddt: torch.Tensor,
|
| 150 |
+
complex_id: str,
|
| 151 |
+
) -> MolecularComplex:
|
| 152 |
+
"""Decode coordinates using the atom and residue arrays of a prepared structure."""
|
| 153 |
+
|
| 154 |
+
records = _ComplexRecords()
|
| 155 |
+
coordinate_index = 0
|
| 156 |
+
confidence_index = 0
|
| 157 |
+
|
| 158 |
+
for chain in structure.chains:
|
| 159 |
+
asym_id = int(chain["asym_id"])
|
| 160 |
+
mol_type = int(chain["mol_type"])
|
| 161 |
+
is_nonpolymer = mol_type == MOL_TYPE_NONPOLYMER
|
| 162 |
+
records.chain_lookup[asym_id] = str(chain["name"])
|
| 163 |
+
records.entity_lookup[int(chain["entity_id"])] = (
|
| 164 |
+
"non-polymer" if is_nonpolymer else "polymer"
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
residue_start = int(chain["res_idx"])
|
| 168 |
+
residue_stop = residue_start + int(chain["res_num"])
|
| 169 |
+
for residue in structure.residues[residue_start:residue_stop]:
|
| 170 |
+
atom_start = int(residue["atom_idx"])
|
| 171 |
+
atom_stop = atom_start + int(residue["atom_num"])
|
| 172 |
+
decoded_atoms: list[tuple[list[float], str, str]] = []
|
| 173 |
+
for atom in structure.atoms[atom_start:atom_stop]:
|
| 174 |
+
if not atom["is_present"]:
|
| 175 |
+
continue
|
| 176 |
+
decoded_atoms.append(
|
| 177 |
+
(
|
| 178 |
+
coords[coordinate_index].tolist(),
|
| 179 |
+
get_element_symbol(int(atom["element"].item())),
|
| 180 |
+
_decode_atom_name(atom["name"]),
|
| 181 |
+
)
|
| 182 |
+
)
|
| 183 |
+
coordinate_index += 1
|
| 184 |
+
|
| 185 |
+
records.add_token(
|
| 186 |
+
residue_name=str(residue["name"]),
|
| 187 |
+
asym_id=asym_id,
|
| 188 |
+
plddt=float(plddt[confidence_index].item()),
|
| 189 |
+
atoms=decoded_atoms,
|
| 190 |
+
hetero=is_nonpolymer,
|
| 191 |
+
)
|
| 192 |
+
confidence_index += 1
|
| 193 |
+
|
| 194 |
+
return records.build(complex_id)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
__all__ = [
|
| 198 |
+
"build_molecular_complex",
|
| 199 |
+
"build_molecular_complex_from_features",
|
| 200 |
+
"get_element_symbol",
|
| 201 |
+
]
|
fastplms/models/esmfold2/esmfold2_paired_msa.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Construct taxonomy-paired MSA features for multichain folding."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
from .esmfold2_constants import (
|
| 11 |
+
MSA_GAP_TOKEN_ID,
|
| 12 |
+
PROTEIN_3TO1,
|
| 13 |
+
PROTEIN_RESIDUE_TO_RES_TYPE,
|
| 14 |
+
PROTEIN_UNK_RES_TYPE,
|
| 15 |
+
)
|
| 16 |
+
from .esmfold2_msa import MSA
|
| 17 |
+
|
| 18 |
+
_TAXONOMY_PATTERN = re.compile(r"key=(-?\d+)")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def protein_letter_to_res_type() -> dict[str, int]:
|
| 22 |
+
"""Return the one-letter residue vocabulary used by the MSA encoder."""
|
| 23 |
+
|
| 24 |
+
vocabulary = {
|
| 25 |
+
one_letter: PROTEIN_RESIDUE_TO_RES_TYPE[three_letter]
|
| 26 |
+
for three_letter, one_letter in PROTEIN_3TO1.items()
|
| 27 |
+
if three_letter in PROTEIN_RESIDUE_TO_RES_TYPE
|
| 28 |
+
}
|
| 29 |
+
vocabulary.update({"-": MSA_GAP_TOKEN_ID, "X": PROTEIN_UNK_RES_TYPE})
|
| 30 |
+
return vocabulary
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _taxonomy_from_header(header: str) -> int:
|
| 34 |
+
match = _TAXONOMY_PATTERN.search(header) if header else None
|
| 35 |
+
return int(match.group(1)) if match is not None else -1
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _emitted_length(sequence: str) -> int:
|
| 39 |
+
return sum(character != "." and not character.islower() for character in sequence)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _decode_a3m_row(
|
| 43 |
+
sequence: str,
|
| 44 |
+
sequence_length: int,
|
| 45 |
+
vocabulary: dict[str, int],
|
| 46 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 47 |
+
residues = np.full(sequence_length, MSA_GAP_TOKEN_ID, dtype=np.int64)
|
| 48 |
+
deletions = np.zeros(sequence_length, dtype=np.float32)
|
| 49 |
+
column = 0
|
| 50 |
+
insertion_count = 0
|
| 51 |
+
for character in sequence:
|
| 52 |
+
if character == "." or character.islower():
|
| 53 |
+
insertion_count += 1
|
| 54 |
+
continue
|
| 55 |
+
if column == sequence_length:
|
| 56 |
+
break
|
| 57 |
+
residues[column] = (
|
| 58 |
+
MSA_GAP_TOKEN_ID
|
| 59 |
+
if character == "-"
|
| 60 |
+
else vocabulary.get(character.upper(), PROTEIN_UNK_RES_TYPE)
|
| 61 |
+
)
|
| 62 |
+
if insertion_count:
|
| 63 |
+
deletions[column] = float(insertion_count)
|
| 64 |
+
insertion_count = 0
|
| 65 |
+
column += 1
|
| 66 |
+
return residues, deletions
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def msa_to_res_type_and_deletions(
|
| 70 |
+
msa: MSA,
|
| 71 |
+
letter_to_res_type: dict[str, int],
|
| 72 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 73 |
+
"""Decode an A3M alignment into arrays ``X`` and ``D`` with shape (m, l)."""
|
| 74 |
+
|
| 75 |
+
sequence_length = _emitted_length(msa.entries[0].sequence)
|
| 76 |
+
residue_rows: list[np.ndarray] = []
|
| 77 |
+
deletion_rows: list[np.ndarray] = []
|
| 78 |
+
for entry in msa.entries:
|
| 79 |
+
residues, deletions = _decode_a3m_row(
|
| 80 |
+
entry.sequence,
|
| 81 |
+
sequence_length,
|
| 82 |
+
letter_to_res_type,
|
| 83 |
+
)
|
| 84 |
+
residue_rows.append(residues)
|
| 85 |
+
deletion_rows.append(deletions)
|
| 86 |
+
return np.stack(residue_rows), np.stack(deletion_rows)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@dataclass(frozen=True)
|
| 90 |
+
class _ChainAlignment:
|
| 91 |
+
residues: np.ndarray
|
| 92 |
+
deletions: np.ndarray
|
| 93 |
+
taxonomies: list[int]
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _chain_alignment(
|
| 97 |
+
msa: MSA | None,
|
| 98 |
+
query_res_types: np.ndarray,
|
| 99 |
+
vocabulary: dict[str, int],
|
| 100 |
+
) -> _ChainAlignment:
|
| 101 |
+
if msa is None or msa.depth == 0:
|
| 102 |
+
return _ChainAlignment(
|
| 103 |
+
residues=query_res_types[None, :],
|
| 104 |
+
deletions=np.zeros((1, query_res_types.shape[0]), dtype=np.float32),
|
| 105 |
+
taxonomies=[-1],
|
| 106 |
+
)
|
| 107 |
+
residues, deletions = msa_to_res_type_and_deletions(msa, vocabulary)
|
| 108 |
+
taxonomies = [_taxonomy_from_header(entry.header) for entry in msa.entries]
|
| 109 |
+
return _ChainAlignment(residues, deletions, taxonomies)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _taxonomy_groups(
|
| 113 |
+
chain_ids: list[int],
|
| 114 |
+
alignments: dict[int, _ChainAlignment],
|
| 115 |
+
) -> dict[int, list[tuple[int, int]]]:
|
| 116 |
+
groups: dict[int, list[tuple[int, int]]] = {}
|
| 117 |
+
for chain_id in chain_ids:
|
| 118 |
+
for row, taxonomy in enumerate(alignments[chain_id].taxonomies):
|
| 119 |
+
if row and taxonomy != -1:
|
| 120 |
+
groups.setdefault(taxonomy, []).append((chain_id, row))
|
| 121 |
+
return {taxonomy: rows for taxonomy, rows in groups.items() if len(rows) > 1}
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _available_rows(
|
| 125 |
+
chain_ids: list[int],
|
| 126 |
+
alignments: dict[int, _ChainAlignment],
|
| 127 |
+
groups: dict[int, list[tuple[int, int]]],
|
| 128 |
+
) -> dict[int, list[int]]:
|
| 129 |
+
used = {row for group in groups.values() for row in group}
|
| 130 |
+
return {
|
| 131 |
+
chain_id: [
|
| 132 |
+
row
|
| 133 |
+
for row in range(1, len(alignments[chain_id].taxonomies))
|
| 134 |
+
if (chain_id, row) not in used
|
| 135 |
+
]
|
| 136 |
+
for chain_id in chain_ids
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _append_taxonomy_rows(
|
| 141 |
+
rows: list[dict[int, int]],
|
| 142 |
+
paired_flags: list[dict[int, int]],
|
| 143 |
+
chain_ids: list[int],
|
| 144 |
+
groups: dict[int, list[tuple[int, int]]],
|
| 145 |
+
available: dict[int, list[int]],
|
| 146 |
+
max_pairs: int,
|
| 147 |
+
) -> None:
|
| 148 |
+
ordered_groups = sorted(
|
| 149 |
+
groups.values(),
|
| 150 |
+
key=lambda group: len({chain_id for chain_id, _row in group}),
|
| 151 |
+
reverse=True,
|
| 152 |
+
)
|
| 153 |
+
for group in ordered_groups:
|
| 154 |
+
rows_by_chain: dict[int, list[int]] = {}
|
| 155 |
+
for chain_id, row in group:
|
| 156 |
+
rows_by_chain.setdefault(chain_id, []).append(row)
|
| 157 |
+
for occurrence in range(max(map(len, rows_by_chain.values()))):
|
| 158 |
+
selected: dict[int, int] = {}
|
| 159 |
+
flags: dict[int, int] = {}
|
| 160 |
+
for chain_id, candidates in rows_by_chain.items():
|
| 161 |
+
selected[chain_id] = candidates[occurrence % len(candidates)]
|
| 162 |
+
flags[chain_id] = 1
|
| 163 |
+
for chain_id in chain_ids:
|
| 164 |
+
if chain_id not in selected:
|
| 165 |
+
flags[chain_id] = 0
|
| 166 |
+
selected[chain_id] = available[chain_id].pop(0) if available[chain_id] else -1
|
| 167 |
+
rows.append(selected)
|
| 168 |
+
paired_flags.append(flags)
|
| 169 |
+
if len(rows) >= max_pairs:
|
| 170 |
+
break
|
| 171 |
+
if len(rows) >= max_pairs:
|
| 172 |
+
break
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def _append_unpaired_rows(
|
| 176 |
+
rows: list[dict[int, int]],
|
| 177 |
+
paired_flags: list[dict[int, int]],
|
| 178 |
+
chain_ids: list[int],
|
| 179 |
+
available: dict[int, list[int]],
|
| 180 |
+
max_total: int,
|
| 181 |
+
) -> None:
|
| 182 |
+
max_remaining = max((len(indices) for indices in available.values()), default=0)
|
| 183 |
+
for _ in range(min(max_total - len(rows), max_remaining)):
|
| 184 |
+
rows.append(
|
| 185 |
+
{
|
| 186 |
+
chain_id: available[chain_id].pop(0) if available[chain_id] else -1
|
| 187 |
+
for chain_id in chain_ids
|
| 188 |
+
}
|
| 189 |
+
)
|
| 190 |
+
paired_flags.append({chain_id: 0 for chain_id in chain_ids})
|
| 191 |
+
if len(rows) >= max_total:
|
| 192 |
+
break
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def _pairing_plan(
|
| 196 |
+
chain_ids: list[int],
|
| 197 |
+
alignments: dict[int, _ChainAlignment],
|
| 198 |
+
max_pairs: int,
|
| 199 |
+
max_total: int,
|
| 200 |
+
max_seqs: int,
|
| 201 |
+
) -> tuple[list[dict[int, int]], list[dict[int, int]]]:
|
| 202 |
+
groups = _taxonomy_groups(chain_ids, alignments)
|
| 203 |
+
available = _available_rows(chain_ids, alignments, groups)
|
| 204 |
+
rows = [{chain_id: 0 for chain_id in chain_ids}]
|
| 205 |
+
flags = [{chain_id: 1 for chain_id in chain_ids}]
|
| 206 |
+
_append_taxonomy_rows(rows, flags, chain_ids, groups, available, max_pairs)
|
| 207 |
+
_append_unpaired_rows(rows, flags, chain_ids, available, max_total)
|
| 208 |
+
return rows[:max_seqs], flags[:max_seqs]
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def _project_alignment_rows(
|
| 212 |
+
chain_ids: list[int],
|
| 213 |
+
alignments: dict[int, _ChainAlignment],
|
| 214 |
+
rows: list[dict[int, int]],
|
| 215 |
+
flags: list[dict[int, int]],
|
| 216 |
+
token_asym_ids: np.ndarray,
|
| 217 |
+
token_res_ids: np.ndarray,
|
| 218 |
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
| 219 |
+
m, t = len(rows), len(token_asym_ids)
|
| 220 |
+
residues = np.full((m, t), MSA_GAP_TOKEN_ID, dtype=np.int64)
|
| 221 |
+
deletions = np.zeros((m, t), dtype=np.float32)
|
| 222 |
+
paired_mask = np.zeros((m, t), dtype=np.float32)
|
| 223 |
+
for chain_id in chain_ids:
|
| 224 |
+
alignment = alignments[chain_id]
|
| 225 |
+
selected_rows = np.asarray([row[chain_id] for row in rows], dtype=np.int64)
|
| 226 |
+
chain_flags = np.asarray([row[chain_id] for row in flags], dtype=np.float32)
|
| 227 |
+
token_mask = token_asym_ids == chain_id
|
| 228 |
+
if not token_mask.any():
|
| 229 |
+
continue
|
| 230 |
+
columns = np.minimum(token_res_ids[token_mask], alignment.residues.shape[1] - 1)
|
| 231 |
+
valid_rows = selected_rows >= 0
|
| 232 |
+
if valid_rows.any():
|
| 233 |
+
output_rows = np.flatnonzero(valid_rows)
|
| 234 |
+
output_columns = np.flatnonzero(token_mask)
|
| 235 |
+
residues[np.ix_(output_rows, output_columns)] = alignment.residues[
|
| 236 |
+
selected_rows[valid_rows]
|
| 237 |
+
][:, columns]
|
| 238 |
+
deletions[np.ix_(output_rows, output_columns)] = alignment.deletions[
|
| 239 |
+
selected_rows[valid_rows]
|
| 240 |
+
][:, columns]
|
| 241 |
+
paired_mask[:, token_mask] = chain_flags[:, None]
|
| 242 |
+
return residues, deletions, paired_mask
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def construct_paired_msa(
|
| 246 |
+
chain_msas: dict[int, MSA | None],
|
| 247 |
+
chain_query_res_types: dict[int, np.ndarray],
|
| 248 |
+
token_asym_ids: np.ndarray,
|
| 249 |
+
token_res_ids: np.ndarray,
|
| 250 |
+
letter_to_res_type: dict[str, int] | None = None,
|
| 251 |
+
*,
|
| 252 |
+
max_pairs: int = 8192,
|
| 253 |
+
max_total: int = 16384,
|
| 254 |
+
max_seqs: int = 16384,
|
| 255 |
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
| 256 |
+
"""Return residue, deletion, and pairing arrays with shape (m, t)."""
|
| 257 |
+
|
| 258 |
+
vocabulary = protein_letter_to_res_type() if letter_to_res_type is None else letter_to_res_type
|
| 259 |
+
chain_ids = sorted(chain_msas)
|
| 260 |
+
alignments = {
|
| 261 |
+
chain_id: _chain_alignment(
|
| 262 |
+
chain_msas[chain_id],
|
| 263 |
+
chain_query_res_types[chain_id],
|
| 264 |
+
vocabulary,
|
| 265 |
+
)
|
| 266 |
+
for chain_id in chain_ids
|
| 267 |
+
}
|
| 268 |
+
rows, flags = _pairing_plan(
|
| 269 |
+
chain_ids,
|
| 270 |
+
alignments,
|
| 271 |
+
max_pairs,
|
| 272 |
+
max_total,
|
| 273 |
+
max_seqs,
|
| 274 |
+
)
|
| 275 |
+
return _project_alignment_rows(
|
| 276 |
+
chain_ids,
|
| 277 |
+
alignments,
|
| 278 |
+
rows,
|
| 279 |
+
flags,
|
| 280 |
+
token_asym_ids,
|
| 281 |
+
token_res_ids,
|
| 282 |
+
)
|
fastplms/models/esmfold2/esmfold2_parsing.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FASTA parsing and writing with explicit stream ownership."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import gzip
|
| 6 |
+
import io
|
| 7 |
+
from collections.abc import Generator, Iterable
|
| 8 |
+
from contextlib import nullcontext
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import NamedTuple, TextIO
|
| 11 |
+
|
| 12 |
+
from .esmfold2_utils_types import PathOrBuffer
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class FastaEntry(NamedTuple):
|
| 16 |
+
"""One FASTA record in source order."""
|
| 17 |
+
|
| 18 |
+
header: str
|
| 19 |
+
sequence: str
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def parse_fasta(text: str) -> Generator[FastaEntry, None, None]:
|
| 23 |
+
"""Yield records from FASTA text without normalizing sequence symbols."""
|
| 24 |
+
|
| 25 |
+
header: str | None = None
|
| 26 |
+
sequence_lines: list[str] = []
|
| 27 |
+
found_record = False
|
| 28 |
+
|
| 29 |
+
for line in text.splitlines():
|
| 30 |
+
if not line or line.startswith("#"):
|
| 31 |
+
continue
|
| 32 |
+
if line.startswith(">"):
|
| 33 |
+
if header is not None:
|
| 34 |
+
found_record = True
|
| 35 |
+
yield FastaEntry(header, "".join(sequence_lines))
|
| 36 |
+
header = line[1:].strip()
|
| 37 |
+
sequence_lines.clear()
|
| 38 |
+
elif header is not None:
|
| 39 |
+
sequence_lines.append(line)
|
| 40 |
+
|
| 41 |
+
if header is not None:
|
| 42 |
+
found_record = True
|
| 43 |
+
yield FastaEntry(header, "".join(sequence_lines))
|
| 44 |
+
if not found_record:
|
| 45 |
+
raise ValueError("Found no sequences in input")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _open_reader(source: PathOrBuffer):
|
| 49 |
+
if isinstance(source, io.TextIOBase):
|
| 50 |
+
return nullcontext(source)
|
| 51 |
+
path = Path(source)
|
| 52 |
+
if path.suffix.lower() == ".gz":
|
| 53 |
+
return gzip.open(path, mode="rt", encoding="utf-8")
|
| 54 |
+
return path.open(mode="r", encoding="utf-8")
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def read_sequences(source: PathOrBuffer) -> Generator[FastaEntry, None, None]:
|
| 58 |
+
"""Read FASTA records while leaving caller-owned streams open."""
|
| 59 |
+
|
| 60 |
+
with _open_reader(source) as handle:
|
| 61 |
+
yield from parse_fasta(handle.read())
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def read_first_sequence(source: PathOrBuffer) -> FastaEntry:
|
| 65 |
+
"""Return the first FASTA record from a path or text stream."""
|
| 66 |
+
|
| 67 |
+
return next(read_sequences(source))
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def count_fasta_sequences(path: str | Path) -> int:
|
| 71 |
+
"""Count FASTA headers without parsing sequence bodies."""
|
| 72 |
+
|
| 73 |
+
source = Path(path)
|
| 74 |
+
if not source.exists():
|
| 75 |
+
return 0
|
| 76 |
+
with source.open(encoding="utf-8") as handle:
|
| 77 |
+
return sum(line.startswith(">") for line in handle)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def append_fasta_sequence(header: str, sequence: str, path: str | Path) -> None:
|
| 81 |
+
"""Append one record, inserting a separator if the file lacks a final newline."""
|
| 82 |
+
|
| 83 |
+
destination = Path(path)
|
| 84 |
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
| 85 |
+
needs_separator = (
|
| 86 |
+
destination.exists()
|
| 87 |
+
and destination.stat().st_size > 0
|
| 88 |
+
and destination.read_bytes()[-1:] != b"\n"
|
| 89 |
+
)
|
| 90 |
+
with destination.open(mode="a", encoding="utf-8") as handle:
|
| 91 |
+
if needs_separator:
|
| 92 |
+
handle.write("\n")
|
| 93 |
+
handle.write(f">{header}\n{sequence}\n")
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _open_writer(destination: PathOrBuffer):
|
| 97 |
+
if isinstance(destination, io.TextIOBase):
|
| 98 |
+
return nullcontext(destination)
|
| 99 |
+
path = Path(destination)
|
| 100 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 101 |
+
return path.open(mode="w", encoding="utf-8")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def write_sequences(sequences: Iterable[tuple[str, str]], destination: PathOrBuffer) -> None:
|
| 105 |
+
"""Write records with one blank-line-free separator between entries."""
|
| 106 |
+
|
| 107 |
+
with _open_writer(destination) as handle:
|
| 108 |
+
_write_records(handle, sequences)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _write_records(handle: TextIO, sequences: Iterable[tuple[str, str]]) -> None:
|
| 112 |
+
for index, (header, sequence) in enumerate(sequences):
|
| 113 |
+
if index:
|
| 114 |
+
handle.write("\n")
|
| 115 |
+
handle.write(f">{header}\n{sequence}")
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
__all__ = [
|
| 119 |
+
"FastaEntry",
|
| 120 |
+
"append_fasta_sequence",
|
| 121 |
+
"count_fasta_sequences",
|
| 122 |
+
"parse_fasta",
|
| 123 |
+
"read_first_sequence",
|
| 124 |
+
"read_sequences",
|
| 125 |
+
"write_sequences",
|
| 126 |
+
]
|
fastplms/models/esmfold2/esmfold2_predicted_aligned_error.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Predicted-aligned-error scores and training loss."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
import torch.nn.functional as F
|
| 7 |
+
from torch import Tensor
|
| 8 |
+
|
| 9 |
+
from .esmfold2_affine3d import Affine3D
|
| 10 |
+
|
| 11 |
+
_CPU_DEVICE = torch.device("cpu")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _compute_pae_masks(mask: Tensor) -> Tensor:
|
| 15 |
+
residue_mask = mask.bool()
|
| 16 |
+
return residue_mask.unsqueeze(-1) & residue_mask.unsqueeze(-2)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _pae_bins(
|
| 20 |
+
max_bin: float = 31,
|
| 21 |
+
num_bins: int = 64,
|
| 22 |
+
device: torch.device = _CPU_DEVICE,
|
| 23 |
+
) -> Tensor:
|
| 24 |
+
"""Return the representative distance for each PAE probability bin."""
|
| 25 |
+
|
| 26 |
+
boundaries = torch.linspace(0, max_bin, steps=num_bins - 1, device=device)
|
| 27 |
+
width = max_bin / (num_bins - 2)
|
| 28 |
+
centers = boundaries + width / 2
|
| 29 |
+
overflow_center = centers[-1:] + width
|
| 30 |
+
return torch.cat((centers, overflow_center))
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _masked_probabilities(logits: Tensor, pair_mask: Tensor) -> Tensor:
|
| 34 |
+
masked_logits = logits.masked_fill(
|
| 35 |
+
~pair_mask.unsqueeze(-1),
|
| 36 |
+
torch.finfo(logits.dtype).min,
|
| 37 |
+
)
|
| 38 |
+
return masked_logits.softmax(dim=-1)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def masked_mean(
|
| 42 |
+
mask: Tensor,
|
| 43 |
+
value: Tensor,
|
| 44 |
+
dim: int | tuple[int, ...] | None = None,
|
| 45 |
+
eps: float = 1e-10,
|
| 46 |
+
) -> Tensor:
|
| 47 |
+
"""Average values over true entries of a broadcast-compatible mask."""
|
| 48 |
+
|
| 49 |
+
weights = mask.expand_as(value)
|
| 50 |
+
weighted_sum = torch.sum(weights * value, dim=dim)
|
| 51 |
+
weight_sum = torch.sum(weights, dim=dim)
|
| 52 |
+
return weighted_sum / (weight_sum + eps)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def compute_predicted_aligned_error(
|
| 56 |
+
logits: Tensor,
|
| 57 |
+
aa_mask: Tensor,
|
| 58 |
+
sequence_id: Tensor | None = None,
|
| 59 |
+
max_bin: float = 31,
|
| 60 |
+
) -> Tensor:
|
| 61 |
+
"""Convert PAE logits ``X`` with shape (..., l, l, n) to distances."""
|
| 62 |
+
|
| 63 |
+
del sequence_id
|
| 64 |
+
pair_mask = _compute_pae_masks(aa_mask)
|
| 65 |
+
probabilities = _masked_probabilities(logits, pair_mask)
|
| 66 |
+
centers = _pae_bins(max_bin, logits.shape[-1], logits.device)
|
| 67 |
+
return torch.sum(probabilities * centers, dim=-1)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@torch.no_grad()
|
| 71 |
+
def compute_tm(logits: Tensor, aa_mask: Tensor, max_bin: float = 31.0) -> Tensor:
|
| 72 |
+
"""Estimate TM score from pairwise PAE logits."""
|
| 73 |
+
|
| 74 |
+
pair_mask = _compute_pae_masks(aa_mask)
|
| 75 |
+
sequence_lengths = aa_mask.sum(dim=-1, keepdim=True)
|
| 76 |
+
centers = _pae_bins(max_bin, logits.shape[-1], logits.device)
|
| 77 |
+
distance_scale = 1.24 * (sequence_lengths.clamp_min(19) - 15) ** (1 / 3) - 1.8
|
| 78 |
+
tm_weights = 1.0 / (1 + (centers / distance_scale.unsqueeze(-1)) ** 2)
|
| 79 |
+
probabilities = _masked_probabilities(logits, pair_mask)
|
| 80 |
+
score_per_pair = torch.sum(probabilities * tm_weights.unsqueeze(-2), dim=-1)
|
| 81 |
+
score_per_anchor = masked_mean(pair_mask, score_per_pair, dim=-1)
|
| 82 |
+
return score_per_anchor.max(dim=-1).values
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _local_coordinates(frames: Affine3D) -> Tensor:
|
| 86 |
+
origins = frames.trans[..., None, :, :]
|
| 87 |
+
return frames.invert()[..., None].apply(origins)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def tm_loss(
|
| 91 |
+
logits: Tensor,
|
| 92 |
+
pred_affine: Tensor,
|
| 93 |
+
targ_affine: Tensor,
|
| 94 |
+
targ_mask: Tensor,
|
| 95 |
+
tm_mask: Tensor | None = None,
|
| 96 |
+
sequence_id: Tensor | None = None,
|
| 97 |
+
max_bin: float = 31,
|
| 98 |
+
) -> Tensor:
|
| 99 |
+
"""Cross-entropy loss for discretized aligned-position errors."""
|
| 100 |
+
|
| 101 |
+
del sequence_id
|
| 102 |
+
predicted_frames = Affine3D.from_tensor(pred_affine)
|
| 103 |
+
target_frames = Affine3D.from_tensor(targ_affine)
|
| 104 |
+
with torch.no_grad():
|
| 105 |
+
squared_error = (
|
| 106 |
+
(_local_coordinates(predicted_frames) - _local_coordinates(target_frames))
|
| 107 |
+
.square()
|
| 108 |
+
.sum(dim=-1)
|
| 109 |
+
)
|
| 110 |
+
boundaries = torch.linspace(
|
| 111 |
+
0,
|
| 112 |
+
max_bin,
|
| 113 |
+
logits.shape[-1] - 1,
|
| 114 |
+
device=logits.device,
|
| 115 |
+
).square()
|
| 116 |
+
target_bins = (squared_error[..., None] > boundaries).sum(dim=-1).long()
|
| 117 |
+
|
| 118 |
+
cross_entropy = F.cross_entropy(
|
| 119 |
+
logits.movedim(3, 1),
|
| 120 |
+
target_bins,
|
| 121 |
+
reduction="none",
|
| 122 |
+
)
|
| 123 |
+
pair_mask = _compute_pae_masks(targ_mask)
|
| 124 |
+
loss_per_sample = masked_mean(pair_mask, cross_entropy, dim=(-1, -2))
|
| 125 |
+
if tm_mask is None:
|
| 126 |
+
return loss_per_sample.mean()
|
| 127 |
+
return masked_mean(tm_mask, loss_per_sample)
|
fastplms/models/esmfold2/esmfold2_prepare_input.py
ADDED
|
@@ -0,0 +1,1130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Translate typed sequence inputs into the tensors consumed by ESMFold2.
|
| 2 |
+
|
| 3 |
+
The conversion has four explicit stages: entity and chain assignment, residue
|
| 4 |
+
tokenization, structural feature construction, and atom-table padding. Keeping
|
| 5 |
+
those stages separate makes the biological indexing rules testable without
|
| 6 |
+
loading model weights.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import math
|
| 12 |
+
import warnings
|
| 13 |
+
from collections import defaultdict
|
| 14 |
+
from contextlib import suppress
|
| 15 |
+
from dataclasses import dataclass, field
|
| 16 |
+
from itertools import combinations
|
| 17 |
+
from typing import Any
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
import torch
|
| 21 |
+
|
| 22 |
+
from .esmfold2_conformers import (
|
| 23 |
+
get_ccd_leaving_atoms,
|
| 24 |
+
get_idealized_atom_pos,
|
| 25 |
+
get_ligand_ccd_atoms_with_charges,
|
| 26 |
+
get_ligand_ccd_bonds,
|
| 27 |
+
get_ligand_idealized_atom_pos,
|
| 28 |
+
)
|
| 29 |
+
from .esmfold2_constants import (
|
| 30 |
+
CHARGED_ATOMS,
|
| 31 |
+
DNA_1TO3,
|
| 32 |
+
DNA_BACKBONE_ATOMS,
|
| 33 |
+
DNA_HEAVY_ATOMS,
|
| 34 |
+
DNA_RESIDUE_TO_RES_TYPE,
|
| 35 |
+
DNA_RNA_LIGAND_INPUT_ID,
|
| 36 |
+
DNA_UNK_RES_TYPE,
|
| 37 |
+
ELEMENT_TO_ATOMIC_NUM,
|
| 38 |
+
ESM_PROTEIN_VOCAB,
|
| 39 |
+
MOL_TYPE_DNA,
|
| 40 |
+
MOL_TYPE_NONPOLYMER,
|
| 41 |
+
MOL_TYPE_PROTEIN,
|
| 42 |
+
MOL_TYPE_RNA,
|
| 43 |
+
MSA_GAP_TOKEN_ID,
|
| 44 |
+
PROTEIN_1TO3,
|
| 45 |
+
PROTEIN_3TO1,
|
| 46 |
+
PROTEIN_HEAVY_ATOMS,
|
| 47 |
+
PROTEIN_RESIDUE_TO_RES_TYPE,
|
| 48 |
+
PROTEIN_UNK_RES_TYPE,
|
| 49 |
+
RNA_1TO3,
|
| 50 |
+
RNA_BACKBONE_ATOMS,
|
| 51 |
+
RNA_HEAVY_ATOMS,
|
| 52 |
+
RNA_RESIDUE_TO_RES_TYPE,
|
| 53 |
+
RNA_UNK_RES_TYPE,
|
| 54 |
+
)
|
| 55 |
+
from .esmfold2_types import (
|
| 56 |
+
MSA,
|
| 57 |
+
DNAInput,
|
| 58 |
+
LigandInput,
|
| 59 |
+
Modification,
|
| 60 |
+
ProteinInput,
|
| 61 |
+
RNAInput,
|
| 62 |
+
StructurePredictionInput,
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
_ZERO_POS = np.zeros(3, dtype=np.float32)
|
| 66 |
+
_ENCODE_ATOM_NAME_CACHE: dict[str, list[int]] = {}
|
| 67 |
+
_ELEMENT_ATOMIC_NUM_CACHE: dict[str, int] = {}
|
| 68 |
+
_TWO_LETTER_ELEMENTS = frozenset({"FE", "ZN", "MG", "MN", "CO", "NI", "CU", "SE", "BR"})
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@dataclass
|
| 72 |
+
class AtomInfo:
|
| 73 |
+
"""One row in the unpadded atom table."""
|
| 74 |
+
|
| 75 |
+
name: str
|
| 76 |
+
element: str
|
| 77 |
+
charge: int
|
| 78 |
+
ref_pos: np.ndarray # R has shape (3,).
|
| 79 |
+
pos: np.ndarray # X has shape (3,).
|
| 80 |
+
token_index: int = -1
|
| 81 |
+
atom_index: int = -1
|
| 82 |
+
space_uid: int = -1
|
| 83 |
+
is_valid: bool = True
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@dataclass
|
| 87 |
+
class TokenInfo:
|
| 88 |
+
"""Biological and atom-span annotations for one model token."""
|
| 89 |
+
|
| 90 |
+
token_index: int
|
| 91 |
+
residue_index: int
|
| 92 |
+
residue_name: str
|
| 93 |
+
mol_type: int
|
| 94 |
+
res_type: int
|
| 95 |
+
input_id: int
|
| 96 |
+
asym_id: int
|
| 97 |
+
sym_id: int
|
| 98 |
+
entity_id: int
|
| 99 |
+
atom_start: int
|
| 100 |
+
atom_count: int
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@dataclass
|
| 104 |
+
class ChainInfo:
|
| 105 |
+
"""One input chain after entity and symmetry assignment."""
|
| 106 |
+
|
| 107 |
+
chain_id: str
|
| 108 |
+
asym_id: int
|
| 109 |
+
entity_id: int
|
| 110 |
+
sym_id: int
|
| 111 |
+
mol_type: int
|
| 112 |
+
tokens: list[TokenInfo] = field(default_factory=list)
|
| 113 |
+
ligand_bonds: list[tuple[str, str]] = field(default_factory=list)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@dataclass
|
| 117 |
+
class _TokenizationState:
|
| 118 |
+
"""Mutable cursor shared by residue tokenizers."""
|
| 119 |
+
|
| 120 |
+
token_index: int
|
| 121 |
+
atom_index: int
|
| 122 |
+
space_uid: int
|
| 123 |
+
tokens: list[TokenInfo] = field(default_factory=list)
|
| 124 |
+
atoms: list[AtomInfo] = field(default_factory=list)
|
| 125 |
+
|
| 126 |
+
def _append_atom(
|
| 127 |
+
self,
|
| 128 |
+
name: str,
|
| 129 |
+
element: str,
|
| 130 |
+
charge: int,
|
| 131 |
+
ref_pos: np.ndarray | None,
|
| 132 |
+
) -> None:
|
| 133 |
+
self.atoms.append(
|
| 134 |
+
AtomInfo(
|
| 135 |
+
name=name,
|
| 136 |
+
element=element,
|
| 137 |
+
charge=charge,
|
| 138 |
+
ref_pos=(ref_pos.copy() if ref_pos is not None else _ZERO_POS.copy()),
|
| 139 |
+
pos=_ZERO_POS.copy(),
|
| 140 |
+
token_index=self.token_index,
|
| 141 |
+
atom_index=self.atom_index,
|
| 142 |
+
space_uid=self.space_uid,
|
| 143 |
+
)
|
| 144 |
+
)
|
| 145 |
+
self.atom_index += 1
|
| 146 |
+
|
| 147 |
+
def _append_token(
|
| 148 |
+
self,
|
| 149 |
+
*,
|
| 150 |
+
residue_index: int,
|
| 151 |
+
residue_name: str,
|
| 152 |
+
mol_type: int,
|
| 153 |
+
res_type: int,
|
| 154 |
+
input_id: int,
|
| 155 |
+
asym_id: int,
|
| 156 |
+
sym_id: int,
|
| 157 |
+
entity_id: int,
|
| 158 |
+
atom_start: int,
|
| 159 |
+
atom_count: int,
|
| 160 |
+
) -> None:
|
| 161 |
+
self.tokens.append(
|
| 162 |
+
TokenInfo(
|
| 163 |
+
token_index=self.token_index,
|
| 164 |
+
residue_index=residue_index,
|
| 165 |
+
residue_name=residue_name,
|
| 166 |
+
mol_type=mol_type,
|
| 167 |
+
res_type=res_type,
|
| 168 |
+
input_id=input_id,
|
| 169 |
+
asym_id=asym_id,
|
| 170 |
+
sym_id=sym_id,
|
| 171 |
+
entity_id=entity_id,
|
| 172 |
+
atom_start=atom_start,
|
| 173 |
+
atom_count=atom_count,
|
| 174 |
+
)
|
| 175 |
+
)
|
| 176 |
+
self.token_index += 1
|
| 177 |
+
|
| 178 |
+
def add_residue_token(
|
| 179 |
+
self,
|
| 180 |
+
atom_specs: list[tuple[str, str, int, np.ndarray | None]],
|
| 181 |
+
**token_fields: Any,
|
| 182 |
+
) -> None:
|
| 183 |
+
start = self.atom_index
|
| 184 |
+
for atom_spec in atom_specs:
|
| 185 |
+
self._append_atom(*atom_spec)
|
| 186 |
+
self._append_token(
|
| 187 |
+
atom_start=start,
|
| 188 |
+
atom_count=len(atom_specs),
|
| 189 |
+
**token_fields,
|
| 190 |
+
)
|
| 191 |
+
self.space_uid += 1
|
| 192 |
+
|
| 193 |
+
def add_atom_tokens(
|
| 194 |
+
self,
|
| 195 |
+
atom_specs: list[tuple[str, str, int, np.ndarray | None]],
|
| 196 |
+
**token_fields: Any,
|
| 197 |
+
) -> None:
|
| 198 |
+
for atom_spec in atom_specs:
|
| 199 |
+
start = self.atom_index
|
| 200 |
+
self._append_atom(*atom_spec)
|
| 201 |
+
self._append_token(atom_start=start, atom_count=1, **token_fields)
|
| 202 |
+
self.space_uid += 1
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def encode_atom_name(name: str) -> list[int]:
|
| 206 |
+
"""Encode a four-character atom name with the model's ASCII offset."""
|
| 207 |
+
cached = _ENCODE_ATOM_NAME_CACHE.get(name)
|
| 208 |
+
if cached is None:
|
| 209 |
+
cached = [0 if char == " " else ord(char) - 32 for char in name.ljust(4)[:4]]
|
| 210 |
+
_ENCODE_ATOM_NAME_CACHE[name] = cached
|
| 211 |
+
return cached
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def get_element_atomic_num(element: str) -> int:
|
| 215 |
+
"""Map an element symbol to the model's atomic-number vocabulary."""
|
| 216 |
+
cached = _ELEMENT_ATOMIC_NUM_CACHE.get(element)
|
| 217 |
+
if cached is None:
|
| 218 |
+
cached = ELEMENT_TO_ATOMIC_NUM.get(element.upper(), 0)
|
| 219 |
+
_ELEMENT_ATOMIC_NUM_CACHE[element] = cached
|
| 220 |
+
return cached
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def _infer_element(atom_name: str) -> str:
|
| 224 |
+
normalized = atom_name.strip()
|
| 225 |
+
if not normalized:
|
| 226 |
+
return "C"
|
| 227 |
+
if normalized[0].isdigit():
|
| 228 |
+
return normalized[1] if len(normalized) > 1 else "H"
|
| 229 |
+
if len(normalized) == 2 and normalized in _TWO_LETTER_ELEMENTS:
|
| 230 |
+
return normalized
|
| 231 |
+
return normalized[0]
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def _compute_res_type(name: str, mol_type: int) -> int:
|
| 235 |
+
if mol_type == MOL_TYPE_PROTEIN:
|
| 236 |
+
return PROTEIN_RESIDUE_TO_RES_TYPE.get(name, PROTEIN_UNK_RES_TYPE)
|
| 237 |
+
if mol_type == MOL_TYPE_DNA:
|
| 238 |
+
return DNA_RESIDUE_TO_RES_TYPE.get(
|
| 239 |
+
name, RNA_RESIDUE_TO_RES_TYPE.get(name, DNA_UNK_RES_TYPE)
|
| 240 |
+
)
|
| 241 |
+
if mol_type == MOL_TYPE_RNA:
|
| 242 |
+
return RNA_RESIDUE_TO_RES_TYPE.get(
|
| 243 |
+
name, DNA_RESIDUE_TO_RES_TYPE.get(name, RNA_UNK_RES_TYPE)
|
| 244 |
+
)
|
| 245 |
+
return PROTEIN_UNK_RES_TYPE
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def _compute_esm_input_id(name: str, mol_type: int) -> int:
|
| 249 |
+
if mol_type != MOL_TYPE_PROTEIN:
|
| 250 |
+
return DNA_RNA_LIGAND_INPUT_ID
|
| 251 |
+
letter = PROTEIN_3TO1.get(name)
|
| 252 |
+
return (
|
| 253 |
+
DNA_RNA_LIGAND_INPUT_ID
|
| 254 |
+
if letter is None
|
| 255 |
+
else ESM_PROTEIN_VOCAB.get(letter, ESM_PROTEIN_VOCAB["X"])
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def _apply_modifications(residues: list[str], modifications: list[Modification] | None) -> set[int]:
|
| 260 |
+
changed: set[int] = set()
|
| 261 |
+
for modification in modifications or ():
|
| 262 |
+
residues[modification.position] = modification.ccd
|
| 263 |
+
changed.add(modification.position)
|
| 264 |
+
return changed
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def _ideal_atom_specs(
|
| 268 |
+
residue_name: str,
|
| 269 |
+
residue_type: int,
|
| 270 |
+
atom_names: list[str],
|
| 271 |
+
*,
|
| 272 |
+
charges: bool = True,
|
| 273 |
+
) -> list[tuple[str, str, int, np.ndarray | None]]:
|
| 274 |
+
return [
|
| 275 |
+
(
|
| 276 |
+
atom_name,
|
| 277 |
+
_infer_element(atom_name),
|
| 278 |
+
CHARGED_ATOMS.get((residue_name, atom_name), 0) if charges else 0,
|
| 279 |
+
get_idealized_atom_pos(residue_type, atom_name),
|
| 280 |
+
)
|
| 281 |
+
for atom_name in atom_names
|
| 282 |
+
]
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def _ccd_atom_specs(
|
| 286 |
+
residue_name: str,
|
| 287 |
+
atoms: list[tuple[str, str, int]],
|
| 288 |
+
excluded: set[str],
|
| 289 |
+
*,
|
| 290 |
+
force_zero: bool = False,
|
| 291 |
+
) -> list[tuple[str, str, int, np.ndarray | None]]:
|
| 292 |
+
return [
|
| 293 |
+
(
|
| 294 |
+
atom_name,
|
| 295 |
+
element,
|
| 296 |
+
charge,
|
| 297 |
+
None if force_zero else get_ligand_idealized_atom_pos(residue_name, atom_name),
|
| 298 |
+
)
|
| 299 |
+
for atom_name, element, charge in atoms
|
| 300 |
+
if atom_name not in excluded
|
| 301 |
+
]
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def tokenize_protein(
|
| 305 |
+
sequence: str,
|
| 306 |
+
modifications: list[Modification] | None,
|
| 307 |
+
entity_id: int,
|
| 308 |
+
asym_id: int,
|
| 309 |
+
sym_id: int,
|
| 310 |
+
token_offset: int,
|
| 311 |
+
atom_offset: int,
|
| 312 |
+
space_uid_offset: int,
|
| 313 |
+
) -> tuple[list[TokenInfo], list[AtomInfo]]:
|
| 314 |
+
"""Tokenize protein residues, atom-tokenizing modified CCD components."""
|
| 315 |
+
residues = [PROTEIN_1TO3.get(letter, "UNK") for letter in sequence]
|
| 316 |
+
modified = _apply_modifications(residues, modifications)
|
| 317 |
+
state = _TokenizationState(token_offset, atom_offset, space_uid_offset)
|
| 318 |
+
|
| 319 |
+
for residue_index, residue_name in enumerate(residues):
|
| 320 |
+
canonical_name = "MET" if residue_name == "MSE" else residue_name
|
| 321 |
+
common_fields = {
|
| 322 |
+
"residue_index": residue_index,
|
| 323 |
+
"mol_type": MOL_TYPE_PROTEIN,
|
| 324 |
+
"asym_id": asym_id,
|
| 325 |
+
"sym_id": sym_id,
|
| 326 |
+
"entity_id": entity_id,
|
| 327 |
+
}
|
| 328 |
+
if residue_index not in modified and canonical_name in PROTEIN_HEAVY_ATOMS:
|
| 329 |
+
residue_type = _compute_res_type(canonical_name, MOL_TYPE_PROTEIN)
|
| 330 |
+
state.add_residue_token(
|
| 331 |
+
_ideal_atom_specs(
|
| 332 |
+
canonical_name,
|
| 333 |
+
residue_type,
|
| 334 |
+
PROTEIN_HEAVY_ATOMS[canonical_name],
|
| 335 |
+
),
|
| 336 |
+
residue_name=canonical_name,
|
| 337 |
+
res_type=residue_type,
|
| 338 |
+
input_id=_compute_esm_input_id(canonical_name, MOL_TYPE_PROTEIN),
|
| 339 |
+
**common_fields,
|
| 340 |
+
)
|
| 341 |
+
continue
|
| 342 |
+
|
| 343 |
+
ccd_atoms = get_ligand_ccd_atoms_with_charges(residue_name)
|
| 344 |
+
if ccd_atoms is None:
|
| 345 |
+
ccd_atoms = [
|
| 346 |
+
(_infer_element(name), _infer_element(name), 0) for name in ("N", "CA", "C", "O")
|
| 347 |
+
]
|
| 348 |
+
excluded = (
|
| 349 |
+
set() if residue_index == len(residues) - 1 else get_ccd_leaving_atoms(residue_name)
|
| 350 |
+
)
|
| 351 |
+
retained = [atom for atom in ccd_atoms if atom[0] not in excluded]
|
| 352 |
+
state.add_atom_tokens(
|
| 353 |
+
_ccd_atom_specs(
|
| 354 |
+
residue_name,
|
| 355 |
+
retained,
|
| 356 |
+
set(),
|
| 357 |
+
force_zero=len(retained) == 1,
|
| 358 |
+
),
|
| 359 |
+
residue_name=residue_name,
|
| 360 |
+
res_type=PROTEIN_UNK_RES_TYPE,
|
| 361 |
+
input_id=DNA_RNA_LIGAND_INPUT_ID,
|
| 362 |
+
**common_fields,
|
| 363 |
+
)
|
| 364 |
+
return state.tokens, state.atoms
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
def tokenize_nucleotide(
|
| 368 |
+
sequence: str,
|
| 369 |
+
modifications: list[Modification] | None,
|
| 370 |
+
mol_type: int,
|
| 371 |
+
entity_id: int,
|
| 372 |
+
asym_id: int,
|
| 373 |
+
sym_id: int,
|
| 374 |
+
token_offset: int,
|
| 375 |
+
atom_offset: int,
|
| 376 |
+
space_uid_offset: int,
|
| 377 |
+
) -> tuple[list[TokenInfo], list[AtomInfo]]:
|
| 378 |
+
"""Tokenize DNA or RNA, retaining backbone atoms for unknown bases."""
|
| 379 |
+
dna = mol_type == MOL_TYPE_DNA
|
| 380 |
+
letter_map = DNA_1TO3 if dna else RNA_1TO3
|
| 381 |
+
heavy_atoms = DNA_HEAVY_ATOMS if dna else RNA_HEAVY_ATOMS
|
| 382 |
+
backbone_atoms = DNA_BACKBONE_ATOMS if dna else RNA_BACKBONE_ATOMS
|
| 383 |
+
unknown_type = DNA_UNK_RES_TYPE if dna else RNA_UNK_RES_TYPE
|
| 384 |
+
residues = [letter_map.get(letter, "UNK") for letter in sequence]
|
| 385 |
+
modified = _apply_modifications(residues, modifications)
|
| 386 |
+
state = _TokenizationState(token_offset, atom_offset, space_uid_offset)
|
| 387 |
+
|
| 388 |
+
for residue_index, residue_name in enumerate(residues):
|
| 389 |
+
common_fields = {
|
| 390 |
+
"residue_index": residue_index,
|
| 391 |
+
"residue_name": residue_name,
|
| 392 |
+
"mol_type": mol_type,
|
| 393 |
+
"asym_id": asym_id,
|
| 394 |
+
"sym_id": sym_id,
|
| 395 |
+
"entity_id": entity_id,
|
| 396 |
+
"input_id": DNA_RNA_LIGAND_INPUT_ID,
|
| 397 |
+
}
|
| 398 |
+
if residue_index not in modified and residue_name in heavy_atoms:
|
| 399 |
+
residue_type = _compute_res_type(residue_name, mol_type)
|
| 400 |
+
state.add_residue_token(
|
| 401 |
+
_ideal_atom_specs(residue_name, residue_type, heavy_atoms[residue_name]),
|
| 402 |
+
res_type=residue_type,
|
| 403 |
+
**common_fields,
|
| 404 |
+
)
|
| 405 |
+
continue
|
| 406 |
+
if residue_index not in modified and residue_name == "UNK":
|
| 407 |
+
state.add_residue_token(
|
| 408 |
+
[(atom_name, _infer_element(atom_name), 0, None) for atom_name in backbone_atoms],
|
| 409 |
+
res_type=unknown_type,
|
| 410 |
+
**common_fields,
|
| 411 |
+
)
|
| 412 |
+
continue
|
| 413 |
+
|
| 414 |
+
ccd_atoms = get_ligand_ccd_atoms_with_charges(residue_name)
|
| 415 |
+
if ccd_atoms is None:
|
| 416 |
+
ccd_atoms = [(_infer_element(name), _infer_element(name), 0) for name in backbone_atoms]
|
| 417 |
+
excluded = (
|
| 418 |
+
set() if residue_index == len(residues) - 1 else get_ccd_leaving_atoms(residue_name)
|
| 419 |
+
)
|
| 420 |
+
state.add_atom_tokens(
|
| 421 |
+
_ccd_atom_specs(residue_name, ccd_atoms, excluded),
|
| 422 |
+
res_type=PROTEIN_UNK_RES_TYPE,
|
| 423 |
+
**common_fields,
|
| 424 |
+
)
|
| 425 |
+
return state.tokens, state.atoms
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
def tokenize_ligand_ccd(
|
| 429 |
+
ccd_codes: list[str],
|
| 430 |
+
entity_id: int,
|
| 431 |
+
asym_id: int,
|
| 432 |
+
sym_id: int,
|
| 433 |
+
token_offset: int,
|
| 434 |
+
atom_offset: int,
|
| 435 |
+
space_uid_offset: int,
|
| 436 |
+
has_covalent_bond: bool,
|
| 437 |
+
) -> tuple[list[TokenInfo], list[AtomInfo]]:
|
| 438 |
+
"""Tokenize CCD ligands with one model token per retained atom."""
|
| 439 |
+
state = _TokenizationState(token_offset, atom_offset, space_uid_offset)
|
| 440 |
+
for residue_index, code in enumerate(ccd_codes):
|
| 441 |
+
ccd_atoms = get_ligand_ccd_atoms_with_charges(code)
|
| 442 |
+
if ccd_atoms is None:
|
| 443 |
+
raise ValueError(f"CCD component {code} not found")
|
| 444 |
+
excluded = get_ccd_leaving_atoms(code) if has_covalent_bond else set()
|
| 445 |
+
state.add_atom_tokens(
|
| 446 |
+
_ccd_atom_specs(code, ccd_atoms, excluded),
|
| 447 |
+
residue_index=residue_index,
|
| 448 |
+
residue_name=code,
|
| 449 |
+
mol_type=MOL_TYPE_NONPOLYMER,
|
| 450 |
+
res_type=PROTEIN_UNK_RES_TYPE,
|
| 451 |
+
input_id=DNA_RNA_LIGAND_INPUT_ID,
|
| 452 |
+
asym_id=asym_id,
|
| 453 |
+
sym_id=sym_id,
|
| 454 |
+
entity_id=entity_id,
|
| 455 |
+
)
|
| 456 |
+
return state.tokens, state.atoms
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
def tokenize_ligand_smiles(
|
| 460 |
+
smiles: str,
|
| 461 |
+
entity_id: int,
|
| 462 |
+
asym_id: int,
|
| 463 |
+
sym_id: int,
|
| 464 |
+
token_offset: int,
|
| 465 |
+
atom_offset: int,
|
| 466 |
+
space_uid_offset: int,
|
| 467 |
+
seed: int | None = None,
|
| 468 |
+
) -> tuple[list[TokenInfo], list[AtomInfo], list[tuple[str, str]]]:
|
| 469 |
+
"""Generate a conformer and tokenize each heavy atom of a SMILES ligand."""
|
| 470 |
+
from rdkit import Chem
|
| 471 |
+
from rdkit.Chem import AllChem
|
| 472 |
+
|
| 473 |
+
molecule = Chem.MolFromSmiles(smiles)
|
| 474 |
+
if molecule is None:
|
| 475 |
+
raise ValueError(f"Failed to parse SMILES: {smiles}")
|
| 476 |
+
molecule = Chem.AddHs(molecule)
|
| 477 |
+
canonical_order = AllChem.CanonicalRankAtoms(molecule) # type: ignore[attr-defined]
|
| 478 |
+
for atom, canonical_index in zip(molecule.GetAtoms(), canonical_order, strict=True):
|
| 479 |
+
name = atom.GetSymbol().upper() + str(canonical_index + 1)
|
| 480 |
+
if len(name) > 4:
|
| 481 |
+
raise ValueError(f"SMILES {smiles} has atom name longer than 4 chars: {name}")
|
| 482 |
+
atom.SetProp("name", name)
|
| 483 |
+
|
| 484 |
+
options = AllChem.ETKDGv3() # type: ignore[attr-defined]
|
| 485 |
+
options.clearConfs = False
|
| 486 |
+
if seed is not None:
|
| 487 |
+
options.randomSeed = seed
|
| 488 |
+
conformer_id = AllChem.EmbedMolecule(molecule, options) # type: ignore[attr-defined]
|
| 489 |
+
if conformer_id == -1:
|
| 490 |
+
options.useRandomCoords = True
|
| 491 |
+
conformer_id = AllChem.EmbedMolecule(molecule, options) # type: ignore[attr-defined]
|
| 492 |
+
if conformer_id != -1:
|
| 493 |
+
with suppress(RuntimeError, ValueError):
|
| 494 |
+
AllChem.UFFOptimizeMolecule( # type: ignore[attr-defined]
|
| 495 |
+
molecule, confId=conformer_id, maxIters=1000
|
| 496 |
+
)
|
| 497 |
+
|
| 498 |
+
molecule = Chem.RemoveHs(molecule)
|
| 499 |
+
if molecule.GetNumConformers() == 0:
|
| 500 |
+
raise ValueError(f"Failed to generate conformer for SMILES: {smiles}")
|
| 501 |
+
conformer = molecule.GetConformer(0)
|
| 502 |
+
atom_specs: list[tuple[str, str, int, np.ndarray | None]] = []
|
| 503 |
+
for atom in molecule.GetAtoms():
|
| 504 |
+
position = conformer.GetAtomPosition(atom.GetIdx())
|
| 505 |
+
atom_specs.append(
|
| 506 |
+
(
|
| 507 |
+
atom.GetProp("name"),
|
| 508 |
+
atom.GetSymbol(),
|
| 509 |
+
atom.GetFormalCharge(),
|
| 510 |
+
np.asarray([position.x, position.y, position.z], dtype=np.float32),
|
| 511 |
+
)
|
| 512 |
+
)
|
| 513 |
+
state = _TokenizationState(token_offset, atom_offset, space_uid_offset)
|
| 514 |
+
state.add_atom_tokens(
|
| 515 |
+
atom_specs,
|
| 516 |
+
residue_index=0,
|
| 517 |
+
residue_name="LIG",
|
| 518 |
+
mol_type=MOL_TYPE_NONPOLYMER,
|
| 519 |
+
res_type=PROTEIN_UNK_RES_TYPE,
|
| 520 |
+
input_id=DNA_RNA_LIGAND_INPUT_ID,
|
| 521 |
+
asym_id=asym_id,
|
| 522 |
+
sym_id=sym_id,
|
| 523 |
+
entity_id=entity_id,
|
| 524 |
+
)
|
| 525 |
+
bonds = [
|
| 526 |
+
(
|
| 527 |
+
bond.GetBeginAtom().GetProp("name"),
|
| 528 |
+
bond.GetEndAtom().GetProp("name"),
|
| 529 |
+
)
|
| 530 |
+
for bond in molecule.GetBonds()
|
| 531 |
+
]
|
| 532 |
+
return state.tokens, state.atoms, bonds
|
| 533 |
+
|
| 534 |
+
|
| 535 |
+
def _get_sequence_key(item: Any) -> str:
|
| 536 |
+
if isinstance(item, ProteinInput):
|
| 537 |
+
return f"PROTEIN:{item.sequence}"
|
| 538 |
+
if isinstance(item, DNAInput):
|
| 539 |
+
return f"DNA:{item.sequence}"
|
| 540 |
+
if isinstance(item, RNAInput):
|
| 541 |
+
return f"RNA:{item.sequence}"
|
| 542 |
+
if isinstance(item, LigandInput):
|
| 543 |
+
return f"LIGAND_CCD:{','.join(item.ccd)}" if item.ccd else f"LIGAND_SMILES:{item.smiles}"
|
| 544 |
+
raise ValueError(f"Unknown input type: {type(item)}")
|
| 545 |
+
|
| 546 |
+
|
| 547 |
+
def _tokenize_chain(
|
| 548 |
+
item: Any,
|
| 549 |
+
chain_id: str,
|
| 550 |
+
*,
|
| 551 |
+
entity_id: int,
|
| 552 |
+
asym_id: int,
|
| 553 |
+
sym_id: int,
|
| 554 |
+
token_offset: int,
|
| 555 |
+
atom_offset: int,
|
| 556 |
+
space_uid_offset: int,
|
| 557 |
+
covalent_chains: set[str],
|
| 558 |
+
seed: int | None,
|
| 559 |
+
) -> tuple[list[TokenInfo], list[AtomInfo], list[tuple[str, str]]]:
|
| 560 |
+
common = {
|
| 561 |
+
"entity_id": entity_id,
|
| 562 |
+
"asym_id": asym_id,
|
| 563 |
+
"sym_id": sym_id,
|
| 564 |
+
"token_offset": token_offset,
|
| 565 |
+
"atom_offset": atom_offset,
|
| 566 |
+
"space_uid_offset": space_uid_offset,
|
| 567 |
+
}
|
| 568 |
+
if isinstance(item, ProteinInput):
|
| 569 |
+
if item.msa is None:
|
| 570 |
+
warnings.warn(
|
| 571 |
+
f"No MSA provided for {item.id}, using single sequence mode",
|
| 572 |
+
stacklevel=2,
|
| 573 |
+
)
|
| 574 |
+
tokens, atoms = tokenize_protein(item.sequence, item.modifications, **common)
|
| 575 |
+
return tokens, atoms, []
|
| 576 |
+
if isinstance(item, (DNAInput, RNAInput)):
|
| 577 |
+
mol_type = MOL_TYPE_DNA if isinstance(item, DNAInput) else MOL_TYPE_RNA
|
| 578 |
+
tokens, atoms = tokenize_nucleotide(
|
| 579 |
+
item.sequence, item.modifications, mol_type=mol_type, **common
|
| 580 |
+
)
|
| 581 |
+
return tokens, atoms, []
|
| 582 |
+
if not isinstance(item, LigandInput):
|
| 583 |
+
raise ValueError(f"Unknown input type: {type(item)}")
|
| 584 |
+
if item.ccd is not None:
|
| 585 |
+
if item.smiles is not None:
|
| 586 |
+
warnings.warn("Both ccd and smiles provided, using ccd", stacklevel=2)
|
| 587 |
+
tokens, atoms = tokenize_ligand_ccd(
|
| 588 |
+
item.ccd,
|
| 589 |
+
has_covalent_bond=chain_id in covalent_chains,
|
| 590 |
+
**common,
|
| 591 |
+
)
|
| 592 |
+
return tokens, atoms, []
|
| 593 |
+
if item.smiles is not None:
|
| 594 |
+
return tokenize_ligand_smiles(item.smiles, seed=seed, **common)
|
| 595 |
+
raise ValueError("LigandInput must have either ccd or smiles")
|
| 596 |
+
|
| 597 |
+
|
| 598 |
+
def build_chains_from_input(
|
| 599 |
+
input: StructurePredictionInput, seed: int | None = None
|
| 600 |
+
) -> tuple[list[ChainInfo], list[TokenInfo], list[AtomInfo]]:
|
| 601 |
+
"""Assign entities and symmetry copies, then tokenize every input chain."""
|
| 602 |
+
chains: list[ChainInfo] = []
|
| 603 |
+
tokens: list[TokenInfo] = []
|
| 604 |
+
atoms: list[AtomInfo] = []
|
| 605 |
+
entity_for_sequence: dict[str, int] = {}
|
| 606 |
+
next_symmetry: dict[int, int] = {}
|
| 607 |
+
covalent_chains = {
|
| 608 |
+
chain_id
|
| 609 |
+
for bond in input.covalent_bonds or ()
|
| 610 |
+
for chain_id in (bond.chain_id1, bond.chain_id2)
|
| 611 |
+
}
|
| 612 |
+
space_uid_offset = 0
|
| 613 |
+
|
| 614 |
+
for item in input.sequences:
|
| 615 |
+
key = _get_sequence_key(item)
|
| 616 |
+
entity_id = entity_for_sequence.setdefault(key, len(entity_for_sequence))
|
| 617 |
+
chain_ids = [item.id] if isinstance(item.id, str) else item.id
|
| 618 |
+
for chain_id in chain_ids:
|
| 619 |
+
sym_id = next_symmetry.get(entity_id, 0)
|
| 620 |
+
next_symmetry[entity_id] = sym_id + 1
|
| 621 |
+
asym_id = len(chains)
|
| 622 |
+
new_tokens, new_atoms, ligand_bonds = _tokenize_chain(
|
| 623 |
+
item,
|
| 624 |
+
chain_id,
|
| 625 |
+
entity_id=entity_id,
|
| 626 |
+
asym_id=asym_id,
|
| 627 |
+
sym_id=sym_id,
|
| 628 |
+
token_offset=len(tokens),
|
| 629 |
+
atom_offset=len(atoms),
|
| 630 |
+
space_uid_offset=space_uid_offset,
|
| 631 |
+
covalent_chains=covalent_chains,
|
| 632 |
+
seed=seed,
|
| 633 |
+
)
|
| 634 |
+
chains.append(
|
| 635 |
+
ChainInfo(
|
| 636 |
+
chain_id=chain_id,
|
| 637 |
+
asym_id=asym_id,
|
| 638 |
+
entity_id=entity_id,
|
| 639 |
+
sym_id=sym_id,
|
| 640 |
+
mol_type=(new_tokens[0].mol_type if new_tokens else MOL_TYPE_PROTEIN),
|
| 641 |
+
tokens=new_tokens,
|
| 642 |
+
ligand_bonds=ligand_bonds,
|
| 643 |
+
)
|
| 644 |
+
)
|
| 645 |
+
tokens.extend(new_tokens)
|
| 646 |
+
atoms.extend(new_atoms)
|
| 647 |
+
space_uid_offset += len({atom.space_uid for atom in new_atoms})
|
| 648 |
+
return chains, tokens, atoms
|
| 649 |
+
|
| 650 |
+
|
| 651 |
+
def _atom_indices_by_name(atoms: list[AtomInfo]) -> dict[int, dict[str, int]]:
|
| 652 |
+
result: dict[int, dict[str, int]] = defaultdict(dict)
|
| 653 |
+
for atom in atoms:
|
| 654 |
+
if atom.is_valid:
|
| 655 |
+
result[atom.token_index][atom.name] = atom.atom_index
|
| 656 |
+
return result
|
| 657 |
+
|
| 658 |
+
|
| 659 |
+
def _ligand_frames(
|
| 660 |
+
tokens: list[TokenInfo],
|
| 661 |
+
atoms: list[AtomInfo],
|
| 662 |
+
atom_indices: dict[int, dict[str, int]],
|
| 663 |
+
) -> dict[int, tuple[int, int, int]]:
|
| 664 |
+
atom_for_token: dict[int, int] = {}
|
| 665 |
+
tokens_by_residue: dict[tuple[int, int], list[int]] = defaultdict(list)
|
| 666 |
+
for token in tokens:
|
| 667 |
+
if token.mol_type != MOL_TYPE_NONPOLYMER:
|
| 668 |
+
continue
|
| 669 |
+
named_atoms = atom_indices.get(token.token_index)
|
| 670 |
+
if named_atoms:
|
| 671 |
+
atom_for_token[token.token_index] = next(iter(named_atoms.values()))
|
| 672 |
+
tokens_by_residue[(token.asym_id, token.residue_index)].append(token.token_index)
|
| 673 |
+
|
| 674 |
+
frames: dict[int, tuple[int, int, int]] = {}
|
| 675 |
+
for residue_tokens in tokens_by_residue.values():
|
| 676 |
+
residue_atoms = [
|
| 677 |
+
atom_for_token[token] for token in residue_tokens if token in atom_for_token
|
| 678 |
+
]
|
| 679 |
+
if len(residue_atoms) < 3:
|
| 680 |
+
for token in residue_tokens:
|
| 681 |
+
if token in atom_for_token:
|
| 682 |
+
atom_index = atom_for_token[token]
|
| 683 |
+
frames[token] = (atom_index, atom_index, atom_index)
|
| 684 |
+
continue
|
| 685 |
+
R = np.asarray([atoms[index].ref_pos for index in residue_atoms])
|
| 686 |
+
distances = np.sqrt(((R[:, None] - R[None]) ** 2).sum(-1))
|
| 687 |
+
nearest = np.argsort(distances, axis=1)
|
| 688 |
+
local = np.column_stack((nearest[:, 1], nearest[:, 0], nearest[:, 2]))
|
| 689 |
+
local_index = {atom_index: index for index, atom_index in enumerate(residue_atoms)}
|
| 690 |
+
for token in residue_tokens:
|
| 691 |
+
atom_index = atom_for_token.get(token)
|
| 692 |
+
if atom_index is None:
|
| 693 |
+
continue
|
| 694 |
+
selected = local[local_index[atom_index]]
|
| 695 |
+
frames[token] = tuple(residue_atoms[int(index)] for index in selected)
|
| 696 |
+
return frames
|
| 697 |
+
|
| 698 |
+
|
| 699 |
+
def _frame_for_token(
|
| 700 |
+
token: TokenInfo,
|
| 701 |
+
named_atoms: dict[str, int],
|
| 702 |
+
ligand_frames: dict[int, tuple[int, int, int]],
|
| 703 |
+
) -> tuple[int, int, int]:
|
| 704 |
+
fallback = next(iter(named_atoms.values()), 0)
|
| 705 |
+
if token.mol_type == MOL_TYPE_PROTEIN:
|
| 706 |
+
return (
|
| 707 |
+
(fallback, fallback, fallback)
|
| 708 |
+
if token.res_type == PROTEIN_UNK_RES_TYPE
|
| 709 |
+
else (
|
| 710 |
+
named_atoms.get("N", 0),
|
| 711 |
+
named_atoms.get("CA", 0),
|
| 712 |
+
named_atoms.get("C", 0),
|
| 713 |
+
)
|
| 714 |
+
)
|
| 715 |
+
if token.mol_type in (MOL_TYPE_DNA, MOL_TYPE_RNA):
|
| 716 |
+
return (
|
| 717 |
+
(fallback, fallback, fallback)
|
| 718 |
+
if token.res_type == PROTEIN_UNK_RES_TYPE
|
| 719 |
+
else (
|
| 720 |
+
named_atoms.get("C1'", 0),
|
| 721 |
+
named_atoms.get("C3'", 0),
|
| 722 |
+
named_atoms.get("C4'", 0),
|
| 723 |
+
)
|
| 724 |
+
)
|
| 725 |
+
if token.mol_type == MOL_TYPE_NONPOLYMER:
|
| 726 |
+
return ligand_frames.get(token.token_index, (fallback, fallback, fallback))
|
| 727 |
+
return fallback, fallback, fallback
|
| 728 |
+
|
| 729 |
+
|
| 730 |
+
def _resolved_frames(
|
| 731 |
+
frames: np.ndarray, tokens: list[TokenInfo], atoms: list[AtomInfo]
|
| 732 |
+
) -> np.ndarray:
|
| 733 |
+
if not tokens:
|
| 734 |
+
return np.zeros(0, dtype=bool)
|
| 735 |
+
X = (
|
| 736 |
+
np.asarray([atom.pos for atom in atoms], dtype=np.float32)
|
| 737 |
+
if atoms
|
| 738 |
+
else np.zeros((0, 3), dtype=np.float32)
|
| 739 |
+
)
|
| 740 |
+
valid_atoms = (
|
| 741 |
+
np.asarray([atom.is_valid for atom in atoms], dtype=bool)
|
| 742 |
+
if atoms
|
| 743 |
+
else np.zeros(0, dtype=bool)
|
| 744 |
+
)
|
| 745 |
+
resolved_atoms = valid_atoms & np.any(X != 0, axis=1)
|
| 746 |
+
origin = X[frames[:, 1]]
|
| 747 |
+
left = X[frames[:, 0]] - origin
|
| 748 |
+
right = X[frames[:, 2]] - origin
|
| 749 |
+
left_norm = np.linalg.norm(left, axis=1)
|
| 750 |
+
right_norm = np.linalg.norm(right, axis=1)
|
| 751 |
+
valid_norms = (left_norm >= 1e-6) & (right_norm >= 1e-6)
|
| 752 |
+
cosine = np.zeros(len(tokens), dtype=np.float32)
|
| 753 |
+
if np.any(valid_norms):
|
| 754 |
+
cosine[valid_norms] = np.sum(left[valid_norms] * right[valid_norms], axis=1) / (
|
| 755 |
+
left_norm[valid_norms] * right_norm[valid_norms]
|
| 756 |
+
)
|
| 757 |
+
angle = np.degrees(np.arccos(np.abs(np.clip(cosine, -1, 1))))
|
| 758 |
+
all_resolved = resolved_atoms[frames].all(axis=1)
|
| 759 |
+
repeated = (frames[:, 0] == frames[:, 1]) & (frames[:, 1] == frames[:, 2])
|
| 760 |
+
return all_resolved & ~repeated & valid_norms & (angle >= 25)
|
| 761 |
+
|
| 762 |
+
|
| 763 |
+
def compute_frame_indices(
|
| 764 |
+
tokens: list[TokenInfo], atoms: list[AtomInfo]
|
| 765 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 766 |
+
"""Return frame atom indices F with shape (l, 3) and validity M with shape (l,)."""
|
| 767 |
+
named_atoms = _atom_indices_by_name(atoms)
|
| 768 |
+
ligand_frames = _ligand_frames(tokens, atoms, named_atoms)
|
| 769 |
+
frames = np.asarray(
|
| 770 |
+
[
|
| 771 |
+
_frame_for_token(token, named_atoms.get(token.token_index, {}), ligand_frames)
|
| 772 |
+
for token in tokens
|
| 773 |
+
],
|
| 774 |
+
dtype=np.int64,
|
| 775 |
+
)
|
| 776 |
+
return frames, _resolved_frames(frames, tokens, atoms)
|
| 777 |
+
|
| 778 |
+
|
| 779 |
+
def _atom_tokenized_residues(
|
| 780 |
+
tokens: list[TokenInfo], atoms: list[AtomInfo]
|
| 781 |
+
) -> dict[tuple[int, int], list[tuple[str, int]]]:
|
| 782 |
+
grouped: dict[tuple[int, int], list[tuple[str, int]]] = defaultdict(list)
|
| 783 |
+
for atom in atoms:
|
| 784 |
+
if not atom.is_valid or atom.token_index >= len(tokens):
|
| 785 |
+
continue
|
| 786 |
+
token = tokens[atom.token_index]
|
| 787 |
+
if token.mol_type == MOL_TYPE_NONPOLYMER or token.res_type == PROTEIN_UNK_RES_TYPE:
|
| 788 |
+
grouped[(token.asym_id, token.residue_index)].append((atom.name, atom.token_index))
|
| 789 |
+
return grouped
|
| 790 |
+
|
| 791 |
+
|
| 792 |
+
def _backbone_token(
|
| 793 |
+
residue_tokens: list[TokenInfo], atom_name: str, atoms: list[AtomInfo]
|
| 794 |
+
) -> int | None:
|
| 795 |
+
if len(residue_tokens) == 1 and residue_tokens[0].res_type != PROTEIN_UNK_RES_TYPE:
|
| 796 |
+
return residue_tokens[0].token_index
|
| 797 |
+
for token in residue_tokens:
|
| 798 |
+
for atom_index in range(token.atom_start, token.atom_start + token.atom_count):
|
| 799 |
+
if atom_index < len(atoms) and atoms[atom_index].name == atom_name:
|
| 800 |
+
return token.token_index
|
| 801 |
+
return residue_tokens[0].token_index if residue_tokens else None
|
| 802 |
+
|
| 803 |
+
|
| 804 |
+
def compute_token_bonds(
|
| 805 |
+
tokens: list[TokenInfo],
|
| 806 |
+
atoms: list[AtomInfo],
|
| 807 |
+
input: StructurePredictionInput,
|
| 808 |
+
chains: list[ChainInfo],
|
| 809 |
+
) -> torch.Tensor:
|
| 810 |
+
"""Build the symmetric token-bond matrix M with shape (l, l, 1)."""
|
| 811 |
+
edges: set[tuple[int, int]] = set()
|
| 812 |
+
|
| 813 |
+
def connect(left: int | None, right: int | None) -> None:
|
| 814 |
+
if left is not None and right is not None and left != right:
|
| 815 |
+
edges.add((min(left, right), max(left, right)))
|
| 816 |
+
|
| 817 |
+
explicit_bonds = {
|
| 818 |
+
(chain.asym_id, 0): chain.ligand_bonds for chain in chains if chain.ligand_bonds
|
| 819 |
+
}
|
| 820 |
+
for residue_key, atom_list in _atom_tokenized_residues(tokens, atoms).items():
|
| 821 |
+
if not atom_list:
|
| 822 |
+
continue
|
| 823 |
+
residue_name = tokens[atom_list[0][1]].residue_name
|
| 824 |
+
token_for_name = {name: token_index for name, token_index in atom_list}
|
| 825 |
+
bonds = explicit_bonds.get(residue_key)
|
| 826 |
+
if bonds is None:
|
| 827 |
+
bonds = get_ligand_ccd_bonds(residue_name)
|
| 828 |
+
if bonds:
|
| 829 |
+
for left_name, right_name in bonds:
|
| 830 |
+
if left_name in token_for_name and right_name in token_for_name:
|
| 831 |
+
connect(token_for_name[left_name], token_for_name[right_name])
|
| 832 |
+
else:
|
| 833 |
+
for left, right in combinations([token_index for _, token_index in atom_list], 2):
|
| 834 |
+
connect(left, right)
|
| 835 |
+
|
| 836 |
+
if input.covalent_bonds:
|
| 837 |
+
chain_for_id = {chain.chain_id: chain for chain in chains}
|
| 838 |
+
residue_atoms: dict[tuple[int, int], list[AtomInfo]] = defaultdict(list)
|
| 839 |
+
for atom in atoms:
|
| 840 |
+
if atom.is_valid and atom.token_index < len(tokens):
|
| 841 |
+
token = tokens[atom.token_index]
|
| 842 |
+
residue_atoms[(token.asym_id, token.residue_index)].append(atom)
|
| 843 |
+
for bond in input.covalent_bonds:
|
| 844 |
+
left_chain = chain_for_id.get(bond.chain_id1)
|
| 845 |
+
right_chain = chain_for_id.get(bond.chain_id2)
|
| 846 |
+
if left_chain is None or right_chain is None:
|
| 847 |
+
continue
|
| 848 |
+
left_atoms = residue_atoms.get((left_chain.asym_id, bond.res_idx1), [])
|
| 849 |
+
right_atoms = residue_atoms.get((right_chain.asym_id, bond.res_idx2), [])
|
| 850 |
+
if bond.atom_idx1 < len(left_atoms) and bond.atom_idx2 < len(right_atoms):
|
| 851 |
+
connect(
|
| 852 |
+
left_atoms[bond.atom_idx1].token_index,
|
| 853 |
+
right_atoms[bond.atom_idx2].token_index,
|
| 854 |
+
)
|
| 855 |
+
|
| 856 |
+
protein_residues: dict[tuple[int, int], list[TokenInfo]] = defaultdict(list)
|
| 857 |
+
for token in tokens:
|
| 858 |
+
if token.mol_type == MOL_TYPE_PROTEIN:
|
| 859 |
+
protein_residues[(token.asym_id, token.residue_index)].append(token)
|
| 860 |
+
for (asym_id, residue_index), residue_tokens in protein_residues.items():
|
| 861 |
+
if not any(token.res_type == PROTEIN_UNK_RES_TYPE for token in residue_tokens):
|
| 862 |
+
continue
|
| 863 |
+
previous = protein_residues.get((asym_id, residue_index - 1))
|
| 864 |
+
following = protein_residues.get((asym_id, residue_index + 1))
|
| 865 |
+
if previous:
|
| 866 |
+
connect(
|
| 867 |
+
_backbone_token(previous, "C", atoms),
|
| 868 |
+
_backbone_token(residue_tokens, "N", atoms),
|
| 869 |
+
)
|
| 870 |
+
if following:
|
| 871 |
+
connect(
|
| 872 |
+
_backbone_token(residue_tokens, "C", atoms),
|
| 873 |
+
_backbone_token(following, "N", atoms),
|
| 874 |
+
)
|
| 875 |
+
|
| 876 |
+
matrix = torch.zeros(len(tokens), len(tokens), 1, dtype=torch.float32)
|
| 877 |
+
for left, right in edges:
|
| 878 |
+
matrix[left, right, 0] = 1.0
|
| 879 |
+
matrix[right, left, 0] = 1.0
|
| 880 |
+
return matrix
|
| 881 |
+
|
| 882 |
+
|
| 883 |
+
def compute_representative_atoms(tokens: list[TokenInfo], atoms: list[AtomInfo]) -> torch.Tensor:
|
| 884 |
+
"""Choose one distogram atom per token and return indices I with shape (l,)."""
|
| 885 |
+
named_atoms = _atom_indices_by_name(atoms)
|
| 886 |
+
representatives = torch.zeros(len(tokens), dtype=torch.int64)
|
| 887 |
+
for token in tokens:
|
| 888 |
+
names = named_atoms.get(token.token_index, {})
|
| 889 |
+
fallback = next(iter(names.values()), 0)
|
| 890 |
+
if token.mol_type == MOL_TYPE_PROTEIN:
|
| 891 |
+
representative = names.get("CB", names.get("CA", fallback))
|
| 892 |
+
elif token.mol_type in (MOL_TYPE_DNA, MOL_TYPE_RNA):
|
| 893 |
+
if token.res_type in (27, 32):
|
| 894 |
+
representative = names.get("C1'", fallback)
|
| 895 |
+
elif token.res_type in (23, 24, 28, 29):
|
| 896 |
+
representative = names.get("C4", names.get("C1'", fallback))
|
| 897 |
+
else:
|
| 898 |
+
representative = names.get("C2", names.get("C1'", fallback))
|
| 899 |
+
else:
|
| 900 |
+
representative = fallback
|
| 901 |
+
representatives[token.token_index] = representative
|
| 902 |
+
return representatives
|
| 903 |
+
|
| 904 |
+
|
| 905 |
+
def _msa_assignments(
|
| 906 |
+
input: StructurePredictionInput, chains: list[ChainInfo]
|
| 907 |
+
) -> dict[int, MSA | None]:
|
| 908 |
+
chain_msas: dict[int, MSA | None] = {}
|
| 909 |
+
chain_index = 0
|
| 910 |
+
for item in input.sequences:
|
| 911 |
+
chain_ids = [item.id] if isinstance(item.id, str) else list(item.id)
|
| 912 |
+
for _ in chain_ids:
|
| 913 |
+
chain = chains[chain_index]
|
| 914 |
+
if isinstance(item, ProteinInput):
|
| 915 |
+
chain_msas[chain.asym_id] = (
|
| 916 |
+
MSA.from_sequences([item.sequence]) if item.msa is None else item.msa
|
| 917 |
+
)
|
| 918 |
+
else:
|
| 919 |
+
chain_msas[chain.asym_id] = None
|
| 920 |
+
chain_index += 1
|
| 921 |
+
return chain_msas
|
| 922 |
+
|
| 923 |
+
|
| 924 |
+
def compute_msa_features(
|
| 925 |
+
input: StructurePredictionInput,
|
| 926 |
+
chains: list[ChainInfo],
|
| 927 |
+
tokens: list[TokenInfo],
|
| 928 |
+
max_seqs: int = 16384,
|
| 929 |
+
) -> dict[str, torch.Tensor]:
|
| 930 |
+
"""Pair per-chain MSAs and return row features with shape (m, l)."""
|
| 931 |
+
from .esmfold2_paired_msa import (
|
| 932 |
+
construct_paired_msa,
|
| 933 |
+
protein_letter_to_res_type,
|
| 934 |
+
)
|
| 935 |
+
|
| 936 |
+
chain_msas = _msa_assignments(input, chains)
|
| 937 |
+
query_types = {
|
| 938 |
+
chain.asym_id: np.asarray(
|
| 939 |
+
[token.res_type for token in tokens if token.asym_id == chain.asym_id],
|
| 940 |
+
dtype=np.int64,
|
| 941 |
+
)
|
| 942 |
+
for chain in chains
|
| 943 |
+
}
|
| 944 |
+
msa_residues, deletion_counts, _ = construct_paired_msa(
|
| 945 |
+
chain_msas,
|
| 946 |
+
query_types,
|
| 947 |
+
np.asarray([token.asym_id for token in tokens], dtype=np.int64),
|
| 948 |
+
np.asarray([token.residue_index for token in tokens], dtype=np.int64),
|
| 949 |
+
letter_to_res_type=protein_letter_to_res_type(),
|
| 950 |
+
max_seqs=max_seqs,
|
| 951 |
+
)
|
| 952 |
+
for token in tokens:
|
| 953 |
+
if chain_msas.get(token.asym_id) is None:
|
| 954 |
+
msa_residues[:, token.token_index] = MSA_GAP_TOKEN_ID
|
| 955 |
+
msa_residues[0, token.token_index] = token.res_type
|
| 956 |
+
if msa_residues.shape[0] == 0:
|
| 957 |
+
msa_residues = np.full((1, len(tokens)), MSA_GAP_TOKEN_ID, dtype=np.int64)
|
| 958 |
+
deletion_counts = np.zeros((1, len(tokens)), dtype=np.float32)
|
| 959 |
+
|
| 960 |
+
msa = torch.from_numpy(msa_residues)
|
| 961 |
+
deletion_count = torch.from_numpy(deletion_counts)
|
| 962 |
+
deletion_value = (np.pi / 2) * torch.arctan(deletion_count / 3)
|
| 963 |
+
return {
|
| 964 |
+
"msa": msa,
|
| 965 |
+
"deletion_value": deletion_value,
|
| 966 |
+
"has_deletion": deletion_count > 0,
|
| 967 |
+
"deletion_mean": deletion_value.mean(dim=0),
|
| 968 |
+
"msa_attention_mask": torch.ones_like(msa, dtype=torch.bool),
|
| 969 |
+
}
|
| 970 |
+
|
| 971 |
+
|
| 972 |
+
def compute_distogram_conditioning(
|
| 973 |
+
input: StructurePredictionInput,
|
| 974 |
+
chains: list[ChainInfo],
|
| 975 |
+
tokens: list[TokenInfo],
|
| 976 |
+
disto_center: torch.Tensor,
|
| 977 |
+
min_dist: float = 2.0,
|
| 978 |
+
max_dist: float = 22.0,
|
| 979 |
+
num_bins: int = 64,
|
| 980 |
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
| 981 |
+
"""Bin user distances into D and return D plus its Boolean mask M."""
|
| 982 |
+
del disto_center
|
| 983 |
+
n_tokens = len(tokens)
|
| 984 |
+
bins = torch.zeros((n_tokens, n_tokens), dtype=torch.long)
|
| 985 |
+
mask = torch.zeros((n_tokens, n_tokens), dtype=torch.bool)
|
| 986 |
+
if not input.distogram_conditioning:
|
| 987 |
+
return bins, mask
|
| 988 |
+
asym_for_chain = {chain.chain_id: chain.asym_id for chain in chains}
|
| 989 |
+
tokens_for_asym: dict[int, list[int]] = defaultdict(list)
|
| 990 |
+
for token in tokens:
|
| 991 |
+
tokens_for_asym[token.asym_id].append(token.token_index)
|
| 992 |
+
boundaries = torch.linspace(min_dist, max_dist, num_bins + 1)
|
| 993 |
+
|
| 994 |
+
for conditioning in input.distogram_conditioning:
|
| 995 |
+
asym_id = asym_for_chain.get(conditioning.chain_id)
|
| 996 |
+
if asym_id is None:
|
| 997 |
+
continue
|
| 998 |
+
indices = tokens_for_asym[asym_id]
|
| 999 |
+
distances = torch.as_tensor(conditioning.distogram, dtype=torch.float32)
|
| 1000 |
+
expected_shape = (len(indices), len(indices))
|
| 1001 |
+
if distances.shape != expected_shape:
|
| 1002 |
+
raise ValueError(
|
| 1003 |
+
f"Distogram shape {distances.shape} doesn't match chain length {len(indices)}"
|
| 1004 |
+
)
|
| 1005 |
+
selected = torch.bucketize(distances, boundaries[:-1]).sub(1).clamp(0, num_bins - 1)
|
| 1006 |
+
token_indices_tensor = torch.as_tensor(indices, dtype=torch.long)
|
| 1007 |
+
bins[token_indices_tensor[:, None], token_indices_tensor[None, :]] = selected
|
| 1008 |
+
mask[token_indices_tensor[:, None], token_indices_tensor[None, :]] = True
|
| 1009 |
+
return bins, mask
|
| 1010 |
+
|
| 1011 |
+
|
| 1012 |
+
def _padded_atoms(atoms: list[AtomInfo]) -> list[AtomInfo]:
|
| 1013 |
+
target = math.ceil(len(atoms) / 32) * 32 if atoms else 32
|
| 1014 |
+
padding = [
|
| 1015 |
+
AtomInfo(
|
| 1016 |
+
name="",
|
| 1017 |
+
element="",
|
| 1018 |
+
charge=0,
|
| 1019 |
+
ref_pos=_ZERO_POS.copy(),
|
| 1020 |
+
pos=_ZERO_POS.copy(),
|
| 1021 |
+
token_index=0,
|
| 1022 |
+
atom_index=index,
|
| 1023 |
+
space_uid=0,
|
| 1024 |
+
is_valid=False,
|
| 1025 |
+
)
|
| 1026 |
+
for index in range(len(atoms), target)
|
| 1027 |
+
]
|
| 1028 |
+
return [*atoms, *padding]
|
| 1029 |
+
|
| 1030 |
+
|
| 1031 |
+
def _token_tensors(tokens: list[TokenInfo]) -> dict[str, torch.Tensor]:
|
| 1032 |
+
fields = {
|
| 1033 |
+
"token_index": "token_index",
|
| 1034 |
+
"residue_index": "residue_index",
|
| 1035 |
+
"asym_id": "asym_id",
|
| 1036 |
+
"sym_id": "sym_id",
|
| 1037 |
+
"entity_id": "entity_id",
|
| 1038 |
+
"mol_type": "mol_type",
|
| 1039 |
+
"res_type": "res_type",
|
| 1040 |
+
"input_ids": "input_id",
|
| 1041 |
+
}
|
| 1042 |
+
return {
|
| 1043 |
+
output_name: torch.from_numpy(
|
| 1044 |
+
np.asarray([getattr(token, attribute) for token in tokens], dtype=np.int64)
|
| 1045 |
+
)
|
| 1046 |
+
for output_name, attribute in fields.items()
|
| 1047 |
+
}
|
| 1048 |
+
|
| 1049 |
+
|
| 1050 |
+
def _atom_tensors(atoms: list[AtomInfo]) -> dict[str, torch.Tensor]:
|
| 1051 |
+
n_atoms = len(atoms)
|
| 1052 |
+
ref_pos = np.zeros((n_atoms, 3), dtype=np.float32)
|
| 1053 |
+
ref_element = np.zeros(n_atoms, dtype=np.int64)
|
| 1054 |
+
ref_charge = np.zeros(n_atoms, dtype=np.int8)
|
| 1055 |
+
ref_name = np.zeros((n_atoms, 4), dtype=np.int64)
|
| 1056 |
+
ref_space = np.zeros(n_atoms, dtype=np.int64)
|
| 1057 |
+
atom_mask = np.zeros(n_atoms, dtype=np.bool_)
|
| 1058 |
+
atom_to_token = np.zeros(n_atoms, dtype=np.int64)
|
| 1059 |
+
positions = np.zeros((n_atoms, 3), dtype=np.float64)
|
| 1060 |
+
valid = np.zeros(n_atoms, dtype=np.bool_)
|
| 1061 |
+
for index, atom in enumerate(atoms):
|
| 1062 |
+
if atom.ref_pos is not None:
|
| 1063 |
+
ref_pos[index] = atom.ref_pos
|
| 1064 |
+
ref_charge[index] = atom.charge
|
| 1065 |
+
ref_space[index] = atom.space_uid if atom.space_uid >= 0 else atom.token_index
|
| 1066 |
+
atom_mask[index] = atom.is_valid
|
| 1067 |
+
valid[index] = atom.is_valid
|
| 1068 |
+
positions[index] = atom.pos
|
| 1069 |
+
if atom.is_valid:
|
| 1070 |
+
ref_element[index] = get_element_atomic_num(atom.element)
|
| 1071 |
+
ref_name[index] = encode_atom_name(atom.name)
|
| 1072 |
+
atom_to_token[index] = atom.token_index
|
| 1073 |
+
|
| 1074 |
+
resolved = valid & np.any(positions != 0, axis=1)
|
| 1075 |
+
X = torch.from_numpy(positions)
|
| 1076 |
+
resolved_mask = torch.from_numpy(resolved)
|
| 1077 |
+
valid_mask = torch.from_numpy(valid)
|
| 1078 |
+
if resolved_mask.any():
|
| 1079 |
+
X = X - X[resolved_mask].mean(dim=0, keepdim=True)
|
| 1080 |
+
X[~valid_mask] = 0.0
|
| 1081 |
+
return {
|
| 1082 |
+
"ref_pos": torch.from_numpy(ref_pos),
|
| 1083 |
+
"ref_element": torch.from_numpy(ref_element),
|
| 1084 |
+
"ref_charge": torch.from_numpy(ref_charge),
|
| 1085 |
+
"ref_atom_name_chars": torch.from_numpy(ref_name),
|
| 1086 |
+
"ref_space_uid": torch.from_numpy(ref_space),
|
| 1087 |
+
"gt_coords": X.float().unsqueeze(0),
|
| 1088 |
+
"atom_attention_mask": torch.from_numpy(atom_mask),
|
| 1089 |
+
"atom_to_token": torch.from_numpy(atom_to_token),
|
| 1090 |
+
"is_resolved": torch.tensor(resolved, dtype=torch.bool),
|
| 1091 |
+
}
|
| 1092 |
+
|
| 1093 |
+
|
| 1094 |
+
def build_feature_tensors(
|
| 1095 |
+
chains: list[ChainInfo],
|
| 1096 |
+
tokens: list[TokenInfo],
|
| 1097 |
+
atoms: list[AtomInfo],
|
| 1098 |
+
input: StructurePredictionInput,
|
| 1099 |
+
) -> dict[str, torch.Tensor]:
|
| 1100 |
+
"""Assemble the complete unbatched ESMFold2 feature dictionary."""
|
| 1101 |
+
token_features = _token_tensors(tokens)
|
| 1102 |
+
atom_features = _atom_tensors(_padded_atoms(atoms))
|
| 1103 |
+
frames, _ = compute_frame_indices(tokens, atoms)
|
| 1104 |
+
msa_features = compute_msa_features(input, chains, tokens)
|
| 1105 |
+
distogram, distogram_mask = compute_distogram_conditioning(
|
| 1106 |
+
input,
|
| 1107 |
+
chains,
|
| 1108 |
+
tokens,
|
| 1109 |
+
torch.zeros(len(tokens), 3, dtype=torch.float32),
|
| 1110 |
+
)
|
| 1111 |
+
return {
|
| 1112 |
+
**token_features,
|
| 1113 |
+
"token_bonds": compute_token_bonds(tokens, atoms, input, chains),
|
| 1114 |
+
"token_attention_mask": torch.ones(len(tokens), dtype=torch.bool),
|
| 1115 |
+
"pocket_feature": torch.zeros(len(tokens), dtype=torch.long),
|
| 1116 |
+
**atom_features,
|
| 1117 |
+
"distogram_atom_idx": compute_representative_atoms(tokens, atoms),
|
| 1118 |
+
"frames_idx": torch.from_numpy(frames).to(torch.int64),
|
| 1119 |
+
"disto_cond": distogram,
|
| 1120 |
+
"disto_cond_mask": distogram_mask,
|
| 1121 |
+
**msa_features,
|
| 1122 |
+
}
|
| 1123 |
+
|
| 1124 |
+
|
| 1125 |
+
def prepare_esmfold2_input(
|
| 1126 |
+
input: StructurePredictionInput, seed: int | None = None
|
| 1127 |
+
) -> tuple[dict[str, torch.Tensor], list[ChainInfo]]:
|
| 1128 |
+
"""Convert one typed request to model features and output-chain metadata."""
|
| 1129 |
+
chains, tokens, atoms = build_chains_from_input(input, seed)
|
| 1130 |
+
return build_feature_tensors(chains, tokens, atoms, input), chains
|
fastplms/models/esmfold2/esmfold2_processor.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Input preparation and output decoding for ESMFold2 inference."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from collections.abc import Mapping
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
import torch
|
| 12 |
+
from torch import Tensor
|
| 13 |
+
|
| 14 |
+
from .esmfold2_conformers import load_ccd
|
| 15 |
+
from .esmfold2_molecular_complex import MolecularComplexResult
|
| 16 |
+
from .esmfold2_output import build_molecular_complex_from_features
|
| 17 |
+
from .esmfold2_prepare_input import ChainInfo, prepare_esmfold2_input
|
| 18 |
+
from .esmfold2_types import MSA, Modification, ProteinInput, StructurePredictionInput
|
| 19 |
+
from .modeling_esmfold2_common import MSA_CONDITIONING_INPUT_NAMES
|
| 20 |
+
from .reproducibility import seed_context
|
| 21 |
+
|
| 22 |
+
# Backward-compatible private alias for the pinned parity helpers. New callers
|
| 23 |
+
# should import ``seed_context`` from the public ``fastplms.models.esmfold2``
|
| 24 |
+
# package instead of reaching into implementation modules.
|
| 25 |
+
_seed_context = seed_context
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass(frozen=True)
|
| 29 |
+
class _SplitProteinState:
|
| 30 |
+
ids: dict[str, list[str]]
|
| 31 |
+
modifications: dict[str, list[Modification]]
|
| 32 |
+
msas: dict[str, MSA | None]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass(frozen=True)
|
| 36 |
+
class _PendingProtein:
|
| 37 |
+
source: ProteinInput
|
| 38 |
+
sequence: str
|
| 39 |
+
state: _SplitProteinState
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _chain_starts(chains: list[str]) -> list[int]:
|
| 43 |
+
starts: list[int] = []
|
| 44 |
+
position = 0
|
| 45 |
+
for chain in chains:
|
| 46 |
+
starts.append(position)
|
| 47 |
+
position += len(chain) + 1
|
| 48 |
+
return starts
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _split_modifications(
|
| 52 |
+
item: ProteinInput,
|
| 53 |
+
chains: list[str],
|
| 54 |
+
starts: list[int],
|
| 55 |
+
) -> dict[str, list[Modification]]:
|
| 56 |
+
grouped: dict[str, list[Modification]] = {}
|
| 57 |
+
if item.modifications is None:
|
| 58 |
+
return grouped
|
| 59 |
+
for chain, start in zip(chains, starts, strict=True):
|
| 60 |
+
end = start + len(chain)
|
| 61 |
+
adjusted = [
|
| 62 |
+
Modification(position=modification.position - start, ccd=modification.ccd)
|
| 63 |
+
for modification in item.modifications
|
| 64 |
+
if start <= modification.position < end
|
| 65 |
+
]
|
| 66 |
+
grouped.setdefault(chain, []).extend(adjusted)
|
| 67 |
+
return grouped
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _split_msas(
|
| 71 |
+
item: ProteinInput,
|
| 72 |
+
chains: list[str],
|
| 73 |
+
starts: list[int],
|
| 74 |
+
) -> dict[str, MSA | None]:
|
| 75 |
+
grouped: dict[str, MSA | None] = {}
|
| 76 |
+
if item.msa is None:
|
| 77 |
+
return grouped
|
| 78 |
+
for chain, start in zip(chains, starts, strict=True):
|
| 79 |
+
if chain not in grouped:
|
| 80 |
+
grouped[chain] = item.msa.select_positions(np.arange(start, start + len(chain)))
|
| 81 |
+
return grouped
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _split_protein(item: ProteinInput) -> tuple[list[_PendingProtein], _SplitProteinState]:
|
| 85 |
+
chains = ":".join(item.sequence.split("|")).split(":")
|
| 86 |
+
starts = _chain_starts(chains)
|
| 87 |
+
base_id = item.id[0] if isinstance(item.id, list) else item.id
|
| 88 |
+
ids: dict[str, list[str]] = {}
|
| 89 |
+
for index, chain in enumerate(chains):
|
| 90 |
+
chain_ids = ids.setdefault(chain, [])
|
| 91 |
+
chain_ids.append(f"{base_id}_{index}")
|
| 92 |
+
state = _SplitProteinState(
|
| 93 |
+
ids=ids,
|
| 94 |
+
modifications=_split_modifications(item, chains, starts),
|
| 95 |
+
msas=_split_msas(item, chains, starts),
|
| 96 |
+
)
|
| 97 |
+
pending = [
|
| 98 |
+
_PendingProtein(item, chain, state)
|
| 99 |
+
for chain, chain_ids in ids.items()
|
| 100 |
+
if chain_ids
|
| 101 |
+
]
|
| 102 |
+
return pending, state
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _resolve_pending(pending: _PendingProtein) -> ProteinInput:
|
| 106 |
+
item = pending.source
|
| 107 |
+
sequence = pending.sequence
|
| 108 |
+
state = pending.state
|
| 109 |
+
return ProteinInput(
|
| 110 |
+
id=state.ids[sequence],
|
| 111 |
+
sequence=sequence,
|
| 112 |
+
msa=state.msas.get(sequence) if item.msa else None,
|
| 113 |
+
modifications=(state.modifications.get(sequence) if item.modifications else None),
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def clean_esmfold2_input(input: StructurePredictionInput) -> StructurePredictionInput:
|
| 118 |
+
"""Expand chain delimiters and group repeated protein sequences by entity."""
|
| 119 |
+
|
| 120 |
+
if input.pocket is not None:
|
| 121 |
+
raise NotImplementedError(
|
| 122 |
+
"ESMFold2 pocket conditioning is present in the upstream input schema but "
|
| 123 |
+
"the published ESMFold2 feature pipeline drops it. FastPLMs refuses this "
|
| 124 |
+
"input instead of silently emitting an all-zero pocket feature."
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
cleaned: list[Any] = []
|
| 128 |
+
for item in input.sequences:
|
| 129 |
+
if not isinstance(item, ProteinInput):
|
| 130 |
+
cleaned.append(item)
|
| 131 |
+
continue
|
| 132 |
+
sequence = ":".join(item.sequence.split("|"))
|
| 133 |
+
if ":" not in sequence:
|
| 134 |
+
cleaned.append(item)
|
| 135 |
+
continue
|
| 136 |
+
if input.covalent_bonds is not None:
|
| 137 |
+
raise ValueError(
|
| 138 |
+
"Covalent bonds are not supported when using chainbreaks. "
|
| 139 |
+
"Chains must be separated into multiple ProteinInput objects."
|
| 140 |
+
)
|
| 141 |
+
pending, _state = _split_protein(item)
|
| 142 |
+
cleaned.extend(pending)
|
| 143 |
+
|
| 144 |
+
resolved = [
|
| 145 |
+
_resolve_pending(item) if isinstance(item, _PendingProtein) else item
|
| 146 |
+
for item in cleaned
|
| 147 |
+
]
|
| 148 |
+
return StructurePredictionInput(
|
| 149 |
+
sequences=resolved,
|
| 150 |
+
pocket=input.pocket,
|
| 151 |
+
distogram_conditioning=input.distogram_conditioning,
|
| 152 |
+
covalent_bonds=input.covalent_bonds,
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _batch_features(
|
| 157 |
+
features: dict[str, Any],
|
| 158 |
+
device: torch.device | str | None,
|
| 159 |
+
) -> dict[str, Any]:
|
| 160 |
+
return {
|
| 161 |
+
name: (value[None].to(device) if device is not None else value[None])
|
| 162 |
+
if isinstance(value, Tensor)
|
| 163 |
+
else value
|
| 164 |
+
for name, value in features.items()
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _sampler_overrides(
|
| 169 |
+
noise_scale: float | None,
|
| 170 |
+
step_scale: float | None,
|
| 171 |
+
max_inference_sigma: int | None,
|
| 172 |
+
) -> dict[str, Any]:
|
| 173 |
+
values = {
|
| 174 |
+
"noise_scale": noise_scale,
|
| 175 |
+
"step_scale": step_scale,
|
| 176 |
+
"max_inference_sigma": max_inference_sigma,
|
| 177 |
+
}
|
| 178 |
+
return {name: value for name, value in values.items() if value is not None}
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
class ESMFold2InputBuilder:
|
| 182 |
+
"""Prepare public input objects, run folding, and decode model tensors."""
|
| 183 |
+
|
| 184 |
+
def __init__(self, ccd_cache: Path | None = None) -> None:
|
| 185 |
+
load_ccd(ccd_cache)
|
| 186 |
+
|
| 187 |
+
def prepare_input(
|
| 188 |
+
self,
|
| 189 |
+
input: StructurePredictionInput,
|
| 190 |
+
seed: int | None = None,
|
| 191 |
+
device: torch.device | str | None = None,
|
| 192 |
+
) -> tuple[dict[str, Any], list[ChainInfo]]:
|
| 193 |
+
cleaned = clean_esmfold2_input(input)
|
| 194 |
+
with seed_context(seed):
|
| 195 |
+
features, chain_infos = prepare_esmfold2_input(cleaned, seed=seed)
|
| 196 |
+
return _batch_features(features, device), chain_infos
|
| 197 |
+
|
| 198 |
+
def prepare_model_input(
|
| 199 |
+
self,
|
| 200 |
+
model: Any,
|
| 201 |
+
input: StructurePredictionInput,
|
| 202 |
+
seed: int | None = None,
|
| 203 |
+
device: torch.device | str | None = None,
|
| 204 |
+
) -> tuple[dict[str, Any], list[ChainInfo]]:
|
| 205 |
+
"""Prepare features while enforcing the checkpoint's MSA contract."""
|
| 206 |
+
|
| 207 |
+
msa_conditioning = getattr(model.config, "msa_conditioning", None)
|
| 208 |
+
if not isinstance(msa_conditioning, bool):
|
| 209 |
+
raise RuntimeError("The ESMFold2 config has no Boolean msa_conditioning contract.")
|
| 210 |
+
if not msa_conditioning:
|
| 211 |
+
explicit_msa_ids = [
|
| 212 |
+
item.id
|
| 213 |
+
for item in input.sequences
|
| 214 |
+
if isinstance(item, ProteinInput) and item.msa is not None
|
| 215 |
+
]
|
| 216 |
+
if explicit_msa_ids:
|
| 217 |
+
raise ValueError(
|
| 218 |
+
"This ESMFold2 checkpoint was trained without MSA conditioning and "
|
| 219 |
+
f"rejects explicit MSAs for protein inputs {explicit_msa_ids!r}."
|
| 220 |
+
)
|
| 221 |
+
features, chain_infos = self.prepare_input(input, seed=seed, device=device)
|
| 222 |
+
if not msa_conditioning:
|
| 223 |
+
for name in MSA_CONDITIONING_INPUT_NAMES:
|
| 224 |
+
features.pop(name, None)
|
| 225 |
+
return features, chain_infos
|
| 226 |
+
|
| 227 |
+
def __call__(
|
| 228 |
+
self,
|
| 229 |
+
input: StructurePredictionInput,
|
| 230 |
+
seed: int | None = None,
|
| 231 |
+
device: torch.device | str | None = None,
|
| 232 |
+
) -> tuple[dict[str, Any], list[ChainInfo]]:
|
| 233 |
+
return self.prepare_input(input, seed=seed, device=device)
|
| 234 |
+
|
| 235 |
+
def _decode_sample(
|
| 236 |
+
self,
|
| 237 |
+
output: Mapping[str, Tensor],
|
| 238 |
+
features: dict[str, Tensor],
|
| 239 |
+
chain_infos: list[ChainInfo],
|
| 240 |
+
sample: int,
|
| 241 |
+
complex_id: str,
|
| 242 |
+
) -> MolecularComplexResult:
|
| 243 |
+
plddt = output["plddt"][sample]
|
| 244 |
+
molecular_complex = build_molecular_complex_from_features(
|
| 245 |
+
coords=output["sample_atom_coords"][sample],
|
| 246 |
+
plddt=plddt,
|
| 247 |
+
atom_mask=features["atom_attention_mask"][0],
|
| 248 |
+
ref_element=features["ref_element"][0],
|
| 249 |
+
ref_atom_name_chars=features["ref_atom_name_chars"][0],
|
| 250 |
+
chain_infos=chain_infos,
|
| 251 |
+
complex_id=complex_id,
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
def sample_tensor(name: str) -> Tensor | None:
|
| 255 |
+
value = output.get(name)
|
| 256 |
+
return None if value is None else value[sample].detach().cpu()
|
| 257 |
+
|
| 258 |
+
def shared_tensor(name: str) -> Tensor | None:
|
| 259 |
+
value = output.get(name)
|
| 260 |
+
return None if value is None else value[0].detach().cpu()
|
| 261 |
+
|
| 262 |
+
ptm = output.get("ptm")
|
| 263 |
+
iptm = output.get("iptm")
|
| 264 |
+
return MolecularComplexResult(
|
| 265 |
+
complex=molecular_complex,
|
| 266 |
+
plddt=plddt.detach().cpu(),
|
| 267 |
+
ptm=float(ptm[sample].item()) if ptm is not None else None,
|
| 268 |
+
iptm=float(iptm[sample].item()) if iptm is not None else None,
|
| 269 |
+
pae=sample_tensor("pae"),
|
| 270 |
+
distogram=shared_tensor("distogram_logits"),
|
| 271 |
+
pair_chains_iptm=sample_tensor("pair_chains_iptm"),
|
| 272 |
+
residue_index=shared_tensor("residue_index"),
|
| 273 |
+
entity_id=shared_tensor("entity_id"),
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
def decode(
|
| 277 |
+
self,
|
| 278 |
+
output: Mapping[str, Tensor],
|
| 279 |
+
features: dict[str, Tensor],
|
| 280 |
+
chain_infos: list[ChainInfo],
|
| 281 |
+
*,
|
| 282 |
+
num_diffusion_samples: int = 1,
|
| 283 |
+
complex_id: str = "pred",
|
| 284 |
+
) -> MolecularComplexResult | list[MolecularComplexResult]:
|
| 285 |
+
results = [
|
| 286 |
+
self._decode_sample(output, features, chain_infos, sample, complex_id)
|
| 287 |
+
for sample in range(output["sample_atom_coords"].shape[0])
|
| 288 |
+
]
|
| 289 |
+
return results[0] if num_diffusion_samples == 1 and len(results) == 1 else results
|
| 290 |
+
|
| 291 |
+
def fold(
|
| 292 |
+
self,
|
| 293 |
+
model: Any,
|
| 294 |
+
input: StructurePredictionInput,
|
| 295 |
+
*,
|
| 296 |
+
num_loops: int = 3,
|
| 297 |
+
num_sampling_steps: int = 200,
|
| 298 |
+
num_diffusion_samples: int = 1,
|
| 299 |
+
seed: int | None = None,
|
| 300 |
+
noise_scale: float | None = None,
|
| 301 |
+
step_scale: float | None = None,
|
| 302 |
+
max_inference_sigma: int | None = None,
|
| 303 |
+
early_exit: bool = False,
|
| 304 |
+
complex_id: str = "pred",
|
| 305 |
+
) -> MolecularComplexResult | list[MolecularComplexResult]:
|
| 306 |
+
features, chain_infos = self.prepare_model_input(
|
| 307 |
+
model,
|
| 308 |
+
input,
|
| 309 |
+
seed=seed,
|
| 310 |
+
device=model.device,
|
| 311 |
+
)
|
| 312 |
+
overrides = _sampler_overrides(noise_scale, step_scale, max_inference_sigma)
|
| 313 |
+
with torch.no_grad(), seed_context(seed):
|
| 314 |
+
output = model(
|
| 315 |
+
**features,
|
| 316 |
+
num_loops=num_loops,
|
| 317 |
+
num_sampling_steps=num_sampling_steps,
|
| 318 |
+
num_diffusion_samples=num_diffusion_samples,
|
| 319 |
+
early_exit=early_exit,
|
| 320 |
+
return_dict=True,
|
| 321 |
+
**overrides,
|
| 322 |
+
)
|
| 323 |
+
return self.decode(
|
| 324 |
+
output,
|
| 325 |
+
features,
|
| 326 |
+
chain_infos,
|
| 327 |
+
num_diffusion_samples=num_diffusion_samples,
|
| 328 |
+
complex_id=complex_id,
|
| 329 |
+
)
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
__all__ = ["ESMFold2InputBuilder", "clean_esmfold2_input", "seed_context"]
|
fastplms/models/esmfold2/esmfold2_protein_chain.py
ADDED
|
@@ -0,0 +1,1450 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Protein-chain data, geometry, and serialization for ESMFold2."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import io
|
| 6 |
+
import warnings
|
| 7 |
+
from collections.abc import Mapping, Sequence
|
| 8 |
+
from dataclasses import asdict, dataclass, replace
|
| 9 |
+
from functools import cached_property
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
import biotite.structure as bs
|
| 14 |
+
import brotli
|
| 15 |
+
import msgpack
|
| 16 |
+
import msgpack_numpy
|
| 17 |
+
import numpy as np
|
| 18 |
+
import torch
|
| 19 |
+
from biotite.database import rcsb
|
| 20 |
+
from biotite.structure.io.pdb import PDBFile
|
| 21 |
+
from biotite.structure.io.pdbx import CIFCategory, CIFColumn, CIFData, CIFFile
|
| 22 |
+
from biotite.structure.io.pdbx import set_structure as set_structure_pdbx
|
| 23 |
+
from scipy.spatial import ConvexHull, KDTree
|
| 24 |
+
from scipy.spatial.distance import cdist, pdist, squareform
|
| 25 |
+
|
| 26 |
+
from . import esmfold2_residue_constants as residue_constants
|
| 27 |
+
from .esmfold2_affine3d import Affine3D
|
| 28 |
+
from .esmfold2_aligner import Aligner
|
| 29 |
+
from .esmfold2_atom_indexer import AtomIndexer
|
| 30 |
+
from .esmfold2_metrics import compute_gdt_ts, compute_lddt_ca
|
| 31 |
+
from .esmfold2_misc import slice_python_object_as_numpy
|
| 32 |
+
from .esmfold2_mmcif_parsing import (
|
| 33 |
+
PLDDT_B_FACTOR_SCALE,
|
| 34 |
+
MmcifWrapper,
|
| 35 |
+
Residue,
|
| 36 |
+
round_mmcif_columns,
|
| 37 |
+
)
|
| 38 |
+
from .esmfold2_normalize_coordinates import (
|
| 39 |
+
apply_frame_to_coords,
|
| 40 |
+
get_protein_normalization_frame,
|
| 41 |
+
)
|
| 42 |
+
from .esmfold2_protein_structure import index_by_atom_name
|
| 43 |
+
from .esmfold2_utils_types import PathOrBuffer
|
| 44 |
+
|
| 45 |
+
CHAIN_ID_CONST = "A"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _str_key_to_int_key(values: dict, ignore_keys: list[str] | None = None) -> dict:
|
| 49 |
+
"""Restore integer dictionary keys after JSON-compatible serialization."""
|
| 50 |
+
ignored = frozenset(ignore_keys or ())
|
| 51 |
+
restored = {}
|
| 52 |
+
for key, value in values.items():
|
| 53 |
+
if isinstance(value, dict) and key not in ignored:
|
| 54 |
+
value = _str_key_to_int_key(value, ignore_keys=ignore_keys)
|
| 55 |
+
restored_key = int(key) if isinstance(key, str) and key.isdigit() else key
|
| 56 |
+
restored[restored_key] = value
|
| 57 |
+
return restored
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _num_non_null_residues(seqres_to_structure_chain: Mapping[int, Residue]) -> int:
|
| 61 |
+
return sum(residue.residue_number is not None for residue in seqres_to_structure_chain.values())
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def infer_cb(
|
| 65 |
+
C,
|
| 66 |
+
N,
|
| 67 |
+
Ca,
|
| 68 |
+
bond_length: float = 1.522,
|
| 69 |
+
bond_angle: float = 1.927,
|
| 70 |
+
dihedral: float = -2.143,
|
| 71 |
+
):
|
| 72 |
+
"""Infer C-beta coordinates from C, N, and C-alpha coordinates."""
|
| 73 |
+
|
| 74 |
+
def normalize(X: np.ndarray) -> np.ndarray:
|
| 75 |
+
return X / np.sqrt(np.square(X).sum(-1, keepdims=True) + 1e-8)
|
| 76 |
+
|
| 77 |
+
with np.errstate(invalid="ignore"):
|
| 78 |
+
n_to_ca = N - Ca
|
| 79 |
+
n_to_c = N - C
|
| 80 |
+
axis = normalize(n_to_ca)
|
| 81 |
+
normal = normalize(np.cross(n_to_c, axis))
|
| 82 |
+
basis = (axis, np.cross(normal, axis), normal)
|
| 83 |
+
offsets = (
|
| 84 |
+
bond_length * np.cos(bond_angle),
|
| 85 |
+
bond_length * np.sin(bond_angle) * np.cos(dihedral),
|
| 86 |
+
-bond_length * np.sin(bond_angle) * np.sin(dihedral),
|
| 87 |
+
)
|
| 88 |
+
return Ca + sum(vector * offset for vector, offset in zip(basis, offsets, strict=False))
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def chain_to_ndarray(
|
| 92 |
+
atom_array: bs.AtomArray, mmcif: MmcifWrapper, chain_id: str, is_predicted=False
|
| 93 |
+
):
|
| 94 |
+
if not isinstance(atom_array, bs.AtomArray):
|
| 95 |
+
raise TypeError("atom_array must be a biotite AtomArray.")
|
| 96 |
+
if not isinstance(mmcif, MmcifWrapper):
|
| 97 |
+
raise TypeError("mmcif must be an MmcifWrapper.")
|
| 98 |
+
if not isinstance(chain_id, str) or not chain_id:
|
| 99 |
+
raise ValueError("chain_id must be a non-empty string.")
|
| 100 |
+
if chain_id not in mmcif.chain_to_seqres or chain_id not in mmcif.seqres_to_structure:
|
| 101 |
+
raise ValueError(f"mmCIF data does not contain sequence mappings for chain {chain_id!r}.")
|
| 102 |
+
entity_id = None
|
| 103 |
+
for entity, chains in mmcif.entities.items():
|
| 104 |
+
if chain_id in chains:
|
| 105 |
+
entity_id = entity
|
| 106 |
+
num_res = len(mmcif.chain_to_seqres[chain_id])
|
| 107 |
+
sequence = mmcif.chain_to_seqres[chain_id]
|
| 108 |
+
|
| 109 |
+
atom_positions = np.full([num_res, residue_constants.atom_type_num, 3], np.nan)
|
| 110 |
+
atom_mask = np.full([num_res, residue_constants.atom_type_num], False, dtype=bool)
|
| 111 |
+
residue_index = np.full([num_res], -1, dtype=np.int64)
|
| 112 |
+
insertion_code = np.full([num_res], "", dtype="<U4")
|
| 113 |
+
|
| 114 |
+
confidence = np.ones([num_res], dtype=np.float32)
|
| 115 |
+
|
| 116 |
+
chain = atom_array[atom_array.chain_id == chain_id]
|
| 117 |
+
if not isinstance(chain, bs.AtomArray):
|
| 118 |
+
raise RuntimeError("Biotite selection did not return an AtomArray.")
|
| 119 |
+
for res_index in range(num_res):
|
| 120 |
+
res_at_position = mmcif.seqres_to_structure[chain_id][res_index]
|
| 121 |
+
|
| 122 |
+
if res_at_position.residue_number is None:
|
| 123 |
+
continue
|
| 124 |
+
|
| 125 |
+
residue_index[res_index] = res_at_position.residue_number
|
| 126 |
+
insertion_code[res_index] = res_at_position.insertion_code
|
| 127 |
+
res = chain[
|
| 128 |
+
(chain.res_id == res_at_position.residue_number)
|
| 129 |
+
& (chain.ins_code == res_at_position.insertion_code)
|
| 130 |
+
& (chain.hetero == res_at_position.hetflag)
|
| 131 |
+
]
|
| 132 |
+
if not isinstance(res, bs.AtomArray):
|
| 133 |
+
raise RuntimeError("Biotite residue selection did not return an AtomArray.")
|
| 134 |
+
|
| 135 |
+
# Atom level features
|
| 136 |
+
for atom in res:
|
| 137 |
+
atom_name = atom.atom_name
|
| 138 |
+
if atom_name == "SE" and atom.res_name == "MSE":
|
| 139 |
+
# Put the coords of the selenium atom in the sulphur column
|
| 140 |
+
atom_name = "SD"
|
| 141 |
+
|
| 142 |
+
if atom_name in residue_constants.atom_order:
|
| 143 |
+
atom_positions[res_index, residue_constants.atom_order[atom_name]] = atom.coord
|
| 144 |
+
atom_mask[res_index, residue_constants.atom_order[atom_name]] = True
|
| 145 |
+
if is_predicted and atom_name == "CA":
|
| 146 |
+
confidence[res_index] = atom.b_factor / PLDDT_B_FACTOR_SCALE
|
| 147 |
+
|
| 148 |
+
if not sequence or not all(sequence):
|
| 149 |
+
raise ValueError("Some residue name was not specified correctly.")
|
| 150 |
+
return (
|
| 151 |
+
sequence,
|
| 152 |
+
atom_positions,
|
| 153 |
+
atom_mask,
|
| 154 |
+
residue_index,
|
| 155 |
+
insertion_code,
|
| 156 |
+
confidence,
|
| 157 |
+
entity_id,
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
@dataclass(frozen=True)
|
| 162 |
+
class ProteinChain:
|
| 163 |
+
"""Dataclass with atom37 representation of a single protein chain."""
|
| 164 |
+
|
| 165 |
+
id: str
|
| 166 |
+
sequence: str
|
| 167 |
+
chain_id: str # author chain id - mutable
|
| 168 |
+
entity_id: int | None
|
| 169 |
+
residue_index: np.ndarray
|
| 170 |
+
insertion_code: np.ndarray
|
| 171 |
+
atom37_positions: np.ndarray
|
| 172 |
+
atom37_mask: np.ndarray
|
| 173 |
+
confidence: np.ndarray
|
| 174 |
+
mmcif: MmcifWrapper | None = None
|
| 175 |
+
atom37_confidence: np.ndarray | None = None # P has shape (l, 37).
|
| 176 |
+
|
| 177 |
+
# Construction and parsing
|
| 178 |
+
@classmethod
|
| 179 |
+
def chain_iterable_from_mmcif(
|
| 180 |
+
cls,
|
| 181 |
+
path: PathOrBuffer | MmcifWrapper,
|
| 182 |
+
id: str | None = None,
|
| 183 |
+
is_predicted: bool = False,
|
| 184 |
+
keep_source: bool = False,
|
| 185 |
+
):
|
| 186 |
+
"""Yield every protein chain represented in an mmCIF structure."""
|
| 187 |
+
mmcif = path if isinstance(path, MmcifWrapper) else MmcifWrapper.read(path, id)
|
| 188 |
+
for chain in bs.chain_iter(mmcif.structure):
|
| 189 |
+
chain = chain[bs.filter_amino_acids(chain) & ~chain.hetero]
|
| 190 |
+
if len(chain) == 0:
|
| 191 |
+
continue
|
| 192 |
+
chain_id = chain.chain_id[0]
|
| 193 |
+
entity_id = None
|
| 194 |
+
for entity, chains in mmcif.entities.items():
|
| 195 |
+
if chain_id in chains:
|
| 196 |
+
entity_id = entity
|
| 197 |
+
if entity_id is None:
|
| 198 |
+
raise ValueError(
|
| 199 |
+
f"Failed to resolve entity identity for mmCIF chain {chain_id!r}."
|
| 200 |
+
)
|
| 201 |
+
(
|
| 202 |
+
sequence,
|
| 203 |
+
atom_positions,
|
| 204 |
+
atom_mask,
|
| 205 |
+
residue_index,
|
| 206 |
+
insertion_code,
|
| 207 |
+
confidence,
|
| 208 |
+
_,
|
| 209 |
+
) = chain_to_ndarray(chain, mmcif, chain_id, is_predicted)
|
| 210 |
+
if not all(sequence):
|
| 211 |
+
raise ValueError("Some residue name was not specified correctly.")
|
| 212 |
+
|
| 213 |
+
yield cls(
|
| 214 |
+
id=mmcif.id,
|
| 215 |
+
sequence=sequence,
|
| 216 |
+
chain_id=chain_id,
|
| 217 |
+
entity_id=entity_id,
|
| 218 |
+
atom37_positions=atom_positions,
|
| 219 |
+
atom37_mask=atom_mask,
|
| 220 |
+
residue_index=residue_index,
|
| 221 |
+
insertion_code=insertion_code,
|
| 222 |
+
confidence=confidence,
|
| 223 |
+
mmcif=mmcif if keep_source else None,
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
@classmethod
|
| 227 |
+
def from_mmcif(
|
| 228 |
+
cls,
|
| 229 |
+
path: PathOrBuffer | MmcifWrapper,
|
| 230 |
+
chain_id: str | None = None,
|
| 231 |
+
entity_id: int | None = None,
|
| 232 |
+
id: str | None = None,
|
| 233 |
+
is_predicted: bool = False,
|
| 234 |
+
keep_source: bool = False,
|
| 235 |
+
):
|
| 236 |
+
"""Return a ProteinChain object from an mmcif file.
|
| 237 |
+
|
| 238 |
+
Args:
|
| 239 |
+
path: Uncompressed mmCIF path, buffer, or parsed wrapper.
|
| 240 |
+
id: Optional structure identifier when parsing a path or buffer.
|
| 241 |
+
is_predicted (bool): If True, reads b factor as the confidence readout. Default: False.
|
| 242 |
+
chain_id (str, optional): Select a chain corresponding to (author) chain id.
|
| 243 |
+
entity_id (int, optional): Select a chain corresponding to a particular entity.
|
| 244 |
+
|
| 245 |
+
If neither `chain_id` nor `entity_id` is specified, defaults to the first entity.
|
| 246 |
+
"""
|
| 247 |
+
mmcif = path if isinstance(path, MmcifWrapper) else MmcifWrapper.read(path, id)
|
| 248 |
+
|
| 249 |
+
if chain_id is not None and entity_id is not None:
|
| 250 |
+
raise ValueError("Pass at most one of chain_id or entity_id.")
|
| 251 |
+
|
| 252 |
+
# If neither chain_id nor entity_id is specified, default to the first entity
|
| 253 |
+
if chain_id is None and entity_id is None:
|
| 254 |
+
if not mmcif.entities:
|
| 255 |
+
raise ValueError("Structure contains no entities")
|
| 256 |
+
entity_id = min(mmcif.entities.keys()) # Pick the first entity by ID
|
| 257 |
+
|
| 258 |
+
if entity_id is not None:
|
| 259 |
+
if entity_id not in mmcif.entities:
|
| 260 |
+
raise ValueError(
|
| 261 |
+
f"Structure does not contain entity `{entity_id}`. "
|
| 262 |
+
f"Valid entities: {mmcif.entities.keys()}"
|
| 263 |
+
)
|
| 264 |
+
chains = mmcif.entities[entity_id]
|
| 265 |
+
|
| 266 |
+
# Prefer the chain with the most resolved residues; ties preserve source order.
|
| 267 |
+
chain_id = max(
|
| 268 |
+
chains,
|
| 269 |
+
key=lambda chain: _num_non_null_residues(mmcif.seqres_to_structure[chain]),
|
| 270 |
+
)
|
| 271 |
+
else:
|
| 272 |
+
if chain_id is None:
|
| 273 |
+
raise RuntimeError("Failed to resolve an mmCIF chain selection.")
|
| 274 |
+
for entity, chains in mmcif.entities.items():
|
| 275 |
+
if chain_id in chains:
|
| 276 |
+
entity_id = entity
|
| 277 |
+
if entity_id is None:
|
| 278 |
+
warnings.warn(
|
| 279 |
+
"Failed to detect entity_id from mmcif file, it may be malformed.",
|
| 280 |
+
stacklevel=2,
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
atom_array = mmcif.structure
|
| 284 |
+
(
|
| 285 |
+
sequence,
|
| 286 |
+
atom_positions,
|
| 287 |
+
atom_mask,
|
| 288 |
+
residue_index,
|
| 289 |
+
insertion_code,
|
| 290 |
+
confidence,
|
| 291 |
+
_,
|
| 292 |
+
) = chain_to_ndarray(atom_array, mmcif, chain_id, is_predicted)
|
| 293 |
+
if not all(sequence):
|
| 294 |
+
raise ValueError("Some residue name was not specified correctly.")
|
| 295 |
+
|
| 296 |
+
return cls(
|
| 297 |
+
id=mmcif.id,
|
| 298 |
+
sequence=sequence,
|
| 299 |
+
chain_id=chain_id,
|
| 300 |
+
entity_id=entity_id,
|
| 301 |
+
atom37_positions=atom_positions,
|
| 302 |
+
atom37_mask=atom_mask.astype(bool),
|
| 303 |
+
residue_index=residue_index,
|
| 304 |
+
insertion_code=insertion_code,
|
| 305 |
+
confidence=confidence,
|
| 306 |
+
mmcif=mmcif if keep_source else None,
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
@classmethod
|
| 310 |
+
def from_atom37(
|
| 311 |
+
cls,
|
| 312 |
+
atom37_positions: np.ndarray | torch.Tensor,
|
| 313 |
+
*,
|
| 314 |
+
id: str | None = None,
|
| 315 |
+
sequence: str | None = None,
|
| 316 |
+
chain_id: str | None = None,
|
| 317 |
+
entity_id: int | None = None,
|
| 318 |
+
residue_index: np.ndarray | torch.Tensor | None = None,
|
| 319 |
+
insertion_code: np.ndarray | None = None,
|
| 320 |
+
confidence: np.ndarray | torch.Tensor | None = None,
|
| 321 |
+
):
|
| 322 |
+
if isinstance(atom37_positions, torch.Tensor):
|
| 323 |
+
atom37_positions = atom37_positions.cpu().numpy()
|
| 324 |
+
if atom37_positions.ndim == 4:
|
| 325 |
+
if atom37_positions.shape[0] != 1:
|
| 326 |
+
raise ValueError(
|
| 327 |
+
"Cannot handle batched inputs, atom37_positions has shape "
|
| 328 |
+
f"{atom37_positions.shape}"
|
| 329 |
+
)
|
| 330 |
+
atom37_positions = atom37_positions[0]
|
| 331 |
+
|
| 332 |
+
if not isinstance(atom37_positions, np.ndarray):
|
| 333 |
+
raise TypeError("atom37_positions must be a NumPy array or Torch tensor.")
|
| 334 |
+
if atom37_positions.ndim != 3 or atom37_positions.shape[1:] != (37, 3):
|
| 335 |
+
raise ValueError(
|
| 336 |
+
"atom37_positions must have shape (length, 37, 3), got "
|
| 337 |
+
f"{atom37_positions.shape}."
|
| 338 |
+
)
|
| 339 |
+
seqlen = atom37_positions.shape[0]
|
| 340 |
+
|
| 341 |
+
atom_mask = np.isfinite(atom37_positions).all(-1)
|
| 342 |
+
|
| 343 |
+
if id is None:
|
| 344 |
+
id = ""
|
| 345 |
+
|
| 346 |
+
if sequence is None:
|
| 347 |
+
sequence = "A" * seqlen
|
| 348 |
+
|
| 349 |
+
if chain_id is None:
|
| 350 |
+
chain_id = "A"
|
| 351 |
+
|
| 352 |
+
if residue_index is None:
|
| 353 |
+
residue_index = np.arange(1, seqlen + 1)
|
| 354 |
+
elif isinstance(residue_index, torch.Tensor):
|
| 355 |
+
residue_index = residue_index.cpu().numpy()
|
| 356 |
+
if residue_index.ndim == 2:
|
| 357 |
+
if residue_index.shape[0] != 1:
|
| 358 |
+
raise ValueError(
|
| 359 |
+
"Cannot handle batched inputs, residue_index has shape "
|
| 360 |
+
f"{residue_index.shape}"
|
| 361 |
+
)
|
| 362 |
+
residue_index = residue_index[0]
|
| 363 |
+
if not isinstance(residue_index, np.ndarray):
|
| 364 |
+
raise TypeError("residue_index must be a NumPy array or Torch tensor.")
|
| 365 |
+
|
| 366 |
+
if insertion_code is None:
|
| 367 |
+
insertion_code = np.array(["" for _ in range(seqlen)])
|
| 368 |
+
|
| 369 |
+
if confidence is None:
|
| 370 |
+
confidence = np.ones(seqlen, dtype=np.float32)
|
| 371 |
+
elif isinstance(confidence, torch.Tensor):
|
| 372 |
+
confidence = confidence.cpu().numpy()
|
| 373 |
+
if confidence.ndim == 2:
|
| 374 |
+
if confidence.shape[0] != 1:
|
| 375 |
+
raise ValueError(
|
| 376 |
+
f"Cannot handle batched inputs, confidence has shape {confidence.shape}"
|
| 377 |
+
)
|
| 378 |
+
confidence = confidence[0]
|
| 379 |
+
if not isinstance(confidence, np.ndarray):
|
| 380 |
+
raise TypeError("confidence must be a NumPy array or Torch tensor.")
|
| 381 |
+
|
| 382 |
+
return cls(
|
| 383 |
+
id=id,
|
| 384 |
+
sequence=sequence, # type: ignore
|
| 385 |
+
chain_id=chain_id,
|
| 386 |
+
entity_id=entity_id,
|
| 387 |
+
atom37_positions=atom37_positions,
|
| 388 |
+
atom37_mask=atom_mask.astype(bool),
|
| 389 |
+
residue_index=residue_index,
|
| 390 |
+
insertion_code=insertion_code,
|
| 391 |
+
confidence=confidence,
|
| 392 |
+
)
|
| 393 |
+
|
| 394 |
+
@classmethod
|
| 395 |
+
def from_backbone_atom_coordinates(
|
| 396 |
+
cls, backbone_atom_coordinates: np.ndarray | torch.Tensor, **kwargs
|
| 397 |
+
):
|
| 398 |
+
"""Create a ProteinChain from a set of backbone atom coordinates.
|
| 399 |
+
|
| 400 |
+
This function simply expands the seqlen x 3 x 3 array of backbone atom
|
| 401 |
+
coordinates to a seqlen x 37 x 3 array of all atom coordinates, with the padded
|
| 402 |
+
positions set to infinity. This allows us to use from_atom37 to create the
|
| 403 |
+
appropriate ProteinChain object with the appropriate atom37_mask.
|
| 404 |
+
|
| 405 |
+
This function passes all kwargs to from_atom37.
|
| 406 |
+
"""
|
| 407 |
+
if isinstance(backbone_atom_coordinates, torch.Tensor):
|
| 408 |
+
backbone_atom_coordinates = backbone_atom_coordinates.cpu().numpy()
|
| 409 |
+
if backbone_atom_coordinates.ndim == 4:
|
| 410 |
+
if backbone_atom_coordinates.shape[0] != 1:
|
| 411 |
+
raise ValueError(
|
| 412 |
+
f"Cannot handle batched inputs, backbone_atom_coordinates has "
|
| 413 |
+
f"shape {backbone_atom_coordinates.shape}"
|
| 414 |
+
)
|
| 415 |
+
backbone_atom_coordinates = backbone_atom_coordinates[0]
|
| 416 |
+
|
| 417 |
+
if not isinstance(backbone_atom_coordinates, np.ndarray):
|
| 418 |
+
raise TypeError(
|
| 419 |
+
"backbone_atom_coordinates must be a NumPy array or Torch tensor."
|
| 420 |
+
)
|
| 421 |
+
if backbone_atom_coordinates.ndim != 3 or backbone_atom_coordinates.shape[-2:] != (
|
| 422 |
+
3,
|
| 423 |
+
3,
|
| 424 |
+
):
|
| 425 |
+
raise ValueError(
|
| 426 |
+
"backbone_atom_coordinates must have shape (length, 3, 3), got "
|
| 427 |
+
f"{backbone_atom_coordinates.shape}."
|
| 428 |
+
)
|
| 429 |
+
|
| 430 |
+
atom37_positions = np.full(
|
| 431 |
+
(backbone_atom_coordinates.shape[0], 37, 3),
|
| 432 |
+
np.inf,
|
| 433 |
+
dtype=backbone_atom_coordinates.dtype,
|
| 434 |
+
)
|
| 435 |
+
atom37_positions[:, :3, :] = backbone_atom_coordinates
|
| 436 |
+
|
| 437 |
+
return cls.from_atom37(atom37_positions=atom37_positions, **kwargs)
|
| 438 |
+
|
| 439 |
+
@classmethod
|
| 440 |
+
def from_pdb(
|
| 441 |
+
cls,
|
| 442 |
+
path: PathOrBuffer,
|
| 443 |
+
chain_id: str = "detect",
|
| 444 |
+
id: str | None = None,
|
| 445 |
+
is_predicted: bool = False,
|
| 446 |
+
) -> ProteinChain:
|
| 447 |
+
"""Return a ProteinChain object from an pdb file. NOTE: prefer mmcif for rcsb PDB files.
|
| 448 |
+
This function is mostly to interface with old PDB files and predicted structures -
|
| 449 |
+
it will not fill out the entity id correctly
|
| 450 |
+
|
| 451 |
+
Args:
|
| 452 |
+
path: PDB path or text buffer.
|
| 453 |
+
id: Optional structure identifier.
|
| 454 |
+
is_predicted (bool): If True, reads b factor as the confidence readout. Default: False.
|
| 455 |
+
chain_id: Author chain identifier. ``"detect"`` selects the first chain.
|
| 456 |
+
"""
|
| 457 |
+
|
| 458 |
+
if id is not None:
|
| 459 |
+
file_id = id
|
| 460 |
+
else:
|
| 461 |
+
match path:
|
| 462 |
+
case Path() | str():
|
| 463 |
+
file_id = Path(path).with_suffix("").name
|
| 464 |
+
case _:
|
| 465 |
+
file_id = "null"
|
| 466 |
+
|
| 467 |
+
atom_array = PDBFile.read(path).get_structure(model=1, extra_fields=["b_factor"])
|
| 468 |
+
if len(atom_array) == 0:
|
| 469 |
+
raise ValueError("PDB contains no atoms.")
|
| 470 |
+
if chain_id == "detect":
|
| 471 |
+
chain_id = atom_array.chain_id[0]
|
| 472 |
+
atom_array = atom_array[
|
| 473 |
+
bs.filter_amino_acids(atom_array)
|
| 474 |
+
& ~atom_array.hetero
|
| 475 |
+
& (atom_array.chain_id == chain_id)
|
| 476 |
+
]
|
| 477 |
+
if len(atom_array) == 0:
|
| 478 |
+
raise ValueError(f"PDB contains no amino-acid atoms for chain {chain_id!r}.")
|
| 479 |
+
|
| 480 |
+
entity_id = 1 # Not supplied in PDBfiles
|
| 481 |
+
|
| 482 |
+
sequence = "".join(
|
| 483 |
+
residue_constants.restype_3to1.get(monomer[0].res_name, "X")
|
| 484 |
+
for monomer in bs.residue_iter(atom_array)
|
| 485 |
+
)
|
| 486 |
+
num_res = len(sequence)
|
| 487 |
+
|
| 488 |
+
atom_positions = np.full(
|
| 489 |
+
[num_res, residue_constants.atom_type_num, 3], np.nan, dtype=np.float32
|
| 490 |
+
)
|
| 491 |
+
atom_mask = np.full([num_res, residue_constants.atom_type_num], False, dtype=bool)
|
| 492 |
+
residue_index = np.full([num_res], -1, dtype=np.int64)
|
| 493 |
+
insertion_code = np.full([num_res], "", dtype="<U4")
|
| 494 |
+
|
| 495 |
+
confidence = np.ones([num_res], dtype=np.float32)
|
| 496 |
+
|
| 497 |
+
for i, res in enumerate(bs.residue_iter(atom_array)):
|
| 498 |
+
res_index = res[0].res_id
|
| 499 |
+
residue_index[i] = res_index
|
| 500 |
+
insertion_code[i] = res[0].ins_code
|
| 501 |
+
|
| 502 |
+
# Atom level features
|
| 503 |
+
for atom in res:
|
| 504 |
+
atom_name = atom.atom_name
|
| 505 |
+
if atom_name == "SE" and atom.res_name == "MSE":
|
| 506 |
+
# Put the coords of the selenium atom in the sulphur column
|
| 507 |
+
atom_name = "SD"
|
| 508 |
+
|
| 509 |
+
if atom_name in residue_constants.atom_order:
|
| 510 |
+
atom_positions[i, residue_constants.atom_order[atom_name]] = atom.coord
|
| 511 |
+
atom_mask[i, residue_constants.atom_order[atom_name]] = True
|
| 512 |
+
if is_predicted and atom_name == "CA":
|
| 513 |
+
confidence[i] = atom.b_factor / PLDDT_B_FACTOR_SCALE
|
| 514 |
+
|
| 515 |
+
if not sequence or not all(sequence):
|
| 516 |
+
raise ValueError("Some residue name was not specified correctly.")
|
| 517 |
+
|
| 518 |
+
return cls(
|
| 519 |
+
id=file_id,
|
| 520 |
+
sequence=sequence,
|
| 521 |
+
chain_id=chain_id,
|
| 522 |
+
entity_id=entity_id,
|
| 523 |
+
atom37_positions=atom_positions,
|
| 524 |
+
atom37_mask=atom_mask.astype(bool),
|
| 525 |
+
residue_index=residue_index,
|
| 526 |
+
insertion_code=insertion_code,
|
| 527 |
+
confidence=confidence,
|
| 528 |
+
mmcif=None,
|
| 529 |
+
)
|
| 530 |
+
|
| 531 |
+
@classmethod
|
| 532 |
+
def from_mds(cls, data: dict[str, Any]) -> ProteinChain:
|
| 533 |
+
return cls(
|
| 534 |
+
id=data["id"],
|
| 535 |
+
chain_id=data["chain_id"],
|
| 536 |
+
entity_id=data["entity_id"],
|
| 537 |
+
sequence=data["sequence"],
|
| 538 |
+
residue_index=data["residue_index"],
|
| 539 |
+
insertion_code=np.asarray(data["insertion_code"]),
|
| 540 |
+
atom37_positions=data["atom37_positions"],
|
| 541 |
+
atom37_mask=data["atom37_mask"].astype(bool),
|
| 542 |
+
confidence=data["confidence"],
|
| 543 |
+
mmcif=None,
|
| 544 |
+
)
|
| 545 |
+
|
| 546 |
+
@classmethod
|
| 547 |
+
def from_rcsb(
|
| 548 |
+
cls,
|
| 549 |
+
pdb_id: str,
|
| 550 |
+
chain_id: str | None = None,
|
| 551 |
+
entity_id: int | None = None,
|
| 552 |
+
keep_source: bool = False,
|
| 553 |
+
) -> ProteinChain:
|
| 554 |
+
f: io.StringIO = rcsb.fetch(pdb_id, "cif") # type: ignore
|
| 555 |
+
return cls.from_mmcif(
|
| 556 |
+
f,
|
| 557 |
+
id=pdb_id,
|
| 558 |
+
chain_id=chain_id,
|
| 559 |
+
entity_id=entity_id,
|
| 560 |
+
keep_source=keep_source,
|
| 561 |
+
is_predicted=False,
|
| 562 |
+
)
|
| 563 |
+
|
| 564 |
+
@classmethod
|
| 565 |
+
def from_atomarray(
|
| 566 |
+
cls, atom_array: bs.AtomArray, id: str | None = None, is_predicted: bool = False
|
| 567 |
+
) -> ProteinChain:
|
| 568 |
+
"""A simple converter from bs.AtomArray -> ProteinChain.
|
| 569 |
+
Uses PDB file format as intermediate."""
|
| 570 |
+
atom_array = atom_array.copy()
|
| 571 |
+
atom_array.box = None # remove surrounding box, from_pdb won't handle this
|
| 572 |
+
pdb_file = PDBFile() # pyright: ignore
|
| 573 |
+
pdb_file.set_structure(atom_array)
|
| 574 |
+
|
| 575 |
+
buf = io.StringIO()
|
| 576 |
+
pdb_file.write(buf)
|
| 577 |
+
buf.seek(0)
|
| 578 |
+
return cls.from_pdb(buf, id=id, is_predicted=is_predicted)
|
| 579 |
+
|
| 580 |
+
# Object invariants and atom views
|
| 581 |
+
def __post_init__(self):
|
| 582 |
+
if not isinstance(self.id, str):
|
| 583 |
+
raise TypeError("id must be a string.")
|
| 584 |
+
if not isinstance(self.sequence, str):
|
| 585 |
+
raise TypeError("sequence must be a string.")
|
| 586 |
+
if not isinstance(self.chain_id, str) or not self.chain_id:
|
| 587 |
+
raise ValueError("chain_id must be a non-empty string.")
|
| 588 |
+
if self.entity_id is not None and (
|
| 589 |
+
not isinstance(self.entity_id, int) or isinstance(self.entity_id, bool)
|
| 590 |
+
):
|
| 591 |
+
raise TypeError("entity_id must be an integer or None.")
|
| 592 |
+
sequence_length = len(self.sequence)
|
| 593 |
+
aligned = {
|
| 594 |
+
"atom37_positions": self.atom37_positions,
|
| 595 |
+
"atom37_mask": self.atom37_mask,
|
| 596 |
+
"residue_index": self.residue_index,
|
| 597 |
+
"insertion_code": self.insertion_code,
|
| 598 |
+
"confidence": self.confidence,
|
| 599 |
+
}
|
| 600 |
+
for name, values in aligned.items():
|
| 601 |
+
if not isinstance(values, np.ndarray):
|
| 602 |
+
raise TypeError(f"{name} must be a NumPy array, got {type(values).__name__}.")
|
| 603 |
+
if values.ndim == 0 or values.shape[0] != sequence_length:
|
| 604 |
+
raise ValueError(
|
| 605 |
+
f"{name} shape {values.shape} does not align with "
|
| 606 |
+
f"sequence length {sequence_length}."
|
| 607 |
+
)
|
| 608 |
+
if self.atom37_positions.shape != (sequence_length, 37, 3):
|
| 609 |
+
raise ValueError(
|
| 610 |
+
"atom37_positions must have shape "
|
| 611 |
+
f"({sequence_length}, 37, 3), got {self.atom37_positions.shape}."
|
| 612 |
+
)
|
| 613 |
+
if self.atom37_mask.shape != (sequence_length, 37):
|
| 614 |
+
raise ValueError(
|
| 615 |
+
"atom37_mask must have shape "
|
| 616 |
+
f"({sequence_length}, 37), got {self.atom37_mask.shape}."
|
| 617 |
+
)
|
| 618 |
+
if self.atom37_mask.dtype != bool:
|
| 619 |
+
raise TypeError(f"atom37_mask must have Boolean dtype, got {self.atom37_mask.dtype}.")
|
| 620 |
+
if not np.issubdtype(self.atom37_positions.dtype, np.number):
|
| 621 |
+
raise TypeError("atom37_positions must use a numeric dtype.")
|
| 622 |
+
if not np.issubdtype(self.residue_index.dtype, np.integer):
|
| 623 |
+
raise TypeError("residue_index must use an integer dtype.")
|
| 624 |
+
if self.insertion_code.dtype.kind not in {"U", "S", "O"}:
|
| 625 |
+
raise TypeError("insertion_code must use a string-compatible dtype.")
|
| 626 |
+
if any(not isinstance(value, str) for value in self.insertion_code.tolist()):
|
| 627 |
+
raise TypeError("insertion_code must contain only strings.")
|
| 628 |
+
for name, values in (
|
| 629 |
+
("residue_index", self.residue_index),
|
| 630 |
+
("insertion_code", self.insertion_code),
|
| 631 |
+
("confidence", self.confidence),
|
| 632 |
+
):
|
| 633 |
+
if values.shape != (sequence_length,):
|
| 634 |
+
raise ValueError(
|
| 635 |
+
f"{name} must have shape ({sequence_length},), got {values.shape}."
|
| 636 |
+
)
|
| 637 |
+
if not np.issubdtype(self.confidence.dtype, np.number):
|
| 638 |
+
raise TypeError("confidence must use a numeric dtype.")
|
| 639 |
+
atom37_confidence = self.atom37_confidence
|
| 640 |
+
if atom37_confidence is not None and not isinstance(atom37_confidence, np.ndarray):
|
| 641 |
+
raise TypeError("atom37_confidence must be a NumPy array when provided.")
|
| 642 |
+
if (
|
| 643 |
+
isinstance(atom37_confidence, np.ndarray)
|
| 644 |
+
and atom37_confidence.shape != self.atom37_mask.shape
|
| 645 |
+
):
|
| 646 |
+
raise ValueError(
|
| 647 |
+
"atom37_confidence shape must match atom37_mask: "
|
| 648 |
+
f"{atom37_confidence.shape} != {self.atom37_mask.shape}."
|
| 649 |
+
)
|
| 650 |
+
if isinstance(atom37_confidence, np.ndarray) and not np.issubdtype(
|
| 651 |
+
atom37_confidence.dtype, np.number
|
| 652 |
+
):
|
| 653 |
+
raise TypeError("atom37_confidence must use a numeric dtype.")
|
| 654 |
+
|
| 655 |
+
@cached_property
|
| 656 |
+
def atoms(self) -> AtomIndexer:
|
| 657 |
+
return AtomIndexer(self, property="atom37_positions", dim=-2)
|
| 658 |
+
|
| 659 |
+
@cached_property
|
| 660 |
+
def atom_mask(self) -> AtomIndexer:
|
| 661 |
+
return AtomIndexer(self, property="atom37_mask", dim=-1)
|
| 662 |
+
|
| 663 |
+
@cached_property
|
| 664 |
+
def atom_array(self) -> bs.AtomArray:
|
| 665 |
+
atoms = []
|
| 666 |
+
for res_idx_i, (
|
| 667 |
+
res_name,
|
| 668 |
+
res_idx,
|
| 669 |
+
ins_code,
|
| 670 |
+
positions,
|
| 671 |
+
mask,
|
| 672 |
+
conf,
|
| 673 |
+
) in enumerate(
|
| 674 |
+
zip(
|
| 675 |
+
self.sequence,
|
| 676 |
+
self.residue_index,
|
| 677 |
+
self.insertion_code,
|
| 678 |
+
self.atom37_positions,
|
| 679 |
+
self.atom37_mask.astype(bool),
|
| 680 |
+
self.confidence,
|
| 681 |
+
strict=False,
|
| 682 |
+
)
|
| 683 |
+
):
|
| 684 |
+
for i, pos in zip(np.where(mask)[0], positions[mask], strict=False):
|
| 685 |
+
b_factor = (
|
| 686 |
+
self.atom37_confidence[res_idx_i, i]
|
| 687 |
+
if self.atom37_confidence is not None
|
| 688 |
+
else conf
|
| 689 |
+
)
|
| 690 |
+
atom = bs.Atom(
|
| 691 |
+
coord=pos,
|
| 692 |
+
chain_id="A" if self.chain_id is None else self.chain_id,
|
| 693 |
+
res_id=res_idx,
|
| 694 |
+
ins_code=ins_code,
|
| 695 |
+
res_name=residue_constants.restype_1to3.get(res_name, "UNK"),
|
| 696 |
+
hetero=False,
|
| 697 |
+
atom_name=residue_constants.atom_types[i],
|
| 698 |
+
element=residue_constants.atom_types[i][0],
|
| 699 |
+
b_factor=float(b_factor) * PLDDT_B_FACTOR_SCALE,
|
| 700 |
+
occupancy=1.0,
|
| 701 |
+
)
|
| 702 |
+
atoms.append(atom)
|
| 703 |
+
return bs.array(atoms)
|
| 704 |
+
|
| 705 |
+
# Coordinate transformations and dataset adapters
|
| 706 |
+
def get_normalization_frame(self) -> Affine3D:
|
| 707 |
+
"""Given a set of coordinates, compute a single frame.
|
| 708 |
+
The frame is built from the mean N, C-alpha, and C coordinates with
|
| 709 |
+
Gram-Schmidt orthogonalization. Its origin is the mean C-alpha position.
|
| 710 |
+
|
| 711 |
+
Returns:
|
| 712 |
+
Affine3D: [] tensor of Affine3D frame
|
| 713 |
+
"""
|
| 714 |
+
coords = torch.from_numpy(self.atom37_positions)
|
| 715 |
+
frame = get_protein_normalization_frame(coords)
|
| 716 |
+
|
| 717 |
+
return frame
|
| 718 |
+
|
| 719 |
+
def apply_frame(self, frame: Affine3D) -> ProteinChain:
|
| 720 |
+
"""Given a frame, apply the frame to the protein's coordinates.
|
| 721 |
+
|
| 722 |
+
Args:
|
| 723 |
+
frame (Affine3D): [] tensor of Affine3D frame
|
| 724 |
+
|
| 725 |
+
Returns:
|
| 726 |
+
ProteinChain: Transformed protein chain
|
| 727 |
+
"""
|
| 728 |
+
coords = torch.from_numpy(self.atom37_positions).to(frame.trans.dtype)
|
| 729 |
+
coords = apply_frame_to_coords(coords, frame)
|
| 730 |
+
atom37_positions = coords.numpy()
|
| 731 |
+
return replace(self, atom37_positions=atom37_positions)
|
| 732 |
+
|
| 733 |
+
def normalize_coordinates(self) -> ProteinChain:
|
| 734 |
+
"""Normalize the coordinates of the protein chain."""
|
| 735 |
+
return self.apply_frame(self.get_normalization_frame())
|
| 736 |
+
|
| 737 |
+
def infer_oxygen(self) -> ProteinChain:
|
| 738 |
+
"""Oxygen position is fixed given N, CA, C atoms. Infer it if not provided."""
|
| 739 |
+
O_missing_indices = np.argwhere(~np.isfinite(self.atoms["O"]).all(axis=1)).squeeze()
|
| 740 |
+
|
| 741 |
+
O_vector = torch.tensor([0.6240, -1.0613, 0.0103], dtype=torch.float32)
|
| 742 |
+
N, CA, C = torch.from_numpy(self.atoms[["N", "CA", "C"]]).float().unbind(dim=1)
|
| 743 |
+
N = torch.roll(N, -3)
|
| 744 |
+
N[..., -1, :] = torch.nan
|
| 745 |
+
|
| 746 |
+
# Get the frame defined by the CA-C-N atom
|
| 747 |
+
frames = Affine3D.from_graham_schmidt(CA, C, N)
|
| 748 |
+
oxygen_coordinates = frames.apply(O_vector)
|
| 749 |
+
atom37_positions = self.atom37_positions.copy()
|
| 750 |
+
atom37_mask = self.atom37_mask.copy()
|
| 751 |
+
|
| 752 |
+
atom37_positions[O_missing_indices, residue_constants.atom_order["O"]] = oxygen_coordinates[
|
| 753 |
+
O_missing_indices
|
| 754 |
+
].numpy()
|
| 755 |
+
atom37_mask[O_missing_indices, residue_constants.atom_order["O"]] = ~np.isnan(
|
| 756 |
+
atom37_positions[O_missing_indices, residue_constants.atom_order["O"]]
|
| 757 |
+
).any(-1)
|
| 758 |
+
new_chain = replace(self, atom37_positions=atom37_positions, atom37_mask=atom37_mask)
|
| 759 |
+
return new_chain
|
| 760 |
+
|
| 761 |
+
@cached_property
|
| 762 |
+
def inferred_cbeta(self) -> np.ndarray:
|
| 763 |
+
"""Infer cbeta positions based on N, C, CA."""
|
| 764 |
+
N, CA, C = np.moveaxis(self.atoms[["N", "CA", "C"]], 1, 0)
|
| 765 |
+
# See usage in trDesign codebase.
|
| 766 |
+
# https://github.com/gjoni/trDesign/blob/f2d5930b472e77bfacc2f437b3966e7a708a8d37/02-GD/utils.py#L140
|
| 767 |
+
CB = infer_cb(C, N, CA, 1.522, 1.927, -2.143)
|
| 768 |
+
return CB
|
| 769 |
+
|
| 770 |
+
def infer_cbeta(self, infer_cbeta_for_glycine: bool = False) -> ProteinChain:
|
| 771 |
+
"""Return a new chain with inferred CB atoms at all residues except GLY.
|
| 772 |
+
|
| 773 |
+
Args:
|
| 774 |
+
infer_cbeta_for_glycine (bool): If True, infers a beta carbon for glycine
|
| 775 |
+
residues, even though that residue doesn't have one. Default off.
|
| 776 |
+
|
| 777 |
+
NOTE(rverkuil): The reason for having this switch in the first place
|
| 778 |
+
is that sometimes we want a (inferred) CB coordinate for every residue,
|
| 779 |
+
for example for making a pairwise distance matrix, or doing an RMSD
|
| 780 |
+
calculation between two designs for a given structural template, w/
|
| 781 |
+
CB atoms.
|
| 782 |
+
"""
|
| 783 |
+
atom37_positions = self.atom37_positions.copy()
|
| 784 |
+
atom37_mask = self.atom37_mask.copy()
|
| 785 |
+
|
| 786 |
+
inferred_cbeta_positions = self.inferred_cbeta
|
| 787 |
+
if not infer_cbeta_for_glycine:
|
| 788 |
+
inferred_cbeta_positions[np.array(list(self.sequence)) == "G", :] = np.nan
|
| 789 |
+
|
| 790 |
+
atom37_positions[:, residue_constants.atom_order["CB"]] = inferred_cbeta_positions
|
| 791 |
+
atom37_mask[:, residue_constants.atom_order["CB"]] = ~np.isnan(
|
| 792 |
+
atom37_positions[:, residue_constants.atom_order["CB"]]
|
| 793 |
+
).any(-1)
|
| 794 |
+
new_chain = replace(self, atom37_positions=atom37_positions, atom37_mask=atom37_mask)
|
| 795 |
+
return new_chain
|
| 796 |
+
|
| 797 |
+
@cached_property
|
| 798 |
+
def pdist_CA(self) -> np.ndarray:
|
| 799 |
+
CA = self.atoms["CA"]
|
| 800 |
+
pdist_CA = squareform(pdist(CA))
|
| 801 |
+
return pdist_CA
|
| 802 |
+
|
| 803 |
+
@cached_property
|
| 804 |
+
def pdist_CB(self) -> np.ndarray:
|
| 805 |
+
pdist_CB = squareform(pdist(self.inferred_cbeta))
|
| 806 |
+
return pdist_CB
|
| 807 |
+
|
| 808 |
+
@classmethod
|
| 809 |
+
def as_complex(cls, chains: Sequence[ProteinChain]):
|
| 810 |
+
raise RuntimeError(
|
| 811 |
+
".as_complex() has been deprecated in favor of .concat(). "
|
| 812 |
+
".concat() will eventually be deprecated in favor of ProteinComplex..."
|
| 813 |
+
)
|
| 814 |
+
|
| 815 |
+
@classmethod
|
| 816 |
+
def concat(cls, chains: Sequence[ProteinChain], use_chainbreak: bool = True):
|
| 817 |
+
if not chains:
|
| 818 |
+
raise ValueError("chains must contain at least one ProteinChain.")
|
| 819 |
+
if any(not isinstance(chain, ProteinChain) for chain in chains):
|
| 820 |
+
raise TypeError("chains must contain only ProteinChain instances.")
|
| 821 |
+
sep_tokens = {
|
| 822 |
+
"residue_index": np.array([-1]),
|
| 823 |
+
"insertion_code": np.array([""]),
|
| 824 |
+
"atom37_positions": np.full([1, 37, 3], np.inf),
|
| 825 |
+
"atom37_mask": np.zeros([1, 37], dtype=bool),
|
| 826 |
+
"confidence": np.array([0]),
|
| 827 |
+
}
|
| 828 |
+
|
| 829 |
+
def join_arrays(arrays: Sequence[np.ndarray], sep: np.ndarray):
|
| 830 |
+
if use_chainbreak:
|
| 831 |
+
full_array = []
|
| 832 |
+
for array in arrays:
|
| 833 |
+
full_array.append(array)
|
| 834 |
+
full_array.append(sep)
|
| 835 |
+
full_array = full_array[:-1]
|
| 836 |
+
return np.concatenate(full_array, 0)
|
| 837 |
+
else:
|
| 838 |
+
return np.concatenate(arrays, 0)
|
| 839 |
+
|
| 840 |
+
array_args: dict[str, np.ndarray] = {
|
| 841 |
+
name: join_arrays([getattr(chain, name) for chain in chains], sep)
|
| 842 |
+
for name, sep in sep_tokens.items()
|
| 843 |
+
}
|
| 844 |
+
|
| 845 |
+
chain_break = residue_constants.CHAIN_BREAK_TOKEN if use_chainbreak else ""
|
| 846 |
+
return cls(
|
| 847 |
+
id=chains[0].id,
|
| 848 |
+
sequence=chain_break.join(chain.sequence for chain in chains),
|
| 849 |
+
chain_id="A",
|
| 850 |
+
entity_id=None,
|
| 851 |
+
mmcif=None,
|
| 852 |
+
**array_args,
|
| 853 |
+
)
|
| 854 |
+
|
| 855 |
+
def find_nonpolymer_contacts(self):
|
| 856 |
+
if self.mmcif is None:
|
| 857 |
+
raise ValueError(
|
| 858 |
+
"find_nonpolymer_contacts requires a chain loaded with keep_source=True."
|
| 859 |
+
)
|
| 860 |
+
nonpolymer_and_chain_id_to_array = self.mmcif.non_polymer_coords
|
| 861 |
+
|
| 862 |
+
results = []
|
| 863 |
+
for (
|
| 864 |
+
nonpolymer,
|
| 865 |
+
_,
|
| 866 |
+
), nonpolymer_array in nonpolymer_and_chain_id_to_array.items():
|
| 867 |
+
if nonpolymer_array.coord is None:
|
| 868 |
+
raise ValueError(
|
| 869 |
+
f"Non-polymer {nonpolymer.comp_id!r} has no coordinate table."
|
| 870 |
+
)
|
| 871 |
+
chain_coords = self.atom37_positions[self.atom37_mask]
|
| 872 |
+
distance = cdist(nonpolymer_array.coord, chain_coords)
|
| 873 |
+
|
| 874 |
+
is_contact = distance < 5
|
| 875 |
+
if not is_contact.any():
|
| 876 |
+
continue
|
| 877 |
+
contacting_atoms = np.where(is_contact.any(0))[0]
|
| 878 |
+
chain_index = np.where(self.atom37_mask)[0]
|
| 879 |
+
contacting_residues = np.unique(chain_index[contacting_atoms])
|
| 880 |
+
|
| 881 |
+
result = {
|
| 882 |
+
"ligand": nonpolymer.name,
|
| 883 |
+
"ligand_id": nonpolymer.comp_id,
|
| 884 |
+
"contacting_residues": contacting_residues.tolist(),
|
| 885 |
+
}
|
| 886 |
+
results.append(result)
|
| 887 |
+
return results
|
| 888 |
+
|
| 889 |
+
def select_residue_indices(
|
| 890 |
+
self, indices: list[int | str], ignore_x_mismatch: bool = False
|
| 891 |
+
) -> ProteinChain:
|
| 892 |
+
numeric_indices = [idx if isinstance(idx, int) else int(idx[1:]) for idx in indices]
|
| 893 |
+
mask = np.isin(self.residue_index, numeric_indices)
|
| 894 |
+
new = self[mask]
|
| 895 |
+
mismatches = []
|
| 896 |
+
for aa, idx in zip(new.sequence, indices, strict=False):
|
| 897 |
+
if isinstance(idx, int):
|
| 898 |
+
continue
|
| 899 |
+
if aa == "X" and ignore_x_mismatch:
|
| 900 |
+
continue
|
| 901 |
+
if aa != idx[0]:
|
| 902 |
+
mismatches.append((aa, idx))
|
| 903 |
+
if mismatches:
|
| 904 |
+
mismatch_str = "; ".join(
|
| 905 |
+
f"Position {idx[1:]}, Expected: {idx[0]}, Received: {aa}" for aa, idx in mismatches
|
| 906 |
+
)
|
| 907 |
+
raise RuntimeError(mismatch_str)
|
| 908 |
+
|
| 909 |
+
return new
|
| 910 |
+
|
| 911 |
+
def to_structure_encoder_inputs(
|
| 912 |
+
self,
|
| 913 |
+
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
| 914 |
+
"""Convert protein chain to structure encoder inputs.
|
| 915 |
+
|
| 916 |
+
Returns:
|
| 917 |
+
tuple: (coordinates, plddt, residue_index) where:
|
| 918 |
+
- coordinates: X with shape (1, l, 37, 3), containing atom positions
|
| 919 |
+
- plddt: P with shape (1, l), containing confidence scores
|
| 920 |
+
- residue_index: R with shape (1, l), containing residue indices
|
| 921 |
+
"""
|
| 922 |
+
# Convert to tensors and add batch dimension
|
| 923 |
+
coordinates = (
|
| 924 |
+
torch.from_numpy(self.atom37_positions).float().unsqueeze(0)
|
| 925 |
+
) # X has shape (1, l, 37, 3).
|
| 926 |
+
plddt = torch.from_numpy(self.confidence).float().unsqueeze(0) # P: (1, l)
|
| 927 |
+
residue_index = (
|
| 928 |
+
torch.from_numpy(self.residue_index).long().unsqueeze(0)
|
| 929 |
+
) # R has shape (1, l).
|
| 930 |
+
|
| 931 |
+
return coordinates, plddt, residue_index
|
| 932 |
+
|
| 933 |
+
# Sequence access, interchange, and compact storage
|
| 934 |
+
def __getitem__(self, idx: int | list[int] | slice | np.ndarray | torch.Tensor):
|
| 935 |
+
if isinstance(idx, int):
|
| 936 |
+
idx = [idx]
|
| 937 |
+
if isinstance(idx, torch.Tensor):
|
| 938 |
+
idx = idx.cpu().numpy()
|
| 939 |
+
|
| 940 |
+
sequence = slice_python_object_as_numpy(self.sequence, idx)
|
| 941 |
+
return replace(
|
| 942 |
+
self,
|
| 943 |
+
sequence=sequence,
|
| 944 |
+
residue_index=self.residue_index[..., idx],
|
| 945 |
+
insertion_code=self.insertion_code[..., idx],
|
| 946 |
+
atom37_positions=self.atom37_positions[..., idx, :, :],
|
| 947 |
+
atom37_mask=self.atom37_mask[..., idx, :],
|
| 948 |
+
confidence=self.confidence[..., idx],
|
| 949 |
+
atom37_confidence=self.atom37_confidence[..., idx, :]
|
| 950 |
+
if self.atom37_confidence is not None
|
| 951 |
+
else None,
|
| 952 |
+
)
|
| 953 |
+
|
| 954 |
+
def __len__(self):
|
| 955 |
+
return len(self.sequence)
|
| 956 |
+
|
| 957 |
+
def cbeta_contacts(self, distance_threshold: float = 8.0) -> np.ndarray:
|
| 958 |
+
distance = self.pdist_CB
|
| 959 |
+
contacts = (distance < distance_threshold).astype(np.int64)
|
| 960 |
+
contacts[np.isnan(distance)] = -1
|
| 961 |
+
np.fill_diagonal(contacts, -1)
|
| 962 |
+
return contacts
|
| 963 |
+
|
| 964 |
+
def to_pdb(self, path: PathOrBuffer, include_insertions: bool = True):
|
| 965 |
+
"""Dssp works better w/o insertions."""
|
| 966 |
+
f = PDBFile()
|
| 967 |
+
if not include_insertions:
|
| 968 |
+
f.set_structure(self.atom_array_no_insertions)
|
| 969 |
+
else:
|
| 970 |
+
f.set_structure(self.atom_array)
|
| 971 |
+
f.write(path)
|
| 972 |
+
|
| 973 |
+
def to_pdb_string(self, include_insertions: bool = True) -> str:
|
| 974 |
+
buf = io.StringIO()
|
| 975 |
+
self.to_pdb(buf, include_insertions=include_insertions)
|
| 976 |
+
buf.seek(0)
|
| 977 |
+
return buf.read()
|
| 978 |
+
|
| 979 |
+
def to_mmcif(self, path: PathOrBuffer):
|
| 980 |
+
f = CIFFile()
|
| 981 |
+
set_structure_pdbx(f, self.atom_array, data_block=self.id)
|
| 982 |
+
|
| 983 |
+
# incantations molstar needs to render pLDDT / confidence onto
|
| 984 |
+
# the structure with "alphafold-view"
|
| 985 |
+
f.block["ma_qa_metric"] = CIFCategory(
|
| 986 |
+
name="ma_qa_metric",
|
| 987 |
+
columns={
|
| 988 |
+
"id": CIFColumn(data=CIFData(array=np.array([1, 2]), dtype=np.int64)),
|
| 989 |
+
"mode": CIFColumn(data=CIFData(array=np.array(["global", "local"]), dtype=np.str_)),
|
| 990 |
+
"name": CIFColumn(data=CIFData(array=np.array(["pLDDT", "pLDDT"]), dtype=np.str_)),
|
| 991 |
+
},
|
| 992 |
+
)
|
| 993 |
+
|
| 994 |
+
# table is a duplicate of data already in the atom array, but
|
| 995 |
+
# needed by molstar to render pLDDT / confidence
|
| 996 |
+
resid_pldd_table = {
|
| 997 |
+
# hard coded to as we currently only support single chain structures
|
| 998 |
+
"label_asym_id": CIFColumn(
|
| 999 |
+
data=CIFData(array=[CHAIN_ID_CONST] * len(self.residue_index), dtype=np.str_)
|
| 1000 |
+
),
|
| 1001 |
+
"label_comp_id": CIFColumn(
|
| 1002 |
+
data=CIFData(
|
| 1003 |
+
array=[residue_constants.restype_1to3.get(c, "UNK") for c in self.sequence],
|
| 1004 |
+
dtype=np.str_,
|
| 1005 |
+
)
|
| 1006 |
+
),
|
| 1007 |
+
"label_seq_id": CIFColumn(data=CIFData(array=self.residue_index, dtype=np.int64)),
|
| 1008 |
+
"ordinal_id": CIFColumn(data=CIFData(array=self.residue_index, dtype=np.int64)),
|
| 1009 |
+
# hard coded to show these are all local plDDT values
|
| 1010 |
+
"metric_id": CIFColumn(
|
| 1011 |
+
data=CIFData(array=["2"] * len(self.residue_index), dtype=np.str_)
|
| 1012 |
+
),
|
| 1013 |
+
"metric_value": CIFColumn(
|
| 1014 |
+
data=CIFData(
|
| 1015 |
+
array=self.confidence * PLDDT_B_FACTOR_SCALE,
|
| 1016 |
+
dtype=np.float32,
|
| 1017 |
+
)
|
| 1018 |
+
),
|
| 1019 |
+
# hard coded to show there are the initial version, there are no revisions
|
| 1020 |
+
"model_id": CIFColumn(
|
| 1021 |
+
data=CIFData(array=["1"] * len(self.residue_index), dtype=np.str_)
|
| 1022 |
+
),
|
| 1023 |
+
}
|
| 1024 |
+
f.block["ma_qa_metric_local"] = CIFCategory(
|
| 1025 |
+
name="ma_qa_metric_local", columns=resid_pldd_table
|
| 1026 |
+
)
|
| 1027 |
+
round_mmcif_columns(f)
|
| 1028 |
+
f.write(path)
|
| 1029 |
+
|
| 1030 |
+
def to_mmcif_string(self) -> str:
|
| 1031 |
+
buf = io.StringIO()
|
| 1032 |
+
self.to_mmcif(buf)
|
| 1033 |
+
buf.seek(0)
|
| 1034 |
+
return buf.read()
|
| 1035 |
+
|
| 1036 |
+
def state_dict(self, backbone_only=False, json_serializable=False):
|
| 1037 |
+
"""This state dict is optimized for storage, so it turns things to fp16 whenever
|
| 1038 |
+
possible. Note that we also only support int32 residue indices, I'm hoping we don't
|
| 1039 |
+
need more than 2**32 residues..."""
|
| 1040 |
+
dct = {k: v for k, v in asdict(self).items() if k not in ["mmcif"]}
|
| 1041 |
+
if backbone_only:
|
| 1042 |
+
dct["atom37_mask"][:, 3:] = False
|
| 1043 |
+
dct["atom37_positions"] = dct["atom37_positions"][dct["atom37_mask"]]
|
| 1044 |
+
if dct.get("atom37_confidence") is not None:
|
| 1045 |
+
dct["atom37_confidence"] = dct["atom37_confidence"][dct["atom37_mask"]]
|
| 1046 |
+
else:
|
| 1047 |
+
dct.pop("atom37_confidence", None)
|
| 1048 |
+
|
| 1049 |
+
for k, v in dct.items():
|
| 1050 |
+
if isinstance(v, np.ndarray):
|
| 1051 |
+
match v.dtype:
|
| 1052 |
+
case np.int64:
|
| 1053 |
+
dct[k] = v.astype(np.int32)
|
| 1054 |
+
case np.float64 | np.float32:
|
| 1055 |
+
dct[k] = v.astype(np.float16)
|
| 1056 |
+
case _:
|
| 1057 |
+
pass
|
| 1058 |
+
if json_serializable:
|
| 1059 |
+
dct[k] = v.tolist()
|
| 1060 |
+
return dct
|
| 1061 |
+
|
| 1062 |
+
def to_blob(self, backbone_only=False) -> bytes:
|
| 1063 |
+
payload = msgpack.dumps(self.state_dict(backbone_only), default=msgpack_numpy.encode)
|
| 1064 |
+
return brotli.compress(payload, quality=5)
|
| 1065 |
+
|
| 1066 |
+
@classmethod
|
| 1067 |
+
def from_open_source(cls, pc: ProteinChain):
|
| 1068 |
+
return cls(**vars(pc))
|
| 1069 |
+
|
| 1070 |
+
@classmethod
|
| 1071 |
+
def from_state_dict(cls, dct):
|
| 1072 |
+
# Note: assembly_composition is *supposed* to have string keys.
|
| 1073 |
+
dct = _str_key_to_int_key(dct, ignore_keys=["assembly_composition"])
|
| 1074 |
+
|
| 1075 |
+
for k, v in dct.items():
|
| 1076 |
+
if isinstance(v, list):
|
| 1077 |
+
dct[k] = np.array(v)
|
| 1078 |
+
|
| 1079 |
+
atom37 = np.full((*dct["atom37_mask"].shape, 3), np.nan)
|
| 1080 |
+
atom37[dct["atom37_mask"]] = dct["atom37_positions"]
|
| 1081 |
+
dct["atom37_positions"] = atom37
|
| 1082 |
+
if "atom37_confidence" in dct:
|
| 1083 |
+
atom37_conf = np.full(dct["atom37_mask"].shape, np.nan, dtype=np.float32)
|
| 1084 |
+
atom37_conf[dct["atom37_mask"]] = dct["atom37_confidence"]
|
| 1085 |
+
dct["atom37_confidence"] = atom37_conf
|
| 1086 |
+
dct = {
|
| 1087 |
+
k: (
|
| 1088 |
+
v.astype(np.float32)
|
| 1089 |
+
if k in ["atom37_positions", "confidence", "atom37_confidence"]
|
| 1090 |
+
else v
|
| 1091 |
+
)
|
| 1092 |
+
for k, v in dct.items()
|
| 1093 |
+
if not (k == "atom37_confidence" and v is None)
|
| 1094 |
+
}
|
| 1095 |
+
return cls(**dct, mmcif=None)
|
| 1096 |
+
|
| 1097 |
+
@classmethod
|
| 1098 |
+
def from_blob(cls, input: Path | str | io.BytesIO | bytes):
|
| 1099 |
+
"""NOTE(@zlin): blob + sparse coding + brotli + fp16 reduces memory
|
| 1100 |
+
of chains from 52G/1M chains to 20G/1M chains, I think this is a good first
|
| 1101 |
+
shot at compressing and dumping chains to disk. I'm sure there's better ways."""
|
| 1102 |
+
match input:
|
| 1103 |
+
case Path() | str():
|
| 1104 |
+
bytes = Path(input).read_bytes()
|
| 1105 |
+
case io.BytesIO():
|
| 1106 |
+
bytes = input.getvalue()
|
| 1107 |
+
case _:
|
| 1108 |
+
bytes = input
|
| 1109 |
+
state = msgpack.loads(brotli.decompress(bytes), object_hook=msgpack_numpy.decode)
|
| 1110 |
+
return cls.from_state_dict(state)
|
| 1111 |
+
|
| 1112 |
+
# Surface and structural comparison metrics
|
| 1113 |
+
def sasa(self, by_residue: bool = True):
|
| 1114 |
+
arr = self.atom_array_no_insertions
|
| 1115 |
+
if len(arr) == 0:
|
| 1116 |
+
raise ValueError("SASA requires at least one resolved atom.")
|
| 1117 |
+
sasa_per_atom = bs.sasa(arr) # type: ignore
|
| 1118 |
+
if by_residue:
|
| 1119 |
+
# Sum per-atom SASA into residue "bins", with np.bincount.
|
| 1120 |
+
if arr.res_id is None:
|
| 1121 |
+
raise RuntimeError("Biotite AtomArray is missing residue identifiers.")
|
| 1122 |
+
# Residue IDs are one-indexed, so discard the unused zero bin.
|
| 1123 |
+
# NOTE(aderry): We compute only for residues with coordinates, return NaN otherwise.
|
| 1124 |
+
num_trailing_residues = len(self) - arr.res_id.max()
|
| 1125 |
+
sasa_per_residue = np.concatenate(
|
| 1126 |
+
[
|
| 1127 |
+
np.bincount(arr.res_id, weights=sasa_per_atom)[1:],
|
| 1128 |
+
np.zeros(num_trailing_residues),
|
| 1129 |
+
]
|
| 1130 |
+
)
|
| 1131 |
+
sasa_per_residue[~self.atom37_mask.any(-1)] = np.nan
|
| 1132 |
+
if len(sasa_per_residue) != len(self):
|
| 1133 |
+
raise RuntimeError("Residue SASA output does not align with the protein chain.")
|
| 1134 |
+
return sasa_per_residue
|
| 1135 |
+
return sasa_per_atom
|
| 1136 |
+
|
| 1137 |
+
def sap_score(self, aggregation: str = "atom") -> np.ndarray:
|
| 1138 |
+
"""Compute per-atom spatial aggregation propensity (SAP).
|
| 1139 |
+
|
| 1140 |
+
Residue aggregation averages resolved atoms and omits unresolved residues.
|
| 1141 |
+
Protein aggregation sums positive atom scores, following Lauer et al. 2011.
|
| 1142 |
+
"""
|
| 1143 |
+
sap_radius = 5.0
|
| 1144 |
+
arr = self.atom_array_no_insertions
|
| 1145 |
+
if len(arr) == 0:
|
| 1146 |
+
raise ValueError("SAP requires at least one resolved atom.")
|
| 1147 |
+
|
| 1148 |
+
for name in ("res_id", "res_name", "atom_name", "coord"):
|
| 1149 |
+
if getattr(arr, name) is None:
|
| 1150 |
+
raise RuntimeError(f"Biotite AtomArray is missing required {name!r} data.")
|
| 1151 |
+
|
| 1152 |
+
# compute SASA and residue-specific properties
|
| 1153 |
+
sasa_per_atom = self.sasa(by_residue=False)
|
| 1154 |
+
resid_to_resname = dict(zip(arr.res_id, arr.res_name, strict=False))
|
| 1155 |
+
|
| 1156 |
+
max_side_chain_asa = np.full(len(self), np.nan)
|
| 1157 |
+
res_hydrophobicity = np.full(len(self), np.nan)
|
| 1158 |
+
resolved_res_mask = self.atom37_mask.any(-1)
|
| 1159 |
+
num_trailing_residues = len(self) - arr.res_id.max()
|
| 1160 |
+
|
| 1161 |
+
max_side_chain_asa[resolved_res_mask] = np.array(
|
| 1162 |
+
[residue_constants.side_chain_asa[resid_to_resname[i]] for i in np.unique(arr.res_id)]
|
| 1163 |
+
)
|
| 1164 |
+
res_hydrophobicity[resolved_res_mask] = np.array(
|
| 1165 |
+
[residue_constants.hydrophobicity[resid_to_resname[i]] for i in np.unique(arr.res_id)]
|
| 1166 |
+
)
|
| 1167 |
+
|
| 1168 |
+
# compute SAP score
|
| 1169 |
+
is_side_chain = ~bs.filter_peptide_backbone(arr)
|
| 1170 |
+
sasa_per_atom[is_side_chain] = 0
|
| 1171 |
+
kdtree = KDTree(arr.coord)
|
| 1172 |
+
neighbors = kdtree.query_ball_tree(kdtree, sap_radius, p=2.0)
|
| 1173 |
+
sap_by_atom = np.zeros_like(sasa_per_atom)
|
| 1174 |
+
for i, nn_list in enumerate(neighbors):
|
| 1175 |
+
saa_nn = np.zeros_like(sasa_per_atom)
|
| 1176 |
+
saa_nn[nn_list] = sasa_per_atom[nn_list]
|
| 1177 |
+
sasa_within_r = np.concatenate(
|
| 1178 |
+
[
|
| 1179 |
+
np.bincount(arr.res_id, weights=saa_nn)[1:],
|
| 1180 |
+
np.zeros(num_trailing_residues),
|
| 1181 |
+
]
|
| 1182 |
+
)
|
| 1183 |
+
sap = np.nansum((sasa_within_r / max_side_chain_asa) * res_hydrophobicity)
|
| 1184 |
+
sap_by_atom[i] = sap
|
| 1185 |
+
|
| 1186 |
+
match aggregation:
|
| 1187 |
+
case "atom":
|
| 1188 |
+
return sap_by_atom
|
| 1189 |
+
case "residue":
|
| 1190 |
+
sap_by_residue = np.concatenate(
|
| 1191 |
+
[
|
| 1192 |
+
np.bincount(arr.res_id, weights=sap_by_atom)[1:],
|
| 1193 |
+
np.zeros(num_trailing_residues),
|
| 1194 |
+
]
|
| 1195 |
+
) / (
|
| 1196 |
+
np.concatenate([np.bincount(arr.res_id)[1:], np.zeros(num_trailing_residues)])
|
| 1197 |
+
+ 1e-8
|
| 1198 |
+
)
|
| 1199 |
+
sap_by_residue[~resolved_res_mask] = np.nan
|
| 1200 |
+
if len(sap_by_residue) != len(self):
|
| 1201 |
+
raise RuntimeError("Residue SAP output does not align with the protein chain.")
|
| 1202 |
+
return sap_by_residue
|
| 1203 |
+
case "protein":
|
| 1204 |
+
return sum(sap_by_atom[sap_by_atom > 0]) # pyright: ignore[reportReturnType]
|
| 1205 |
+
case _:
|
| 1206 |
+
raise ValueError(
|
| 1207 |
+
f"Invalid aggregation method: {aggregation}. Must be one of "
|
| 1208 |
+
"'atom', 'residue', or 'protein'"
|
| 1209 |
+
)
|
| 1210 |
+
|
| 1211 |
+
def globularity(self) -> float:
|
| 1212 |
+
# Computes globularity using total volumes divided by MVEE.
|
| 1213 |
+
# We make the simplifying approximation that atoms never overlap.
|
| 1214 |
+
# The globularity is only computed where structure exists.
|
| 1215 |
+
# Besides the approximation above, this is inspired by:
|
| 1216 |
+
|
| 1217 |
+
# https://www.mdpi.com/2073-4352/11/12/1539
|
| 1218 |
+
# The non-overlapping-atom approximation can produce globularity above one.
|
| 1219 |
+
mask = self.atom37_mask.any(-1)
|
| 1220 |
+
points = self.atom37_positions[self.atom37_mask]
|
| 1221 |
+
sequence = [aa for aa, m in zip(self.sequence, mask, strict=False) if m] # type: ignore
|
| 1222 |
+
A, _ = self._mvee(points, tol=1e-3)
|
| 1223 |
+
mvee_volume = (4 * np.pi) / (3 * np.sqrt(np.linalg.det(A)))
|
| 1224 |
+
volume = sum(residue_constants.amino_acid_volumes[x] for x in sequence)
|
| 1225 |
+
ratio = volume / mvee_volume
|
| 1226 |
+
|
| 1227 |
+
# The paper compares the ellipsoidal profile with scalar t, a measurement
|
| 1228 |
+
# of elongation. We want a single number, so we multiply by 1/(2t), so
|
| 1229 |
+
# that value is normalized between 0-1
|
| 1230 |
+
eigenvalues = np.linalg.eigvals(A)
|
| 1231 |
+
R = 1 / np.sqrt(eigenvalues)
|
| 1232 |
+
# ellipsoid radii length triangle inequality coefficient
|
| 1233 |
+
t = max(R[0] / (R[1] + R[2]), R[1] / (R[0] + R[2]), R[2] / (R[0] + R[1]))
|
| 1234 |
+
elongation_metric = 1 / max(t, 1)
|
| 1235 |
+
return ratio * elongation_metric
|
| 1236 |
+
|
| 1237 |
+
@staticmethod
|
| 1238 |
+
def _mvee(P: np.ndarray, tol, max_iter=10000):
|
| 1239 |
+
# Finds minimum volume enclosing ellipsoid of a set of points.
|
| 1240 |
+
# Returns A, c where the ellipse is defined as:
|
| 1241 |
+
# (x-c).T @ A @ (x-c) = 1
|
| 1242 |
+
hull = ConvexHull(P)
|
| 1243 |
+
P = P[hull.vertices]
|
| 1244 |
+
P = P.T
|
| 1245 |
+
|
| 1246 |
+
# Data points
|
| 1247 |
+
d, n = P.shape
|
| 1248 |
+
Q = np.zeros((d + 1, n))
|
| 1249 |
+
Q[:d, :] = P[:d, :n]
|
| 1250 |
+
Q[d, :] = np.ones((1, n))
|
| 1251 |
+
|
| 1252 |
+
# Initializations
|
| 1253 |
+
count = 1
|
| 1254 |
+
err = 1.0
|
| 1255 |
+
u = np.full((n, 1), 1 / n) # First iteration.
|
| 1256 |
+
|
| 1257 |
+
# Khachiyan Algorithm
|
| 1258 |
+
for _ in range(max_iter):
|
| 1259 |
+
X = Q.dot(np.diag(u.squeeze())) @ Q.T
|
| 1260 |
+
M = np.diag(Q.T @ np.linalg.inv(X) @ Q)
|
| 1261 |
+
maximum, j = np.max(M), np.argmax(M)
|
| 1262 |
+
step_size = (maximum - d - 1) / ((d + 1) * (maximum - 1))
|
| 1263 |
+
new_u = (1 - step_size) * u
|
| 1264 |
+
new_u[j] += step_size
|
| 1265 |
+
count += 1
|
| 1266 |
+
err = np.linalg.norm(new_u - u)
|
| 1267 |
+
u = new_u
|
| 1268 |
+
if err < tol:
|
| 1269 |
+
break
|
| 1270 |
+
else:
|
| 1271 |
+
raise ValueError("MVEE did not converge")
|
| 1272 |
+
|
| 1273 |
+
d = P.shape[0] # Fixed: use P.shape[0] instead of P.shape
|
| 1274 |
+
U = np.diag(u.squeeze())
|
| 1275 |
+
|
| 1276 |
+
# The A matrix for the ellipse
|
| 1277 |
+
A = (1 / d) * np.linalg.inv(P @ U @ P.T - (P @ u) @ (P @ u).T)
|
| 1278 |
+
|
| 1279 |
+
# Center of the ellipse
|
| 1280 |
+
c = P @ u
|
| 1281 |
+
|
| 1282 |
+
return A, c
|
| 1283 |
+
|
| 1284 |
+
def radius_of_gyration(self):
|
| 1285 |
+
arr = self.atom_array_no_insertions
|
| 1286 |
+
return bs.gyration_radius(arr)
|
| 1287 |
+
|
| 1288 |
+
def align(
|
| 1289 |
+
self,
|
| 1290 |
+
target: ProteinChain,
|
| 1291 |
+
mobile_inds: list[int] | np.ndarray | None = None,
|
| 1292 |
+
target_inds: list[int] | np.ndarray | None = None,
|
| 1293 |
+
only_use_backbone: bool = False,
|
| 1294 |
+
):
|
| 1295 |
+
"""
|
| 1296 |
+
Aligns the current protein to the provided target.
|
| 1297 |
+
|
| 1298 |
+
Args:
|
| 1299 |
+
target (ProteinChain): The target protein to align to.
|
| 1300 |
+
mobile_inds: Mobile atom indices, not residue indices.
|
| 1301 |
+
target_inds: Target atom indices, not residue indices.
|
| 1302 |
+
only_use_backbone (bool, optional): If True, only align the backbone atoms.
|
| 1303 |
+
"""
|
| 1304 |
+
aligner = Aligner(
|
| 1305 |
+
self if mobile_inds is None else self[mobile_inds],
|
| 1306 |
+
target if target_inds is None else target[target_inds],
|
| 1307 |
+
only_use_backbone,
|
| 1308 |
+
)
|
| 1309 |
+
|
| 1310 |
+
return aligner.apply(self)
|
| 1311 |
+
|
| 1312 |
+
def rmsd(
|
| 1313 |
+
self,
|
| 1314 |
+
target: ProteinChain,
|
| 1315 |
+
also_check_reflection: bool = False,
|
| 1316 |
+
mobile_inds: list[int] | np.ndarray | None = None,
|
| 1317 |
+
target_inds: list[int] | np.ndarray | None = None,
|
| 1318 |
+
only_compute_backbone_rmsd: bool = False,
|
| 1319 |
+
):
|
| 1320 |
+
"""
|
| 1321 |
+
Compute the RMSD between this protein chain and another.
|
| 1322 |
+
|
| 1323 |
+
Args:
|
| 1324 |
+
target (ProteinChain): The target (other) protein chain to compare to.
|
| 1325 |
+
also_check_reflection: Compare the reflected mobile coordinates too.
|
| 1326 |
+
mobile_inds: Mobile atom indices, not residue indices.
|
| 1327 |
+
target_inds: Target atom indices, not residue indices.
|
| 1328 |
+
only_compute_backbone_rmsd: Restrict the score to backbone atoms.
|
| 1329 |
+
"""
|
| 1330 |
+
if isinstance(target, bs.AtomArray):
|
| 1331 |
+
raise ValueError(
|
| 1332 |
+
"Support for bs.AtomArray removed, use ProteinChain.from_atomarry for ProteinChain."
|
| 1333 |
+
)
|
| 1334 |
+
aligner = Aligner(
|
| 1335 |
+
self if mobile_inds is None else self[mobile_inds],
|
| 1336 |
+
target if target_inds is None else target[target_inds],
|
| 1337 |
+
only_compute_backbone_rmsd,
|
| 1338 |
+
)
|
| 1339 |
+
avg_rmsd = aligner.rmsd
|
| 1340 |
+
|
| 1341 |
+
if not also_check_reflection:
|
| 1342 |
+
return avg_rmsd
|
| 1343 |
+
|
| 1344 |
+
aligner = Aligner(
|
| 1345 |
+
self if mobile_inds is None else self[mobile_inds],
|
| 1346 |
+
target if target_inds is None else target[target_inds],
|
| 1347 |
+
only_compute_backbone_rmsd,
|
| 1348 |
+
use_reflection=True,
|
| 1349 |
+
)
|
| 1350 |
+
avg_rmsd_neg = aligner.rmsd
|
| 1351 |
+
|
| 1352 |
+
return min(avg_rmsd, avg_rmsd_neg)
|
| 1353 |
+
|
| 1354 |
+
def lddt_ca(
|
| 1355 |
+
self,
|
| 1356 |
+
native: ProteinChain,
|
| 1357 |
+
mobile_inds: list[int] | np.ndarray | None = None,
|
| 1358 |
+
target_inds: list[int] | np.ndarray | None = None,
|
| 1359 |
+
**kwargs,
|
| 1360 |
+
) -> float | np.ndarray:
|
| 1361 |
+
"""Compute the LDDT between this protein chain and another. NOTE: LDDT IS NOT SYMMETRIC.
|
| 1362 |
+
The call should always be prediction.lddt_ca(native).
|
| 1363 |
+
|
| 1364 |
+
Arguments:
|
| 1365 |
+
native (ProteinChain): The ground truth protein chain
|
| 1366 |
+
mobile_inds: Mobile atom indices, not residue indices.
|
| 1367 |
+
target_inds: Target atom indices, not residue indices.
|
| 1368 |
+
|
| 1369 |
+
Returns:
|
| 1370 |
+
float | np.ndarray: The LDDT score between the two protein chains, either
|
| 1371 |
+
a single float or per-residue LDDT scores if `per_residue` is True.
|
| 1372 |
+
"""
|
| 1373 |
+
lddt = compute_lddt_ca(
|
| 1374 |
+
torch.tensor(self.atom37_positions[mobile_inds]).unsqueeze(0),
|
| 1375 |
+
torch.tensor(native.atom37_positions[target_inds]).unsqueeze(0),
|
| 1376 |
+
torch.tensor(native.atom37_mask[mobile_inds]).unsqueeze(0),
|
| 1377 |
+
**kwargs,
|
| 1378 |
+
)
|
| 1379 |
+
return float(lddt) if lddt.numel() == 1 else lddt.numpy().flatten()
|
| 1380 |
+
|
| 1381 |
+
def gdt_ts(
|
| 1382 |
+
self,
|
| 1383 |
+
target: ProteinChain,
|
| 1384 |
+
mobile_inds: list[int] | np.ndarray | None = None,
|
| 1385 |
+
target_inds: list[int] | np.ndarray | None = None,
|
| 1386 |
+
**kwargs,
|
| 1387 |
+
) -> float | np.ndarray:
|
| 1388 |
+
"""Compute the GDT_TS between this protein chain and another.
|
| 1389 |
+
|
| 1390 |
+
Arguments:
|
| 1391 |
+
target (ProteinChain): The other protein chain to compare to.
|
| 1392 |
+
mobile_inds: Mobile atom indices, not residue indices.
|
| 1393 |
+
target_inds: Target atom indices, not residue indices.
|
| 1394 |
+
|
| 1395 |
+
Returns:
|
| 1396 |
+
float: The GDT_TS score between the two protein chains.
|
| 1397 |
+
"""
|
| 1398 |
+
gdt_ts = compute_gdt_ts(
|
| 1399 |
+
mobile=torch.tensor(
|
| 1400 |
+
index_by_atom_name(self.atom37_positions[mobile_inds], "CA"),
|
| 1401 |
+
dtype=torch.float32,
|
| 1402 |
+
).unsqueeze(0),
|
| 1403 |
+
target=torch.tensor(
|
| 1404 |
+
index_by_atom_name(target.atom37_positions[target_inds], "CA"),
|
| 1405 |
+
dtype=torch.float32,
|
| 1406 |
+
).unsqueeze(0),
|
| 1407 |
+
atom_exists_mask=torch.tensor(
|
| 1408 |
+
index_by_atom_name(self.atom37_mask[mobile_inds], "CA", dim=-1)
|
| 1409 |
+
& index_by_atom_name(target.atom37_mask[target_inds], "CA", dim=-1)
|
| 1410 |
+
).unsqueeze(0),
|
| 1411 |
+
**kwargs,
|
| 1412 |
+
)
|
| 1413 |
+
return float(gdt_ts) if gdt_ts.numel() == 1 else gdt_ts.numpy().flatten()
|
| 1414 |
+
|
| 1415 |
+
@cached_property
|
| 1416 |
+
def residue_index_no_insertions(self) -> np.ndarray:
|
| 1417 |
+
return self.residue_index + np.cumsum(self.insertion_code != "")
|
| 1418 |
+
|
| 1419 |
+
@cached_property
|
| 1420 |
+
def atom_array_no_insertions(self) -> bs.AtomArray:
|
| 1421 |
+
atoms = []
|
| 1422 |
+
for res_idx, (res_name, positions, mask, conf) in enumerate(
|
| 1423 |
+
zip(
|
| 1424 |
+
self.sequence,
|
| 1425 |
+
self.atom37_positions,
|
| 1426 |
+
self.atom37_mask.astype(bool),
|
| 1427 |
+
self.confidence,
|
| 1428 |
+
strict=False,
|
| 1429 |
+
)
|
| 1430 |
+
):
|
| 1431 |
+
for i, pos in zip(np.where(mask)[0], positions[mask], strict=False):
|
| 1432 |
+
b_factor = (
|
| 1433 |
+
self.atom37_confidence[res_idx, i]
|
| 1434 |
+
if self.atom37_confidence is not None
|
| 1435 |
+
else conf
|
| 1436 |
+
)
|
| 1437 |
+
atom = bs.Atom(
|
| 1438 |
+
coord=pos,
|
| 1439 |
+
# hard coded to as we currently only support single chain structures
|
| 1440 |
+
chain_id=CHAIN_ID_CONST,
|
| 1441 |
+
res_id=res_idx + 1,
|
| 1442 |
+
res_name=residue_constants.restype_1to3.get(res_name, "UNK"),
|
| 1443 |
+
hetero=False,
|
| 1444 |
+
atom_name=residue_constants.atom_types[i],
|
| 1445 |
+
element=residue_constants.atom_types[i][0],
|
| 1446 |
+
b_factor=float(b_factor) * PLDDT_B_FACTOR_SCALE,
|
| 1447 |
+
occupancy=1.0,
|
| 1448 |
+
)
|
| 1449 |
+
atoms.append(atom)
|
| 1450 |
+
return bs.array(atoms)
|
fastplms/models/esmfold2/esmfold2_protein_complex.py
ADDED
|
@@ -0,0 +1,1240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Protein-complex data, assembly expansion, and geometry for ESMFold2."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import io
|
| 6 |
+
import itertools
|
| 7 |
+
import random
|
| 8 |
+
import re
|
| 9 |
+
import warnings
|
| 10 |
+
from collections.abc import Iterable, Sequence
|
| 11 |
+
from dataclasses import asdict, dataclass, replace
|
| 12 |
+
from functools import cached_property
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from subprocess import check_output
|
| 15 |
+
from tempfile import TemporaryDirectory
|
| 16 |
+
from typing import Any
|
| 17 |
+
|
| 18 |
+
import biotite.structure as bs
|
| 19 |
+
import brotli
|
| 20 |
+
import msgpack
|
| 21 |
+
import msgpack_numpy
|
| 22 |
+
import numpy as np
|
| 23 |
+
import torch
|
| 24 |
+
from biotite.database import rcsb
|
| 25 |
+
from biotite.file import InvalidFileError
|
| 26 |
+
from biotite.structure.io.pdb import PDBFile
|
| 27 |
+
from biotite.structure.io.pdbx import CIFCategory, CIFColumn, CIFData, CIFFile
|
| 28 |
+
from biotite.structure.io.pdbx import set_structure as set_structure_pdbx
|
| 29 |
+
from biotite.structure.io.pdbx.convert import _get_transformations, get_structure
|
| 30 |
+
from biotite.structure.util import matrix_rotate
|
| 31 |
+
from scipy.spatial import KDTree
|
| 32 |
+
|
| 33 |
+
from . import esmfold2_residue_constants as residue_constants
|
| 34 |
+
from .esmfold2_affine3d import Affine3D
|
| 35 |
+
from .esmfold2_aligner import Aligner
|
| 36 |
+
from .esmfold2_atom_indexer import AtomIndexer
|
| 37 |
+
from .esmfold2_metrics import compute_gdt_ts, compute_lddt_ca
|
| 38 |
+
from .esmfold2_misc import slice_python_object_as_numpy
|
| 39 |
+
from .esmfold2_mmcif_parsing import (
|
| 40 |
+
MmcifWrapper,
|
| 41 |
+
NoProteinError,
|
| 42 |
+
round_mmcif_columns,
|
| 43 |
+
)
|
| 44 |
+
from .esmfold2_protein_chain import (
|
| 45 |
+
ProteinChain,
|
| 46 |
+
_str_key_to_int_key,
|
| 47 |
+
chain_to_ndarray,
|
| 48 |
+
index_by_atom_name,
|
| 49 |
+
infer_cb,
|
| 50 |
+
)
|
| 51 |
+
from .esmfold2_utils_types import PathOrBuffer
|
| 52 |
+
|
| 53 |
+
SINGLE_LETTER_CHAIN_IDS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _parse_operation_expression(expression: str) -> list[tuple[str, ...]]:
|
| 57 |
+
"""Expand an mmCIF operation expression in application order."""
|
| 58 |
+
|
| 59 |
+
def expand_group(group: str) -> list[str]:
|
| 60 |
+
operation_ids: list[str] = []
|
| 61 |
+
for term in group.split(","):
|
| 62 |
+
if "-" not in term:
|
| 63 |
+
operation_ids.append(term)
|
| 64 |
+
continue
|
| 65 |
+
first, last = (int(value) for value in term.split("-"))
|
| 66 |
+
operation_ids.extend(str(value) for value in range(first, last + 1))
|
| 67 |
+
return operation_ids
|
| 68 |
+
|
| 69 |
+
groups = [group for group in expression.replace(")", "").split("(") if group]
|
| 70 |
+
groups.reverse()
|
| 71 |
+
return list(itertools.product(*(expand_group(group) for group in groups)))
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _apply_transformations_fast(chains, transformation_dict, operations):
|
| 75 |
+
"""Return transformed copies of each affected protein chain."""
|
| 76 |
+
transformed_chains = []
|
| 77 |
+
for chain in chains:
|
| 78 |
+
for operation in operations:
|
| 79 |
+
coordinates = chain.atom37_positions.copy()
|
| 80 |
+
for op_step in operation:
|
| 81 |
+
transform = transformation_dict[op_step]
|
| 82 |
+
coordinates = matrix_rotate(coordinates, transform.rotation)
|
| 83 |
+
coordinates += transform.target_translation
|
| 84 |
+
transformed_chains.append(replace(chain, atom37_positions=coordinates))
|
| 85 |
+
return transformed_chains
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@dataclass
|
| 89 |
+
class ProteinComplexMetadata:
|
| 90 |
+
entity_lookup: dict[int, int | str]
|
| 91 |
+
chain_lookup: dict[int, str]
|
| 92 |
+
mmcif: MmcifWrapper | None = None
|
| 93 |
+
# This is a dictionary that maps assembly ids to the list of unique chains
|
| 94 |
+
# in that assembly. Allows for usage of `switch_assembly`.
|
| 95 |
+
assembly_composition: dict[str, list[str]] | None = None
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@dataclass
|
| 99 |
+
class DockQSingleScore:
|
| 100 |
+
native_chains: tuple[str, str]
|
| 101 |
+
DockQ: float
|
| 102 |
+
interface_rms: float
|
| 103 |
+
ligand_rms: float
|
| 104 |
+
fnat: float
|
| 105 |
+
fnonnat: float
|
| 106 |
+
clashes: float
|
| 107 |
+
F1: float
|
| 108 |
+
DockQ_F1: float
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@dataclass
|
| 112 |
+
class DockQResult:
|
| 113 |
+
total_dockq: float
|
| 114 |
+
native_interfaces: int
|
| 115 |
+
chain_mapping: dict[str, str]
|
| 116 |
+
interfaces: dict[tuple[str, str], DockQSingleScore]
|
| 117 |
+
# zip(aligned.chain_iter(), native.chain_iter()) gives you the pairing
|
| 118 |
+
# aligned.rmsd(native) should give you a low rmsd irrespective of shuffling
|
| 119 |
+
aligned: ProteinComplex
|
| 120 |
+
aligned_rmsd: float
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
@dataclass(frozen=True)
|
| 124 |
+
class ProteinComplex:
|
| 125 |
+
"""Dataclass with atom37 representation of an entire protein complex."""
|
| 126 |
+
|
| 127 |
+
id: str
|
| 128 |
+
sequence: str
|
| 129 |
+
entity_id: np.ndarray # entities map to unique sequences
|
| 130 |
+
chain_id: np.ndarray # multiple chains might share an entity id
|
| 131 |
+
sym_id: np.ndarray # complexes might be copies of the same chain
|
| 132 |
+
residue_index: np.ndarray
|
| 133 |
+
insertion_code: np.ndarray
|
| 134 |
+
atom37_positions: np.ndarray
|
| 135 |
+
atom37_mask: np.ndarray
|
| 136 |
+
confidence: np.ndarray
|
| 137 |
+
# This metadata is parsed from the MMCIF file. For synthetic data, we do a best effort.
|
| 138 |
+
metadata: ProteinComplexMetadata
|
| 139 |
+
atom37_confidence: np.ndarray | None = None # P has shape (l, 37).
|
| 140 |
+
|
| 141 |
+
# Coordinate completion, concatenation, and comparison
|
| 142 |
+
def infer_oxygen(self) -> ProteinComplex:
|
| 143 |
+
"""Oxygen position is fixed given N, CA, C atoms. Infer it if not provided."""
|
| 144 |
+
O_missing_indices = np.argwhere(~np.isfinite(self.atoms["O"]).all(axis=1)).squeeze()
|
| 145 |
+
|
| 146 |
+
O_vector = torch.tensor([0.6240, -1.0613, 0.0103], dtype=torch.float32)
|
| 147 |
+
N, CA, C = torch.from_numpy(self.atoms[["N", "CA", "C"]]).float().unbind(dim=1)
|
| 148 |
+
N = torch.roll(N, -3)
|
| 149 |
+
N[..., -1, :] = torch.nan
|
| 150 |
+
|
| 151 |
+
# Get the frame defined by the CA-C-N atom
|
| 152 |
+
frames = Affine3D.from_graham_schmidt(CA, C, N)
|
| 153 |
+
oxygen_coordinates = frames.apply(O_vector)
|
| 154 |
+
atom37_positions = self.atom37_positions.copy()
|
| 155 |
+
atom37_mask = self.atom37_mask.copy()
|
| 156 |
+
|
| 157 |
+
atom37_positions[O_missing_indices, residue_constants.atom_order["O"]] = oxygen_coordinates[
|
| 158 |
+
O_missing_indices
|
| 159 |
+
].numpy()
|
| 160 |
+
atom37_mask[O_missing_indices, residue_constants.atom_order["O"]] = ~np.isnan(
|
| 161 |
+
atom37_positions[O_missing_indices, residue_constants.atom_order["O"]]
|
| 162 |
+
).any(-1)
|
| 163 |
+
new_chain = replace(self, atom37_positions=atom37_positions, atom37_mask=atom37_mask)
|
| 164 |
+
return new_chain
|
| 165 |
+
|
| 166 |
+
def infer_cbeta(self, infer_cbeta_for_glycine: bool = False) -> ProteinComplex:
|
| 167 |
+
"""Return a new chain with inferred CB atoms at all residues except GLY.
|
| 168 |
+
|
| 169 |
+
Args:
|
| 170 |
+
infer_cbeta_for_glycine (bool): If True, infers a beta carbon for glycine
|
| 171 |
+
residues, even though that residue doesn't have one. Default off.
|
| 172 |
+
|
| 173 |
+
NOTE(rverkuil): The reason for having this switch in the first place
|
| 174 |
+
is that sometimes we want a (inferred) CB coordinate for every residue,
|
| 175 |
+
for example for making a pairwise distance matrix, or doing an RMSD
|
| 176 |
+
calculation between two designs for a given structural template, w/
|
| 177 |
+
CB atoms.
|
| 178 |
+
"""
|
| 179 |
+
atom37_positions = self.atom37_positions.copy()
|
| 180 |
+
atom37_mask = self.atom37_mask.copy()
|
| 181 |
+
|
| 182 |
+
N, CA, C = np.moveaxis(self.atoms[["N", "CA", "C"]], 1, 0)
|
| 183 |
+
# See usage in trDesign codebase.
|
| 184 |
+
# https://github.com/gjoni/trDesign/blob/f2d5930b472e77bfacc2f437b3966e7a708a8d37/02-GD/utils.py#L140
|
| 185 |
+
inferred_cbeta_positions = infer_cb(C, N, CA, 1.522, 1.927, -2.143)
|
| 186 |
+
if not infer_cbeta_for_glycine:
|
| 187 |
+
inferred_cbeta_positions[np.array(list(self.sequence)) == "G", :] = np.nan
|
| 188 |
+
|
| 189 |
+
atom37_positions[:, residue_constants.atom_order["CB"]] = inferred_cbeta_positions
|
| 190 |
+
atom37_mask[:, residue_constants.atom_order["CB"]] = ~np.isnan(
|
| 191 |
+
atom37_positions[:, residue_constants.atom_order["CB"]]
|
| 192 |
+
).any(-1)
|
| 193 |
+
new_chain = replace(self, atom37_positions=atom37_positions, atom37_mask=atom37_mask)
|
| 194 |
+
return new_chain
|
| 195 |
+
|
| 196 |
+
@classmethod
|
| 197 |
+
def from_open_source(cls, pc: ProteinComplex):
|
| 198 |
+
# TODO(@zeming): deprecated, should delete
|
| 199 |
+
return pc
|
| 200 |
+
|
| 201 |
+
@classmethod
|
| 202 |
+
def concat(cls, objs: list[ProteinComplex]) -> ProteinComplex:
|
| 203 |
+
pdb_ids = [obj.id for obj in objs]
|
| 204 |
+
if len(set(pdb_ids)) > 1:
|
| 205 |
+
raise RuntimeError(
|
| 206 |
+
"Concatention of protein complexes across different PDB ids is unsupported"
|
| 207 |
+
)
|
| 208 |
+
return ProteinComplex.from_chains(
|
| 209 |
+
list(itertools.chain.from_iterable(obj.chain_iter() for obj in objs))
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
def _sanity_check_complexes_are_comparable(self, other: ProteinComplex):
|
| 213 |
+
if len(self) != len(other):
|
| 214 |
+
raise ValueError("Protein complexes must have the same length")
|
| 215 |
+
if len(list(self.chain_iter())) != len(list(other.chain_iter())):
|
| 216 |
+
raise ValueError("Protein complexes must have the same number of chains")
|
| 217 |
+
|
| 218 |
+
def rmsd(
|
| 219 |
+
self,
|
| 220 |
+
target: ProteinComplex,
|
| 221 |
+
also_check_reflection: bool = False,
|
| 222 |
+
mobile_inds: list[int] | np.ndarray | None = None,
|
| 223 |
+
target_inds: list[int] | np.ndarray | None = None,
|
| 224 |
+
only_compute_backbone_rmsd: bool = False,
|
| 225 |
+
compute_chain_assignment: bool = True,
|
| 226 |
+
):
|
| 227 |
+
"""
|
| 228 |
+
Compute the RMSD between this protein chain and another.
|
| 229 |
+
|
| 230 |
+
Args:
|
| 231 |
+
target (ProteinComplex): The target (other) protein complex to compare to.
|
| 232 |
+
also_check_reflection: Compare the reflected mobile coordinates too.
|
| 233 |
+
mobile_inds: Mobile atom indices, not residue indices.
|
| 234 |
+
target_inds: Target atom indices, not residue indices.
|
| 235 |
+
only_compute_backbone_rmsd: Restrict the score to backbone atoms.
|
| 236 |
+
"""
|
| 237 |
+
aligned = self.dockq(target).aligned if compute_chain_assignment else self
|
| 238 |
+
|
| 239 |
+
aligner = Aligner(
|
| 240 |
+
aligned if mobile_inds is None else aligned[mobile_inds],
|
| 241 |
+
target if target_inds is None else target[target_inds],
|
| 242 |
+
only_compute_backbone_rmsd,
|
| 243 |
+
)
|
| 244 |
+
avg_rmsd = aligner.rmsd
|
| 245 |
+
|
| 246 |
+
if not also_check_reflection:
|
| 247 |
+
return avg_rmsd
|
| 248 |
+
|
| 249 |
+
aligner = Aligner(
|
| 250 |
+
aligned if mobile_inds is None else aligned[mobile_inds],
|
| 251 |
+
target if target_inds is None else target[target_inds],
|
| 252 |
+
only_compute_backbone_rmsd,
|
| 253 |
+
use_reflection=True,
|
| 254 |
+
)
|
| 255 |
+
avg_rmsd_neg = aligner.rmsd
|
| 256 |
+
|
| 257 |
+
return min(avg_rmsd, avg_rmsd_neg)
|
| 258 |
+
|
| 259 |
+
def lddt_ca(
|
| 260 |
+
self,
|
| 261 |
+
target: ProteinComplex,
|
| 262 |
+
mobile_inds: list[int] | np.ndarray | None = None,
|
| 263 |
+
target_inds: list[int] | np.ndarray | None = None,
|
| 264 |
+
compute_chain_assignment: bool = True,
|
| 265 |
+
**kwargs,
|
| 266 |
+
) -> float | np.ndarray:
|
| 267 |
+
"""Compute the LDDT between this protein complex and another.
|
| 268 |
+
|
| 269 |
+
Arguments:
|
| 270 |
+
target (ProteinComplex): The other protein complex to compare to.
|
| 271 |
+
mobile_inds: Mobile atom indices, not residue indices.
|
| 272 |
+
target_inds: Target atom indices, not residue indices.
|
| 273 |
+
|
| 274 |
+
Returns:
|
| 275 |
+
float | np.ndarray: The LDDT score between the two protein chains, either
|
| 276 |
+
a single float or per-residue LDDT scores if `per_residue` is True.
|
| 277 |
+
"""
|
| 278 |
+
aligned = self.dockq(target).aligned if compute_chain_assignment else self
|
| 279 |
+
lddt = compute_lddt_ca(
|
| 280 |
+
torch.tensor(aligned.atom37_positions[mobile_inds]).unsqueeze(0),
|
| 281 |
+
torch.tensor(target.atom37_positions[target_inds]).unsqueeze(0),
|
| 282 |
+
torch.tensor(aligned.atom37_mask[mobile_inds]).unsqueeze(0),
|
| 283 |
+
**kwargs,
|
| 284 |
+
)
|
| 285 |
+
return float(lddt) if lddt.numel() == 1 else lddt.numpy().flatten()
|
| 286 |
+
|
| 287 |
+
def gdt_ts(
|
| 288 |
+
self,
|
| 289 |
+
target: ProteinComplex,
|
| 290 |
+
mobile_inds: list[int] | np.ndarray | None = None,
|
| 291 |
+
target_inds: list[int] | np.ndarray | None = None,
|
| 292 |
+
compute_chain_assignment: bool = True,
|
| 293 |
+
**kwargs,
|
| 294 |
+
) -> float | np.ndarray:
|
| 295 |
+
"""Compute the GDT_TS between this protein complex and another.
|
| 296 |
+
|
| 297 |
+
Arguments:
|
| 298 |
+
target (ProteinComplex): The other protein complex to compare to.
|
| 299 |
+
mobile_inds: Mobile atom indices, not residue indices.
|
| 300 |
+
target_inds: Target atom indices, not residue indices.
|
| 301 |
+
|
| 302 |
+
Returns:
|
| 303 |
+
float: The GDT_TS score between the two protein chains.
|
| 304 |
+
"""
|
| 305 |
+
aligned = self.dockq(target).aligned if compute_chain_assignment else self
|
| 306 |
+
gdt_ts = compute_gdt_ts(
|
| 307 |
+
mobile=torch.tensor(
|
| 308 |
+
index_by_atom_name(aligned.atom37_positions[mobile_inds], "CA"),
|
| 309 |
+
dtype=torch.float32,
|
| 310 |
+
).unsqueeze(0),
|
| 311 |
+
target=torch.tensor(
|
| 312 |
+
index_by_atom_name(target.atom37_positions[target_inds], "CA"),
|
| 313 |
+
dtype=torch.float32,
|
| 314 |
+
).unsqueeze(0),
|
| 315 |
+
atom_exists_mask=torch.tensor(
|
| 316 |
+
index_by_atom_name(aligned.atom37_mask[mobile_inds], "CA", dim=-1)
|
| 317 |
+
& index_by_atom_name(target.atom37_mask[target_inds], "CA", dim=-1)
|
| 318 |
+
).unsqueeze(0),
|
| 319 |
+
**kwargs,
|
| 320 |
+
)
|
| 321 |
+
return float(gdt_ts) if gdt_ts.numel() == 1 else gdt_ts.numpy().flatten()
|
| 322 |
+
|
| 323 |
+
def dockq(self, native: ProteinComplex):
|
| 324 |
+
# This function uses dockqv2 to compute the DockQ score. Because it does a mapping
|
| 325 |
+
# over all possible chains, it's quite slow. Be careful not to use this in an inference loop
|
| 326 |
+
# or something that requires fast scoring. It defaults to 8 CPUs.
|
| 327 |
+
#
|
| 328 |
+
# TODO(@zeming): Because we haven't properly implemented protein complexes for mmcif,
|
| 329 |
+
# if your protein has multi-letter or repeated chain IDs, this will fail. Please call
|
| 330 |
+
# Normalize chain IDs before DockQ when IDs repeat or use multiple letters.
|
| 331 |
+
|
| 332 |
+
try:
|
| 333 |
+
pass
|
| 334 |
+
except BaseException:
|
| 335 |
+
raise RuntimeError("DockQ is not installed. Please update your environment.") from None
|
| 336 |
+
self._sanity_check_complexes_are_comparable(native)
|
| 337 |
+
|
| 338 |
+
def sanity_check_chain_ids(pc: ProteinComplex):
|
| 339 |
+
ids = []
|
| 340 |
+
for i, chain in enumerate(pc.chain_iter()):
|
| 341 |
+
if i > len(SINGLE_LETTER_CHAIN_IDS):
|
| 342 |
+
raise ValueError("Too many chains to write to PDB file")
|
| 343 |
+
if len(chain.chain_id) > 1:
|
| 344 |
+
raise ValueError("We only supports single letter chain IDs for DockQ")
|
| 345 |
+
ids.append(chain.chain_id)
|
| 346 |
+
if len(set(ids)) != len(ids):
|
| 347 |
+
raise ValueError(f"Duplicate chain IDs in protein complex: {ids}")
|
| 348 |
+
return ids
|
| 349 |
+
|
| 350 |
+
sanity_check_chain_ids(self)
|
| 351 |
+
sanity_check_chain_ids(native)
|
| 352 |
+
|
| 353 |
+
with TemporaryDirectory() as tdir:
|
| 354 |
+
dir = Path(tdir)
|
| 355 |
+
self.to_pdb(dir / "self.pdb")
|
| 356 |
+
native.to_pdb(dir / "native.pdb")
|
| 357 |
+
|
| 358 |
+
output = check_output(["DockQ", dir / "self.pdb", dir / "native.pdb"])
|
| 359 |
+
lines = output.decode().split("\n")
|
| 360 |
+
|
| 361 |
+
# Remove the header comments
|
| 362 |
+
start_index = next(i for i, line in enumerate(lines) if line.startswith("Model"))
|
| 363 |
+
lines = lines[start_index:]
|
| 364 |
+
|
| 365 |
+
result = {}
|
| 366 |
+
interfaces = []
|
| 367 |
+
current_interface: dict = {}
|
| 368 |
+
|
| 369 |
+
for line in lines:
|
| 370 |
+
line = line.strip()
|
| 371 |
+
if not line:
|
| 372 |
+
continue
|
| 373 |
+
|
| 374 |
+
if line.startswith(("Model :", "Native :")):
|
| 375 |
+
pass # Tmp pdb file location, it's useless...
|
| 376 |
+
elif line.startswith("Total DockQ"):
|
| 377 |
+
total_dockq_match = re.search(
|
| 378 |
+
r"Total DockQ over (\d+) native interfaces: ([\d.]+) with "
|
| 379 |
+
r"(.*) model:native mapping",
|
| 380 |
+
line,
|
| 381 |
+
)
|
| 382 |
+
if total_dockq_match:
|
| 383 |
+
result["value"] = float(total_dockq_match.group(2))
|
| 384 |
+
result["native interfaces"] = int(total_dockq_match.group(1))
|
| 385 |
+
native_chains, self_chains = total_dockq_match.group(3).split(":")
|
| 386 |
+
result["mapping"] = dict(zip(native_chains, self_chains, strict=False))
|
| 387 |
+
else:
|
| 388 |
+
raise RuntimeError(
|
| 389 |
+
"Failed to parse DockQ output, maybe your DockQ version is wrong?"
|
| 390 |
+
)
|
| 391 |
+
elif line.startswith("Native chains:"):
|
| 392 |
+
if current_interface:
|
| 393 |
+
interfaces.append(current_interface)
|
| 394 |
+
current_interface = {"Native chains": line.split(":")[1].strip().split(", ")}
|
| 395 |
+
elif line.startswith("Model chains:"):
|
| 396 |
+
current_interface["Model chains"] = line.split(":")[1].strip().split(", ")
|
| 397 |
+
elif ":" in line:
|
| 398 |
+
key, value = line.split(":", 1)
|
| 399 |
+
current_interface[key.strip()] = float(value.strip())
|
| 400 |
+
|
| 401 |
+
if current_interface:
|
| 402 |
+
interfaces.append(current_interface)
|
| 403 |
+
|
| 404 |
+
def parse_dict(d: dict[str, Any]) -> DockQSingleScore:
|
| 405 |
+
return DockQSingleScore(
|
| 406 |
+
native_chains=tuple(d["Native chains"]), # type: ignore
|
| 407 |
+
DockQ=float(d["DockQ"]),
|
| 408 |
+
interface_rms=float(d["irms"]),
|
| 409 |
+
ligand_rms=float(d["Lrms"]), # Note the capitalization difference
|
| 410 |
+
fnat=float(d["fnat"]),
|
| 411 |
+
fnonnat=float(d["fnonnat"]),
|
| 412 |
+
clashes=float(d["clashes"]),
|
| 413 |
+
F1=float(d["F1"]),
|
| 414 |
+
DockQ_F1=float(d["DockQ_F1"]),
|
| 415 |
+
)
|
| 416 |
+
|
| 417 |
+
inv_mapping = {v: k for k, v in result["mapping"].items()}
|
| 418 |
+
|
| 419 |
+
self_chain_map = {c.chain_id: c for c in self.chain_iter()}
|
| 420 |
+
realigned = []
|
| 421 |
+
for chain in native.chain_iter():
|
| 422 |
+
realigned.append(self_chain_map[inv_mapping[chain.chain_id]])
|
| 423 |
+
|
| 424 |
+
realigned = ProteinComplex.from_chains(realigned)
|
| 425 |
+
aligner = Aligner(realigned, native)
|
| 426 |
+
realigned = aligner.apply(realigned)
|
| 427 |
+
|
| 428 |
+
result = DockQResult(
|
| 429 |
+
total_dockq=result["value"],
|
| 430 |
+
native_interfaces=result["native interfaces"],
|
| 431 |
+
chain_mapping=result["mapping"],
|
| 432 |
+
interfaces={
|
| 433 |
+
(i["Model chains"][0], i["Model chains"][1]): parse_dict(i) for i in interfaces
|
| 434 |
+
},
|
| 435 |
+
aligned=realigned,
|
| 436 |
+
aligned_rmsd=aligner.rmsd,
|
| 437 |
+
)
|
| 438 |
+
|
| 439 |
+
return result
|
| 440 |
+
|
| 441 |
+
# Object invariants, slicing, and chain views
|
| 442 |
+
def __post_init__(self):
|
| 443 |
+
if not isinstance(self.sequence, str):
|
| 444 |
+
raise TypeError("sequence must be a string.")
|
| 445 |
+
sequence_length = len(self.sequence)
|
| 446 |
+
aligned = {
|
| 447 |
+
"atom37_positions": self.atom37_positions,
|
| 448 |
+
"atom37_mask": self.atom37_mask,
|
| 449 |
+
"residue_index": self.residue_index,
|
| 450 |
+
"insertion_code": self.insertion_code,
|
| 451 |
+
"confidence": self.confidence,
|
| 452 |
+
"entity_id": self.entity_id,
|
| 453 |
+
"chain_id": self.chain_id,
|
| 454 |
+
"sym_id": self.sym_id,
|
| 455 |
+
}
|
| 456 |
+
for name, values in aligned.items():
|
| 457 |
+
if not isinstance(values, np.ndarray):
|
| 458 |
+
raise TypeError(f"{name} must be a NumPy array, got {type(values).__name__}.")
|
| 459 |
+
if values.ndim == 0 or values.shape[0] != sequence_length:
|
| 460 |
+
raise ValueError(
|
| 461 |
+
f"{name} shape {values.shape} does not align with "
|
| 462 |
+
f"sequence length {sequence_length}."
|
| 463 |
+
)
|
| 464 |
+
if self.atom37_positions.shape != (sequence_length, 37, 3):
|
| 465 |
+
raise ValueError(
|
| 466 |
+
"atom37_positions must have shape "
|
| 467 |
+
f"({sequence_length}, 37, 3), got {self.atom37_positions.shape}."
|
| 468 |
+
)
|
| 469 |
+
if self.atom37_mask.shape != (sequence_length, 37):
|
| 470 |
+
raise ValueError(
|
| 471 |
+
"atom37_mask must have shape "
|
| 472 |
+
f"({sequence_length}, 37), got {self.atom37_mask.shape}."
|
| 473 |
+
)
|
| 474 |
+
if self.atom37_mask.dtype != bool:
|
| 475 |
+
raise TypeError(f"atom37_mask must have Boolean dtype, got {self.atom37_mask.dtype}.")
|
| 476 |
+
if not np.issubdtype(self.atom37_positions.dtype, np.number):
|
| 477 |
+
raise TypeError("atom37_positions must use a numeric dtype.")
|
| 478 |
+
for name, values in (
|
| 479 |
+
("residue_index", self.residue_index),
|
| 480 |
+
("insertion_code", self.insertion_code),
|
| 481 |
+
("confidence", self.confidence),
|
| 482 |
+
("entity_id", self.entity_id),
|
| 483 |
+
("chain_id", self.chain_id),
|
| 484 |
+
("sym_id", self.sym_id),
|
| 485 |
+
):
|
| 486 |
+
if values.shape != (sequence_length,):
|
| 487 |
+
raise ValueError(
|
| 488 |
+
f"{name} must have shape ({sequence_length},), got {values.shape}."
|
| 489 |
+
)
|
| 490 |
+
if not np.issubdtype(self.confidence.dtype, np.number):
|
| 491 |
+
raise TypeError("confidence must use a numeric dtype.")
|
| 492 |
+
atom37_confidence = self.atom37_confidence
|
| 493 |
+
if atom37_confidence is not None and not isinstance(atom37_confidence, np.ndarray):
|
| 494 |
+
raise TypeError("atom37_confidence must be a NumPy array when provided.")
|
| 495 |
+
if (
|
| 496 |
+
isinstance(atom37_confidence, np.ndarray)
|
| 497 |
+
and atom37_confidence.shape != self.atom37_mask.shape
|
| 498 |
+
):
|
| 499 |
+
raise ValueError(
|
| 500 |
+
"atom37_confidence shape must match atom37_mask: "
|
| 501 |
+
f"{atom37_confidence.shape} != {self.atom37_mask.shape}."
|
| 502 |
+
)
|
| 503 |
+
|
| 504 |
+
def __getitem__(self, idx: int | list[int] | slice | np.ndarray):
|
| 505 |
+
"""This function slices protein complexes without consideration of chain breaks
|
| 506 |
+
NOTE: When slicing with a boolean mask, it's possible that the output array won't
|
| 507 |
+
be the expected length. This is because we do our best to preserve chainbreak tokens.
|
| 508 |
+
"""
|
| 509 |
+
|
| 510 |
+
if isinstance(idx, int):
|
| 511 |
+
idx = [idx]
|
| 512 |
+
if isinstance(idx, list):
|
| 513 |
+
raise ValueError("ProteinComplex doesn't supports indexing with lists of indices")
|
| 514 |
+
|
| 515 |
+
if isinstance(idx, np.ndarray):
|
| 516 |
+
is_chainbreak = np.asarray([s == "|" for s in self.sequence])
|
| 517 |
+
idx = idx.astype(bool) | is_chainbreak
|
| 518 |
+
|
| 519 |
+
complex = self._unsafe_slice(idx)
|
| 520 |
+
if len(complex) == 0:
|
| 521 |
+
return complex
|
| 522 |
+
|
| 523 |
+
# detect runs of chainbreaks by searching for instances of '||' in complex.sequence
|
| 524 |
+
chainbreak_runs = np.asarray(
|
| 525 |
+
[complex.sequence[i : i + 2] == "||" for i in range(len(complex.sequence) - 1)]
|
| 526 |
+
+ [complex.sequence[-1] == "|"]
|
| 527 |
+
)
|
| 528 |
+
# We should remove as many chainbreaks as possible from the start of the sequence
|
| 529 |
+
for i in range(len(chainbreak_runs)):
|
| 530 |
+
if complex.sequence[i] == "|":
|
| 531 |
+
chainbreak_runs[i] = True
|
| 532 |
+
else:
|
| 533 |
+
break
|
| 534 |
+
complex = complex._unsafe_slice(~chainbreak_runs)
|
| 535 |
+
return complex
|
| 536 |
+
|
| 537 |
+
def _unsafe_slice(self, idx: int | list[int] | slice | np.ndarray):
|
| 538 |
+
sequence = slice_python_object_as_numpy(self.sequence, idx)
|
| 539 |
+
return replace(
|
| 540 |
+
self,
|
| 541 |
+
sequence=sequence,
|
| 542 |
+
entity_id=self.entity_id[..., idx],
|
| 543 |
+
chain_id=self.chain_id[..., idx],
|
| 544 |
+
sym_id=self.sym_id[..., idx],
|
| 545 |
+
residue_index=self.residue_index[..., idx],
|
| 546 |
+
insertion_code=self.insertion_code[..., idx],
|
| 547 |
+
atom37_positions=self.atom37_positions[..., idx, :, :],
|
| 548 |
+
atom37_mask=self.atom37_mask[..., idx, :],
|
| 549 |
+
confidence=self.confidence[..., idx],
|
| 550 |
+
atom37_confidence=self.atom37_confidence[..., idx, :]
|
| 551 |
+
if self.atom37_confidence is not None
|
| 552 |
+
else None,
|
| 553 |
+
)
|
| 554 |
+
|
| 555 |
+
def __len__(self):
|
| 556 |
+
return len(self.sequence)
|
| 557 |
+
|
| 558 |
+
@property
|
| 559 |
+
def num_chains(self):
|
| 560 |
+
return len(self.chain_boundaries)
|
| 561 |
+
|
| 562 |
+
@cached_property
|
| 563 |
+
def atoms(self) -> AtomIndexer:
|
| 564 |
+
return AtomIndexer(self, property="atom37_positions", dim=-2)
|
| 565 |
+
|
| 566 |
+
@cached_property
|
| 567 |
+
def atom_mask(self) -> AtomIndexer:
|
| 568 |
+
return AtomIndexer(self, property="atom37_mask", dim=-1)
|
| 569 |
+
|
| 570 |
+
@cached_property
|
| 571 |
+
def chain_lengths(self) -> np.ndarray:
|
| 572 |
+
return np.diff(self.chain_boundaries, axis=1).flatten()
|
| 573 |
+
|
| 574 |
+
@cached_property
|
| 575 |
+
def chain_boundaries(self) -> list[tuple[int, int]]:
|
| 576 |
+
cb = [-1]
|
| 577 |
+
for i, s in enumerate(self.sequence):
|
| 578 |
+
if s == "|":
|
| 579 |
+
cb.append(i)
|
| 580 |
+
cb.append(len(self))
|
| 581 |
+
return [(cb[i] + 1, cb[i + 1]) for i in range(len(cb) - 1)]
|
| 582 |
+
|
| 583 |
+
def get_chain_by_index(self, index: int) -> ProteinChain:
|
| 584 |
+
try:
|
| 585 |
+
start, end = self.chain_boundaries[index]
|
| 586 |
+
return self[start:end].as_chain()
|
| 587 |
+
except IndexError:
|
| 588 |
+
raise IndexError(f"Chain index {index} out of bounds") from None
|
| 589 |
+
|
| 590 |
+
def get_chain_by_id(
|
| 591 |
+
self, chain_id: str, sample_chain_if_duplicate: bool = True
|
| 592 |
+
) -> ProteinChain:
|
| 593 |
+
valid_indices = [
|
| 594 |
+
index
|
| 595 |
+
for index, id_of_index in self.metadata.chain_lookup.items()
|
| 596 |
+
if id_of_index == chain_id
|
| 597 |
+
]
|
| 598 |
+
if not valid_indices:
|
| 599 |
+
raise KeyError(f"Chain ID {chain_id} not found")
|
| 600 |
+
if sample_chain_if_duplicate:
|
| 601 |
+
index_to_return = random.choice(valid_indices)
|
| 602 |
+
return self.get_chain_by_index(index_to_return)
|
| 603 |
+
else:
|
| 604 |
+
if len(valid_indices) > 1:
|
| 605 |
+
raise ValueError(f"Multiple chains with chain ID {chain_id} found")
|
| 606 |
+
return self.get_chain_by_index(valid_indices[0])
|
| 607 |
+
|
| 608 |
+
def chain_iter(self) -> Iterable[ProteinChain]:
|
| 609 |
+
for start, end in self.chain_boundaries:
|
| 610 |
+
c = self[start:end]
|
| 611 |
+
yield c.as_chain()
|
| 612 |
+
|
| 613 |
+
def as_chain(self, force_conversion: bool = False) -> ProteinChain:
|
| 614 |
+
"""Convert the ProteinComplex to a ProteinChain.
|
| 615 |
+
|
| 616 |
+
Args:
|
| 617 |
+
force_conversion: Flatten multiple chains to access chain-only utilities.
|
| 618 |
+
|
| 619 |
+
"""
|
| 620 |
+
if not force_conversion:
|
| 621 |
+
if len(np.unique(self.chain_id)) != 1:
|
| 622 |
+
raise ValueError(
|
| 623 |
+
f"Protein complex {self.id!r} has multiple chains; "
|
| 624 |
+
"pass force_conversion=True to flatten it."
|
| 625 |
+
)
|
| 626 |
+
if len(np.unique(self.entity_id)) != 1:
|
| 627 |
+
raise ValueError(
|
| 628 |
+
f"Protein complex {self.id!r} has multiple entities; "
|
| 629 |
+
"pass force_conversion=True to flatten it."
|
| 630 |
+
)
|
| 631 |
+
if self.chain_id[0] not in self.metadata.chain_lookup:
|
| 632 |
+
warnings.warn(
|
| 633 |
+
"Chain ID not found in metadata, using 'A' as default",
|
| 634 |
+
stacklevel=2,
|
| 635 |
+
)
|
| 636 |
+
if self.entity_id[0] not in self.metadata.entity_lookup:
|
| 637 |
+
warnings.warn(
|
| 638 |
+
"Entity ID not found in metadata, using None as default",
|
| 639 |
+
stacklevel=2,
|
| 640 |
+
)
|
| 641 |
+
chain_id = self.metadata.chain_lookup.get(self.chain_id[0], "A")
|
| 642 |
+
entity_id = self.metadata.entity_lookup.get(self.entity_id[0], None)
|
| 643 |
+
else:
|
| 644 |
+
chain_id = "A"
|
| 645 |
+
entity_id = None
|
| 646 |
+
|
| 647 |
+
return ProteinChain(
|
| 648 |
+
id=self.id,
|
| 649 |
+
sequence=self.sequence,
|
| 650 |
+
chain_id=chain_id,
|
| 651 |
+
entity_id=entity_id,
|
| 652 |
+
atom37_positions=self.atom37_positions,
|
| 653 |
+
atom37_mask=self.atom37_mask,
|
| 654 |
+
residue_index=self.residue_index,
|
| 655 |
+
insertion_code=self.insertion_code,
|
| 656 |
+
confidence=self.confidence,
|
| 657 |
+
mmcif=self.metadata.mmcif,
|
| 658 |
+
atom37_confidence=self.atom37_confidence,
|
| 659 |
+
)
|
| 660 |
+
|
| 661 |
+
# Contact topology and mmCIF export
|
| 662 |
+
@cached_property
|
| 663 |
+
def per_chain_kd_trees(self):
|
| 664 |
+
# Iterate over chains, build KDTree for each chain
|
| 665 |
+
kdtrees = []
|
| 666 |
+
|
| 667 |
+
CA = self.atoms["CA"]
|
| 668 |
+
|
| 669 |
+
for start, end in self.chain_boundaries:
|
| 670 |
+
chain_CA = CA[start:end]
|
| 671 |
+
chain_CA = chain_CA[np.isfinite(chain_CA).all(axis=-1)]
|
| 672 |
+
kdtrees.append(KDTree(chain_CA))
|
| 673 |
+
|
| 674 |
+
return kdtrees
|
| 675 |
+
|
| 676 |
+
def chain_adjacency(self, cutoff: float = 8.0) -> np.ndarray:
|
| 677 |
+
# Compute adjacency matrix for protein complex
|
| 678 |
+
num_chains = self.num_chains
|
| 679 |
+
adjacency = np.zeros((num_chains, num_chains), dtype=bool)
|
| 680 |
+
for (i, kdtree), (j, kdtree2) in itertools.combinations(
|
| 681 |
+
enumerate(self.per_chain_kd_trees), 2
|
| 682 |
+
):
|
| 683 |
+
adj = kdtree.query_ball_tree(kdtree2, cutoff)
|
| 684 |
+
any_is_adjacent = any(len(a) > 0 for a in adj)
|
| 685 |
+
adjacency[i, j] = any_is_adjacent
|
| 686 |
+
adjacency[j, i] = any_is_adjacent
|
| 687 |
+
return adjacency
|
| 688 |
+
|
| 689 |
+
def chain_adjacency_by_index(self, index: int, cutoff: float = 8.0) -> np.ndarray:
|
| 690 |
+
num_chains = len(self.chain_boundaries)
|
| 691 |
+
adjacency = np.zeros(num_chains, dtype=bool)
|
| 692 |
+
for i, kdtree in enumerate(self.per_chain_kd_trees):
|
| 693 |
+
if i == index:
|
| 694 |
+
continue
|
| 695 |
+
adj = kdtree.query_ball_tree(self.per_chain_kd_trees[index], cutoff)
|
| 696 |
+
adjacency[i] = any(len(a) > 0 for a in adj)
|
| 697 |
+
return adjacency
|
| 698 |
+
|
| 699 |
+
def add_prefix_to_chain_ids(self, prefix: str) -> ProteinComplex:
|
| 700 |
+
"""Rename all chains in the complex with a given prefix.
|
| 701 |
+
|
| 702 |
+
Args:
|
| 703 |
+
prefix (str): The prefix to use for the new chain IDs. Each chain will be
|
| 704 |
+
named as "{prefix}_{chain_id}".
|
| 705 |
+
|
| 706 |
+
Returns:
|
| 707 |
+
ProteinComplex: A new protein complex with renamed chains.
|
| 708 |
+
"""
|
| 709 |
+
new_chains = []
|
| 710 |
+
for chain in self.chain_iter():
|
| 711 |
+
# Create new chain with updated chain_id
|
| 712 |
+
new_chain = replace(chain, chain_id=f"{prefix}_{chain.chain_id}")
|
| 713 |
+
new_chains.append(new_chain)
|
| 714 |
+
return ProteinComplex.from_chains(new_chains)
|
| 715 |
+
|
| 716 |
+
def sasa(self, by_residue: bool = True):
|
| 717 |
+
chain = self.as_chain(force_conversion=True)
|
| 718 |
+
return chain.sasa(by_residue=by_residue)
|
| 719 |
+
|
| 720 |
+
def to_mmcif_string(self) -> str:
|
| 721 |
+
"""Convert the ProteinComplex to mmCIF format.
|
| 722 |
+
|
| 723 |
+
Returns:
|
| 724 |
+
str: The mmCIF content as a string.
|
| 725 |
+
"""
|
| 726 |
+
# Convert the ProteinComplex to a biotite AtomArray
|
| 727 |
+
# Collect all atoms from all chains
|
| 728 |
+
all_atoms = []
|
| 729 |
+
for chain in self.chain_iter():
|
| 730 |
+
chain_atom_array = chain.atom_array
|
| 731 |
+
# Convert AtomArray to list of atoms and add to collection
|
| 732 |
+
all_atoms.extend(chain_atom_array)
|
| 733 |
+
|
| 734 |
+
# Create combined AtomArray from all atoms
|
| 735 |
+
if not all_atoms:
|
| 736 |
+
raise ValueError("No atoms found in protein complex")
|
| 737 |
+
|
| 738 |
+
atom_array = bs.array(all_atoms)
|
| 739 |
+
|
| 740 |
+
# Create CIF file
|
| 741 |
+
f = CIFFile()
|
| 742 |
+
set_structure_pdbx(f, atom_array, data_block=self.id)
|
| 743 |
+
|
| 744 |
+
# Add entity information for proper mmCIF structure
|
| 745 |
+
self._add_entity_information(f)
|
| 746 |
+
round_mmcif_columns(f)
|
| 747 |
+
|
| 748 |
+
# Write to string
|
| 749 |
+
output = io.StringIO()
|
| 750 |
+
f.write(output)
|
| 751 |
+
return output.getvalue()
|
| 752 |
+
|
| 753 |
+
def _add_entity_information(self, cif_file: CIFFile) -> None:
|
| 754 |
+
"""Add entity, entity_poly, and struct_asym sections to CIF file."""
|
| 755 |
+
|
| 756 |
+
# Group chains by sequence to create unique entities
|
| 757 |
+
entity_map = {} # sequence -> entity_id
|
| 758 |
+
chain_to_entity = {} # chain_id -> entity_id
|
| 759 |
+
entity_sequences = {} # entity_id -> sequence
|
| 760 |
+
entity_id_counter = 1
|
| 761 |
+
|
| 762 |
+
for chain in self.chain_iter():
|
| 763 |
+
sequence = chain.sequence
|
| 764 |
+
if sequence not in entity_map:
|
| 765 |
+
entity_map[sequence] = entity_id_counter
|
| 766 |
+
entity_sequences[entity_id_counter] = sequence
|
| 767 |
+
entity_id_counter += 1
|
| 768 |
+
chain_to_entity[chain.chain_id] = entity_map[sequence]
|
| 769 |
+
|
| 770 |
+
# Create _entity section
|
| 771 |
+
entity_ids = []
|
| 772 |
+
entity_types = []
|
| 773 |
+
entity_descriptions = []
|
| 774 |
+
|
| 775 |
+
for entity_id in sorted(entity_sequences.keys()):
|
| 776 |
+
entity_ids.append(str(entity_id))
|
| 777 |
+
entity_types.append("polymer")
|
| 778 |
+
entity_descriptions.append(f"Protein chain (entity {entity_id})")
|
| 779 |
+
|
| 780 |
+
cif_file.block["entity"] = CIFCategory(
|
| 781 |
+
name="entity",
|
| 782 |
+
columns={
|
| 783 |
+
"id": CIFColumn(data=CIFData(array=np.array(entity_ids), dtype=np.str_)),
|
| 784 |
+
"type": CIFColumn(data=CIFData(array=np.array(entity_types), dtype=np.str_)),
|
| 785 |
+
"pdbx_description": CIFColumn(
|
| 786 |
+
data=CIFData(array=np.array(entity_descriptions), dtype=np.str_)
|
| 787 |
+
),
|
| 788 |
+
},
|
| 789 |
+
)
|
| 790 |
+
|
| 791 |
+
# Create _entity_poly section
|
| 792 |
+
poly_entity_ids = []
|
| 793 |
+
poly_types = []
|
| 794 |
+
poly_nstd_linkages = []
|
| 795 |
+
poly_sequences = []
|
| 796 |
+
|
| 797 |
+
for entity_id in sorted(entity_sequences.keys()):
|
| 798 |
+
poly_entity_ids.append(str(entity_id))
|
| 799 |
+
poly_types.append("polypeptide(L)")
|
| 800 |
+
poly_nstd_linkages.append("no")
|
| 801 |
+
poly_sequences.append(entity_sequences[entity_id])
|
| 802 |
+
|
| 803 |
+
cif_file.block["entity_poly"] = CIFCategory(
|
| 804 |
+
name="entity_poly",
|
| 805 |
+
columns={
|
| 806 |
+
"entity_id": CIFColumn(
|
| 807 |
+
data=CIFData(array=np.array(poly_entity_ids), dtype=np.str_)
|
| 808 |
+
),
|
| 809 |
+
"type": CIFColumn(data=CIFData(array=np.array(poly_types), dtype=np.str_)),
|
| 810 |
+
"nstd_linkage": CIFColumn(
|
| 811 |
+
data=CIFData(array=np.array(poly_nstd_linkages), dtype=np.str_)
|
| 812 |
+
),
|
| 813 |
+
"pdbx_seq_one_letter_code": CIFColumn(
|
| 814 |
+
data=CIFData(array=np.array(poly_sequences), dtype=np.str_)
|
| 815 |
+
),
|
| 816 |
+
},
|
| 817 |
+
)
|
| 818 |
+
|
| 819 |
+
# Create _struct_asym section
|
| 820 |
+
asym_ids = []
|
| 821 |
+
asym_entity_ids = []
|
| 822 |
+
asym_details = []
|
| 823 |
+
|
| 824 |
+
for chain in self.chain_iter():
|
| 825 |
+
asym_ids.append(chain.chain_id)
|
| 826 |
+
asym_entity_ids.append(str(chain_to_entity[chain.chain_id]))
|
| 827 |
+
asym_details.append("")
|
| 828 |
+
|
| 829 |
+
cif_file.block["struct_asym"] = CIFCategory(
|
| 830 |
+
name="struct_asym",
|
| 831 |
+
columns={
|
| 832 |
+
"id": CIFColumn(data=CIFData(array=np.array(asym_ids), dtype=np.str_)),
|
| 833 |
+
"entity_id": CIFColumn(
|
| 834 |
+
data=CIFData(array=np.array(asym_entity_ids), dtype=np.str_)
|
| 835 |
+
),
|
| 836 |
+
"details": CIFColumn(data=CIFData(array=np.array(asym_details), dtype=np.str_)),
|
| 837 |
+
},
|
| 838 |
+
)
|
| 839 |
+
|
| 840 |
+
# Construction, PDB interchange, and compact storage
|
| 841 |
+
@classmethod
|
| 842 |
+
def from_pdb(
|
| 843 |
+
cls, path: PathOrBuffer, id: str | None = None, is_predicted: bool = False
|
| 844 |
+
) -> ProteinComplex:
|
| 845 |
+
atom_array = PDBFile.read(path).get_structure(model=1, extra_fields=["b_factor"])
|
| 846 |
+
|
| 847 |
+
chains = []
|
| 848 |
+
for chain in bs.chain_iter(atom_array):
|
| 849 |
+
chain = chain[~chain.hetero]
|
| 850 |
+
if len(chain) == 0:
|
| 851 |
+
continue
|
| 852 |
+
chains.append(ProteinChain.from_atomarray(chain, id, is_predicted))
|
| 853 |
+
return ProteinComplex.from_chains(chains)
|
| 854 |
+
|
| 855 |
+
def to_pdb(self, path: PathOrBuffer, include_insertions: bool = True):
|
| 856 |
+
atom_array = None
|
| 857 |
+
for chain in self.chain_iter():
|
| 858 |
+
carr = chain.atom_array if include_insertions else chain.atom_array_no_insertions
|
| 859 |
+
atom_array = carr if atom_array is None else atom_array + carr
|
| 860 |
+
f = PDBFile()
|
| 861 |
+
f.set_structure(atom_array)
|
| 862 |
+
f.write(path)
|
| 863 |
+
|
| 864 |
+
def to_pdb_string(self, include_insertions: bool = True) -> str:
|
| 865 |
+
buf = io.StringIO()
|
| 866 |
+
self.to_pdb(buf, include_insertions=include_insertions)
|
| 867 |
+
buf.seek(0)
|
| 868 |
+
return buf.read()
|
| 869 |
+
|
| 870 |
+
def normalize_chain_ids_for_pdb(self):
|
| 871 |
+
# Since PDB files have 1-letter chain IDs and don't support the idea of a symmetric index,
|
| 872 |
+
# we can normalize it instead which might be necessary for DockQ and to_pdb.
|
| 873 |
+
ids = SINGLE_LETTER_CHAIN_IDS
|
| 874 |
+
chains = []
|
| 875 |
+
for i, chain in enumerate(self.chain_iter()):
|
| 876 |
+
chain = replace(chain, chain_id=ids[i])
|
| 877 |
+
if i > len(ids):
|
| 878 |
+
raise RuntimeError("Too many chains to write to PDB file")
|
| 879 |
+
chains.append(chain)
|
| 880 |
+
|
| 881 |
+
return ProteinComplex.from_chains(chains)
|
| 882 |
+
|
| 883 |
+
def find_assembly_ids_with_chain(self, id: str) -> list[str]:
|
| 884 |
+
good_chains = []
|
| 885 |
+
if (comp := self.metadata.assembly_composition) is not None:
|
| 886 |
+
for assembly_id, chain_ids in comp.items():
|
| 887 |
+
if id in chain_ids:
|
| 888 |
+
good_chains.append(assembly_id)
|
| 889 |
+
else:
|
| 890 |
+
raise ValueError(
|
| 891 |
+
"Cannot switch assemblies on this ProteinComplex; construct it from "
|
| 892 |
+
"mmCIF to retain assembly metadata"
|
| 893 |
+
)
|
| 894 |
+
return good_chains
|
| 895 |
+
|
| 896 |
+
def switch_assembly(self, id: str):
|
| 897 |
+
if self.metadata.mmcif is None:
|
| 898 |
+
raise ValueError(
|
| 899 |
+
"Cannot switch assemblies without retained mmCIF source metadata."
|
| 900 |
+
)
|
| 901 |
+
return get_assembly_fast(self.metadata.mmcif, assembly_id=id)
|
| 902 |
+
|
| 903 |
+
def state_dict(self, backbone_only=False, json_serializable=False):
|
| 904 |
+
"""This state dict is optimized for storage, so it turns things to fp16 whenever
|
| 905 |
+
possible. Note that we also only support int32 residue indices, I'm hoping we don't
|
| 906 |
+
need more than 2**32 residues..."""
|
| 907 |
+
dct = {k: v for k, v in vars(self).items()}
|
| 908 |
+
if backbone_only:
|
| 909 |
+
# Frozen dataclasses do not make their NumPy members immutable. Work on a
|
| 910 |
+
# private mask so requesting a compact backbone payload cannot clear the
|
| 911 |
+
# caller's side-chain atoms in-place.
|
| 912 |
+
atom37_mask = dct["atom37_mask"].copy()
|
| 913 |
+
atom37_mask[:, 3:] = False
|
| 914 |
+
dct["atom37_mask"] = atom37_mask
|
| 915 |
+
dct["atom37_positions"] = dct["atom37_positions"][dct["atom37_mask"]]
|
| 916 |
+
if dct.get("atom37_confidence") is not None:
|
| 917 |
+
dct["atom37_confidence"] = dct["atom37_confidence"][dct["atom37_mask"]]
|
| 918 |
+
else:
|
| 919 |
+
dct.pop("atom37_confidence", None)
|
| 920 |
+
for k, v in dct.items():
|
| 921 |
+
if isinstance(v, np.ndarray):
|
| 922 |
+
match v.dtype:
|
| 923 |
+
case np.int64:
|
| 924 |
+
dct[k] = v.astype(np.int32)
|
| 925 |
+
case np.float64 | np.float32:
|
| 926 |
+
dct[k] = v.astype(np.float16)
|
| 927 |
+
case _:
|
| 928 |
+
pass
|
| 929 |
+
if json_serializable:
|
| 930 |
+
dct[k] = v.tolist()
|
| 931 |
+
elif isinstance(v, ProteinComplexMetadata):
|
| 932 |
+
dct[k] = asdict(v)
|
| 933 |
+
dct["metadata"]["mmcif"] = None
|
| 934 |
+
# These can be populated with non-serializable objects and are not needed for reconstruction
|
| 935 |
+
dct.pop("atoms", None)
|
| 936 |
+
dct.pop("atom_mask", None)
|
| 937 |
+
dct.pop("per_chain_kd_trees", None)
|
| 938 |
+
return dct
|
| 939 |
+
|
| 940 |
+
def to_blob(self, backbone_only=False) -> bytes:
|
| 941 |
+
payload = msgpack.dumps(self.state_dict(backbone_only), default=msgpack_numpy.encode)
|
| 942 |
+
return brotli.compress(payload, quality=5)
|
| 943 |
+
|
| 944 |
+
@classmethod
|
| 945 |
+
def from_state_dict(cls, dct):
|
| 946 |
+
# Note: assembly_composition is *supposed* to have string keys.
|
| 947 |
+
dct = _str_key_to_int_key(dct, ignore_keys=["assembly_composition"])
|
| 948 |
+
|
| 949 |
+
for k, v in dct.items():
|
| 950 |
+
if isinstance(v, list):
|
| 951 |
+
dct[k] = np.array(v)
|
| 952 |
+
|
| 953 |
+
atom37 = np.full((*dct["atom37_mask"].shape, 3), np.nan)
|
| 954 |
+
atom37[dct["atom37_mask"]] = dct["atom37_positions"]
|
| 955 |
+
dct["atom37_positions"] = atom37
|
| 956 |
+
if "atom37_confidence" in dct:
|
| 957 |
+
atom37_conf = np.full(dct["atom37_mask"].shape, np.nan, dtype=np.float32)
|
| 958 |
+
atom37_conf[dct["atom37_mask"]] = dct["atom37_confidence"]
|
| 959 |
+
dct["atom37_confidence"] = atom37_conf
|
| 960 |
+
dct = {
|
| 961 |
+
k: (
|
| 962 |
+
v.astype(np.float32)
|
| 963 |
+
if k in ["atom37_positions", "confidence", "atom37_confidence"]
|
| 964 |
+
else v
|
| 965 |
+
)
|
| 966 |
+
for k, v in dct.items()
|
| 967 |
+
}
|
| 968 |
+
if "chain_boundaries" in dct:
|
| 969 |
+
del dct["chain_boundaries"]
|
| 970 |
+
if "chain_boundaries" in dct["metadata"]:
|
| 971 |
+
del dct["metadata"]["chain_boundaries"]
|
| 972 |
+
dct["metadata"] = ProteinComplexMetadata(**dct["metadata"])
|
| 973 |
+
return cls(**dct)
|
| 974 |
+
|
| 975 |
+
@classmethod
|
| 976 |
+
def from_blob(cls, input: Path | str | io.BytesIO | bytes):
|
| 977 |
+
"""NOTE(@zlin): blob + sparse coding + brotli + fp16 reduces memory
|
| 978 |
+
of chains from 52G/1M chains to 20G/1M chains, I think this is a good first
|
| 979 |
+
shot at compressing and dumping chains to disk. I'm sure there's better ways."""
|
| 980 |
+
match input:
|
| 981 |
+
case Path() | str():
|
| 982 |
+
bytes = Path(input).read_bytes()
|
| 983 |
+
case io.BytesIO():
|
| 984 |
+
bytes = input.getvalue()
|
| 985 |
+
case _:
|
| 986 |
+
bytes = input
|
| 987 |
+
state = msgpack.loads(
|
| 988 |
+
brotli.decompress(bytes),
|
| 989 |
+
object_hook=msgpack_numpy.decode,
|
| 990 |
+
strict_map_key=False,
|
| 991 |
+
)
|
| 992 |
+
return cls.from_state_dict(state)
|
| 993 |
+
|
| 994 |
+
@classmethod
|
| 995 |
+
def from_rcsb(cls, pdb_id: str, keep_source: bool = False) -> ProteinComplex:
|
| 996 |
+
f: io.StringIO = rcsb.fetch(pdb_id, "cif") # type: ignore
|
| 997 |
+
return cls.from_mmcif(f, id=pdb_id, keep_source=keep_source, is_predicted=False)
|
| 998 |
+
|
| 999 |
+
@classmethod
|
| 1000 |
+
def from_mmcif(
|
| 1001 |
+
cls,
|
| 1002 |
+
path: PathOrBuffer,
|
| 1003 |
+
id: str | None = None,
|
| 1004 |
+
assembly_id: str | None = None,
|
| 1005 |
+
is_predicted: bool = False,
|
| 1006 |
+
keep_source: bool = False,
|
| 1007 |
+
):
|
| 1008 |
+
"""Return a ProteinComplex object from an mmcif file.
|
| 1009 |
+
TODO(@zeming): there's actually multiple complexes per file, but for ease of implementation,
|
| 1010 |
+
we only consider the first defined complex!
|
| 1011 |
+
|
| 1012 |
+
Args:
|
| 1013 |
+
path: Uncompressed mmCIF path or text buffer.
|
| 1014 |
+
id: Optional structure identifier.
|
| 1015 |
+
is_predicted (bool): If True, reads b factor as the confidence readout. Default: False.
|
| 1016 |
+
chain_id (str, optional): Select a chain corresponding to (author) chain id.
|
| 1017 |
+
"""
|
| 1018 |
+
mmcif = MmcifWrapper.read(path, id)
|
| 1019 |
+
return get_assembly_fast(mmcif, assembly_id=assembly_id)
|
| 1020 |
+
|
| 1021 |
+
@classmethod
|
| 1022 |
+
def from_chains(
|
| 1023 |
+
cls,
|
| 1024 |
+
chains: Sequence[ProteinChain],
|
| 1025 |
+
mmcif: MmcifWrapper | None = None,
|
| 1026 |
+
all_assembly_metadata_dictionary: dict[str, list[str]] | None = None,
|
| 1027 |
+
):
|
| 1028 |
+
if not chains:
|
| 1029 |
+
raise ValueError("Cannot create a ProteinComplex from an empty list of chains")
|
| 1030 |
+
|
| 1031 |
+
# TODO(roshan): Make a proper protein complex class
|
| 1032 |
+
def join_arrays(arrays: Sequence[np.ndarray], sep: np.ndarray):
|
| 1033 |
+
full_array = []
|
| 1034 |
+
for array in arrays:
|
| 1035 |
+
full_array.append(array)
|
| 1036 |
+
full_array.append(sep)
|
| 1037 |
+
full_array = full_array[:-1]
|
| 1038 |
+
return np.concatenate(full_array, 0)
|
| 1039 |
+
|
| 1040 |
+
sep_tokens = {
|
| 1041 |
+
"residue_index": np.array([-1]),
|
| 1042 |
+
"insertion_code": np.array([""]),
|
| 1043 |
+
"atom37_positions": np.full([1, 37, 3], np.nan),
|
| 1044 |
+
"atom37_mask": np.zeros([1, 37], dtype=bool),
|
| 1045 |
+
"confidence": np.array([0]),
|
| 1046 |
+
}
|
| 1047 |
+
|
| 1048 |
+
any_has_atom37_conf = any(c.atom37_confidence is not None for c in chains)
|
| 1049 |
+
if any_has_atom37_conf:
|
| 1050 |
+
sep_tokens["atom37_confidence"] = np.full([1, 37], np.nan, dtype=np.float32)
|
| 1051 |
+
|
| 1052 |
+
def _get_chain_attr(chain: ProteinChain, name: str) -> np.ndarray:
|
| 1053 |
+
val = getattr(chain, name)
|
| 1054 |
+
if val is None and name == "atom37_confidence":
|
| 1055 |
+
return np.full([len(chain), 37], np.nan, dtype=np.float32)
|
| 1056 |
+
return val
|
| 1057 |
+
|
| 1058 |
+
array_args: dict[str, np.ndarray] = {
|
| 1059 |
+
name: join_arrays([_get_chain_attr(chain, name) for chain in chains], sep)
|
| 1060 |
+
for name, sep in sep_tokens.items()
|
| 1061 |
+
}
|
| 1062 |
+
|
| 1063 |
+
multimer_arrays = []
|
| 1064 |
+
chain2num_max = -1
|
| 1065 |
+
chain2num = {}
|
| 1066 |
+
ent2num_max = -1
|
| 1067 |
+
ent2num = {}
|
| 1068 |
+
total_index = 0
|
| 1069 |
+
for i, c in enumerate(chains):
|
| 1070 |
+
num_res = c.residue_index.shape[0]
|
| 1071 |
+
if c.chain_id not in chain2num:
|
| 1072 |
+
chain2num[c.chain_id] = (chain2num_max := chain2num_max + 1)
|
| 1073 |
+
chain_id_array = np.full([num_res], chain2num[c.chain_id], dtype=np.int64)
|
| 1074 |
+
|
| 1075 |
+
if c.entity_id is None:
|
| 1076 |
+
entity_num = (ent2num_max := ent2num_max + 1)
|
| 1077 |
+
else:
|
| 1078 |
+
if c.entity_id not in ent2num:
|
| 1079 |
+
ent2num[c.entity_id] = (ent2num_max := ent2num_max + 1)
|
| 1080 |
+
entity_num = ent2num[c.entity_id]
|
| 1081 |
+
entity_id_array = np.full([num_res], entity_num, dtype=np.int64)
|
| 1082 |
+
|
| 1083 |
+
sym_id_array = np.full([num_res], i, dtype=np.int64)
|
| 1084 |
+
|
| 1085 |
+
multimer_arrays.append(
|
| 1086 |
+
{
|
| 1087 |
+
"chain_id": chain_id_array,
|
| 1088 |
+
"entity_id": entity_id_array,
|
| 1089 |
+
"sym_id": sym_id_array,
|
| 1090 |
+
}
|
| 1091 |
+
)
|
| 1092 |
+
|
| 1093 |
+
total_index += num_res + 1
|
| 1094 |
+
|
| 1095 |
+
sep = np.array([-1])
|
| 1096 |
+
update = {
|
| 1097 |
+
name: join_arrays([dct[name] for dct in multimer_arrays], sep=sep)
|
| 1098 |
+
for name in ["chain_id", "entity_id", "sym_id"]
|
| 1099 |
+
}
|
| 1100 |
+
array_args.update(update)
|
| 1101 |
+
|
| 1102 |
+
metadata = ProteinComplexMetadata(
|
| 1103 |
+
mmcif=mmcif,
|
| 1104 |
+
chain_lookup={v: k for k, v in chain2num.items()},
|
| 1105 |
+
entity_lookup={v: k for k, v in ent2num.items()},
|
| 1106 |
+
assembly_composition=all_assembly_metadata_dictionary,
|
| 1107 |
+
)
|
| 1108 |
+
|
| 1109 |
+
return cls(
|
| 1110 |
+
id=chains[0].id,
|
| 1111 |
+
sequence=residue_constants.CHAIN_BREAK_TOKEN.join(chain.sequence for chain in chains),
|
| 1112 |
+
metadata=metadata,
|
| 1113 |
+
**array_args,
|
| 1114 |
+
)
|
| 1115 |
+
|
| 1116 |
+
|
| 1117 |
+
# Biological-assembly expansion
|
| 1118 |
+
def get_assembly_fast(
|
| 1119 |
+
mmcif: MmcifWrapper,
|
| 1120 |
+
assembly_id=None,
|
| 1121 |
+
model=None,
|
| 1122 |
+
data_block=None,
|
| 1123 |
+
altloc="first",
|
| 1124 |
+
use_author_fields=True,
|
| 1125 |
+
):
|
| 1126 |
+
pdbx_file = mmcif.raw
|
| 1127 |
+
if pdbx_file is None:
|
| 1128 |
+
raise InvalidFileError("No mmCIF data loaded")
|
| 1129 |
+
assembly_gen_category = pdbx_file.block["pdbx_struct_assembly_gen"]
|
| 1130 |
+
if assembly_gen_category is None:
|
| 1131 |
+
raise InvalidFileError("File has no 'pdbx_struct_assembly_gen' category")
|
| 1132 |
+
|
| 1133 |
+
struct_oper_category = pdbx_file.block["pdbx_struct_oper_list"]
|
| 1134 |
+
if struct_oper_category is None:
|
| 1135 |
+
raise InvalidFileError("File has no 'pdbx_struct_oper_list' category")
|
| 1136 |
+
|
| 1137 |
+
if assembly_id is None:
|
| 1138 |
+
assembly_id = assembly_gen_category["assembly_id"].data.array[0]
|
| 1139 |
+
elif assembly_id not in assembly_gen_category["assembly_id"].data.array:
|
| 1140 |
+
raise KeyError(f"File has no Assembly ID '{assembly_id}'")
|
| 1141 |
+
|
| 1142 |
+
### Calculate all possible transformations
|
| 1143 |
+
transformations = _get_transformations(struct_oper_category)
|
| 1144 |
+
|
| 1145 |
+
### Get structure according to additional parameters
|
| 1146 |
+
structure = get_structure(
|
| 1147 |
+
pdbx_file, model, data_block, altloc, ["label_asym_id"], use_author_fields
|
| 1148 |
+
)[0] # type: ignore
|
| 1149 |
+
# TODO(@zeming) This line will remove all non-protein structural elements,
|
| 1150 |
+
# we should remove this when we want to parse these too.
|
| 1151 |
+
structure: bs.AtomArray = structure[
|
| 1152 |
+
bs.filter_amino_acids(structure) & ~structure.hetero # type: ignore
|
| 1153 |
+
]
|
| 1154 |
+
if len(structure) == 0:
|
| 1155 |
+
raise NoProteinError
|
| 1156 |
+
unique_asym_ids = np.unique(structure.label_asym_id) # type: ignore
|
| 1157 |
+
asym2chain = {}
|
| 1158 |
+
asym2auth = {}
|
| 1159 |
+
for asym_id in unique_asym_ids:
|
| 1160 |
+
sub_structure: bs.AtomArray = structure[structure.label_asym_id == asym_id] # type: ignore
|
| 1161 |
+
chain_id: str = sub_structure[0].chain_id # type: ignore
|
| 1162 |
+
(
|
| 1163 |
+
sequence,
|
| 1164 |
+
atom_positions,
|
| 1165 |
+
atom_mask,
|
| 1166 |
+
residue_index,
|
| 1167 |
+
insertion_code,
|
| 1168 |
+
confidence,
|
| 1169 |
+
entity_id,
|
| 1170 |
+
) = chain_to_ndarray(sub_structure, mmcif, chain_id, False)
|
| 1171 |
+
|
| 1172 |
+
asym2chain[asym_id] = ProteinChain(
|
| 1173 |
+
id=mmcif.id or "unknown",
|
| 1174 |
+
sequence=sequence,
|
| 1175 |
+
chain_id=chain_id,
|
| 1176 |
+
entity_id=entity_id,
|
| 1177 |
+
atom37_positions=atom_positions,
|
| 1178 |
+
atom37_mask=atom_mask,
|
| 1179 |
+
residue_index=residue_index,
|
| 1180 |
+
insertion_code=insertion_code,
|
| 1181 |
+
confidence=confidence,
|
| 1182 |
+
mmcif=None,
|
| 1183 |
+
)
|
| 1184 |
+
asym2auth[asym_id] = chain_id
|
| 1185 |
+
|
| 1186 |
+
### Get transformations and apply them to the affected asym IDs
|
| 1187 |
+
assembly = []
|
| 1188 |
+
assembly_id_dict: dict[str, list[str]] = {}
|
| 1189 |
+
|
| 1190 |
+
# Process the target assembly ID
|
| 1191 |
+
for aid, op_expr, asym_id_expr in zip(
|
| 1192 |
+
assembly_gen_category["assembly_id"].data.array,
|
| 1193 |
+
assembly_gen_category["oper_expression"].data.array,
|
| 1194 |
+
assembly_gen_category["asym_id_list"].data.array,
|
| 1195 |
+
strict=False,
|
| 1196 |
+
):
|
| 1197 |
+
if aid == assembly_id:
|
| 1198 |
+
# Parse operations and asym IDs for this specific entry
|
| 1199 |
+
operations = _parse_operation_expression(op_expr)
|
| 1200 |
+
asym_ids = asym_id_expr.split(",")
|
| 1201 |
+
|
| 1202 |
+
# Filter affected asym IDs to only protein chains, preserving order
|
| 1203 |
+
sub_structures = [asym2chain[asym_id] for asym_id in asym_ids if asym_id in asym2chain]
|
| 1204 |
+
|
| 1205 |
+
# Apply transformations
|
| 1206 |
+
sub_assembly = _apply_transformations_fast(sub_structures, transformations, operations)
|
| 1207 |
+
assembly.extend(sub_assembly)
|
| 1208 |
+
|
| 1209 |
+
# Build assembly_id_dict for this entry
|
| 1210 |
+
assembly_id_dict[aid] = assembly_id_dict.get(aid, []) + [
|
| 1211 |
+
asym2auth[id_] for id_ in asym_ids if id_ in asym2auth
|
| 1212 |
+
]
|
| 1213 |
+
|
| 1214 |
+
if len(assembly) == 0:
|
| 1215 |
+
raise NoProteinError
|
| 1216 |
+
return ProteinComplex.from_chains(assembly, mmcif, assembly_id_dict)
|
| 1217 |
+
|
| 1218 |
+
|
| 1219 |
+
def protein_chain_to_protein_complex(chain: ProteinChain) -> ProteinComplex:
|
| 1220 |
+
if "|" not in chain.sequence:
|
| 1221 |
+
return ProteinComplex.from_chains([chain])
|
| 1222 |
+
chain_breaks = np.array(list(chain.sequence)) == "|"
|
| 1223 |
+
chain_break_inds = np.where(chain_breaks)[0]
|
| 1224 |
+
chain_break_inds = np.concatenate([[0], chain_break_inds, [len(chain)]])
|
| 1225 |
+
chain_break_inds = np.array(list(itertools.pairwise(chain_break_inds)))
|
| 1226 |
+
complex_chains = []
|
| 1227 |
+
for start, end in chain_break_inds:
|
| 1228 |
+
if start != 0:
|
| 1229 |
+
start += 1
|
| 1230 |
+
complex_chains.append(chain[start:end])
|
| 1231 |
+
complex_chains = [
|
| 1232 |
+
ProteinChain.from_atom37(
|
| 1233 |
+
chain.atom37_positions,
|
| 1234 |
+
sequence=chain.sequence,
|
| 1235 |
+
chain_id=SINGLE_LETTER_CHAIN_IDS[i],
|
| 1236 |
+
entity_id=i,
|
| 1237 |
+
)
|
| 1238 |
+
for i, chain in enumerate(complex_chains)
|
| 1239 |
+
]
|
| 1240 |
+
return ProteinComplex.from_chains(complex_chains)
|
fastplms/models/esmfold2/esmfold2_protein_structure.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Atom selection, rigid alignment, RMSD, and GDT-TS primitives."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from collections.abc import Callable
|
| 6 |
+
from typing import TypeVar
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import torch
|
| 10 |
+
import torch.nn.functional as F
|
| 11 |
+
from torch import Tensor
|
| 12 |
+
from torch.amp import autocast # type: ignore
|
| 13 |
+
|
| 14 |
+
from .esmfold2_affine3d import Affine3D
|
| 15 |
+
from .esmfold2_misc import unbinpack
|
| 16 |
+
from .esmfold2_normalize_coordinates import index_by_atom_name
|
| 17 |
+
|
| 18 |
+
ArrayOrTensor = TypeVar("ArrayOrTensor", np.ndarray, Tensor)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _coordinate_operations(
|
| 22 |
+
coordinates: ArrayOrTensor,
|
| 23 |
+
) -> tuple[Callable[[ArrayOrTensor], ArrayOrTensor], Callable[..., ArrayOrTensor]]:
|
| 24 |
+
if isinstance(coordinates, np.ndarray):
|
| 25 |
+
|
| 26 |
+
def normalize(X: ArrayOrTensor) -> ArrayOrTensor:
|
| 27 |
+
return X / np.linalg.norm(X, axis=-1, keepdims=True)
|
| 28 |
+
|
| 29 |
+
return normalize, np.cross
|
| 30 |
+
return F.normalize, torch.cross # type: ignore[return-value]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def infer_cbeta_from_atom37(
|
| 34 |
+
atom37: ArrayOrTensor,
|
| 35 |
+
bond_length: float = 1.522,
|
| 36 |
+
bond_angle: float = 1.927,
|
| 37 |
+
dihedral: float = -2.143,
|
| 38 |
+
) -> ArrayOrTensor:
|
| 39 |
+
"""Infer C-beta coordinates from backbone tensor ``X``.
|
| 40 |
+
|
| 41 |
+
The scalar keyword arguments encode the bond length, bond angle, and
|
| 42 |
+
dihedral in radians used by the checkpoint's training geometry.
|
| 43 |
+
"""
|
| 44 |
+
|
| 45 |
+
n_position = index_by_atom_name(atom37, "N", dim=-2)
|
| 46 |
+
ca_position = index_by_atom_name(atom37, "CA", dim=-2)
|
| 47 |
+
c_position = index_by_atom_name(atom37, "C", dim=-2)
|
| 48 |
+
normalize, cross = _coordinate_operations(atom37)
|
| 49 |
+
with np.errstate(invalid="ignore"):
|
| 50 |
+
n_to_ca = n_position - ca_position
|
| 51 |
+
n_to_c = n_position - c_position
|
| 52 |
+
unit_n_to_ca = normalize(n_to_ca)
|
| 53 |
+
normal = normalize(cross(n_to_c, unit_n_to_ca))
|
| 54 |
+
basis = [unit_n_to_ca, cross(normal, unit_n_to_ca), normal]
|
| 55 |
+
coefficients = [
|
| 56 |
+
bond_length * np.cos(bond_angle),
|
| 57 |
+
bond_length * np.sin(bond_angle) * np.cos(dihedral),
|
| 58 |
+
-bond_length * np.sin(bond_angle) * np.sin(dihedral),
|
| 59 |
+
]
|
| 60 |
+
offset = sum(
|
| 61 |
+
vector * coefficient for vector, coefficient in zip(basis, coefficients, strict=True)
|
| 62 |
+
)
|
| 63 |
+
return ca_position + offset
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _unpack_alignment_inputs(
|
| 67 |
+
mobile: Tensor,
|
| 68 |
+
target: Tensor,
|
| 69 |
+
atom_mask: Tensor | None,
|
| 70 |
+
sequence_id: Tensor | None,
|
| 71 |
+
) -> tuple[Tensor, Tensor, Tensor | None]:
|
| 72 |
+
if sequence_id is None:
|
| 73 |
+
return mobile, target, atom_mask
|
| 74 |
+
unpacked_mobile = unbinpack(mobile, sequence_id, pad_value=torch.nan)
|
| 75 |
+
unpacked_target = unbinpack(target, sequence_id, pad_value=torch.nan)
|
| 76 |
+
if atom_mask is None:
|
| 77 |
+
unpacked_mask = torch.isfinite(unpacked_target).all(dim=-1)
|
| 78 |
+
else:
|
| 79 |
+
unpacked_mask = unbinpack(atom_mask, sequence_id, pad_value=0)
|
| 80 |
+
return unpacked_mobile, unpacked_target, unpacked_mask
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _flatten_atom_axes(
|
| 84 |
+
mobile: Tensor,
|
| 85 |
+
target: Tensor,
|
| 86 |
+
atom_mask: Tensor | None,
|
| 87 |
+
) -> tuple[Tensor, Tensor, Tensor | None]:
|
| 88 |
+
b = mobile.shape[0]
|
| 89 |
+
flat_mobile = mobile.view(b, -1, 3) if mobile.dim() == 4 else mobile
|
| 90 |
+
flat_target = target.view(b, -1, 3) if target.dim() == 4 else target
|
| 91 |
+
flat_mask = atom_mask
|
| 92 |
+
if flat_mask is not None and flat_mask.dim() == 3:
|
| 93 |
+
flat_mask = flat_mask.view(b, -1)
|
| 94 |
+
return flat_mobile, flat_target, flat_mask
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _masked_coordinates(
|
| 98 |
+
mobile: Tensor,
|
| 99 |
+
target: Tensor,
|
| 100 |
+
atom_mask: Tensor | None,
|
| 101 |
+
) -> tuple[Tensor, Tensor, Tensor]:
|
| 102 |
+
if atom_mask is None:
|
| 103 |
+
atom_mask = torch.ones(
|
| 104 |
+
mobile.shape[:2],
|
| 105 |
+
dtype=torch.bool,
|
| 106 |
+
device=mobile.device,
|
| 107 |
+
)
|
| 108 |
+
return mobile, target, atom_mask
|
| 109 |
+
expanded_mask = atom_mask.unsqueeze(-1)
|
| 110 |
+
return (
|
| 111 |
+
mobile.masked_fill(~expanded_mask, 0),
|
| 112 |
+
target.masked_fill(~expanded_mask, 0),
|
| 113 |
+
atom_mask,
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
@torch.no_grad()
|
| 118 |
+
@autocast("cuda", enabled=False)
|
| 119 |
+
def compute_alignment_tensors(
|
| 120 |
+
mobile: Tensor,
|
| 121 |
+
target: Tensor,
|
| 122 |
+
atom_exists_mask: Tensor | None = None,
|
| 123 |
+
sequence_id: Tensor | None = None,
|
| 124 |
+
) -> tuple[Tensor, Tensor, Tensor, Tensor, Tensor, Tensor]:
|
| 125 |
+
"""Center and align coordinate tensors ``X`` and ``Y``.
|
| 126 |
+
|
| 127 |
+
Inputs have shape (b, n, 3), or (b, l, n_atoms, 3). The returned rotation
|
| 128 |
+
tensor ``R`` has shape (b, 3, 3), and atom counts have shape (b, 1).
|
| 129 |
+
"""
|
| 130 |
+
|
| 131 |
+
mobile, target, atom_exists_mask = _unpack_alignment_inputs(
|
| 132 |
+
mobile,
|
| 133 |
+
target,
|
| 134 |
+
atom_exists_mask,
|
| 135 |
+
sequence_id,
|
| 136 |
+
)
|
| 137 |
+
if mobile.shape != target.shape:
|
| 138 |
+
raise AssertionError("Batch structure shapes do not match!")
|
| 139 |
+
mobile, target, atom_exists_mask = _flatten_atom_axes(
|
| 140 |
+
mobile,
|
| 141 |
+
target,
|
| 142 |
+
atom_exists_mask,
|
| 143 |
+
)
|
| 144 |
+
mobile, target, atom_exists_mask = _masked_coordinates(
|
| 145 |
+
mobile,
|
| 146 |
+
target,
|
| 147 |
+
atom_exists_mask,
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
num_valid_atoms = atom_exists_mask.sum(dim=-1, keepdim=True)
|
| 151 |
+
centroid_mobile = mobile.sum(dim=-2, keepdim=True) / num_valid_atoms.unsqueeze(-1)
|
| 152 |
+
centroid_target = target.sum(dim=-2, keepdim=True) / num_valid_atoms.unsqueeze(-1)
|
| 153 |
+
centroid_mobile[num_valid_atoms == 0] = 0
|
| 154 |
+
centroid_target[num_valid_atoms == 0] = 0
|
| 155 |
+
|
| 156 |
+
expanded_mask = atom_exists_mask.unsqueeze(-1)
|
| 157 |
+
centered_mobile = (mobile - centroid_mobile).masked_fill(~expanded_mask, 0)
|
| 158 |
+
centered_target = (target - centroid_target).masked_fill(~expanded_mask, 0)
|
| 159 |
+
covariance = torch.matmul(centered_mobile.transpose(1, 2), centered_target)
|
| 160 |
+
left_vectors, _, right_vectors = torch.svd(covariance)
|
| 161 |
+
rotation = torch.matmul(left_vectors, right_vectors.transpose(1, 2))
|
| 162 |
+
return (
|
| 163 |
+
centered_mobile,
|
| 164 |
+
centroid_mobile,
|
| 165 |
+
centered_target,
|
| 166 |
+
centroid_target,
|
| 167 |
+
rotation,
|
| 168 |
+
num_valid_atoms,
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _validate_reduction(reduction: str, allowed: tuple[str, ...]) -> None:
|
| 173 |
+
if reduction not in allowed:
|
| 174 |
+
raise ValueError("Unrecognized reduction: '{reduction}'")
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
@torch.no_grad()
|
| 178 |
+
@autocast("cuda", enabled=False)
|
| 179 |
+
def compute_rmsd_no_alignment(
|
| 180 |
+
aligned: Tensor,
|
| 181 |
+
target: Tensor,
|
| 182 |
+
num_valid_atoms: Tensor,
|
| 183 |
+
reduction: str = "batch",
|
| 184 |
+
) -> Tensor:
|
| 185 |
+
"""Measure RMSD after alignment using a declared reduction."""
|
| 186 |
+
|
| 187 |
+
_validate_reduction(reduction, ("per_residue", "per_sample", "batch"))
|
| 188 |
+
difference = aligned - target
|
| 189 |
+
if reduction == "per_residue":
|
| 190 |
+
mean_squared_error = difference.square().view(difference.size(0), -1, 9).mean(-1)
|
| 191 |
+
else:
|
| 192 |
+
mean_squared_error = difference.square().sum(dim=(1, 2)) / num_valid_atoms.squeeze(-1)
|
| 193 |
+
rmsd = torch.sqrt(mean_squared_error)
|
| 194 |
+
if reduction in {"per_residue", "per_sample"}:
|
| 195 |
+
return rmsd
|
| 196 |
+
valid_samples = num_valid_atoms.squeeze(-1) > 0
|
| 197 |
+
return rmsd.masked_fill(~valid_samples, 0).sum() / (valid_samples.sum() + 1e-8)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
@torch.no_grad()
|
| 201 |
+
@autocast("cuda", enabled=False)
|
| 202 |
+
def compute_affine_and_rmsd(
|
| 203 |
+
mobile: Tensor,
|
| 204 |
+
target: Tensor,
|
| 205 |
+
atom_exists_mask: Tensor | None = None,
|
| 206 |
+
sequence_id: Tensor | None = None,
|
| 207 |
+
) -> tuple[Affine3D, Tensor]:
|
| 208 |
+
"""Fit ``X`` onto ``Y`` and return the rigid transform and batch RMSD."""
|
| 209 |
+
|
| 210 |
+
(
|
| 211 |
+
centered_mobile,
|
| 212 |
+
centroid_mobile,
|
| 213 |
+
centered_target,
|
| 214 |
+
centroid_target,
|
| 215 |
+
rotation,
|
| 216 |
+
num_valid_atoms,
|
| 217 |
+
) = compute_alignment_tensors(mobile, target, atom_exists_mask, sequence_id)
|
| 218 |
+
translation = torch.matmul(-centroid_mobile, rotation) + centroid_target
|
| 219 |
+
affine = Affine3D.from_tensor_pair(
|
| 220 |
+
translation,
|
| 221 |
+
rotation.unsqueeze(dim=-3).transpose(-2, -1),
|
| 222 |
+
)
|
| 223 |
+
rotated_mobile = torch.matmul(centered_mobile, rotation)
|
| 224 |
+
rmsd = compute_rmsd_no_alignment(
|
| 225 |
+
rotated_mobile,
|
| 226 |
+
centered_target,
|
| 227 |
+
num_valid_atoms,
|
| 228 |
+
reduction="batch",
|
| 229 |
+
)
|
| 230 |
+
return affine, rmsd
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def compute_gdt_ts_no_alignment(
|
| 234 |
+
aligned: Tensor,
|
| 235 |
+
target: Tensor,
|
| 236 |
+
atom_exists_mask: Tensor,
|
| 237 |
+
reduction: str = "batch",
|
| 238 |
+
) -> Tensor:
|
| 239 |
+
"""Compute GDT-TS for already aligned coordinate tensors."""
|
| 240 |
+
|
| 241 |
+
_validate_reduction(reduction, ("per_sample", "batch"))
|
| 242 |
+
if atom_exists_mask is None:
|
| 243 |
+
atom_exists_mask = torch.isfinite(target).all(dim=-1)
|
| 244 |
+
deviation = torch.linalg.vector_norm(aligned - target, dim=-1)
|
| 245 |
+
counts = atom_exists_mask.sum(dim=-1)
|
| 246 |
+
score_1 = ((deviation < 1) * atom_exists_mask).sum(dim=-1) / counts
|
| 247 |
+
score_2 = ((deviation < 2) * atom_exists_mask).sum(dim=-1) / counts
|
| 248 |
+
score_4 = ((deviation < 4) * atom_exists_mask).sum(dim=-1) / counts
|
| 249 |
+
score_8 = ((deviation < 8) * atom_exists_mask).sum(dim=-1) / counts
|
| 250 |
+
score = (score_1 + score_2 + score_4 + score_8) * 0.25
|
| 251 |
+
return score.mean() if reduction == "batch" else score
|