Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +17 -0
- CITATION.cff +30 -0
- LICENSE +202 -0
- NOTICE +16 -0
- README.md +224 -1
- checkpoints/PepReDi_base.pt +3 -0
- checkpoints/SMILESReDi_base.ckpt +3 -0
- checkpoints/UAAReDi_base.pt +3 -0
- configs/paper.json +20 -0
- configs/targets.fasta +6 -0
- docs/ARTIFACTS.md +32 -0
- docs/artifacts.json +38 -0
- environment.yml +28 -0
- requirements.txt +18 -0
- scripts/run_experiment.py +177 -0
- scripts/verify_release.py +52 -0
- smiles/__init__.py +1 -0
- smiles/conjugation/AoaDau.py +732 -0
- smiles/conjugation/CptFactory.py +312 -0
- smiles/conjugation/DSEBSN38.py +377 -0
- smiles/conjugation/DSECCpt.py +522 -0
- smiles/conjugation/DSEPSN38.py +373 -0
- smiles/conjugation/DSPACpt.py +517 -0
- smiles/conjugation/DSPASN38.py +515 -0
- smiles/conjugation/DauFactory.py +298 -0
- smiles/conjugation/GlutarylDau.py +362 -0
- smiles/conjugation/MalHexCpt.py +560 -0
- smiles/conjugation/SN38Factory.py +308 -0
- smiles/conjugation/SSCpt.py +891 -0
- smiles/conjugation/ValAlaDau.py +348 -0
- smiles/conjugation/ValCitDau.py +358 -0
- smiles/conjugation/VcMMAE.py +457 -0
- smiles/conjugation/__init__.py +1 -0
- smiles/conjugation/pdc_builder.py +223 -0
- smiles/evaluation/__init__.py +1 -0
- smiles/evaluation/metrics.py +570 -0
- smiles/evaluation/pdc_analyzer.py +431 -0
- smiles/evaluation/peptide_analyzer.py +1259 -0
- smiles/evaluation/run_metrics.py +282 -0
- smiles/evaluation/run_metrics_uaa.py +590 -0
- smiles/generation/__init__.py +1 -0
- smiles/generation/cupofmoca_guided.py +996 -0
- smiles/generation/cupofmoca_guided_aa.py +1126 -0
- smiles/generation/cupofmoca_guided_uaa.py +1196 -0
- smiles/generation/cupofmoca_staged.py +584 -0
- smiles/generation/cupofmoca_staged_aa.py +724 -0
- smiles/generation/cupofmoca_staged_uaa.py +797 -0
- smiles/generation/guided.py +651 -0
- smiles/generation/guided_aa.py +872 -0
- smiles/generation/guided_uaa.py +981 -0
.gitignore
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
.pytest_cache/
|
| 4 |
+
.mypy_cache/
|
| 5 |
+
.ruff_cache/
|
| 6 |
+
.DS_Store
|
| 7 |
+
.idea/
|
| 8 |
+
.vscode/
|
| 9 |
+
.env
|
| 10 |
+
generation_logs/
|
| 11 |
+
results/
|
| 12 |
+
outputs/
|
| 13 |
+
*.log
|
| 14 |
+
|
| 15 |
+
# Hugging Face and PyTorch downloads should remain outside this repository.
|
| 16 |
+
.cache/
|
| 17 |
+
|
CITATION.cff
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
cff-version: 1.2.0
|
| 2 |
+
message: "If you use this software, please cite the CupOFMoCA paper."
|
| 3 |
+
title: "CupOFMoCA: Coupled Objective-Guided Discrete Flows for Molecular Conjugate Assembly"
|
| 4 |
+
type: software
|
| 5 |
+
authors:
|
| 6 |
+
- family-names: Zhang
|
| 7 |
+
given-names: Ruoxi Rosie
|
| 8 |
+
- family-names: Li
|
| 9 |
+
given-names: Ziang
|
| 10 |
+
- family-names: Gu
|
| 11 |
+
given-names: Jiatao
|
| 12 |
+
- family-names: Chatterjee
|
| 13 |
+
given-names: Pranam
|
| 14 |
+
license: Apache-2.0
|
| 15 |
+
repository-code: "https://huggingface.co/ChatterjeeLab/CupOFMoCA"
|
| 16 |
+
preferred-citation:
|
| 17 |
+
type: conference-paper
|
| 18 |
+
title: "CupOFMoCA: Coupled Objective-Guided Discrete Flows for Molecular Conjugate Assembly"
|
| 19 |
+
authors:
|
| 20 |
+
- family-names: Zhang
|
| 21 |
+
given-names: Ruoxi Rosie
|
| 22 |
+
- family-names: Li
|
| 23 |
+
given-names: Ziang
|
| 24 |
+
- family-names: Gu
|
| 25 |
+
given-names: Jiatao
|
| 26 |
+
- family-names: Chatterjee
|
| 27 |
+
given-names: Pranam
|
| 28 |
+
collection-title: "Advances in Neural Information Processing Systems"
|
| 29 |
+
year: 2026
|
| 30 |
+
|
LICENSE
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
Apache License
|
| 3 |
+
Version 2.0, January 2004
|
| 4 |
+
http://www.apache.org/licenses/
|
| 5 |
+
|
| 6 |
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
| 7 |
+
|
| 8 |
+
1. Definitions.
|
| 9 |
+
|
| 10 |
+
"License" shall mean the terms and conditions for use, reproduction,
|
| 11 |
+
and distribution as defined by Sections 1 through 9 of this document.
|
| 12 |
+
|
| 13 |
+
"Licensor" shall mean the copyright owner or entity authorized by
|
| 14 |
+
the copyright owner that is granting the License.
|
| 15 |
+
|
| 16 |
+
"Legal Entity" shall mean the union of the acting entity and all
|
| 17 |
+
other entities that control, are controlled by, or are under common
|
| 18 |
+
control with that entity. For the purposes of this definition,
|
| 19 |
+
"control" means (i) the power, direct or indirect, to cause the
|
| 20 |
+
direction or management of such entity, whether by contract or
|
| 21 |
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
| 22 |
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
| 23 |
+
|
| 24 |
+
"You" (or "Your") shall mean an individual or Legal Entity
|
| 25 |
+
exercising permissions granted by this License.
|
| 26 |
+
|
| 27 |
+
"Source" form shall mean the preferred form for making modifications,
|
| 28 |
+
including but not limited to software source code, documentation
|
| 29 |
+
source, and configuration files.
|
| 30 |
+
|
| 31 |
+
"Object" form shall mean any form resulting from mechanical
|
| 32 |
+
transformation or translation of a Source form, including but
|
| 33 |
+
not limited to compiled object code, generated documentation,
|
| 34 |
+
and conversions to other media types.
|
| 35 |
+
|
| 36 |
+
"Work" shall mean the work of authorship, whether in Source or
|
| 37 |
+
Object form, made available under the License, as indicated by a
|
| 38 |
+
copyright notice that is included in or attached to the work
|
| 39 |
+
(an example is provided in the Appendix below).
|
| 40 |
+
|
| 41 |
+
"Derivative Works" shall mean any work, whether in Source or Object
|
| 42 |
+
form, that is based on (or derived from) the Work and for which the
|
| 43 |
+
editorial revisions, annotations, elaborations, or other modifications
|
| 44 |
+
represent, as a whole, an original work of authorship. For the purposes
|
| 45 |
+
of this License, Derivative Works shall not include works that remain
|
| 46 |
+
separable from, or merely link (or bind by name) to the interfaces of,
|
| 47 |
+
the Work and Derivative Works thereof.
|
| 48 |
+
|
| 49 |
+
"Contribution" shall mean any work of authorship, including
|
| 50 |
+
the original version of the Work and any modifications or additions
|
| 51 |
+
to that Work or Derivative Works thereof, that is intentionally
|
| 52 |
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
| 53 |
+
or by an individual or Legal Entity authorized to submit on behalf of
|
| 54 |
+
the copyright owner. For the purposes of this definition, "submitted"
|
| 55 |
+
means any form of electronic, verbal, or written communication sent
|
| 56 |
+
to the Licensor or its representatives, including but not limited to
|
| 57 |
+
communication on electronic mailing lists, source code control systems,
|
| 58 |
+
and issue tracking systems that are managed by, or on behalf of, the
|
| 59 |
+
Licensor for the purpose of discussing and improving the Work, but
|
| 60 |
+
excluding communication that is conspicuously marked or otherwise
|
| 61 |
+
designated in writing by the copyright owner as "Not a Contribution."
|
| 62 |
+
|
| 63 |
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
| 64 |
+
on behalf of whom a Contribution has been received by Licensor and
|
| 65 |
+
subsequently incorporated within the Work.
|
| 66 |
+
|
| 67 |
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
| 68 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 69 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 70 |
+
copyright license to reproduce, prepare Derivative Works of,
|
| 71 |
+
publicly display, publicly perform, sublicense, and distribute the
|
| 72 |
+
Work and such Derivative Works in Source or Object form.
|
| 73 |
+
|
| 74 |
+
3. Grant of Patent License. Subject to the terms and conditions of
|
| 75 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 76 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 77 |
+
(except as stated in this section) patent license to make, have made,
|
| 78 |
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
| 79 |
+
where such license applies only to those patent claims licensable
|
| 80 |
+
by such Contributor that are necessarily infringed by their
|
| 81 |
+
Contribution(s) alone or by combination of their Contribution(s)
|
| 82 |
+
with the Work to which such Contribution(s) was submitted. If You
|
| 83 |
+
institute patent litigation against any entity (including a
|
| 84 |
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
| 85 |
+
or a Contribution incorporated within the Work constitutes direct
|
| 86 |
+
or contributory patent infringement, then any patent licenses
|
| 87 |
+
granted to You under this License for that Work shall terminate
|
| 88 |
+
as of the date such litigation is filed.
|
| 89 |
+
|
| 90 |
+
4. Redistribution. You may reproduce and distribute copies of the
|
| 91 |
+
Work or Derivative Works thereof in any medium, with or without
|
| 92 |
+
modifications, and in Source or Object form, provided that You
|
| 93 |
+
meet the following conditions:
|
| 94 |
+
|
| 95 |
+
(a) You must give any other recipients of the Work or
|
| 96 |
+
Derivative Works a copy of this License; and
|
| 97 |
+
|
| 98 |
+
(b) You must cause any modified files to carry prominent notices
|
| 99 |
+
stating that You changed the files; and
|
| 100 |
+
|
| 101 |
+
(c) You must retain, in the Source form of any Derivative Works
|
| 102 |
+
that You distribute, all copyright, patent, trademark, and
|
| 103 |
+
attribution notices from the Source form of the Work,
|
| 104 |
+
excluding those notices that do not pertain to any part of
|
| 105 |
+
the Derivative Works; and
|
| 106 |
+
|
| 107 |
+
(d) If the Work includes a "NOTICE" text file as part of its
|
| 108 |
+
distribution, then any Derivative Works that You distribute must
|
| 109 |
+
include a readable copy of the attribution notices contained
|
| 110 |
+
within such NOTICE file, excluding those notices that do not
|
| 111 |
+
pertain to any part of the Derivative Works, in at least one
|
| 112 |
+
of the following places: within a NOTICE text file distributed
|
| 113 |
+
as part of the Derivative Works; within the Source form or
|
| 114 |
+
documentation, if provided along with the Derivative Works; or,
|
| 115 |
+
within a display generated by the Derivative Works, if and
|
| 116 |
+
wherever such third-party notices normally appear. The contents
|
| 117 |
+
of the NOTICE file are for informational purposes only and
|
| 118 |
+
do not modify the License. You may add Your own attribution
|
| 119 |
+
notices within Derivative Works that You distribute, alongside
|
| 120 |
+
or as an addendum to the NOTICE text from the Work, provided
|
| 121 |
+
that such additional attribution notices cannot be construed
|
| 122 |
+
as modifying the License.
|
| 123 |
+
|
| 124 |
+
You may add Your own copyright statement to Your modifications and
|
| 125 |
+
may provide additional or different license terms and conditions
|
| 126 |
+
for use, reproduction, or distribution of Your modifications, or
|
| 127 |
+
for any such Derivative Works as a whole, provided Your use,
|
| 128 |
+
reproduction, and distribution of the Work otherwise complies with
|
| 129 |
+
the conditions stated in this License.
|
| 130 |
+
|
| 131 |
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
| 132 |
+
any Contribution intentionally submitted for inclusion in the Work
|
| 133 |
+
by You to the Licensor shall be under the terms and conditions of
|
| 134 |
+
this License, without any additional terms or conditions.
|
| 135 |
+
Notwithstanding the above, nothing herein shall supersede or modify
|
| 136 |
+
the terms of any separate license agreement you may have executed
|
| 137 |
+
with Licensor regarding such Contributions.
|
| 138 |
+
|
| 139 |
+
6. Trademarks. This License does not grant permission to use the trade
|
| 140 |
+
names, trademarks, service marks, or product names of the Licensor,
|
| 141 |
+
except as required for reasonable and customary use in describing the
|
| 142 |
+
origin of the Work and reproducing the content of the NOTICE file.
|
| 143 |
+
|
| 144 |
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
| 145 |
+
agreed to in writing, Licensor provides the Work (and each
|
| 146 |
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
| 147 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
| 148 |
+
implied, including, without limitation, any warranties or conditions
|
| 149 |
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
| 150 |
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
| 151 |
+
appropriateness of using or redistributing the Work and assume any
|
| 152 |
+
risks associated with Your exercise of permissions under this License.
|
| 153 |
+
|
| 154 |
+
8. Limitation of Liability. In no event and under no legal theory,
|
| 155 |
+
whether in tort (including negligence), contract, or otherwise,
|
| 156 |
+
unless required by applicable law (such as deliberate and grossly
|
| 157 |
+
negligent acts) or agreed to in writing, shall any Contributor be
|
| 158 |
+
liable to You for damages, including any direct, indirect, special,
|
| 159 |
+
incidental, or consequential damages of any character arising as a
|
| 160 |
+
result of this License or out of the use or inability to use the
|
| 161 |
+
Work (including but not limited to damages for loss of goodwill,
|
| 162 |
+
work stoppage, computer failure or malfunction, or any and all
|
| 163 |
+
other commercial damages or losses), even if such Contributor
|
| 164 |
+
has been advised of the possibility of such damages.
|
| 165 |
+
|
| 166 |
+
9. Accepting Warranty or Additional Liability. While redistributing
|
| 167 |
+
the Work or Derivative Works thereof, You may choose to offer,
|
| 168 |
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
| 169 |
+
or other liability obligations and/or rights consistent with this
|
| 170 |
+
License. However, in accepting such obligations, You may act only
|
| 171 |
+
on Your own behalf and on Your sole responsibility, not on behalf
|
| 172 |
+
of any other Contributor, and only if You agree to indemnify,
|
| 173 |
+
defend, and hold each Contributor harmless for any liability
|
| 174 |
+
incurred by, or claims asserted against, such Contributor by reason
|
| 175 |
+
of your accepting any such warranty or additional liability.
|
| 176 |
+
|
| 177 |
+
END OF TERMS AND CONDITIONS
|
| 178 |
+
|
| 179 |
+
APPENDIX: How to apply the Apache License to your work.
|
| 180 |
+
|
| 181 |
+
To apply the Apache License to your work, attach the following
|
| 182 |
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
| 183 |
+
replaced with your own identifying information. (Don't include
|
| 184 |
+
the brackets!) The text should be enclosed in the appropriate
|
| 185 |
+
comment syntax for the file format. We also recommend that a
|
| 186 |
+
file or class name and description of purpose be included on the
|
| 187 |
+
same "printed page" as the copyright notice for easier
|
| 188 |
+
identification within third-party archives.
|
| 189 |
+
|
| 190 |
+
Copyright [yyyy] [name of copyright owner]
|
| 191 |
+
|
| 192 |
+
Licensed under the Apache License, Version 2.0 (the "License");
|
| 193 |
+
you may not use this file except in compliance with the License.
|
| 194 |
+
You may obtain a copy of the License at
|
| 195 |
+
|
| 196 |
+
http://www.apache.org/licenses/LICENSE-2.0
|
| 197 |
+
|
| 198 |
+
Unless required by applicable law or agreed to in writing, software
|
| 199 |
+
distributed under the License is distributed on an "AS IS" BASIS,
|
| 200 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 201 |
+
See the License for the specific language governing permissions and
|
| 202 |
+
limitations under the License.
|
NOTICE
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
CupOFMoCA
|
| 2 |
+
Copyright 2026 CupOFMoCA authors
|
| 3 |
+
|
| 4 |
+
The code in this repository is licensed under Apache License 2.0. Model
|
| 5 |
+
weights and third-party model downloads can be subject to their source model
|
| 6 |
+
licenses; users are responsible for reviewing those terms.
|
| 7 |
+
|
| 8 |
+
third_party/SMILESToPeptide contains the exact peptide-to-SMILES conversion
|
| 9 |
+
helpers used in the submitted PDC experiments. The implementation is adapted
|
| 10 |
+
from p2smi by Aaron Feller, distributed under the MIT License. Its license is
|
| 11 |
+
included at third_party/SMILESToPeptide/LICENSE-p2smi.
|
| 12 |
+
|
| 13 |
+
Runtime model dependencies include:
|
| 14 |
+
- facebook/esm2_t33_650M_UR50D
|
| 15 |
+
- aaronfeller/PeptideCLM-23M-all
|
| 16 |
+
|
README.md
CHANGED
|
@@ -1,3 +1,226 @@
|
|
| 1 |
---
|
| 2 |
-
license:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
license: apache-2.0
|
| 3 |
+
library_name: pytorch
|
| 4 |
+
tags:
|
| 5 |
+
- chemistry
|
| 6 |
+
- drug-discovery
|
| 7 |
+
- molecular-generation
|
| 8 |
+
- peptide
|
| 9 |
+
- discrete-flow
|
| 10 |
+
- peptide-drug-conjugate
|
| 11 |
---
|
| 12 |
+
|
| 13 |
+
# CupOFMoCA
|
| 14 |
+
|
| 15 |
+
Code and PDC model artifacts for **CupOFMoCA: Coupled Objective-Guided Discrete Flows for
|
| 16 |
+
Molecular Conjugate Assembly** (NeurIPS 2026).
|
| 17 |
+
|
| 18 |
+
CupOFMoCA performs coupled, objective-guided discrete-flow generation while enforcing chemical
|
| 19 |
+
assembly constraints. This release supports the paper's peptide-drug conjugate (PDC) experiments
|
| 20 |
+
across SMILES, natural amino-acid (AA), and unnatural amino-acid (UAA) representations.
|
| 21 |
+
|
| 22 |
+
## Model overview
|
| 23 |
+
|
| 24 |
+
CupOFMoCA is a research pipeline for generating peptide-drug conjugate candidates. It couples a
|
| 25 |
+
pretrained discrete-flow generator with property guidance and conjugation-validity checks during
|
| 26 |
+
sampling. This repository contains three generator checkpoints and the property scorers used by the
|
| 27 |
+
released PDC pipeline:
|
| 28 |
+
|
| 29 |
+
| Checkpoint | Representation | Role |
|
| 30 |
+
|---|---|---|
|
| 31 |
+
| `SMILESReDi_base.ckpt` | peptide SMILES | pretrained SMILES discrete-flow generator |
|
| 32 |
+
| `PepReDi_base.pt` | canonical amino acids | pretrained AA discrete-flow generator |
|
| 33 |
+
| `UAAReDi_base.pt` | extended amino-acid vocabulary | pretrained UAA discrete-flow generator |
|
| 34 |
+
|
| 35 |
+
The checkpoints are loaded by the repository's custom PyTorch code; this is not a Transformers
|
| 36 |
+
pipeline and does not provide a hosted-inference widget.
|
| 37 |
+
|
| 38 |
+
### Intended uses
|
| 39 |
+
|
| 40 |
+
- Reproduce and inspect the paper's PDC generation experiments.
|
| 41 |
+
- Generate computational PDC candidates for research and method development.
|
| 42 |
+
- Extend or compare constrained, multi-objective molecular-generation methods.
|
| 43 |
+
|
| 44 |
+
The release is not intended for clinical decisions, direct therapeutic use, or as a substitute for
|
| 45 |
+
chemical synthesis, experimental validation, toxicology, or safety assessment.
|
| 46 |
+
|
| 47 |
+
### Training data
|
| 48 |
+
|
| 49 |
+
- **AA-PepReDi:** peptides from PepNN and BioLip2, together with 6--49-residue sequences from
|
| 50 |
+
PPIRef, using an 80:10:10 train/validation/test split.
|
| 51 |
+
- **UAA-PepReDi:** 150,000 synthetic sequences constructed with `p2smi` across combinations of
|
| 52 |
+
non-canonical and D-amino-acid composition, using an 80:10:10 split.
|
| 53 |
+
- **SMILESReDi:** the released pretrained SMILESReDi weights are used without additional
|
| 54 |
+
CupOFMoCA fine-tuning.
|
| 55 |
+
|
| 56 |
+
Architecture, optimization, data construction, and scorer details are reported in Appendix E of
|
| 57 |
+
the paper. Training datasets are not redistributed in this repository.
|
| 58 |
+
|
| 59 |
+
### Evaluation
|
| 60 |
+
|
| 61 |
+
The paper evaluates generated PDCs for assembly validity and predicted functional properties across
|
| 62 |
+
ERBB2, GNRHR, and NRP1, using SMILES, AA, and UAA representations. The reported results and
|
| 63 |
+
experimental protocol are in the paper; the submitted evaluation routines are included under
|
| 64 |
+
`smiles/evaluation/`.
|
| 65 |
+
|
| 66 |
+
### Limitations
|
| 67 |
+
|
| 68 |
+
- Outputs and objective values are computational predictions, not experimentally validated
|
| 69 |
+
compounds or guarantees of efficacy, synthesizability, toxicity, or safety.
|
| 70 |
+
- The released configurations cover three receptor targets and fixed target-specific conjugation
|
| 71 |
+
chemistries; performance outside this scope has not been established.
|
| 72 |
+
- The release contains the paper's PDC stack only. Its PROTAC experiments require separate code and
|
| 73 |
+
artifacts.
|
| 74 |
+
- Results can vary with hardware, CUDA/PyTorch versions, and revisions of models downloaded at
|
| 75 |
+
runtime.
|
| 76 |
+
|
| 77 |
+
## Repository layout
|
| 78 |
+
|
| 79 |
+
```text
|
| 80 |
+
checkpoints/ generator weights
|
| 81 |
+
configs/ paper settings and target sequences
|
| 82 |
+
docs/ model-artifact details and checksums
|
| 83 |
+
scripts/run_experiment.py unified experiment runner
|
| 84 |
+
scripts/verify_release.py release integrity checker
|
| 85 |
+
smiles/
|
| 86 |
+
generation/ generation entry points
|
| 87 |
+
evaluation/ metrics and analyzers
|
| 88 |
+
models/ ReDi model and data code
|
| 89 |
+
conjugation/ PDC assembly builders
|
| 90 |
+
scoring/ property scorers
|
| 91 |
+
smiles_tokenizer/ tokenizer code and vocabulary
|
| 92 |
+
third_party/ peptide-to-SMILES conversion helper
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
The release currently covers the PDC implementation. The PROTAC experiments described in the paper
|
| 96 |
+
use a separate code and artifact stack that is not included here.
|
| 97 |
+
|
| 98 |
+
## 1. Environment
|
| 99 |
+
|
| 100 |
+
A CUDA GPU is strongly recommended. The tested environment used Python 3.9, PyTorch 2.5.1, and
|
| 101 |
+
CUDA 12.1.
|
| 102 |
+
|
| 103 |
+
```bash
|
| 104 |
+
conda env create -f environment.yml
|
| 105 |
+
conda activate cupofmoca
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
Alternatively, install a PyTorch build appropriate for your CUDA driver and then install the pinned
|
| 109 |
+
packages:
|
| 110 |
+
|
| 111 |
+
```bash
|
| 112 |
+
pip install -r requirements.txt
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
On first use, Transformers downloads `facebook/esm2_t33_650M_UR50D` and
|
| 116 |
+
`aaronfeller/PeptideCLM-23M-all` from Hugging Face. Set `HF_HOME` to a persistent cache if needed.
|
| 117 |
+
|
| 118 |
+
Verify the bundled artifacts and source paths before running an experiment:
|
| 119 |
+
|
| 120 |
+
```bash
|
| 121 |
+
python scripts/verify_release.py
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
## 2. Run PDC generation
|
| 125 |
+
|
| 126 |
+
Run commands from the repository root. Preview a configured command without loading models:
|
| 127 |
+
|
| 128 |
+
```bash
|
| 129 |
+
python scripts/run_experiment.py \
|
| 130 |
+
--representation uaa \
|
| 131 |
+
--variant cupofmoca \
|
| 132 |
+
--method guided \
|
| 133 |
+
--target NRP1 \
|
| 134 |
+
--dry-run
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
Run the experiment:
|
| 138 |
+
|
| 139 |
+
```bash
|
| 140 |
+
python scripts/run_experiment.py \
|
| 141 |
+
--representation uaa \
|
| 142 |
+
--variant cupofmoca \
|
| 143 |
+
--method guided \
|
| 144 |
+
--target NRP1
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
Run a small smoke test:
|
| 148 |
+
|
| 149 |
+
```bash
|
| 150 |
+
python scripts/run_experiment.py \
|
| 151 |
+
--representation aa --variant baseline --method staged --target ERBB2 \
|
| 152 |
+
--num-candidates 1
|
| 153 |
+
```
|
| 154 |
+
|
| 155 |
+
Outputs are written under `outputs/` in separate run directories. Existing outputs are protected
|
| 156 |
+
unless `--force` is supplied. Arguments after `--` are passed to the underlying generator:
|
| 157 |
+
|
| 158 |
+
```bash
|
| 159 |
+
python scripts/run_experiment.py \
|
| 160 |
+
--representation smiles --variant cupofmoca --method guided --target GNRHR \
|
| 161 |
+
-- --top_k 100
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
### Configurations
|
| 165 |
+
|
| 166 |
+
| Target | Protein | Conjugation builder | Default SMILES length |
|
| 167 |
+
|---|---|---|---:|
|
| 168 |
+
| ERBB2 | receptor tyrosine-protein kinase erbB-2 | `SSCpt` | 80 |
|
| 169 |
+
| GNRHR | gonadotropin-releasing hormone receptor | `AoaDau` | 80 |
|
| 170 |
+
| NRP1 | neuropilin-1 | `DSPASN38` | 100 |
|
| 171 |
+
|
| 172 |
+
AA and UAA runs use length 10. Shared settings include 128 optimization steps, top-p 1.0,
|
| 173 |
+
objective weights `[1, 1]`, and acceptance sampling with beta 50 for CupOFMoCA. The machine-readable
|
| 174 |
+
configuration is in `configs/paper.json`; target sequences are in `configs/targets.fasta`.
|
| 175 |
+
|
| 176 |
+
### Direct entry points
|
| 177 |
+
|
| 178 |
+
The full argument surfaces remain available under `smiles/generation/`:
|
| 179 |
+
|
| 180 |
+
```text
|
| 181 |
+
guided.py cupofmoca_guided.py
|
| 182 |
+
guided_aa.py cupofmoca_guided_aa.py
|
| 183 |
+
guided_uaa.py cupofmoca_guided_uaa.py
|
| 184 |
+
staged.py cupofmoca_staged.py
|
| 185 |
+
staged_aa.py cupofmoca_staged_aa.py
|
| 186 |
+
staged_uaa.py cupofmoca_staged_uaa.py
|
| 187 |
+
```
|
| 188 |
+
|
| 189 |
+
Run one directly as a module, for example:
|
| 190 |
+
|
| 191 |
+
```bash
|
| 192 |
+
python -m smiles.generation.cupofmoca_guided_uaa --help
|
| 193 |
+
```
|
| 194 |
+
|
| 195 |
+
`smiles/evaluation/run_metrics.py` and `smiles/evaluation/run_metrics_uaa.py` contain the submitted evaluation routines.
|
| 196 |
+
Use `python <file> --help` to inspect their arguments.
|
| 197 |
+
|
| 198 |
+
## 3. Included checkpoints
|
| 199 |
+
|
| 200 |
+
The release includes the SMILES, AA, and UAA generator weights together with the binding,
|
| 201 |
+
hemolysis, non-fouling, and solubility scorers needed by the PDC pipeline. Artifact roles, sizes, and
|
| 202 |
+
SHA-256 values are listed in [docs/ARTIFACTS.md](docs/ARTIFACTS.md).
|
| 203 |
+
|
| 204 |
+
The AA-input affinity predictor is not included because the PDC generation configuration uses the
|
| 205 |
+
bundled SMILES-input scorer (`--affinity_type smiles`).
|
| 206 |
+
|
| 207 |
+
Large `*.pt` and `*.ckpt` files are configured for Git LFS. The repository is approximately 1.9 GB
|
| 208 |
+
before environment and model-cache downloads.
|
| 209 |
+
|
| 210 |
+
The checkpoint formats are loaded through PyTorch. As with any pickle-backed PyTorch artifact,
|
| 211 |
+
only load weights obtained from a source you trust.
|
| 212 |
+
|
| 213 |
+
## 4. Notes
|
| 214 |
+
|
| 215 |
+
- The paper reports NVIDIA RTX A6000 48 GB GPUs. Lower-memory devices may require smaller batches.
|
| 216 |
+
- Exact bitwise output can vary across CUDA, PyTorch, GPU, and downloaded model revisions.
|
| 217 |
+
- Generated structures are computational hypotheses that require experimental validation. Property
|
| 218 |
+
models are predictors, not clinical or safety guarantees.
|
| 219 |
+
- The peptide-to-SMILES helper used by the experiments is vendored under `third_party/` with its
|
| 220 |
+
upstream license notice.
|
| 221 |
+
|
| 222 |
+
## 5. Citation and license
|
| 223 |
+
|
| 224 |
+
Citation metadata is provided in `CITATION.cff`. The repository code is released under Apache-2.0;
|
| 225 |
+
see `LICENSE` and `NOTICE`. Third-party components and downloaded model artifacts retain their
|
| 226 |
+
respective terms.
|
checkpoints/PepReDi_base.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3b6437edee514bd7adb9aacea776f3dd97c59ebc7b4928b390eafdd87eaeb8c9
|
| 3 |
+
size 344474053
|
checkpoints/SMILESReDi_base.ckpt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:8b2a6049e7ce6c2ef2ab0da0987ee14a5c7e3df737c5c32202631f5c230a81df
|
| 3 |
+
size 1038182356
|
checkpoints/UAAReDi_base.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:fa9c25692f308e87befee15cecdd4a24d9e2270e039b12d073023390632b2a8d
|
| 3 |
+
size 344824232
|
configs/paper.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"default_seeds": [42, 123, 456],
|
| 3 |
+
"num_candidates_per_seed": 100,
|
| 4 |
+
"num_samples_per_batch": 1,
|
| 5 |
+
"optimization_steps": 128,
|
| 6 |
+
"top_p": 1.0,
|
| 7 |
+
"top_k": 200,
|
| 8 |
+
"cascade_k": 8,
|
| 9 |
+
"beta_min": 50.0,
|
| 10 |
+
"beta_max": 50.0,
|
| 11 |
+
"weights": [1.0, 1.0],
|
| 12 |
+
"require_valid_x0": true,
|
| 13 |
+
"max_x0_retries": 10,
|
| 14 |
+
"targets": {
|
| 15 |
+
"ERBB2": {"builder": "SSCpt", "smiles_length": 80},
|
| 16 |
+
"GNRHR": {"builder": "AoaDau", "smiles_length": 80},
|
| 17 |
+
"NRP1": {"builder": "DSPASN38", "smiles_length": 100}
|
| 18 |
+
},
|
| 19 |
+
"representation_lengths": {"aa": 10, "uaa": 10}
|
| 20 |
+
}
|
configs/targets.fasta
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
>ERBB2
|
| 2 |
+
MELAALCRWGLLLALLPPGAASTQVCTGTDMKLRLPASPETHLDMLRHLYQGCQVVQGNLELTYLPTNASLSFLQDIQEVQGYVLIAHNQVRQVPLQRLRIVRGTQLFEDNYALAVLDNGDPLNNTTPVTGASPGGLRELQLRSLTEILKGGVLIQRNPQLCYQDTILWKDIFHKNNQLALTLIDTNRSRACHPCSPMCKGSRCWGESSEDCQSLTRTVCAGGCARCKGPLPTDCCHEQCAAGCTGPKHSDCLACLHFNHSGICELHCPALVTYNTDTFESMPNPEGRYTFGASCVTACPYNYLSTDVGSCTLVCPLHNQEVTAEDGTQRCEKCSKPCARVCYGLGMEHLREVRAVTSANIQEFAGCKKIFGSLAFLPESFDGDPASNTAPLQPEQLQVFETLEEITGYLYISAWPDSLPDLSVFQNLQVIRGRILHNGAYSLTLQGLGISWLGLRSLRELGSGLALIHHNTHLCFVHTVPWDQLFRNPHQALLHTANRPEDECVGEGLACHQLCARGHCWGPGPTQCVNCSQFLRGQECVEECRVLQGLPREYVNARHCLPCHPECQPQNGSVTCFGPEADQCVACAHYKDPPFCVARCPSGVKPDLSYMPIWKFPDEEGACQPCPINCTHSCVDLDDKGCPAEQRASPLTSIISAVVGILLVVVLGVVFGILIKRRQQKIRKYTMRRLLQETELVEPLTPSGAMPNQAQMRILKETELRKVKVLGSGAFGTVYKGIWIPDGENVKIPVAIKVLRENTSPKANKEILDEAYVMAGVGSPYVSRLLGICLTSTVQLVTQLMPYGCLLDHVRENRGRLGSQDLLNWCMQIAKGMSYLEDVRLVHRDLAARNVLVKSPNHVKITDFGLARLLDIDETEYHADGGKVPIKWMALESILRRRFTHQSDVWSYGVTVWELMTFGAKPYDGIPAREIPDLLEKGERLPQPPICTIDVYMIMVKCWMIDSECRPRFRELVSEFSRMARDPQRFVVIQNEDLGPASPLDSTFYRSLLEDDDMGDLVDAEEYLVPQQGFFCPDPAPGAGGMVHHRHRSSSTRSGGGDLTLGLEPSEEEAPRSPLAPSEGAGSDVFDGDLGMGAAKGLQSLPTHDPSPLQRYSEDPTVPLPSETDGYVAPLTCSPQPEYVNQPDVRPQPPSPREGPLPAARPAGATLERPKTLSPGKNGVVKDVFAFGGAVENPEYLTPQGGAAPQPHPPPAFSPAFDNLYYWDQDPPERGAPPSTFKGTPTAENPEYLGLDVPV
|
| 3 |
+
>GNRHR
|
| 4 |
+
MANSASPEQNQNHCSAINNSIPLMQGNLPTLTLSGKIRVTVTFFLFLLSATFNASFLLKLQKWTQKKEKGKKLSRMKLLLKHLTLANLLETLIVMPLDGMWNITVQWYAGELLCKVLSYLKLFSMYAPAFMMVVISLDRSLAITRPLALKSNSKVGQSMVGLAWILSSVFAGPQLYIFRMIHLADSSGQTKVFSQCVTHCSFSQWWHQAFYNFFTFSCLFIIPLFIMLICNAKIIFTLTRVLHQDPHELQLNQSKNNIPRARLKTLKMTVAFATSFTVCWTPYYVLGIWYWFDPEMLNRLSDPVNHFFFLFAFLNPCFDPLIYGYFSL
|
| 5 |
+
>NRP1
|
| 6 |
+
MERGLPLLCAVLALVLAPAGAFRNDKCGDTIKIESPGYLTSPGYPHSYHPSEKCEWLIQAPDPYQRIMINFNPHFDLEDRDCKYDYVEVFDGENENGHFRGKFCGKIAPPPVVSSGPFLFIKFVSDYETHGAGFSIRYEIFKRGPECSQNYTTPSGVIKSPGFPEKYPNSLECTYIVFVPKMSEIILEFESFDLEPDSNPPGGMFCRYDRLEIWDGFPDVGPHIGRYCGQKTPGRIRSSSGILSMVFYTDSAIAKEGFSANYSVLQSSVSEDFKCMEALGMESGEIHSDQITASSQYSTNWSAERSRLNYPENGWTPGEDSYREWIQVDLGLLRFVTAVGTQGAISKETKKKYYVKTYKIDVSSNGEDWITIKEGNKPVLFQGNTNPTDVVVAVFPKPLITRFVRIKPATWETGISMRFEVYGCKITDYPCSGMLGMVSGLISDSQITSSNQGDRNWMPENIRLVTSRSGWALPPAPHSYINEWLQIDLGEEKIVRGIIIQGGKHRENKVFMRKFKIGYSNNGSDWKMIMDDSKRKAKSFEGNNNYDTPELRTFPALSTRFIRIYPERATHGGLGLRMELLGCEVEAPTAGPTTPNGNLVDECDDDQANCHSGTGDDFQLTGGTTVLATEKPTVIDSTIQSEFPTYGFNCEFGWGSHKTFCHWEHDNHVQLKWSVLTSKTGPIQDHTGDGNFIYSQADENQKGKVARLVSPVVYSQNSAHCMTFWYHMSGSHVGTLRVKLRYQKPEEYDQLVWMAIGHQGDHWKEGRVLLHKSLKLYQVIFEGEIGKGNLGGIAVDDISINNHISQEDCAKPADLDKKNPEIKIDETGSTPGYEGEGEGDKNISRKPGNVLKTLDPILITIIAMSALGVLLGAVCGVVLYCACWHNGMSERNLSALENYNFELVDGVKLKKDKLNTQSTYSEA
|
docs/ARTIFACTS.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Model artifacts
|
| 2 |
+
|
| 3 |
+
The release includes every local checkpoint needed by the submitted PDC generation code. Run
|
| 4 |
+
`python scripts/verify_release.py` to verify byte counts, SHA-256 hashes, and private-path hygiene.
|
| 5 |
+
The machine-readable manifest is in `docs/artifacts.json`.
|
| 6 |
+
|
| 7 |
+
| Artifact | Purpose | Size | SHA-256 |
|
| 8 |
+
|---|---|---:|---|
|
| 9 |
+
| `checkpoints/SMILESReDi_base.ckpt` | SMILES generator | 1,038,182,356 B | `8b2a6049e7ce6c2ef2ab0da0987ee14a5c7e3df737c5c32202631f5c230a81df` |
|
| 10 |
+
| `checkpoints/PepReDi_base.pt` | natural-AA generator | 344,474,053 B | `3b6437edee514bd7adb9aacea776f3dd97c59ebc7b4928b390eafdd87eaeb8c9` |
|
| 11 |
+
| `checkpoints/UAAReDi_base.pt` | UAA generator | 344,824,232 B | `fa9c25692f308e87befee15cecdd4a24d9e2270e039b12d073023390632b2a8d` |
|
| 12 |
+
| `smiles/scoring/checkpoints/binding-affinity.pt` | SMILES binding scorer | 132,489,414 B | `8daf2ce5ec84045e94bbe41b0a57669287ac937a2a51e97da1af36ae8dc5e16f` |
|
| 13 |
+
| `smiles/scoring/checkpoints/hemolysis-xgboost.json` | hemolysis scorer | 1,932,561 B | `69552199fa7722369211aa0e33adc9850816fb816f555ce9e207c73e0543b0ee` |
|
| 14 |
+
| `smiles/scoring/checkpoints/nonfouling-xgboost.json` | non-fouling scorer | 645,478 B | `08364879c255e236cf0a337505e99e36824f1a72090f5829d3f7870d4ca11644` |
|
| 15 |
+
| `smiles/scoring/checkpoints/solubility-xgboost.json` | solubility scorer | 196,887 B | `3c6a534db85550c947d864aa5cf0b1a2862333bc50eb4b6d5053074cc7d66f02` |
|
| 16 |
+
|
| 17 |
+
ESM-2 (`facebook/esm2_t33_650M_UR50D`) and PeptideCLM
|
| 18 |
+
(`aaronfeller/PeptideCLM-23M-all`) are downloaded from Hugging Face on first use and therefore are
|
| 19 |
+
not duplicated here.
|
| 20 |
+
|
| 21 |
+
The optional AA-input binding predictor was not used for the reported PDC runs, which used
|
| 22 |
+
`--affinity_type smiles`, and is intentionally excluded (it would add about 2.7 GB). The public
|
| 23 |
+
wrapper passes the reported SMILES-input setting explicitly.
|
| 24 |
+
|
| 25 |
+
When publishing with `hf upload`, the Hugging Face Hub client handles large-file storage. If this
|
| 26 |
+
release is mirrored to a Git LFS remote:
|
| 27 |
+
|
| 28 |
+
```bash
|
| 29 |
+
git lfs install
|
| 30 |
+
git lfs track "*.pt" "*.ckpt"
|
| 31 |
+
git add .gitattributes checkpoints smiles/scoring/checkpoints
|
| 32 |
+
```
|
docs/artifacts.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"path": "checkpoints/SMILESReDi_base.ckpt",
|
| 4 |
+
"bytes": 1038182356,
|
| 5 |
+
"sha256": "8b2a6049e7ce6c2ef2ab0da0987ee14a5c7e3df737c5c32202631f5c230a81df"
|
| 6 |
+
},
|
| 7 |
+
{
|
| 8 |
+
"path": "checkpoints/PepReDi_base.pt",
|
| 9 |
+
"bytes": 344474053,
|
| 10 |
+
"sha256": "3b6437edee514bd7adb9aacea776f3dd97c59ebc7b4928b390eafdd87eaeb8c9"
|
| 11 |
+
},
|
| 12 |
+
{
|
| 13 |
+
"path": "checkpoints/UAAReDi_base.pt",
|
| 14 |
+
"bytes": 344824232,
|
| 15 |
+
"sha256": "fa9c25692f308e87befee15cecdd4a24d9e2270e039b12d073023390632b2a8d"
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
"path": "smiles/scoring/checkpoints/binding-affinity.pt",
|
| 19 |
+
"bytes": 132489414,
|
| 20 |
+
"sha256": "8daf2ce5ec84045e94bbe41b0a57669287ac937a2a51e97da1af36ae8dc5e16f"
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"path": "smiles/scoring/checkpoints/hemolysis-xgboost.json",
|
| 24 |
+
"bytes": 1932561,
|
| 25 |
+
"sha256": "69552199fa7722369211aa0e33adc9850816fb816f555ce9e207c73e0543b0ee"
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
"path": "smiles/scoring/checkpoints/nonfouling-xgboost.json",
|
| 29 |
+
"bytes": 645478,
|
| 30 |
+
"sha256": "08364879c255e236cf0a337505e99e36824f1a72090f5829d3f7870d4ca11644"
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"path": "smiles/scoring/checkpoints/solubility-xgboost.json",
|
| 34 |
+
"bytes": 196887,
|
| 35 |
+
"sha256": "3c6a534db85550c947d864aa5cf0b1a2862333bc50eb4b6d5053074cc7d66f02"
|
| 36 |
+
}
|
| 37 |
+
]
|
| 38 |
+
|
environment.yml
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: cupofmoca
|
| 2 |
+
channels:
|
| 3 |
+
- pytorch
|
| 4 |
+
- nvidia
|
| 5 |
+
- conda-forge
|
| 6 |
+
dependencies:
|
| 7 |
+
- python=3.9
|
| 8 |
+
- pip
|
| 9 |
+
- pytorch=2.5.1
|
| 10 |
+
- pytorch-cuda=12.1
|
| 11 |
+
- numpy=1.26.4
|
| 12 |
+
- scipy=1.13.1
|
| 13 |
+
- pandas=2.3.2
|
| 14 |
+
- scikit-learn=1.6.1
|
| 15 |
+
- xgboost=2.1.4
|
| 16 |
+
- matplotlib=3.9.4
|
| 17 |
+
- seaborn=0.13.2
|
| 18 |
+
- pillow=11.3.0
|
| 19 |
+
- rdkit=2025.03.6
|
| 20 |
+
- pip:
|
| 21 |
+
- lightning==2.5.5
|
| 22 |
+
- transformers==4.41.2
|
| 23 |
+
- datasets==4.0.0
|
| 24 |
+
- fair-esm==2.0.0
|
| 25 |
+
- SmilesPE==0.0.3
|
| 26 |
+
- easydict==1.13
|
| 27 |
+
- tqdm==4.67.1
|
| 28 |
+
|
requirements.txt
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch==2.5.1
|
| 2 |
+
lightning==2.5.5
|
| 3 |
+
transformers==4.41.2
|
| 4 |
+
datasets==4.0.0
|
| 5 |
+
fair-esm==2.0.0
|
| 6 |
+
rdkit==2025.3.6
|
| 7 |
+
SmilesPE==0.0.3
|
| 8 |
+
numpy==1.26.4
|
| 9 |
+
scipy==1.13.1
|
| 10 |
+
pandas==2.3.2
|
| 11 |
+
scikit-learn==1.6.1
|
| 12 |
+
xgboost==2.1.4
|
| 13 |
+
matplotlib==3.9.4
|
| 14 |
+
seaborn==0.13.2
|
| 15 |
+
pillow==11.3.0
|
| 16 |
+
easydict==1.13
|
| 17 |
+
tqdm==4.67.1
|
| 18 |
+
|
scripts/run_experiment.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Run reproducible CupOFMoCA PDC experiments.
|
| 3 |
+
|
| 4 |
+
This wrapper keeps the submitted generation programs intact while providing one
|
| 5 |
+
portable interface, bundled checkpoint defaults, named targets, and collision-
|
| 6 |
+
safe outputs.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import argparse
|
| 12 |
+
import json
|
| 13 |
+
import shlex
|
| 14 |
+
import subprocess
|
| 15 |
+
import sys
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 20 |
+
CONFIG = json.loads((ROOT / "configs" / "paper.json").read_text())
|
| 21 |
+
|
| 22 |
+
CHECKPOINTS = {
|
| 23 |
+
"smiles": ROOT / "checkpoints" / "SMILESReDi_base.ckpt",
|
| 24 |
+
"aa": ROOT / "checkpoints" / "PepReDi_base.pt",
|
| 25 |
+
"uaa": ROOT / "checkpoints" / "UAAReDi_base.pt",
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def read_fasta(path: Path) -> dict[str, str]:
|
| 30 |
+
records: dict[str, str] = {}
|
| 31 |
+
name = None
|
| 32 |
+
chunks: list[str] = []
|
| 33 |
+
for raw in path.read_text().splitlines():
|
| 34 |
+
line = raw.strip()
|
| 35 |
+
if not line:
|
| 36 |
+
continue
|
| 37 |
+
if line.startswith(">"):
|
| 38 |
+
if name is not None:
|
| 39 |
+
records[name] = "".join(chunks)
|
| 40 |
+
name, chunks = line[1:].split()[0], []
|
| 41 |
+
elif name is None:
|
| 42 |
+
raise ValueError(f"Sequence before FASTA header in {path}")
|
| 43 |
+
else:
|
| 44 |
+
chunks.append(line)
|
| 45 |
+
if name is not None:
|
| 46 |
+
records[name] = "".join(chunks)
|
| 47 |
+
return records
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def parse_args() -> argparse.Namespace:
|
| 51 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 52 |
+
parser.add_argument("--representation", choices=("smiles", "aa", "uaa"), required=True)
|
| 53 |
+
parser.add_argument("--variant", choices=("baseline", "cupofmoca"), default="cupofmoca")
|
| 54 |
+
parser.add_argument("--method", choices=("guided", "staged"), default="guided")
|
| 55 |
+
parser.add_argument("--target", choices=tuple(CONFIG["targets"]), required=True)
|
| 56 |
+
parser.add_argument(
|
| 57 |
+
"--seeds", type=int, nargs="+", default=CONFIG["default_seeds"],
|
| 58 |
+
help="Random seeds for generation.",
|
| 59 |
+
)
|
| 60 |
+
parser.add_argument("--num-candidates", type=int, default=CONFIG["num_candidates_per_seed"])
|
| 61 |
+
parser.add_argument("--gen-len", type=int, default=None)
|
| 62 |
+
parser.add_argument("--checkpoint", type=Path, default=None)
|
| 63 |
+
parser.add_argument("--output-root", type=Path, default=ROOT / "outputs")
|
| 64 |
+
parser.add_argument("--python", default=sys.executable)
|
| 65 |
+
parser.add_argument("--dry-run", action="store_true")
|
| 66 |
+
parser.add_argument("--force", action="store_true", help="Allow an existing output to be replaced.")
|
| 67 |
+
parser.add_argument(
|
| 68 |
+
"extra_args", nargs=argparse.REMAINDER,
|
| 69 |
+
help="Arguments after '--' are appended to the underlying generator command.",
|
| 70 |
+
)
|
| 71 |
+
args = parser.parse_args()
|
| 72 |
+
if args.num_candidates < 1:
|
| 73 |
+
parser.error("--num-candidates must be positive")
|
| 74 |
+
return args
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def build_command(args: argparse.Namespace, seed: int, target_seq: str, output: Path) -> list[str]:
|
| 78 |
+
suffix = "" if args.representation == "smiles" else f"_{args.representation}"
|
| 79 |
+
prefix = "cupofmoca_" if args.variant == "cupofmoca" else ""
|
| 80 |
+
program = f"smiles.generation.{prefix}{args.method}{suffix}"
|
| 81 |
+
checkpoint = (args.checkpoint or CHECKPOINTS[args.representation]).resolve()
|
| 82 |
+
target_cfg = CONFIG["targets"][args.target]
|
| 83 |
+
gen_len = args.gen_len or (
|
| 84 |
+
target_cfg["smiles_length"]
|
| 85 |
+
if args.representation == "smiles"
|
| 86 |
+
else CONFIG["representation_lengths"][args.representation]
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
cmd = [
|
| 90 |
+
args.python, "-u", "-m", program,
|
| 91 |
+
"--checkpoint", str(checkpoint),
|
| 92 |
+
"--target", target_seq,
|
| 93 |
+
"--num_samples", str(CONFIG["num_samples_per_batch"]),
|
| 94 |
+
"--num_batches", str(args.num_candidates),
|
| 95 |
+
"--gen_len", str(gen_len),
|
| 96 |
+
"--optimization_steps", str(CONFIG["optimization_steps"]),
|
| 97 |
+
"--weights", *map(str, CONFIG["weights"]),
|
| 98 |
+
"--top_p", str(CONFIG["top_p"]),
|
| 99 |
+
"--seed", str(seed),
|
| 100 |
+
"--output_file", str(output),
|
| 101 |
+
]
|
| 102 |
+
|
| 103 |
+
if args.representation in {"aa", "uaa"}:
|
| 104 |
+
cmd += ["--tokenizer_type", "amino"]
|
| 105 |
+
if args.representation == "aa":
|
| 106 |
+
# This is the scorer used by the PDC paper runs. The optional AA-input
|
| 107 |
+
# scorer is not needed and its multi-GB checkpoint is intentionally omitted.
|
| 108 |
+
cmd += ["--affinity_type", "smiles"]
|
| 109 |
+
|
| 110 |
+
if args.method == "guided":
|
| 111 |
+
cmd += [
|
| 112 |
+
"--builder_type", target_cfg["builder"],
|
| 113 |
+
"--require_valid_x0",
|
| 114 |
+
"--max_x0_retries", str(CONFIG["max_x0_retries"]),
|
| 115 |
+
"--output_dir", str(output.parent / "logs"),
|
| 116 |
+
]
|
| 117 |
+
|
| 118 |
+
if args.variant == "cupofmoca":
|
| 119 |
+
cmd += [
|
| 120 |
+
"--use_acceptance",
|
| 121 |
+
"--beta_min", str(CONFIG["beta_min"]),
|
| 122 |
+
"--beta_max", str(CONFIG["beta_max"]),
|
| 123 |
+
]
|
| 124 |
+
if args.method == "guided":
|
| 125 |
+
cmd += ["--guidance_method", "exp_tilt"]
|
| 126 |
+
if args.representation in {"aa", "uaa"}:
|
| 127 |
+
cmd += ["--cascade_k", str(CONFIG["cascade_k"])]
|
| 128 |
+
else:
|
| 129 |
+
cmd += ["--output_dir", str(output.parent / "logs")]
|
| 130 |
+
|
| 131 |
+
extra = list(args.extra_args)
|
| 132 |
+
if extra and extra[0] == "--":
|
| 133 |
+
extra = extra[1:]
|
| 134 |
+
return cmd + extra
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def main() -> int:
|
| 138 |
+
args = parse_args()
|
| 139 |
+
targets = read_fasta(ROOT / "configs" / "targets.fasta")
|
| 140 |
+
target_seq = targets[args.target]
|
| 141 |
+
failures = 0
|
| 142 |
+
|
| 143 |
+
for seed in args.seeds:
|
| 144 |
+
run_dir = (
|
| 145 |
+
args.output_root.resolve()
|
| 146 |
+
/ args.target
|
| 147 |
+
/ args.representation
|
| 148 |
+
/ f"{args.variant}_{args.method}"
|
| 149 |
+
/ f"seed_{seed}"
|
| 150 |
+
)
|
| 151 |
+
output = run_dir / "molecules.txt"
|
| 152 |
+
if output.exists() and not args.force and not args.dry_run:
|
| 153 |
+
print(f"Refusing to overwrite {output}; pass --force to replace it.", file=sys.stderr)
|
| 154 |
+
failures += 1
|
| 155 |
+
continue
|
| 156 |
+
if output.exists() and args.force:
|
| 157 |
+
output.unlink()
|
| 158 |
+
command = build_command(args, seed, target_seq, output)
|
| 159 |
+
display = " ".join(
|
| 160 |
+
f"<{args.target} sequence, {len(target_seq)} aa>" if item == target_seq else shlex.quote(item)
|
| 161 |
+
for item in command
|
| 162 |
+
)
|
| 163 |
+
print(display)
|
| 164 |
+
if args.dry_run:
|
| 165 |
+
continue
|
| 166 |
+
run_dir.mkdir(parents=True, exist_ok=True)
|
| 167 |
+
completed = subprocess.run(command, cwd=ROOT, check=False)
|
| 168 |
+
if completed.returncode:
|
| 169 |
+
failures += 1
|
| 170 |
+
print(f"Seed {seed} failed with exit code {completed.returncode}.", file=sys.stderr)
|
| 171 |
+
|
| 172 |
+
return 1 if failures else 0
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
if __name__ == "__main__":
|
| 176 |
+
raise SystemExit(main())
|
| 177 |
+
|
scripts/verify_release.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Verify bundled PDC artifacts and portability-sensitive source files."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import hashlib
|
| 7 |
+
import json
|
| 8 |
+
import sys
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 13 |
+
MANIFEST = ROOT / "docs" / "artifacts.json"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def sha256(path: Path) -> str:
|
| 17 |
+
digest = hashlib.sha256()
|
| 18 |
+
with path.open("rb") as handle:
|
| 19 |
+
for block in iter(lambda: handle.read(8 * 1024 * 1024), b""):
|
| 20 |
+
digest.update(block)
|
| 21 |
+
return digest.hexdigest()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def main() -> int:
|
| 25 |
+
entries = json.loads(MANIFEST.read_text())
|
| 26 |
+
failures = 0
|
| 27 |
+
for entry in entries:
|
| 28 |
+
path = ROOT / entry["path"]
|
| 29 |
+
if not path.is_file():
|
| 30 |
+
print(f"MISSING {entry['path']}")
|
| 31 |
+
failures += 1
|
| 32 |
+
continue
|
| 33 |
+
size_ok = path.stat().st_size == entry["bytes"]
|
| 34 |
+
hash_ok = sha256(path) == entry["sha256"]
|
| 35 |
+
state = "OK" if size_ok and hash_ok else "FAILED"
|
| 36 |
+
print(f"{state:7} {entry['path']}")
|
| 37 |
+
failures += int(not (size_ok and hash_ok))
|
| 38 |
+
|
| 39 |
+
private_roots = tuple("/" + stem for stem in ("scratch/", "Users/", "home/"))
|
| 40 |
+
for path in list((ROOT / "smiles").rglob("*.py")) + list((ROOT / "scripts").rglob("*.py")):
|
| 41 |
+
text = path.read_text(errors="replace")
|
| 42 |
+
if any(root in text for root in private_roots):
|
| 43 |
+
print(f"PRIVATE {path.relative_to(ROOT)}")
|
| 44 |
+
failures += 1
|
| 45 |
+
|
| 46 |
+
print("Release verification passed." if not failures else f"Verification found {failures} problem(s).")
|
| 47 |
+
return 1 if failures else 0
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
if __name__ == "__main__":
|
| 51 |
+
raise SystemExit(main())
|
| 52 |
+
|
smiles/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""CupOFMoCA peptide-drug conjugate implementation."""
|
smiles/conjugation/AoaDau.py
ADDED
|
@@ -0,0 +1,732 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AoaDaunorubicin PDC Builder
|
| 3 |
+
|
| 4 |
+
Conjugation chemistry: Lysine ε-amine + Aoa-Daunorubicin (amide bond formation)
|
| 5 |
+
|
| 6 |
+
Reference PDC: PDC_00208 (GnRH-III-[8Lys(Dau=Aoa)])
|
| 7 |
+
- Receptor: GNRHR
|
| 8 |
+
- Linker: Aminooxyacetic acid (Aoa)
|
| 9 |
+
- Payload: Daunorubicin
|
| 10 |
+
|
| 11 |
+
Architecture:
|
| 12 |
+
Peptide──Lys──NH──C(=O)──CH₂──O──N═C──Daunorubicin
|
| 13 |
+
│ │
|
| 14 |
+
amide bond Aoa linker oxime bond (pre-formed)
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from rdkit import Chem
|
| 18 |
+
from rdkit.Chem import RWMol, AllChem
|
| 19 |
+
|
| 20 |
+
from .pdc_builder import PDC_Builder
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# ============================================================================
|
| 24 |
+
# Aoa-Daunorubicin PDC Builder
|
| 25 |
+
# ============================================================================
|
| 26 |
+
|
| 27 |
+
class AoaDaunorubicin_PDC_Builder(PDC_Builder):
|
| 28 |
+
"""
|
| 29 |
+
PDC builder for Aminooxyacetic acid (Aoa) - Daunorubicin conjugates
|
| 30 |
+
|
| 31 |
+
Conjugation chemistry (amide bond formation):
|
| 32 |
+
- Peptide site: Lysine ε-amine (side chain -NH2)
|
| 33 |
+
- Linker-payload site: Carboxylic acid of Aoa (already conjugated to Dau via oxime)
|
| 34 |
+
- Reaction: Amide bond formation (Lys-NH2 + HOOC-Aoa-Dau → Lys-NH-CO-Aoa-Dau)
|
| 35 |
+
|
| 36 |
+
Architecture:
|
| 37 |
+
Peptide──Lys──NH──C(=O)──CH₂──O──N═C(CH₃)──Daunorubicin
|
| 38 |
+
└── amide ──┘ └── oxime (pre-formed) ──┘
|
| 39 |
+
|
| 40 |
+
Reference: PDC_00208 (GnRH-III-[8Lys(Dau=Aoa)])
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
# Default Aoa-Daunorubicin SMILES with free carboxylic acid
|
| 44 |
+
# Structure: HOOC-CH2-O-N=C(CH3)-Daunorubicin
|
| 45 |
+
# The oxime bond is already formed between Aoa and Dau ketone
|
| 46 |
+
DEFAULT_AOA_DAU_SMILES = (
|
| 47 |
+
"OC(=O)CO/N=C(\\C)[C@@]1(O)Cc2c(O)c3c(c(O)c2[C@H](OC2C[C@@H](N)[C@@H](O)[C@@H](C)O2)C1)"
|
| 48 |
+
"C(=O)c1c(OC)cccc1C3=O"
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
def __init__(self, aoa_dau_smiles=None):
|
| 52 |
+
"""
|
| 53 |
+
Initialize Aoa-Daunorubicin builder
|
| 54 |
+
|
| 55 |
+
Args:
|
| 56 |
+
aoa_dau_smiles: Optional custom Aoa-Dau SMILES. If None, uses default.
|
| 57 |
+
Must have free carboxylic acid for conjugation.
|
| 58 |
+
"""
|
| 59 |
+
if aoa_dau_smiles is None:
|
| 60 |
+
aoa_dau_smiles = self.DEFAULT_AOA_DAU_SMILES
|
| 61 |
+
|
| 62 |
+
super().__init__(aoa_dau_smiles, component_name="Aoa-Daunorubicin (linker-payload)")
|
| 63 |
+
|
| 64 |
+
def _verify_reactive_group(self):
|
| 65 |
+
"""Verify carboxylic acid group is present for amide bond formation"""
|
| 66 |
+
# Pattern: Carboxylic acid (COOH)
|
| 67 |
+
carboxylic_acid_pattern = Chem.MolFromSmarts('[CX3](=O)[OX2H1]')
|
| 68 |
+
|
| 69 |
+
if not self.linker_payload_mol.HasSubstructMatch(carboxylic_acid_pattern):
|
| 70 |
+
print(" Warning: No carboxylic acid group detected in Aoa-Dau")
|
| 71 |
+
else:
|
| 72 |
+
print(" Aoa-Daunorubicin loaded with carboxylic acid reactive group")
|
| 73 |
+
|
| 74 |
+
# Also verify oxime bond is present (Aoa-Dau linkage)
|
| 75 |
+
oxime_pattern = Chem.MolFromSmarts('[#6]-[#8]-[#7]=[#6]')
|
| 76 |
+
if self.linker_payload_mol.HasSubstructMatch(oxime_pattern):
|
| 77 |
+
print(" Oxime bond (Aoa-Dau linkage) detected")
|
| 78 |
+
else:
|
| 79 |
+
print(" Warning: No oxime bond detected - check Aoa-Dau structure")
|
| 80 |
+
|
| 81 |
+
def find_conjugation_sites(self, peptide_smiles):
|
| 82 |
+
"""
|
| 83 |
+
Find all lysine ε-amine groups in peptide
|
| 84 |
+
|
| 85 |
+
Returns list of dicts with site information including atom index
|
| 86 |
+
"""
|
| 87 |
+
return self.find_peptide_lysines(peptide_smiles)
|
| 88 |
+
|
| 89 |
+
def find_peptide_lysines(self, peptide_smiles):
|
| 90 |
+
"""
|
| 91 |
+
Find all lysine residues with free ε-amine in peptide
|
| 92 |
+
|
| 93 |
+
Lysine side chain: -CH2-CH2-CH2-CH2-NH2 (CCCCN)
|
| 94 |
+
|
| 95 |
+
Returns:
|
| 96 |
+
List of dicts with 'atom_idx' (nitrogen index) and metadata
|
| 97 |
+
"""
|
| 98 |
+
peptide_mol = Chem.MolFromSmiles(peptide_smiles)
|
| 99 |
+
if peptide_mol is None:
|
| 100 |
+
raise ValueError("Invalid peptide SMILES")
|
| 101 |
+
|
| 102 |
+
lysines = []
|
| 103 |
+
|
| 104 |
+
# SMARTS pattern for Lysine ε-amine: 4 CH2 groups ending in NH2
|
| 105 |
+
# This specifically matches side chain, not N-terminus
|
| 106 |
+
lys_pattern = Chem.MolFromSmarts('[CH2][CH2][CH2][CH2][NH2]')
|
| 107 |
+
matches = peptide_mol.GetSubstructMatches(lys_pattern)
|
| 108 |
+
|
| 109 |
+
for match in matches:
|
| 110 |
+
# match indices: 0=CH2, 1=CH2, 2=CH2, 3=CH2, 4=NH2
|
| 111 |
+
nitrogen_idx = match[4]
|
| 112 |
+
|
| 113 |
+
lysines.append({
|
| 114 |
+
'atom_idx': nitrogen_idx,
|
| 115 |
+
'side_chain_atoms': list(match),
|
| 116 |
+
'type': 'Lys_epsilon_amine'
|
| 117 |
+
})
|
| 118 |
+
|
| 119 |
+
# Also count N-terminus for informational purposes
|
| 120 |
+
# N-terminus: NH2 attached to backbone carbon (CH for most AAs, CH2 for Gly)
|
| 121 |
+
n_terminus_pattern = Chem.MolFromSmarts('[NH2][CH,CH2][C](=O)')
|
| 122 |
+
n_term_matches = peptide_mol.GetSubstructMatches(n_terminus_pattern)
|
| 123 |
+
|
| 124 |
+
if n_term_matches:
|
| 125 |
+
print(f" (Also found {len(n_term_matches)} N-terminus α-amine(s) - excluded by default)")
|
| 126 |
+
|
| 127 |
+
return lysines
|
| 128 |
+
|
| 129 |
+
def count_all_nh2_sites(self, peptide_smiles):
|
| 130 |
+
"""
|
| 131 |
+
Count all NH2 sites in peptide (both Lys ε-amines and N-terminus)
|
| 132 |
+
|
| 133 |
+
Useful for understanding what sites are available.
|
| 134 |
+
|
| 135 |
+
Returns:
|
| 136 |
+
Dict with counts: {'lys_epsilon': n, 'n_terminus': n, 'other': n, 'total': n}
|
| 137 |
+
"""
|
| 138 |
+
peptide_mol = Chem.MolFromSmiles(peptide_smiles)
|
| 139 |
+
if peptide_mol is None:
|
| 140 |
+
raise ValueError("Invalid peptide SMILES")
|
| 141 |
+
|
| 142 |
+
counts = {'lys_epsilon': 0, 'n_terminus': 0, 'other': 0, 'total': 0}
|
| 143 |
+
|
| 144 |
+
# Lys ε-amine: CH2-CH2-CH2-CH2-NH2 (4-carbon side chain)
|
| 145 |
+
lys_pattern = Chem.MolFromSmarts('[CH2][CH2][CH2][CH2][NH2]')
|
| 146 |
+
counts['lys_epsilon'] = len(peptide_mol.GetSubstructMatches(lys_pattern))
|
| 147 |
+
|
| 148 |
+
# N-terminus: NH2 attached to backbone carbon (either CH for most AAs, or CH2 for Gly)
|
| 149 |
+
# Pattern: NH2-C-C(=O) where C is part of backbone
|
| 150 |
+
n_term_pattern = Chem.MolFromSmarts('[NH2][CH,CH2][C](=O)')
|
| 151 |
+
counts['n_terminus'] = len(peptide_mol.GetSubstructMatches(n_term_pattern))
|
| 152 |
+
|
| 153 |
+
# Total NH2 (any primary amine)
|
| 154 |
+
all_nh2_pattern = Chem.MolFromSmarts('[NH2]')
|
| 155 |
+
total_nh2 = len(peptide_mol.GetSubstructMatches(all_nh2_pattern))
|
| 156 |
+
|
| 157 |
+
counts['other'] = total_nh2 - counts['lys_epsilon'] - counts['n_terminus']
|
| 158 |
+
counts['total'] = total_nh2
|
| 159 |
+
|
| 160 |
+
return counts
|
| 161 |
+
|
| 162 |
+
def _find_carboxylic_acid_atoms(self, mol):
|
| 163 |
+
"""
|
| 164 |
+
Find the carboxylic acid carbon (the reactive site for amide formation)
|
| 165 |
+
|
| 166 |
+
Returns: (carbon_idx, oxygen_idx) of the C(=O)OH group
|
| 167 |
+
"""
|
| 168 |
+
# Pattern: C(=O)OH
|
| 169 |
+
cooh_pattern = Chem.MolFromSmarts('[CX3](=O)[OX2H1]')
|
| 170 |
+
matches = mol.GetSubstructMatches(cooh_pattern)
|
| 171 |
+
|
| 172 |
+
if not matches:
|
| 173 |
+
return None, None
|
| 174 |
+
|
| 175 |
+
# Return first match: (carbon_idx, hydroxyl_oxygen_idx)
|
| 176 |
+
match = matches[0]
|
| 177 |
+
return match[0], match[1]
|
| 178 |
+
|
| 179 |
+
def _perform_conjugation(self, peptide_mol, site_info, exclude_n_terminus=True):
|
| 180 |
+
"""
|
| 181 |
+
Perform amide bond conjugation between NH2 groups and Aoa carboxylic acid
|
| 182 |
+
|
| 183 |
+
Args:
|
| 184 |
+
peptide_mol: RDKit Mol object
|
| 185 |
+
site_info: Not used (kept for compatibility)
|
| 186 |
+
exclude_n_terminus: If True, skip N-terminus conjugation
|
| 187 |
+
|
| 188 |
+
Returns: List of all possible conjugate SMILES
|
| 189 |
+
"""
|
| 190 |
+
return self._manual_conjugation(peptide_mol, site_info, exclude_n_terminus)
|
| 191 |
+
|
| 192 |
+
def _manual_conjugation(self, peptide_mol, lysine_info, exclude_n_terminus=True):
|
| 193 |
+
"""
|
| 194 |
+
Create ALL possible conjugates by forming amide bond at NH2 sites
|
| 195 |
+
|
| 196 |
+
Chemistry:
|
| 197 |
+
- Any-NH2 + HOOC-Aoa-Dau → Any-NH-C(=O)-Aoa-Dau + H2O
|
| 198 |
+
|
| 199 |
+
Args:
|
| 200 |
+
peptide_mol: RDKit Mol object of peptide
|
| 201 |
+
lysine_info: Not used (kept for interface compatibility)
|
| 202 |
+
exclude_n_terminus: If True, only conjugate at Lys ε-amines (4-carbon chain),
|
| 203 |
+
not at N-terminus α-amine. Default: True
|
| 204 |
+
|
| 205 |
+
Returns:
|
| 206 |
+
List of all possible conjugate SMILES
|
| 207 |
+
"""
|
| 208 |
+
if exclude_n_terminus:
|
| 209 |
+
# SMARTS that matches only Lys ε-amine: requires 4 CH2 chain before NH2
|
| 210 |
+
# This includes the connection to the peptide backbone (*) to preserve the full peptide
|
| 211 |
+
# Pattern: *-CH2-CH2-CH2-CH2-NH2 (where * is the connection to the peptide)
|
| 212 |
+
amide_rxn = AllChem.ReactionFromSmarts(
|
| 213 |
+
'[*:5][CH2][CH2][CH2][CH2:4][NH2:1].[C:2](=[O:3])[OH]>>[*:5][CH2][CH2][CH2][CH2:4][NH:1][C:2](=[O:3])'
|
| 214 |
+
)
|
| 215 |
+
else:
|
| 216 |
+
# Match ANY primary amine (includes N-terminus)
|
| 217 |
+
# Include wildcard connection to preserve the rest of the molecule
|
| 218 |
+
amide_rxn = AllChem.ReactionFromSmarts(
|
| 219 |
+
'[*:4][NH2:1].[C:2](=[O:3])[OH]>>[*:4][NH:1][C:2](=[O:3])'
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
# Run the reaction - returns all possible products
|
| 223 |
+
products = amide_rxn.RunReactants((peptide_mol, self.linker_payload_mol))
|
| 224 |
+
|
| 225 |
+
if not products:
|
| 226 |
+
raise ValueError("Amide bond formation reaction failed - no products generated")
|
| 227 |
+
|
| 228 |
+
# Collect ALL valid conjugates
|
| 229 |
+
conjugates = []
|
| 230 |
+
seen_smiles = set() # Deduplicate
|
| 231 |
+
|
| 232 |
+
for product_tuple in products:
|
| 233 |
+
try:
|
| 234 |
+
conjugate_mol = product_tuple[0]
|
| 235 |
+
Chem.SanitizeMol(conjugate_mol)
|
| 236 |
+
conjugate_smiles = Chem.MolToSmiles(conjugate_mol)
|
| 237 |
+
|
| 238 |
+
# Deduplicate (same SMILES = same molecule)
|
| 239 |
+
if conjugate_smiles not in seen_smiles:
|
| 240 |
+
seen_smiles.add(conjugate_smiles)
|
| 241 |
+
conjugates.append(conjugate_smiles)
|
| 242 |
+
except Exception as e:
|
| 243 |
+
# Skip invalid products
|
| 244 |
+
continue
|
| 245 |
+
|
| 246 |
+
if not conjugates:
|
| 247 |
+
raise ValueError("All products failed sanitization")
|
| 248 |
+
|
| 249 |
+
return conjugates
|
| 250 |
+
|
| 251 |
+
def conjugate_to_peptide(self, peptide_smiles, exclude_n_terminus=True, return_all=True):
|
| 252 |
+
"""
|
| 253 |
+
Conjugate Aoa-Daunorubicin to peptide via amide bond at NH2 groups
|
| 254 |
+
|
| 255 |
+
Args:
|
| 256 |
+
peptide_smiles: SMILES string of peptide (must contain NH2)
|
| 257 |
+
exclude_n_terminus: If True (default), only conjugate at Lys ε-amines,
|
| 258 |
+
not at N-terminus. Set to False to include N-terminus.
|
| 259 |
+
return_all: If True, return all possible conjugates. If False, return first only.
|
| 260 |
+
|
| 261 |
+
Returns:
|
| 262 |
+
If return_all=True: List of all possible conjugate SMILES
|
| 263 |
+
If return_all=False: Single conjugate SMILES (first one)
|
| 264 |
+
"""
|
| 265 |
+
peptide_mol = Chem.MolFromSmiles(peptide_smiles)
|
| 266 |
+
if peptide_mol is None:
|
| 267 |
+
raise ValueError(f"Invalid peptide SMILES: {peptide_smiles}")
|
| 268 |
+
|
| 269 |
+
sites = self.find_conjugation_sites(peptide_smiles)
|
| 270 |
+
print(f"Found {len(sites)} Lys ε-amine site(s)")
|
| 271 |
+
|
| 272 |
+
# Get all conjugates
|
| 273 |
+
conjugates = self._perform_conjugation(peptide_mol, None, exclude_n_terminus)
|
| 274 |
+
|
| 275 |
+
site_type = "Lys ε-amine only" if exclude_n_terminus else "all NH2 (including N-terminus)"
|
| 276 |
+
print(f"Generated {len(conjugates)} unique conjugate(s) [{site_type}]")
|
| 277 |
+
|
| 278 |
+
if return_all:
|
| 279 |
+
return conjugates
|
| 280 |
+
else:
|
| 281 |
+
return conjugates[0] if conjugates else None
|
| 282 |
+
|
| 283 |
+
def batch_conjugate(self, peptide_smiles_list, exclude_n_terminus=True, return_all=True):
|
| 284 |
+
"""
|
| 285 |
+
Batch conjugate multiple peptides
|
| 286 |
+
|
| 287 |
+
Args:
|
| 288 |
+
peptide_smiles_list: List of peptide SMILES strings
|
| 289 |
+
exclude_n_terminus: If True (default), only conjugate at Lys ε-amines
|
| 290 |
+
return_all: If True, return all possible conjugates per peptide
|
| 291 |
+
|
| 292 |
+
Returns:
|
| 293 |
+
Dict with:
|
| 294 |
+
'conjugates': List of lists (if return_all=True) or list of SMILES
|
| 295 |
+
'successful': List of successful peptide indices
|
| 296 |
+
'failed': List of (index, error) tuples
|
| 297 |
+
'total_conjugates': Total number of conjugates generated
|
| 298 |
+
"""
|
| 299 |
+
results = {
|
| 300 |
+
'conjugates': [],
|
| 301 |
+
'successful': [],
|
| 302 |
+
'failed': [],
|
| 303 |
+
'total_conjugates': 0
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
for i, peptide_smiles in enumerate(peptide_smiles_list):
|
| 307 |
+
try:
|
| 308 |
+
conjugates = self.conjugate_to_peptide(
|
| 309 |
+
peptide_smiles,
|
| 310 |
+
exclude_n_terminus=exclude_n_terminus,
|
| 311 |
+
return_all=return_all
|
| 312 |
+
)
|
| 313 |
+
if return_all:
|
| 314 |
+
results['conjugates'].append(conjugates)
|
| 315 |
+
results['total_conjugates'] += len(conjugates)
|
| 316 |
+
else:
|
| 317 |
+
results['conjugates'].append(conjugates)
|
| 318 |
+
results['total_conjugates'] += 1
|
| 319 |
+
results['successful'].append(i)
|
| 320 |
+
except Exception as e:
|
| 321 |
+
print(f" Peptide {i} failed: {e}")
|
| 322 |
+
results['conjugates'].append(None)
|
| 323 |
+
results['failed'].append((i, str(e)))
|
| 324 |
+
|
| 325 |
+
print(f"\n=== Summary ===")
|
| 326 |
+
print(f"Peptides: {len(peptide_smiles_list)}, Success: {len(results['successful'])}, Failed: {len(results['failed'])}")
|
| 327 |
+
print(f"Total conjugates generated: {results['total_conjugates']}")
|
| 328 |
+
|
| 329 |
+
return results
|
| 330 |
+
|
| 331 |
+
def flatten_batch_results(self, results):
|
| 332 |
+
"""
|
| 333 |
+
Flatten batch conjugation results into a single list of all conjugates
|
| 334 |
+
|
| 335 |
+
Args:
|
| 336 |
+
results: Dict returned by batch_conjugate()
|
| 337 |
+
|
| 338 |
+
Returns:
|
| 339 |
+
List of all conjugate SMILES (flattened)
|
| 340 |
+
"""
|
| 341 |
+
all_conjugates = []
|
| 342 |
+
for conjugate_list in results['conjugates']:
|
| 343 |
+
if conjugate_list is not None:
|
| 344 |
+
if isinstance(conjugate_list, list):
|
| 345 |
+
all_conjugates.extend(conjugate_list)
|
| 346 |
+
else:
|
| 347 |
+
all_conjugates.append(conjugate_list)
|
| 348 |
+
return all_conjugates
|
| 349 |
+
|
| 350 |
+
# =========================================================================
|
| 351 |
+
# Attachment Site Compatibility Checks (Lys-amine + Aoa-COOH specific)
|
| 352 |
+
# =========================================================================
|
| 353 |
+
|
| 354 |
+
def has_aoa_amide_conjugation(self, mol):
|
| 355 |
+
"""
|
| 356 |
+
Check if molecule has the Aoa-amide conjugation pattern
|
| 357 |
+
|
| 358 |
+
Pattern: N-C(=O)-CH2-O-N=C (amide-CH2-O-oxime linkage)
|
| 359 |
+
This is the signature of successful Lys-Aoa conjugation.
|
| 360 |
+
|
| 361 |
+
Returns:
|
| 362 |
+
bool: True if Aoa conjugation pattern is present
|
| 363 |
+
"""
|
| 364 |
+
# Specific Aoa-conjugation pattern
|
| 365 |
+
# Looking for: N-C(=O)-C-O-N=C (amide-CH2-O-oxime linkage)
|
| 366 |
+
aoa_conjugation_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[CH2][OX2][NX2]=[#6]')
|
| 367 |
+
if aoa_conjugation_pattern:
|
| 368 |
+
return mol.HasSubstructMatch(aoa_conjugation_pattern)
|
| 369 |
+
return False
|
| 370 |
+
|
| 371 |
+
def has_oxime_bond(self, mol):
|
| 372 |
+
"""
|
| 373 |
+
Check if molecule has oxime bond (Aoa-Dau linkage)
|
| 374 |
+
|
| 375 |
+
Pattern: C-O-N=C
|
| 376 |
+
This confirms the Aoa-Dau linker-payload is intact.
|
| 377 |
+
"""
|
| 378 |
+
oxime_pattern = Chem.MolFromSmarts('[#6]-[#8]-[#7]=[#6]')
|
| 379 |
+
if oxime_pattern:
|
| 380 |
+
return mol.HasSubstructMatch(oxime_pattern)
|
| 381 |
+
return False
|
| 382 |
+
|
| 383 |
+
def has_unreacted_aoa_carboxylic_acid(self, mol):
|
| 384 |
+
"""
|
| 385 |
+
Check if molecule has unreacted Aoa carboxylic acid
|
| 386 |
+
|
| 387 |
+
The specific Aoa pattern: HOOC-CH2-O-N=C
|
| 388 |
+
If this is present, the Aoa hasn't been conjugated to the peptide.
|
| 389 |
+
|
| 390 |
+
Note: We specifically check for the Aoa-COOH pattern, not just any COOH,
|
| 391 |
+
because peptides naturally have carboxylic acids (C-terminus, Asp, Glu).
|
| 392 |
+
|
| 393 |
+
Returns:
|
| 394 |
+
bool: True if unreacted Aoa-COOH is present (bad)
|
| 395 |
+
"""
|
| 396 |
+
# Specific pattern: Free COOH attached to CH2-O-N=C (Aoa pattern)
|
| 397 |
+
unreacted_aoa_pattern = Chem.MolFromSmarts('[CX3](=[OX1])[OX2H1][CH2][OX2][NX2]=[#6]')
|
| 398 |
+
if unreacted_aoa_pattern:
|
| 399 |
+
return mol.HasSubstructMatch(unreacted_aoa_pattern)
|
| 400 |
+
return False
|
| 401 |
+
|
| 402 |
+
def find_amide_conjugation_sites(self, mol):
|
| 403 |
+
"""
|
| 404 |
+
Find amide bonds that are part of the Aoa conjugation
|
| 405 |
+
|
| 406 |
+
Specifically looks for: N-C(=O)-CH2-O-N=C pattern
|
| 407 |
+
|
| 408 |
+
Returns:
|
| 409 |
+
List of (nitrogen_idx, carbonyl_carbon_idx) tuples
|
| 410 |
+
"""
|
| 411 |
+
aoa_conjugation_pattern = Chem.MolFromSmarts('[NX3:1][CX3:2](=[OX1])[CH2][OX2][NX2]=[#6]')
|
| 412 |
+
conjugation_sites = []
|
| 413 |
+
|
| 414 |
+
if aoa_conjugation_pattern:
|
| 415 |
+
matches = mol.GetSubstructMatches(aoa_conjugation_pattern)
|
| 416 |
+
for match in matches:
|
| 417 |
+
nitrogen_idx = match[0]
|
| 418 |
+
carbonyl_c_idx = match[1]
|
| 419 |
+
conjugation_sites.append((nitrogen_idx, carbonyl_c_idx))
|
| 420 |
+
|
| 421 |
+
return conjugation_sites
|
| 422 |
+
|
| 423 |
+
def check_valency_at_conjugation_site(self, mol, nitrogen_idx):
|
| 424 |
+
"""
|
| 425 |
+
Check valency at amide nitrogen conjugation site
|
| 426 |
+
|
| 427 |
+
For Lys-Aoa conjugation, the nitrogen should be:
|
| 428 |
+
- Amide nitrogen (bonded to carbonyl carbon)
|
| 429 |
+
- Secondary amine (2 bonds to heavy atoms)
|
| 430 |
+
- No charge
|
| 431 |
+
|
| 432 |
+
Args:
|
| 433 |
+
mol: RDKit Mol object
|
| 434 |
+
nitrogen_idx: Index of the nitrogen atom to check
|
| 435 |
+
|
| 436 |
+
Returns:
|
| 437 |
+
bool: True if valency is valid
|
| 438 |
+
"""
|
| 439 |
+
atom = mol.GetAtomWithIdx(nitrogen_idx)
|
| 440 |
+
|
| 441 |
+
if atom.GetSymbol() != 'N':
|
| 442 |
+
return True # Not nitrogen, skip
|
| 443 |
+
|
| 444 |
+
degree = atom.GetDegree() # Bonds to heavy atoms
|
| 445 |
+
total_hs = atom.GetTotalNumHs()
|
| 446 |
+
formal_charge = atom.GetFormalCharge()
|
| 447 |
+
|
| 448 |
+
# Get neighbor information
|
| 449 |
+
neighbors = [mol.GetAtomWithIdx(n.GetIdx()) for n in atom.GetNeighbors()]
|
| 450 |
+
|
| 451 |
+
# Count carbonyl carbons (C=O) among neighbors
|
| 452 |
+
carbonyl_neighbors = 0
|
| 453 |
+
for neighbor in neighbors:
|
| 454 |
+
if neighbor.GetSymbol() == 'C':
|
| 455 |
+
# Check if this carbon has a double bond to oxygen
|
| 456 |
+
for n2 in neighbor.GetNeighbors():
|
| 457 |
+
if n2.GetSymbol() == 'O':
|
| 458 |
+
bond = mol.GetBondBetweenAtoms(neighbor.GetIdx(), n2.GetIdx())
|
| 459 |
+
if bond and bond.GetBondType() == Chem.BondType.DOUBLE:
|
| 460 |
+
carbonyl_neighbors += 1
|
| 461 |
+
break
|
| 462 |
+
|
| 463 |
+
# ========== Valid Amide Nitrogen States ==========
|
| 464 |
+
|
| 465 |
+
# 1. Secondary amide: N bonded to 2 heavy atoms (one is carbonyl C), 1 H
|
| 466 |
+
# R-NH-C(=O)-R'
|
| 467 |
+
if degree == 2 and total_hs == 1 and formal_charge == 0 and carbonyl_neighbors >= 1:
|
| 468 |
+
return True
|
| 469 |
+
|
| 470 |
+
# 2. Tertiary amide: N bonded to 3 heavy atoms (one is carbonyl C), 0 H
|
| 471 |
+
# R-N(R')-C(=O)-R''
|
| 472 |
+
if degree == 3 and total_hs == 0 and formal_charge == 0 and carbonyl_neighbors >= 1:
|
| 473 |
+
return True
|
| 474 |
+
|
| 475 |
+
# 3. Aromatic N in peptide backbone is okay
|
| 476 |
+
if atom.GetIsAromatic():
|
| 477 |
+
return True
|
| 478 |
+
|
| 479 |
+
# ========== Invalid States ==========
|
| 480 |
+
|
| 481 |
+
# Quaternary nitrogen with positive charge (not typical for amide)
|
| 482 |
+
if degree >= 4 and formal_charge > 0:
|
| 483 |
+
return False
|
| 484 |
+
|
| 485 |
+
# N with no carbonyl neighbor but expected to be amide
|
| 486 |
+
if carbonyl_neighbors == 0:
|
| 487 |
+
# This might be a free amine - need context to determine if that's okay
|
| 488 |
+
# For Aoa conjugation, we expect amide formation
|
| 489 |
+
pass # Allow for now, other checks will catch issues
|
| 490 |
+
|
| 491 |
+
return True
|
| 492 |
+
|
| 493 |
+
def check_attachment_site_compatibility(self, mol):
|
| 494 |
+
"""
|
| 495 |
+
Check if Lys-Aoa amide attachment site chemistry is valid
|
| 496 |
+
|
| 497 |
+
For Aoa-Daunorubicin conjugation, validates:
|
| 498 |
+
1. Has Aoa-amide conjugation pattern (N-C(=O)-CH2-O-N=C)
|
| 499 |
+
2. Has oxime bond intact (Aoa-Dau linkage)
|
| 500 |
+
3. No unreacted Aoa-COOH (conjugation must have happened)
|
| 501 |
+
4. Valid valency at conjugation nitrogen sites
|
| 502 |
+
|
| 503 |
+
Args:
|
| 504 |
+
mol: RDKit Mol object of the conjugate
|
| 505 |
+
|
| 506 |
+
Returns:
|
| 507 |
+
bool: True if attachment site is valid, False otherwise
|
| 508 |
+
"""
|
| 509 |
+
# ===== CRITICAL CHECK: Must have Aoa-amide conjugation pattern =====
|
| 510 |
+
if not self.has_aoa_amide_conjugation(mol):
|
| 511 |
+
return False
|
| 512 |
+
|
| 513 |
+
# ===== Check oxime bond is intact =====
|
| 514 |
+
if not self.has_oxime_bond(mol):
|
| 515 |
+
return False
|
| 516 |
+
|
| 517 |
+
# ===== Check no unreacted Aoa-COOH =====
|
| 518 |
+
# If the original Aoa carboxylic acid pattern is still present,
|
| 519 |
+
# conjugation didn't happen properly
|
| 520 |
+
if self.has_unreacted_aoa_carboxylic_acid(mol):
|
| 521 |
+
return False
|
| 522 |
+
|
| 523 |
+
# ===== Check valency at conjugation sites =====
|
| 524 |
+
conjugation_sites = self.find_amide_conjugation_sites(mol)
|
| 525 |
+
|
| 526 |
+
for nitrogen_idx, carbonyl_c_idx in conjugation_sites:
|
| 527 |
+
if not self.check_valency_at_conjugation_site(mol, nitrogen_idx):
|
| 528 |
+
return False
|
| 529 |
+
|
| 530 |
+
return True
|
| 531 |
+
|
| 532 |
+
def _basic_validation(self, mol):
|
| 533 |
+
"""
|
| 534 |
+
Basic validation checks for Aoa-Daunorubicin conjugates
|
| 535 |
+
|
| 536 |
+
Includes attachment site compatibility check with valency validation.
|
| 537 |
+
|
| 538 |
+
Checks:
|
| 539 |
+
1. Attachment site compatibility (Aoa-amide pattern, oxime, valency)
|
| 540 |
+
2. Daunorubicin core present
|
| 541 |
+
3. Aminosugar present (daunosamine)
|
| 542 |
+
|
| 543 |
+
Note: We do NOT check for absence of COOH because peptides naturally
|
| 544 |
+
have carboxylic acids (C-terminus, Asp, Glu residues).
|
| 545 |
+
"""
|
| 546 |
+
checks = {}
|
| 547 |
+
|
| 548 |
+
# Check 1: Has amide bond (general check)
|
| 549 |
+
amide_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[#6]')
|
| 550 |
+
checks['has_amide_bond'] = mol.HasSubstructMatch(amide_pattern)
|
| 551 |
+
|
| 552 |
+
# Check 2: Oxime bond present (Aoa-Dau linkage: C-O-N=C)
|
| 553 |
+
checks['has_oxime_bond'] = self.has_oxime_bond(mol)
|
| 554 |
+
|
| 555 |
+
# Check 3: Specific Aoa-conjugation pattern
|
| 556 |
+
checks['has_aoa_conjugation'] = self.has_aoa_amide_conjugation(mol)
|
| 557 |
+
|
| 558 |
+
# Check 4: No unreacted Aoa-COOH
|
| 559 |
+
checks['no_unreacted_aoa'] = not self.has_unreacted_aoa_carboxylic_acid(mol)
|
| 560 |
+
|
| 561 |
+
# Check 5: Full attachment site compatibility (includes valency)
|
| 562 |
+
checks['attachment_site_valid'] = self.check_attachment_site_compatibility(mol)
|
| 563 |
+
|
| 564 |
+
# Check 6: Daunorubicin core present (anthraquinone with specific substitution)
|
| 565 |
+
anthraquinone = Chem.MolFromSmarts('[#6]1[#6][#6][#6]2[#6](=[#8])[#6]3[#6][#6][#6][#6][#6]3[#6](=[#8])[#6]2[#6]1')
|
| 566 |
+
checks['has_daunorubicin_core'] = mol.HasSubstructMatch(anthraquinone)
|
| 567 |
+
|
| 568 |
+
# Check 7: Has aminosugar (daunosamine - 6-membered ring with N)
|
| 569 |
+
aminosugar = Chem.MolFromSmarts('[OX2]1[#6][#6][#6]([NX3])[#6][#6]1')
|
| 570 |
+
checks['has_aminosugar'] = mol.HasSubstructMatch(aminosugar)
|
| 571 |
+
|
| 572 |
+
# Find and report conjugation sites for diagnostics
|
| 573 |
+
conjugation_sites = self.find_amide_conjugation_sites(mol)
|
| 574 |
+
checks['conjugation_sites'] = conjugation_sites
|
| 575 |
+
|
| 576 |
+
# Overall pass/fail - attachment_site_valid is the key check
|
| 577 |
+
checks['_passed'] = checks['attachment_site_valid']
|
| 578 |
+
|
| 579 |
+
return checks
|
| 580 |
+
|
| 581 |
+
def validate_conjugate(self, conjugate_smiles):
|
| 582 |
+
"""
|
| 583 |
+
Validate that the conjugate is chemically reasonable
|
| 584 |
+
|
| 585 |
+
Returns:
|
| 586 |
+
(passed: bool, checks: dict)
|
| 587 |
+
"""
|
| 588 |
+
mol = Chem.MolFromSmiles(conjugate_smiles)
|
| 589 |
+
|
| 590 |
+
if mol is None:
|
| 591 |
+
return False, {'error': 'Could not parse SMILES'}
|
| 592 |
+
|
| 593 |
+
checks = self._basic_validation(mol)
|
| 594 |
+
checks['parseable'] = True
|
| 595 |
+
|
| 596 |
+
passed = checks.get('_passed', True)
|
| 597 |
+
if '_passed' in checks:
|
| 598 |
+
del checks['_passed']
|
| 599 |
+
|
| 600 |
+
return passed, checks
|
| 601 |
+
|
| 602 |
+
|
| 603 |
+
# ============================================================================
|
| 604 |
+
# Main - Test Examples
|
| 605 |
+
# ============================================================================
|
| 606 |
+
|
| 607 |
+
if __name__ == "__main__":
|
| 608 |
+
|
| 609 |
+
print("=" * 70)
|
| 610 |
+
print("Aoa-Daunorubicin PDC Builder - Test")
|
| 611 |
+
print("=" * 70 + "\n")
|
| 612 |
+
|
| 613 |
+
# Initialize builder
|
| 614 |
+
builder = AoaDaunorubicin_PDC_Builder()
|
| 615 |
+
|
| 616 |
+
# Reference peptide: GnRH-III (PEP00168)
|
| 617 |
+
gnrh_iii_smiles = (
|
| 618 |
+
"[H]NCCCC[C@H](NC(=O)[C@H](Cc1cn([H])c2ccccc12)NC(=O)[C@H](CC(=O)O)"
|
| 619 |
+
"NC(=O)[C@H](Cc1cn([H])cn1)NC(=O)[C@H](CO)NC(=O)[C@H](Cc1cn([H])c2ccccc12)"
|
| 620 |
+
"NC(=O)[C@H](Cc1cn([H])cn1)NC(=O)[C@@H]1CCC(=O)N1)C(=O)N1CCC[C@H]1C(=O)NCC(N)=O"
|
| 621 |
+
)
|
| 622 |
+
|
| 623 |
+
# Test peptides
|
| 624 |
+
test_peptides = [
|
| 625 |
+
# Simple test: Gly-Lys-Gly (has N-terminus NH2 + Lys ε-amine = 2 NH2 sites)
|
| 626 |
+
"NCC(=O)N[C@@H](CCCCN)C(=O)NCC(=O)O",
|
| 627 |
+
# Lys-Arg (has N-terminus NH2 + Lys ε-amine = 2 NH2 sites)
|
| 628 |
+
"N[C@@H](CCCCN)C(=O)N[C@@H](CCCNC(=N)N)C(=O)O",
|
| 629 |
+
# GnRH-III
|
| 630 |
+
gnrh_iii_smiles,
|
| 631 |
+
]
|
| 632 |
+
|
| 633 |
+
print("\n" + "=" * 70)
|
| 634 |
+
print("Testing NH2 Site Detection")
|
| 635 |
+
print("=" * 70 + "\n")
|
| 636 |
+
|
| 637 |
+
for i, pep in enumerate(test_peptides):
|
| 638 |
+
name = ["Gly-Lys-Gly", "Lys-Arg", "GnRH-III"][i]
|
| 639 |
+
counts = builder.count_all_nh2_sites(pep)
|
| 640 |
+
print(f"{name}:")
|
| 641 |
+
print(f" Lys ε-amines: {counts['lys_epsilon']}")
|
| 642 |
+
print(f" N-terminus: {counts['n_terminus']}")
|
| 643 |
+
print(f" Other NH2: {counts['other']}")
|
| 644 |
+
print(f" Total NH2: {counts['total']}")
|
| 645 |
+
print()
|
| 646 |
+
|
| 647 |
+
print("\n" + "=" * 70)
|
| 648 |
+
print("Testing: exclude_n_terminus=True (DEFAULT)")
|
| 649 |
+
print("=" * 70 + "\n")
|
| 650 |
+
|
| 651 |
+
peptide = test_peptides[0] # Gly-Lys-Gly
|
| 652 |
+
print(f"Input peptide (Gly-Lys-Gly): {peptide}")
|
| 653 |
+
|
| 654 |
+
try:
|
| 655 |
+
conjugates = builder.conjugate_to_peptide(peptide, exclude_n_terminus=True)
|
| 656 |
+
print(f"\n SUCCESS!")
|
| 657 |
+
print(f"Number of conjugates: {len(conjugates)} (should be 1 - Lys only)")
|
| 658 |
+
|
| 659 |
+
for i, conj in enumerate(conjugates):
|
| 660 |
+
valid, checks = builder.validate_conjugate(conj)
|
| 661 |
+
print(f" Conjugate {i+1}: {'PASSED' if valid else 'FAILED'}")
|
| 662 |
+
print(conj)
|
| 663 |
+
|
| 664 |
+
except Exception as e:
|
| 665 |
+
print(f"\n FAILED: {e}")
|
| 666 |
+
import traceback
|
| 667 |
+
traceback.print_exc()
|
| 668 |
+
|
| 669 |
+
print("\n" + "=" * 70)
|
| 670 |
+
print("Testing: exclude_n_terminus=False (include N-terminus)")
|
| 671 |
+
print("=" * 70 + "\n")
|
| 672 |
+
|
| 673 |
+
print(f"Input peptide (Gly-Lys-Gly): {peptide}")
|
| 674 |
+
|
| 675 |
+
try:
|
| 676 |
+
conjugates = builder.conjugate_to_peptide(peptide, exclude_n_terminus=False)
|
| 677 |
+
print(f"\n SUCCESS!")
|
| 678 |
+
print(f"Number of conjugates: {len(conjugates)} (should be 2 - Lys + N-terminus)")
|
| 679 |
+
|
| 680 |
+
for i, conj in enumerate(conjugates):
|
| 681 |
+
valid, checks = builder.validate_conjugate(conj)
|
| 682 |
+
print(f" Conjugate {i+1}: {'PASSED' if valid else 'FAILED'}")
|
| 683 |
+
print(conj)
|
| 684 |
+
|
| 685 |
+
except Exception as e:
|
| 686 |
+
print(f"\n FAILED: {e}")
|
| 687 |
+
import traceback
|
| 688 |
+
traceback.print_exc()
|
| 689 |
+
|
| 690 |
+
print("\n" + "=" * 70)
|
| 691 |
+
print("Testing GnRH-III (has only 1 Lys at position 8)")
|
| 692 |
+
print("=" * 70 + "\n")
|
| 693 |
+
|
| 694 |
+
print(f"Input peptide (GnRH-III): {gnrh_iii_smiles[:60]}...")
|
| 695 |
+
print("\nNote: GnRH-III sequence is Glp-His-Trp-Ser-His-Asp-Trp-Lys-Pro-Gly")
|
| 696 |
+
print(" Position 4 is Serine (not Lysine), so only 1 Lys at position 8")
|
| 697 |
+
|
| 698 |
+
# First check the NH2 sites
|
| 699 |
+
counts = builder.count_all_nh2_sites(gnrh_iii_smiles)
|
| 700 |
+
print(f"\nNH2 site breakdown:")
|
| 701 |
+
print(f" Lys ε-amines: {counts['lys_epsilon']} (expected: 1 for Lys8)")
|
| 702 |
+
print(f" N-terminus: {counts['n_terminus']} (expected: 0 - Glp is cyclic)")
|
| 703 |
+
print(f" Other: {counts['other']}")
|
| 704 |
+
|
| 705 |
+
try:
|
| 706 |
+
conjugates = builder.conjugate_to_peptide(gnrh_iii_smiles, exclude_n_terminus=True)
|
| 707 |
+
print(f"\n SUCCESS!")
|
| 708 |
+
print(f"Number of conjugates: {len(conjugates)}")
|
| 709 |
+
|
| 710 |
+
for i, conj in enumerate(conjugates):
|
| 711 |
+
valid, checks = builder.validate_conjugate(conj)
|
| 712 |
+
status = "PASSED" if valid else "FAILED"
|
| 713 |
+
print(f" Conjugate {i+1}: {status} - {len(conj)} chars")
|
| 714 |
+
print(conj)
|
| 715 |
+
|
| 716 |
+
except Exception as e:
|
| 717 |
+
print(f"\n FAILED: {e}")
|
| 718 |
+
import traceback
|
| 719 |
+
traceback.print_exc()
|
| 720 |
+
|
| 721 |
+
print("\n" + "=" * 70)
|
| 722 |
+
print("Batch Conjugation Test (exclude_n_terminus=True)")
|
| 723 |
+
print("=" * 70 + "\n")
|
| 724 |
+
|
| 725 |
+
results = builder.batch_conjugate(test_peptides, exclude_n_terminus=True)
|
| 726 |
+
|
| 727 |
+
print(f"\nResults breakdown:")
|
| 728 |
+
for i in results['successful']:
|
| 729 |
+
conjugates = results['conjugates'][i]
|
| 730 |
+
num_valid = sum(1 for c in conjugates if builder.validate_conjugate(c)[0])
|
| 731 |
+
name = ["Gly-Lys-Gly", "Lys-Arg", "GnRH-III"][i]
|
| 732 |
+
print(f" {name}: {len(conjugates)} conjugate(s), {num_valid} valid")
|
smiles/conjugation/CptFactory.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Camptothecin PDC Factory - Drug-level builder with linker sampling
|
| 3 |
+
|
| 4 |
+
Instead of specifying a particular linker-drug combo (e.g. SSCpt, DSPACpt),
|
| 5 |
+
the user specifies only the drug (Camptothecin) and a linker is sampled
|
| 6 |
+
according to ERBB2+Cpt entry frequencies from PDCdb.
|
| 7 |
+
|
| 8 |
+
Linker frequencies (ERBB2 + Camptothecin):
|
| 9 |
+
===========================================
|
| 10 |
+
Disulfide bond (SSCpt) - 7 entries
|
| 11 |
+
Maleimide-hexanoate (MalHexCpt) - 5 entries
|
| 12 |
+
3-Disulfanylpropanoic acid (DSPACpt) - 5 entries
|
| 13 |
+
2-(Disulfanyl)ethyl carbonate (DSECCpt) - 5 entries
|
| 14 |
+
Total: 22 entries
|
| 15 |
+
|
| 16 |
+
Sampling probabilities:
|
| 17 |
+
SSCpt: 7/22 ≈ 0.318
|
| 18 |
+
MalHexCpt: 5/22 ≈ 0.227
|
| 19 |
+
DSPACpt: 5/22 ≈ 0.227
|
| 20 |
+
DSECCpt: 5/22 ≈ 0.227
|
| 21 |
+
|
| 22 |
+
Usage:
|
| 23 |
+
======
|
| 24 |
+
# Drug-only mode - linker is sampled per peptide
|
| 25 |
+
factory = CamptothecinPDC_Factory()
|
| 26 |
+
conjugates = factory.conjugate_to_peptide(peptide_smiles)
|
| 27 |
+
|
| 28 |
+
# Fixed linker mode - use a specific linker
|
| 29 |
+
factory = CamptothecinPDC_Factory(linker='SSCpt')
|
| 30 |
+
conjugates = factory.conjugate_to_peptide(peptide_smiles)
|
| 31 |
+
|
| 32 |
+
# All linkers mode - generate conjugates with every linker
|
| 33 |
+
factory = CamptothecinPDC_Factory(linker='all')
|
| 34 |
+
conjugates = factory.conjugate_to_peptide(peptide_smiles)
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
import numpy as np
|
| 38 |
+
import contextlib
|
| 39 |
+
import io
|
| 40 |
+
|
| 41 |
+
from .SSCpt import DisulfideCamptothecin_PDC_Builder
|
| 42 |
+
from .DSPACpt import DisulfanylpropanoicAcidCpt_PDC_Builder
|
| 43 |
+
from .MalHexCpt import MaleimideHexanoateCpt_PDC_Builder
|
| 44 |
+
from .DSECCpt import DisulfanylethylCarbonateCpt_PDC_Builder
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ============================================================================
|
| 48 |
+
# Linker registry: name → (builder_class, PDCdb frequency, description)
|
| 49 |
+
# ============================================================================
|
| 50 |
+
|
| 51 |
+
ERBB2_CPT_LINKER_REGISTRY = {
|
| 52 |
+
'SSCpt': {
|
| 53 |
+
'class': DisulfideCamptothecin_PDC_Builder,
|
| 54 |
+
'frequency': 7,
|
| 55 |
+
'linker_id': 'LIN00005',
|
| 56 |
+
'description': 'Disulfide bond (ester + carbonate variants)',
|
| 57 |
+
'pdc_refs': ['PDC_00294', 'PDC_00295'],
|
| 58 |
+
},
|
| 59 |
+
'MalHexCpt': {
|
| 60 |
+
'class': MaleimideHexanoateCpt_PDC_Builder,
|
| 61 |
+
'frequency': 5,
|
| 62 |
+
'linker_id': 'LIN00031',
|
| 63 |
+
'description': '4-yl 6-(2,5-dioxo-2,5-dihydro-1H-pyrrol-1-yl)hexanoate',
|
| 64 |
+
'pdc_refs': ['PDC_00336'],
|
| 65 |
+
},
|
| 66 |
+
'DSPACpt': {
|
| 67 |
+
'class': DisulfanylpropanoicAcidCpt_PDC_Builder,
|
| 68 |
+
'frequency': 5,
|
| 69 |
+
'linker_id': 'LIN00012',
|
| 70 |
+
'description': '3-Disulfanylpropanoic acid',
|
| 71 |
+
'pdc_refs': ['PDC_00335'],
|
| 72 |
+
},
|
| 73 |
+
'DSECCpt': {
|
| 74 |
+
'class': DisulfanylethylCarbonateCpt_PDC_Builder,
|
| 75 |
+
'frequency': 5,
|
| 76 |
+
'linker_id': 'LIN00039',
|
| 77 |
+
'description': '2-(Disulfanyl)ethyl hydrogen carbonate',
|
| 78 |
+
'pdc_refs': ['PDC_00337'],
|
| 79 |
+
},
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _compute_linker_probabilities(registry=None):
|
| 84 |
+
"""Compute sampling probabilities from frequency counts."""
|
| 85 |
+
if registry is None:
|
| 86 |
+
registry = ERBB2_CPT_LINKER_REGISTRY
|
| 87 |
+
names = list(registry.keys())
|
| 88 |
+
freqs = np.array([registry[n]['frequency'] for n in names], dtype=np.float64)
|
| 89 |
+
probs = freqs / freqs.sum()
|
| 90 |
+
return names, probs
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class CamptothecinPDC_Factory:
|
| 94 |
+
"""
|
| 95 |
+
Drug-level PDC factory for Camptothecin conjugates.
|
| 96 |
+
|
| 97 |
+
Wraps all four ERBB2+Cpt linker builders and provides:
|
| 98 |
+
- 'sample' mode (default): randomly pick a linker per call according to
|
| 99 |
+
PDCdb entry frequencies
|
| 100 |
+
- 'all' mode: generate conjugates with every linker
|
| 101 |
+
- fixed linker mode: always use a specific linker (e.g. 'SSCpt')
|
| 102 |
+
|
| 103 |
+
The interface mirrors the individual builder's `conjugate_to_peptide` so it
|
| 104 |
+
can be used as a drop-in replacement in the Conjugation classifier.
|
| 105 |
+
"""
|
| 106 |
+
|
| 107 |
+
def __init__(self, linker='sample', rng_seed=None):
|
| 108 |
+
"""
|
| 109 |
+
Args:
|
| 110 |
+
linker: One of:
|
| 111 |
+
- 'sample' (default): sample linker per peptide according to
|
| 112 |
+
ERBB2+Cpt frequency weights
|
| 113 |
+
- 'all': use every linker and pool conjugates
|
| 114 |
+
- A specific linker name ('SSCpt', 'DSPACpt', 'MalHexCpt', 'DSECCpt')
|
| 115 |
+
rng_seed: Optional seed for reproducible linker sampling
|
| 116 |
+
"""
|
| 117 |
+
self.linker_mode = linker
|
| 118 |
+
self.registry = ERBB2_CPT_LINKER_REGISTRY
|
| 119 |
+
self.rng = np.random.default_rng(rng_seed)
|
| 120 |
+
|
| 121 |
+
# Pre-compute probabilities
|
| 122 |
+
self._linker_names, self._linker_probs = _compute_linker_probabilities(self.registry)
|
| 123 |
+
|
| 124 |
+
# Validate fixed linker
|
| 125 |
+
if linker not in ('sample', 'all') and linker not in self.registry:
|
| 126 |
+
raise ValueError(
|
| 127 |
+
f"Unknown linker '{linker}'. "
|
| 128 |
+
f"Choose from: 'sample', 'all', {list(self.registry.keys())}"
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
# Pre-instantiate builders (suppress verbose init output)
|
| 132 |
+
self._builders = {}
|
| 133 |
+
for name, entry in self.registry.items():
|
| 134 |
+
with contextlib.redirect_stdout(io.StringIO()):
|
| 135 |
+
self._builders[name] = entry['class']()
|
| 136 |
+
|
| 137 |
+
# Track which linker was last sampled (useful for logging)
|
| 138 |
+
self.last_sampled_linker = None
|
| 139 |
+
|
| 140 |
+
# ------------------------------------------------------------------
|
| 141 |
+
# Linker selection
|
| 142 |
+
# ------------------------------------------------------------------
|
| 143 |
+
|
| 144 |
+
def sample_linker(self):
|
| 145 |
+
"""Sample a linker name according to frequency weights."""
|
| 146 |
+
idx = self.rng.choice(len(self._linker_names), p=self._linker_probs)
|
| 147 |
+
return self._linker_names[idx]
|
| 148 |
+
|
| 149 |
+
def get_builder(self, linker_name):
|
| 150 |
+
"""Return the pre-instantiated builder for a given linker name."""
|
| 151 |
+
return self._builders[linker_name]
|
| 152 |
+
|
| 153 |
+
@property
|
| 154 |
+
def linker_probabilities(self):
|
| 155 |
+
"""Return dict of linker name → sampling probability."""
|
| 156 |
+
return dict(zip(self._linker_names, self._linker_probs.tolist()))
|
| 157 |
+
|
| 158 |
+
# ------------------------------------------------------------------
|
| 159 |
+
# Conjugation interface (mirrors individual builders)
|
| 160 |
+
# ------------------------------------------------------------------
|
| 161 |
+
|
| 162 |
+
def conjugate_to_peptide(self, peptide_smiles, exclude_n_terminus=True, return_all=True):
|
| 163 |
+
"""
|
| 164 |
+
Conjugate Camptothecin to peptide, selecting linker by mode.
|
| 165 |
+
|
| 166 |
+
Args:
|
| 167 |
+
peptide_smiles: SMILES string of peptide (must contain Lys -NH2)
|
| 168 |
+
exclude_n_terminus: If True, only conjugate at Lys ε-amines
|
| 169 |
+
return_all: If True, return all positional conjugates
|
| 170 |
+
|
| 171 |
+
Returns:
|
| 172 |
+
List of conjugate SMILES.
|
| 173 |
+
In 'sample' mode: conjugates from the sampled linker.
|
| 174 |
+
In 'all' mode: conjugates from every linker (pooled).
|
| 175 |
+
In fixed mode: conjugates from the specified linker.
|
| 176 |
+
"""
|
| 177 |
+
if self.linker_mode == 'all':
|
| 178 |
+
self.last_sampled_linker = 'all'
|
| 179 |
+
return self._conjugate_all_linkers(peptide_smiles, exclude_n_terminus, return_all)
|
| 180 |
+
elif self.linker_mode == 'sample':
|
| 181 |
+
chosen = self.sample_linker()
|
| 182 |
+
self.last_sampled_linker = chosen
|
| 183 |
+
return self._conjugate_with_linker(chosen, peptide_smiles, exclude_n_terminus, return_all)
|
| 184 |
+
else:
|
| 185 |
+
self.last_sampled_linker = self.linker_mode
|
| 186 |
+
return self._conjugate_with_linker(self.linker_mode, peptide_smiles, exclude_n_terminus, return_all)
|
| 187 |
+
|
| 188 |
+
def _conjugate_with_linker(self, linker_name, peptide_smiles, exclude_n_terminus, return_all):
|
| 189 |
+
"""Run conjugation with a single, specific linker."""
|
| 190 |
+
builder = self._builders[linker_name]
|
| 191 |
+
with contextlib.redirect_stdout(io.StringIO()):
|
| 192 |
+
conjugates = builder.conjugate_to_peptide(
|
| 193 |
+
peptide_smiles,
|
| 194 |
+
exclude_n_terminus=exclude_n_terminus,
|
| 195 |
+
return_all=return_all,
|
| 196 |
+
)
|
| 197 |
+
# Normalize to list
|
| 198 |
+
if not isinstance(conjugates, list):
|
| 199 |
+
conjugates = [conjugates] if conjugates else []
|
| 200 |
+
return conjugates
|
| 201 |
+
|
| 202 |
+
def _conjugate_all_linkers(self, peptide_smiles, exclude_n_terminus, return_all):
|
| 203 |
+
"""Generate conjugates with every linker and pool them."""
|
| 204 |
+
all_conjugates = []
|
| 205 |
+
seen = set()
|
| 206 |
+
for name in self._linker_names:
|
| 207 |
+
try:
|
| 208 |
+
conjs = self._conjugate_with_linker(name, peptide_smiles, exclude_n_terminus, return_all)
|
| 209 |
+
for c in conjs:
|
| 210 |
+
if c not in seen:
|
| 211 |
+
seen.add(c)
|
| 212 |
+
all_conjugates.append(c)
|
| 213 |
+
except Exception:
|
| 214 |
+
continue
|
| 215 |
+
if not all_conjugates:
|
| 216 |
+
raise ValueError("No conjugates generated with any linker")
|
| 217 |
+
return all_conjugates
|
| 218 |
+
|
| 219 |
+
# ------------------------------------------------------------------
|
| 220 |
+
# Validation (delegates to the appropriate builder)
|
| 221 |
+
# ------------------------------------------------------------------
|
| 222 |
+
|
| 223 |
+
def validate_conjugate(self, conjugate_smiles, linker_name=None):
|
| 224 |
+
"""
|
| 225 |
+
Validate a conjugate SMILES.
|
| 226 |
+
|
| 227 |
+
If linker_name is given, validate with that builder.
|
| 228 |
+
Otherwise, try all builders and return the best result.
|
| 229 |
+
"""
|
| 230 |
+
if linker_name and linker_name in self._builders:
|
| 231 |
+
return self._builders[linker_name].validate_conjugate(conjugate_smiles)
|
| 232 |
+
|
| 233 |
+
# Try each builder; return first passing result
|
| 234 |
+
for name, builder in self._builders.items():
|
| 235 |
+
passed, checks = builder.validate_conjugate(conjugate_smiles)
|
| 236 |
+
if passed:
|
| 237 |
+
checks['matched_linker'] = name
|
| 238 |
+
return True, checks
|
| 239 |
+
|
| 240 |
+
# None passed - return last failure
|
| 241 |
+
return False, checks
|
| 242 |
+
|
| 243 |
+
def check_attachment_site_compatibility(self, conjugate_smiles, linker_name=None):
|
| 244 |
+
"""Delegate to appropriate builder's attachment check."""
|
| 245 |
+
if linker_name and linker_name in self._builders:
|
| 246 |
+
return self._builders[linker_name].check_attachment_site_compatibility(conjugate_smiles)
|
| 247 |
+
|
| 248 |
+
for name, builder in self._builders.items():
|
| 249 |
+
passed, details = builder.check_attachment_site_compatibility(conjugate_smiles)
|
| 250 |
+
if passed:
|
| 251 |
+
details['matched_linker'] = name
|
| 252 |
+
return True, details
|
| 253 |
+
return False, details
|
| 254 |
+
|
| 255 |
+
# ------------------------------------------------------------------
|
| 256 |
+
# Batch interface
|
| 257 |
+
# ------------------------------------------------------------------
|
| 258 |
+
|
| 259 |
+
def batch_conjugate(self, peptide_smiles_list, exclude_n_terminus=True, return_all=True):
|
| 260 |
+
"""
|
| 261 |
+
Batch conjugate multiple peptides.
|
| 262 |
+
|
| 263 |
+
In 'sample' mode each peptide gets a freshly sampled linker.
|
| 264 |
+
|
| 265 |
+
Returns:
|
| 266 |
+
Dict with 'conjugates', 'successful', 'failed', 'total_conjugates',
|
| 267 |
+
and 'linkers_used' (list of linker names, one per peptide).
|
| 268 |
+
"""
|
| 269 |
+
results = {
|
| 270 |
+
'conjugates': [],
|
| 271 |
+
'successful': [],
|
| 272 |
+
'failed': [],
|
| 273 |
+
'total_conjugates': 0,
|
| 274 |
+
'linkers_used': [],
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
for i, peptide_smiles in enumerate(peptide_smiles_list):
|
| 278 |
+
try:
|
| 279 |
+
conjugates = self.conjugate_to_peptide(
|
| 280 |
+
peptide_smiles,
|
| 281 |
+
exclude_n_terminus=exclude_n_terminus,
|
| 282 |
+
return_all=return_all,
|
| 283 |
+
)
|
| 284 |
+
if return_all:
|
| 285 |
+
results['conjugates'].append(conjugates)
|
| 286 |
+
results['total_conjugates'] += len(conjugates)
|
| 287 |
+
else:
|
| 288 |
+
results['conjugates'].append(conjugates)
|
| 289 |
+
results['total_conjugates'] += 1
|
| 290 |
+
results['successful'].append(i)
|
| 291 |
+
# Track which linker was used (only meaningful in sample/fixed mode)
|
| 292 |
+
results['linkers_used'].append(
|
| 293 |
+
self.linker_mode if self.linker_mode not in ('sample', 'all') else None
|
| 294 |
+
)
|
| 295 |
+
except Exception as e:
|
| 296 |
+
results['conjugates'].append(None)
|
| 297 |
+
results['failed'].append((i, str(e)))
|
| 298 |
+
results['linkers_used'].append(None)
|
| 299 |
+
|
| 300 |
+
return results
|
| 301 |
+
|
| 302 |
+
# ------------------------------------------------------------------
|
| 303 |
+
# Informational
|
| 304 |
+
# ------------------------------------------------------------------
|
| 305 |
+
|
| 306 |
+
def __repr__(self):
|
| 307 |
+
probs = self.linker_probabilities
|
| 308 |
+
lines = [f"CamptothecinPDC_Factory(mode='{self.linker_mode}')"]
|
| 309 |
+
for name, prob in probs.items():
|
| 310 |
+
desc = self.registry[name]['description']
|
| 311 |
+
lines.append(f" {name:12s} p={prob:.3f} ({desc})")
|
| 312 |
+
return '\n'.join(lines)
|
smiles/conjugation/DSEBSN38.py
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
4-(2-Disulfanylethoxy)-4-oxobutanoic Acid - SN-38 PDC Builder (DSEBSN38)
|
| 3 |
+
|
| 4 |
+
Conjugation chemistry: Cysteine thiol + Linker thiol → Disulfide bond (S-S)
|
| 5 |
+
|
| 6 |
+
Reference PDCs:
|
| 7 |
+
- PDC_02100 (SN38(10)-4-CRGDK)
|
| 8 |
+
- PDC_02103 (SN38(10)-4-(Ac)CGVQIRGDK)
|
| 9 |
+
- PDC_02105 (SN38(10)-4-(Ac)CRGDK)
|
| 10 |
+
- PDC_02108 (SN38(10)-4-CDRGDK)
|
| 11 |
+
- PDC_02109 (SN38(10)-4-(Ac)CDRGDK)
|
| 12 |
+
- PDC_02111 (SN38(20)-4-CRGDK)
|
| 13 |
+
|
| 14 |
+
Receptor: NRP1 (Neuropilin-1)
|
| 15 |
+
Linker: LIN00168 - 4-(2-Disulfanylethoxy)-4-oxobutanoic acid
|
| 16 |
+
Payload: 7-Ethyl-10-hydroxycamptothecin (SN-38)
|
| 17 |
+
Architecture: Monomer, Cys thiol conjugation (disulfide bond)
|
| 18 |
+
|
| 19 |
+
Chemistry Note:
|
| 20 |
+
===============
|
| 21 |
+
LIN00168 provides a longer disulfide-ester linkage via succinic acid scaffold:
|
| 22 |
+
HOOC─CH₂─CH₂─C(═O)─O─CH₂─CH₂─S─SH
|
| 23 |
+
|
| 24 |
+
butanoic acid ethoxy-disulfanyl
|
| 25 |
+
|
| 26 |
+
Linker-to-Drug Connection (Ester at drug OH, thiol for peptide Cys):
|
| 27 |
+
SN-38(10-O)─C(═O)─CH₂─CH₂─C(═O)─O─CH₂─CH₂─SH
|
| 28 |
+
|
| 29 |
+
Full PDC Architecture:
|
| 30 |
+
Peptide──Cys──S─S──CH₂─CH₂─O─C(═O)─CH₂─CH₂─C(═O)─O──SN-38
|
| 31 |
+
└── disulfide ─┘ └── linker ──┘ └ ester ┘
|
| 32 |
+
|
| 33 |
+
Usage:
|
| 34 |
+
======
|
| 35 |
+
builder = DisulfanylethOxyButanoicAcidSN38_PDC_Builder()
|
| 36 |
+
conjugates = builder.conjugate_to_peptide(peptide_smiles)
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
from rdkit import Chem
|
| 40 |
+
from rdkit.Chem import AllChem, Descriptors
|
| 41 |
+
|
| 42 |
+
from .pdc_builder import PDC_Builder
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ============================================================================
|
| 46 |
+
# 4-(2-Disulfanylethoxy)-4-oxobutanoic Acid - SN-38 PDC Builder
|
| 47 |
+
# ============================================================================
|
| 48 |
+
|
| 49 |
+
class DisulfanylethOxyButanoicAcidSN38_PDC_Builder(PDC_Builder):
|
| 50 |
+
"""
|
| 51 |
+
PDC builder for 4-(2-Disulfanylethoxy)-4-oxobutanoic Acid + SN-38 conjugates
|
| 52 |
+
|
| 53 |
+
Conjugation chemistry (disulfide bond formation):
|
| 54 |
+
- Peptide site: Cysteine thiol (side chain -SH)
|
| 55 |
+
- Linker-payload site: Terminal thiol (-SH) on linker
|
| 56 |
+
- Reaction: Peptide-Cys-SH + HS-Linker-Drug → Peptide-Cys-S-S-Linker-Drug
|
| 57 |
+
|
| 58 |
+
Linker: LIN00168 - 4-(2-Disulfanylethoxy)-4-oxobutanoic acid
|
| 59 |
+
IUPAC: 4-(2-(disulfanyl)ethoxy)-4-oxobutanoic acid
|
| 60 |
+
Connectivity: HOOC-CH2-CH2-C(=O)-O-CH2-CH2-S-SH
|
| 61 |
+
Succinic acid monoester with 2-(disulfanyl)ethanol
|
| 62 |
+
|
| 63 |
+
Drug: 7-Ethyl-10-hydroxycamptothecin (SN-38)
|
| 64 |
+
Connection: Ester at C-10 hydroxyl (default) or C-20 hydroxyl
|
| 65 |
+
|
| 66 |
+
Reference: PDC_02100, PDC_02111 (NRP1)
|
| 67 |
+
"""
|
| 68 |
+
|
| 69 |
+
# ========================================================================
|
| 70 |
+
# Linker-Drug SMILES Variants
|
| 71 |
+
# ========================================================================
|
| 72 |
+
|
| 73 |
+
# SN-38 with DSEB ester at C-10 (phenolic OH), free thiol
|
| 74 |
+
# Drug(10-O)-C(=O)-CH2-CH2-C(=O)-O-CH2-CH2-SH
|
| 75 |
+
POS10_LINKER_SN38_SMILES = (
|
| 76 |
+
"CCC1(O)C(=O)OCc2c1cc1n(c2=O)Cc2c(CC)c3cc(OC(=O)CCC(=O)OCCS)ccc3nc2-1"
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
# SN-38 with DSEB ester at C-20 (tertiary OH), free thiol
|
| 80 |
+
POS20_LINKER_SN38_SMILES = (
|
| 81 |
+
"CCC1(OC(=O)CCC(=O)OCCS)C(=O)OCc2c1cc1n(c2=O)Cc2c(CC)c3cc(O)ccc3nc2-1"
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
DEFAULT_LINKER_SN38_SMILES = POS10_LINKER_SN38_SMILES
|
| 85 |
+
|
| 86 |
+
DRUG_POSITIONS = {
|
| 87 |
+
'10': POS10_LINKER_SN38_SMILES,
|
| 88 |
+
'20': POS20_LINKER_SN38_SMILES,
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
def __init__(self, linker_drug_smiles=None, drug_position='all'):
|
| 92 |
+
"""
|
| 93 |
+
Initialize DSEB-SN38 builder
|
| 94 |
+
|
| 95 |
+
Args:
|
| 96 |
+
linker_drug_smiles: Optional custom Linker-Drug SMILES.
|
| 97 |
+
drug_position: 'all' (default), '10', or '20'.
|
| 98 |
+
"""
|
| 99 |
+
self.drug_position = drug_position
|
| 100 |
+
|
| 101 |
+
if linker_drug_smiles is not None:
|
| 102 |
+
self._variants = [linker_drug_smiles]
|
| 103 |
+
self._variant_names = ['custom']
|
| 104 |
+
super().__init__(
|
| 105 |
+
linker_drug_smiles,
|
| 106 |
+
component_name="Custom-DSEB-SN38 (NRP1)"
|
| 107 |
+
)
|
| 108 |
+
elif drug_position == 'all':
|
| 109 |
+
self._variants = [self.POS10_LINKER_SN38_SMILES, self.POS20_LINKER_SN38_SMILES]
|
| 110 |
+
self._variant_names = ['pos10', 'pos20']
|
| 111 |
+
super().__init__(
|
| 112 |
+
self.POS10_LINKER_SN38_SMILES,
|
| 113 |
+
component_name="DSEB-SN38 (LIN00168, all positions)"
|
| 114 |
+
)
|
| 115 |
+
else:
|
| 116 |
+
if drug_position not in self.DRUG_POSITIONS:
|
| 117 |
+
raise ValueError(f"Unknown drug_position '{drug_position}'. "
|
| 118 |
+
f"Choose from: 'all', '10', '20'")
|
| 119 |
+
smiles = self.DRUG_POSITIONS[drug_position]
|
| 120 |
+
self._variants = [smiles]
|
| 121 |
+
self._variant_names = [f'pos{drug_position}']
|
| 122 |
+
super().__init__(
|
| 123 |
+
smiles,
|
| 124 |
+
component_name=f"DSEB-SN38 (LIN00168, C-{drug_position})"
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
def _verify_reactive_group(self):
|
| 128 |
+
"""Verify thiol group is present for disulfide bond formation"""
|
| 129 |
+
for atom in self.linker_payload_mol.GetAtoms():
|
| 130 |
+
if atom.GetAtomicNum() == 16:
|
| 131 |
+
if atom.GetTotalNumHs() >= 1 and atom.GetDegree() == 1:
|
| 132 |
+
print(" DSEB-SN38 loaded with free thiol reactive group")
|
| 133 |
+
break
|
| 134 |
+
else:
|
| 135 |
+
print(" Warning: No free thiol group detected in DSEB-SN38")
|
| 136 |
+
|
| 137 |
+
ester_pattern = Chem.MolFromSmarts('[#6][CX3](=[OX1])[OX2][#6]')
|
| 138 |
+
if self.linker_payload_mol.HasSubstructMatch(ester_pattern):
|
| 139 |
+
print(" Ester bond (linker-to-drug) detected")
|
| 140 |
+
else:
|
| 141 |
+
print(" Warning: No ester bond detected")
|
| 142 |
+
|
| 143 |
+
# ------------------------------------------------------------------
|
| 144 |
+
# Conjugation site finding (Cysteine thiol)
|
| 145 |
+
# ------------------------------------------------------------------
|
| 146 |
+
|
| 147 |
+
def find_conjugation_sites(self, peptide_smiles):
|
| 148 |
+
"""Find all free cysteine thiol groups in peptide"""
|
| 149 |
+
return self.find_peptide_cysteines(peptide_smiles)
|
| 150 |
+
|
| 151 |
+
def find_peptide_cysteines(self, peptide_smiles):
|
| 152 |
+
"""Find all free cysteine residues (thiol groups) in peptide"""
|
| 153 |
+
peptide_mol = Chem.MolFromSmiles(peptide_smiles)
|
| 154 |
+
if peptide_mol is None:
|
| 155 |
+
raise ValueError("Invalid peptide SMILES")
|
| 156 |
+
|
| 157 |
+
cysteines = []
|
| 158 |
+
for atom in peptide_mol.GetAtoms():
|
| 159 |
+
if atom.GetAtomicNum() == 16:
|
| 160 |
+
total_hs = atom.GetTotalNumHs()
|
| 161 |
+
degree = atom.GetDegree()
|
| 162 |
+
if total_hs >= 1 and degree == 1:
|
| 163 |
+
cysteines.append({
|
| 164 |
+
'atom_idx': atom.GetIdx(),
|
| 165 |
+
'num_hydrogens': total_hs,
|
| 166 |
+
'neighbors': [peptide_mol.GetAtomWithIdx(n.GetIdx()).GetSymbol()
|
| 167 |
+
for n in atom.GetNeighbors()],
|
| 168 |
+
'type': 'Cys_thiol'
|
| 169 |
+
})
|
| 170 |
+
return cysteines
|
| 171 |
+
|
| 172 |
+
# ------------------------------------------------------------------
|
| 173 |
+
# Conjugation reaction (disulfide bond formation)
|
| 174 |
+
# ------------------------------------------------------------------
|
| 175 |
+
|
| 176 |
+
def _perform_conjugation(self, peptide_mol, site_info, exclude_n_terminus=True):
|
| 177 |
+
"""Form disulfide bond between peptide Cys thiol and linker-drug thiol."""
|
| 178 |
+
all_conjugates = []
|
| 179 |
+
seen = set()
|
| 180 |
+
|
| 181 |
+
for variant_smiles in self._variants:
|
| 182 |
+
variant_mol = Chem.MolFromSmiles(variant_smiles)
|
| 183 |
+
if variant_mol is None:
|
| 184 |
+
continue
|
| 185 |
+
|
| 186 |
+
disulfide_rxn = AllChem.ReactionFromSmarts(
|
| 187 |
+
'[C:1][SH:2].[SH:3][C:4]>>[C:1][S:2][S:3][C:4]'
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
products = disulfide_rxn.RunReactants((peptide_mol, variant_mol))
|
| 191 |
+
if not products:
|
| 192 |
+
continue
|
| 193 |
+
|
| 194 |
+
for product_tuple in products:
|
| 195 |
+
try:
|
| 196 |
+
conjugate_mol = product_tuple[0]
|
| 197 |
+
Chem.SanitizeMol(conjugate_mol)
|
| 198 |
+
conjugate_smiles = Chem.MolToSmiles(conjugate_mol)
|
| 199 |
+
if conjugate_smiles not in seen:
|
| 200 |
+
seen.add(conjugate_smiles)
|
| 201 |
+
all_conjugates.append(conjugate_smiles)
|
| 202 |
+
except Exception:
|
| 203 |
+
continue
|
| 204 |
+
|
| 205 |
+
if not all_conjugates:
|
| 206 |
+
raise ValueError("Disulfide bond formation failed - no products generated")
|
| 207 |
+
|
| 208 |
+
return all_conjugates
|
| 209 |
+
|
| 210 |
+
def conjugate_to_peptide(self, peptide_smiles, exclude_n_terminus=True, return_all=True):
|
| 211 |
+
"""
|
| 212 |
+
Conjugate DSEB-SN38 to peptide via disulfide bond at Cys thiol.
|
| 213 |
+
|
| 214 |
+
Args:
|
| 215 |
+
peptide_smiles: SMILES string of peptide (must contain Cys)
|
| 216 |
+
exclude_n_terminus: Not used for Cys conjugation (kept for interface)
|
| 217 |
+
return_all: If True, return all possible conjugates
|
| 218 |
+
|
| 219 |
+
Returns:
|
| 220 |
+
List of all possible conjugate SMILES
|
| 221 |
+
"""
|
| 222 |
+
peptide_mol = Chem.MolFromSmiles(peptide_smiles)
|
| 223 |
+
if peptide_mol is None:
|
| 224 |
+
raise ValueError(f"Invalid peptide SMILES: {peptide_smiles}")
|
| 225 |
+
|
| 226 |
+
sites = self.find_conjugation_sites(peptide_smiles)
|
| 227 |
+
print(f"Found {len(sites)} Cys thiol site(s)")
|
| 228 |
+
|
| 229 |
+
conjugates = self._perform_conjugation(peptide_mol, None, exclude_n_terminus)
|
| 230 |
+
|
| 231 |
+
pos_label = f"C-{self.drug_position}" if self.drug_position != 'all' else "C-10 + C-20"
|
| 232 |
+
print(f"Generated {len(conjugates)} unique conjugate(s) [Cys disulfide] [DSEB {pos_label}]")
|
| 233 |
+
|
| 234 |
+
if return_all:
|
| 235 |
+
return conjugates
|
| 236 |
+
else:
|
| 237 |
+
return conjugates[0] if conjugates else None
|
| 238 |
+
|
| 239 |
+
def batch_conjugate(self, peptide_smiles_list, exclude_n_terminus=True, return_all=True):
|
| 240 |
+
"""Batch conjugate multiple peptides."""
|
| 241 |
+
results = {
|
| 242 |
+
'conjugates': [], 'successful': [], 'failed': [], 'total_conjugates': 0
|
| 243 |
+
}
|
| 244 |
+
for i, peptide_smiles in enumerate(peptide_smiles_list):
|
| 245 |
+
try:
|
| 246 |
+
conjugates = self.conjugate_to_peptide(
|
| 247 |
+
peptide_smiles, exclude_n_terminus=exclude_n_terminus, return_all=return_all
|
| 248 |
+
)
|
| 249 |
+
if return_all:
|
| 250 |
+
results['conjugates'].append(conjugates)
|
| 251 |
+
results['total_conjugates'] += len(conjugates)
|
| 252 |
+
else:
|
| 253 |
+
results['conjugates'].append(conjugates)
|
| 254 |
+
results['total_conjugates'] += 1
|
| 255 |
+
results['successful'].append(i)
|
| 256 |
+
except Exception as e:
|
| 257 |
+
print(f" Peptide {i} failed: {e}")
|
| 258 |
+
results['conjugates'].append(None)
|
| 259 |
+
results['failed'].append((i, str(e)))
|
| 260 |
+
|
| 261 |
+
print(f"\n=== Summary ===")
|
| 262 |
+
print(f"Peptides: {len(peptide_smiles_list)}, Success: {len(results['successful'])}, "
|
| 263 |
+
f"Failed: {len(results['failed'])}")
|
| 264 |
+
print(f"Total conjugates generated: {results['total_conjugates']}")
|
| 265 |
+
return results
|
| 266 |
+
|
| 267 |
+
def flatten_batch_results(self, results):
|
| 268 |
+
"""Flatten batch conjugation results into a single list"""
|
| 269 |
+
all_conjugates = []
|
| 270 |
+
for conjugate_list in results['conjugates']:
|
| 271 |
+
if conjugate_list is not None:
|
| 272 |
+
if isinstance(conjugate_list, list):
|
| 273 |
+
all_conjugates.extend(conjugate_list)
|
| 274 |
+
else:
|
| 275 |
+
all_conjugates.append(conjugate_list)
|
| 276 |
+
return all_conjugates
|
| 277 |
+
|
| 278 |
+
# ========================================================================
|
| 279 |
+
# Validation
|
| 280 |
+
# ========================================================================
|
| 281 |
+
|
| 282 |
+
def has_disulfide_bond(self, mol):
|
| 283 |
+
"""Check if molecule has disulfide bond (S-S)"""
|
| 284 |
+
disulfide_pattern = Chem.MolFromSmarts('[SX2]-[SX2]')
|
| 285 |
+
return mol.HasSubstructMatch(disulfide_pattern)
|
| 286 |
+
|
| 287 |
+
def has_free_thiol(self, mol):
|
| 288 |
+
"""Check if molecule has unconjugated (free) thiol groups"""
|
| 289 |
+
for atom in mol.GetAtoms():
|
| 290 |
+
if atom.GetAtomicNum() == 16:
|
| 291 |
+
if atom.GetTotalNumHs() >= 1 and atom.GetDegree() == 1:
|
| 292 |
+
return True
|
| 293 |
+
return False
|
| 294 |
+
|
| 295 |
+
def has_ester_bond_to_drug(self, mol):
|
| 296 |
+
"""Check for ester bond connecting linker to drug"""
|
| 297 |
+
ester_pattern = Chem.MolFromSmarts('[#6][CX3](=[OX1])[OX2][#6]')
|
| 298 |
+
return mol.HasSubstructMatch(ester_pattern)
|
| 299 |
+
|
| 300 |
+
def has_sn38_core(self, mol):
|
| 301 |
+
"""Check for SN-38 core structure"""
|
| 302 |
+
cpt_core = Chem.MolFromSmarts('c1ccc2ncc3C(=O)OCc3c2c1')
|
| 303 |
+
return mol.HasSubstructMatch(cpt_core)
|
| 304 |
+
|
| 305 |
+
def check_attachment_site_compatibility(self, conjugate_smiles):
|
| 306 |
+
"""Check attachment site compatibility for Cys-SS-DSEB-SN38 conjugation."""
|
| 307 |
+
mol = Chem.MolFromSmiles(conjugate_smiles)
|
| 308 |
+
if mol is None:
|
| 309 |
+
return False, {'parseable': False, 'error': 'Could not parse SMILES'}
|
| 310 |
+
|
| 311 |
+
details = {
|
| 312 |
+
'parseable': True,
|
| 313 |
+
'validation_method': 'cys_disulfide_ester',
|
| 314 |
+
'linker': 'LIN00168 (4-(2-Disulfanylethoxy)-4-oxobutanoic acid)',
|
| 315 |
+
'drug': 'SN-38 (7-Ethyl-10-hydroxycamptothecin)',
|
| 316 |
+
}
|
| 317 |
+
details['has_disulfide_bond'] = self.has_disulfide_bond(mol)
|
| 318 |
+
details['no_free_thiol'] = not self.has_free_thiol(mol)
|
| 319 |
+
details['has_ester_bond'] = self.has_ester_bond_to_drug(mol)
|
| 320 |
+
|
| 321 |
+
passed = all([details['has_disulfide_bond'], details['no_free_thiol']])
|
| 322 |
+
return passed, details
|
| 323 |
+
|
| 324 |
+
def _basic_validation(self, mol):
|
| 325 |
+
"""Basic validation checks for DSEB-SN38 conjugates."""
|
| 326 |
+
checks = {}
|
| 327 |
+
checks['has_disulfide_bond'] = mol.HasSubstructMatch(Chem.MolFromSmarts('[#16]-[#16]'))
|
| 328 |
+
checks['no_free_thiol'] = not self.has_free_thiol(mol)
|
| 329 |
+
checks['has_ester_bond'] = self.has_ester_bond_to_drug(mol)
|
| 330 |
+
checks['has_sn38_core'] = self.has_sn38_core(mol)
|
| 331 |
+
checks['_passed'] = all([checks['has_disulfide_bond'], checks['no_free_thiol']])
|
| 332 |
+
return checks
|
| 333 |
+
|
| 334 |
+
def validate_conjugate(self, conjugate_smiles):
|
| 335 |
+
"""Validate that the conjugate is chemically reasonable."""
|
| 336 |
+
mol = Chem.MolFromSmiles(conjugate_smiles)
|
| 337 |
+
if mol is None:
|
| 338 |
+
return False, {'error': 'Could not parse SMILES'}
|
| 339 |
+
checks = self._basic_validation(mol)
|
| 340 |
+
checks['parseable'] = True
|
| 341 |
+
passed = checks.get('_passed', True)
|
| 342 |
+
if '_passed' in checks:
|
| 343 |
+
del checks['_passed']
|
| 344 |
+
return passed, checks
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
# ============================================================================
|
| 348 |
+
# Main - Test Examples
|
| 349 |
+
# ============================================================================
|
| 350 |
+
|
| 351 |
+
if __name__ == "__main__":
|
| 352 |
+
|
| 353 |
+
print("=" * 70)
|
| 354 |
+
print("DSEB-SN38 PDC Builder - Test")
|
| 355 |
+
print("Reference: NRP1 + LIN00168 + SN-38")
|
| 356 |
+
print("=" * 70 + "\n")
|
| 357 |
+
|
| 358 |
+
test_peptides = [
|
| 359 |
+
"NCC(=O)N[C@@H](CS)C(=O)NCC(=O)O", # Gly-Cys-Gly
|
| 360 |
+
]
|
| 361 |
+
|
| 362 |
+
builder = DisulfanylethOxyButanoicAcidSN38_PDC_Builder()
|
| 363 |
+
|
| 364 |
+
for i, peptide in enumerate(test_peptides):
|
| 365 |
+
print(f"\n--- Test peptide {i+1} ---")
|
| 366 |
+
print(f"Input: {peptide}")
|
| 367 |
+
|
| 368 |
+
try:
|
| 369 |
+
conjugates = builder.conjugate_to_peptide(peptide)
|
| 370 |
+
print(f" Generated {len(conjugates)} conjugate(s)")
|
| 371 |
+
for j, conj in enumerate(conjugates):
|
| 372 |
+
valid, checks = builder.validate_conjugate(conj)
|
| 373 |
+
print(f" Conjugate {j+1}: {'PASSED' if valid else 'FAILED'}")
|
| 374 |
+
except Exception as e:
|
| 375 |
+
print(f" FAILED: {e}")
|
| 376 |
+
import traceback
|
| 377 |
+
traceback.print_exc()
|
smiles/conjugation/DSECCpt.py
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Disulfanylethyl Carbonate - Camptothecin PDC Builder (DSECCpt)
|
| 3 |
+
|
| 4 |
+
Conjugation chemistry: Lysine ε-amine + Cys-SS-Linker-Camptothecin (amide bond formation)
|
| 5 |
+
|
| 6 |
+
Reference PDC:
|
| 7 |
+
- PDC_00337 (SMAC-P1 CPT conjugate 7)
|
| 8 |
+
|
| 9 |
+
Receptor: ERBB2 (Receptor tyrosine-protein kinase erbB-2)
|
| 10 |
+
Linker: LIN00039 - 2-(Disulfanyl)ethyl hydrogen carbonate (disulfide + carbonate)
|
| 11 |
+
Payload: DRG00009 - Camptothecin
|
| 12 |
+
Architecture: Monomer, Lys side chain conjugation
|
| 13 |
+
|
| 14 |
+
Chemistry Note:
|
| 15 |
+
===============
|
| 16 |
+
LIN00039 (2-(Disulfanyl)ethyl hydrogen carbonate) provides a disulfide-carbonate linkage:
|
| 17 |
+
HS─S─CH₂─CH₂─O─C(═O)─OH
|
| 18 |
+
|
| 19 |
+
Linker-to-Drug Connection (Carbonate):
|
| 20 |
+
Cys─S─S─CH₂─CH₂─O─C(═O)─O─Camptothecin
|
| 21 |
+
│
|
| 22 |
+
carbonate bond (C-20 OH of CPT)
|
| 23 |
+
|
| 24 |
+
Full PDC Architecture:
|
| 25 |
+
Peptide──Lys──NH──C(═O)──Cys──S─S──CH₂─CH₂─O──C(═O)─O──Camptothecin
|
| 26 |
+
└── amide ──┘ └── disulfide ──┘ └── carbonate ──┘
|
| 27 |
+
|
| 28 |
+
Note: This linker chemistry is equivalent to the SSCpt carbonate variant (Z8/PDC_00295).
|
| 29 |
+
|
| 30 |
+
Usage:
|
| 31 |
+
======
|
| 32 |
+
builder = DisulfanylethylCarbonateCpt_PDC_Builder()
|
| 33 |
+
conjugates = builder.conjugate_to_peptide(peptide_smiles)
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
from rdkit import Chem
|
| 37 |
+
from rdkit.Chem import AllChem, Descriptors
|
| 38 |
+
|
| 39 |
+
from .pdc_builder import PDC_Builder
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ============================================================================
|
| 43 |
+
# Disulfanylethyl Carbonate - Camptothecin PDC Builder
|
| 44 |
+
# ============================================================================
|
| 45 |
+
|
| 46 |
+
class DisulfanylethylCarbonateCpt_PDC_Builder(PDC_Builder):
|
| 47 |
+
"""
|
| 48 |
+
PDC builder for 2-(Disulfanyl)ethyl Carbonate + Camptothecin conjugates
|
| 49 |
+
|
| 50 |
+
Conjugation chemistry (amide bond formation):
|
| 51 |
+
- Peptide site: Lysine ε-amine (side chain -NH2)
|
| 52 |
+
- Linker-payload site: Carboxylic acid of Cys (Cys-SS-Linker-Drug)
|
| 53 |
+
- Reaction: Lys-NH2 + HOOC-Cys-SS-Linker-Drug → Lys-NH-CO-Cys-SS-Linker-Drug
|
| 54 |
+
|
| 55 |
+
Linker: LIN00039 - 2-(Disulfanyl)ethyl hydrogen carbonate
|
| 56 |
+
SMILES: C(CSS)OC(=O)O
|
| 57 |
+
IUPAC: 2-(disulfanyl)ethyl hydrogen carbonate
|
| 58 |
+
Connectivity: HS-S-CH2-CH2-O-C(=O)-OH
|
| 59 |
+
MW: 154.21, MF: C3H6O3S2
|
| 60 |
+
|
| 61 |
+
Drug: DRG00009 - Camptothecin
|
| 62 |
+
Canonical SMILES: CCC1(O)C(=O)OCc2c1cc1n(c2=O)Cc2cc3ccccc3nc2-1
|
| 63 |
+
Connection: Carbonate at C-20 hydroxyl
|
| 64 |
+
|
| 65 |
+
Reference: PDC_00337 (SMAC-P1 CPT conjugate 7, ERBB2)
|
| 66 |
+
"""
|
| 67 |
+
|
| 68 |
+
# ========================================================================
|
| 69 |
+
# Cys-SS-Linker-Drug SMILES
|
| 70 |
+
# ========================================================================
|
| 71 |
+
#
|
| 72 |
+
# Pre-formed Cys-DisulfanylethylCarbonate-Camptothecin assembly:
|
| 73 |
+
# HOOC─Cys(NH₂)─CH₂─S─S─CH₂─CH₂─O─C(═O)─O─CPT(C-20)
|
| 74 |
+
#
|
| 75 |
+
# Cys provides COOH for amide bond with Lys ε-amine
|
| 76 |
+
# Disulfide bond (S-S) is cleavable inside cells
|
| 77 |
+
# Carbonate bond (O-C(=O)-O) connects linker to CPT C-20 hydroxyl
|
| 78 |
+
#
|
| 79 |
+
# ========================================================================
|
| 80 |
+
|
| 81 |
+
CYS_SS_LINKER_CPT_SMILES = (
|
| 82 |
+
"N[C@H](CSSCCOC(=O)OC1(CC)C(=O)OCc2c1cc1n(c2=O)Cc2cc3ccccc3nc2-1)C(=O)O"
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
def __init__(self, cys_linker_drug_smiles=None):
|
| 86 |
+
"""
|
| 87 |
+
Initialize Disulfanylethyl Carbonate-Camptothecin builder
|
| 88 |
+
|
| 89 |
+
Args:
|
| 90 |
+
cys_linker_drug_smiles: Optional custom Cys-SS-Linker-Drug SMILES.
|
| 91 |
+
If None, uses default LIN00039+CPT assembly.
|
| 92 |
+
Must have free carboxylic acid for conjugation.
|
| 93 |
+
"""
|
| 94 |
+
if cys_linker_drug_smiles is None:
|
| 95 |
+
cys_linker_drug_smiles = self.CYS_SS_LINKER_CPT_SMILES
|
| 96 |
+
|
| 97 |
+
super().__init__(
|
| 98 |
+
cys_linker_drug_smiles,
|
| 99 |
+
component_name="Cys-SS-DisulfanylethylCarbonate-Camptothecin (LIN00039+DRG00009)"
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
def _verify_reactive_group(self):
|
| 103 |
+
"""Verify carboxylic acid group is present for amide bond formation"""
|
| 104 |
+
# Pattern: Carboxylic acid (COOH)
|
| 105 |
+
carboxylic_acid_pattern = Chem.MolFromSmarts('[CX3](=O)[OX2H1]')
|
| 106 |
+
|
| 107 |
+
if not self.linker_payload_mol.HasSubstructMatch(carboxylic_acid_pattern):
|
| 108 |
+
print(" Warning: No carboxylic acid group detected in Cys-SS-Linker-Drug")
|
| 109 |
+
else:
|
| 110 |
+
print(" Cys-SS-DSEC-Camptothecin loaded with carboxylic acid reactive group")
|
| 111 |
+
|
| 112 |
+
# Verify disulfide bond is present
|
| 113 |
+
disulfide_pattern = Chem.MolFromSmarts('[#16]-[#16]')
|
| 114 |
+
if self.linker_payload_mol.HasSubstructMatch(disulfide_pattern):
|
| 115 |
+
print(" Disulfide bond (S-S) detected")
|
| 116 |
+
else:
|
| 117 |
+
print(" Warning: No disulfide bond detected - check structure")
|
| 118 |
+
|
| 119 |
+
# Verify Cys α-amine is present
|
| 120 |
+
cys_amine_pattern = Chem.MolFromSmarts('[NH2][CH]([CH2]S)C(=O)')
|
| 121 |
+
if self.linker_payload_mol.HasSubstructMatch(cys_amine_pattern):
|
| 122 |
+
print(" Cys α-amine detected (will be free in conjugate)")
|
| 123 |
+
else:
|
| 124 |
+
print(" Warning: Cys α-amine pattern not detected")
|
| 125 |
+
|
| 126 |
+
# Verify carbonate bond to drug (O-C(=O)-O pattern)
|
| 127 |
+
carbonate_pattern = Chem.MolFromSmarts('[OX2][CX3](=[OX1])[OX2]')
|
| 128 |
+
if self.linker_payload_mol.HasSubstructMatch(carbonate_pattern):
|
| 129 |
+
print(" Carbonate bond (linker-to-drug) detected")
|
| 130 |
+
else:
|
| 131 |
+
print(" Warning: No carbonate bond detected - check linker-drug connection")
|
| 132 |
+
|
| 133 |
+
def find_conjugation_sites(self, peptide_smiles):
|
| 134 |
+
"""
|
| 135 |
+
Find all lysine ε-amine groups in peptide
|
| 136 |
+
|
| 137 |
+
Returns list of dicts with site information including atom index
|
| 138 |
+
"""
|
| 139 |
+
return self.find_peptide_lysines(peptide_smiles)
|
| 140 |
+
|
| 141 |
+
def find_peptide_lysines(self, peptide_smiles):
|
| 142 |
+
"""
|
| 143 |
+
Find all lysine residues with free ε-amine in peptide
|
| 144 |
+
|
| 145 |
+
Lysine side chain: -CH2-CH2-CH2-CH2-NH2 (CCCCN)
|
| 146 |
+
|
| 147 |
+
Returns:
|
| 148 |
+
List of dicts with 'atom_idx' (nitrogen index) and metadata
|
| 149 |
+
"""
|
| 150 |
+
peptide_mol = Chem.MolFromSmiles(peptide_smiles)
|
| 151 |
+
if peptide_mol is None:
|
| 152 |
+
raise ValueError("Invalid peptide SMILES")
|
| 153 |
+
|
| 154 |
+
lysines = []
|
| 155 |
+
|
| 156 |
+
lys_pattern = Chem.MolFromSmarts('[CH2][CH2][CH2][CH2][NH2]')
|
| 157 |
+
matches = peptide_mol.GetSubstructMatches(lys_pattern)
|
| 158 |
+
|
| 159 |
+
for match in matches:
|
| 160 |
+
nitrogen_idx = match[4]
|
| 161 |
+
lysines.append({
|
| 162 |
+
'atom_idx': nitrogen_idx,
|
| 163 |
+
'side_chain_atoms': list(match),
|
| 164 |
+
'type': 'Lys_epsilon_amine'
|
| 165 |
+
})
|
| 166 |
+
|
| 167 |
+
n_terminus_pattern = Chem.MolFromSmarts('[NH2][CH,CH2][C](=O)')
|
| 168 |
+
n_term_matches = peptide_mol.GetSubstructMatches(n_terminus_pattern)
|
| 169 |
+
|
| 170 |
+
if n_term_matches:
|
| 171 |
+
print(f" (Also found {len(n_term_matches)} N-terminus α-amine(s) - excluded by default)")
|
| 172 |
+
|
| 173 |
+
return lysines
|
| 174 |
+
|
| 175 |
+
def _perform_conjugation(self, peptide_mol, site_info, exclude_n_terminus=True):
|
| 176 |
+
"""
|
| 177 |
+
Perform amide bond conjugation between NH2 groups and Cys carboxylic acid
|
| 178 |
+
|
| 179 |
+
Args:
|
| 180 |
+
peptide_mol: RDKit Mol object
|
| 181 |
+
site_info: Not used (kept for compatibility)
|
| 182 |
+
exclude_n_terminus: If True, skip N-terminus conjugation
|
| 183 |
+
|
| 184 |
+
Returns: List of all possible conjugate SMILES
|
| 185 |
+
"""
|
| 186 |
+
if exclude_n_terminus:
|
| 187 |
+
amide_rxn = AllChem.ReactionFromSmarts(
|
| 188 |
+
'[*:5][CH2][CH2][CH2][CH2:4][NH2:1].[C:2](=[O:3])[OH]>>[*:5][CH2][CH2][CH2][CH2:4][NH:1][C:2](=[O:3])'
|
| 189 |
+
)
|
| 190 |
+
else:
|
| 191 |
+
amide_rxn = AllChem.ReactionFromSmarts(
|
| 192 |
+
'[*:4][NH2:1].[C:2](=[O:3])[OH]>>[*:4][NH:1][C:2](=[O:3])'
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
products = amide_rxn.RunReactants((peptide_mol, self.linker_payload_mol))
|
| 196 |
+
|
| 197 |
+
if not products:
|
| 198 |
+
raise ValueError("Amide bond formation reaction failed - no products generated")
|
| 199 |
+
|
| 200 |
+
conjugates = []
|
| 201 |
+
seen_smiles = set()
|
| 202 |
+
|
| 203 |
+
for product_tuple in products:
|
| 204 |
+
try:
|
| 205 |
+
conjugate_mol = product_tuple[0]
|
| 206 |
+
Chem.SanitizeMol(conjugate_mol)
|
| 207 |
+
conjugate_smiles = Chem.MolToSmiles(conjugate_mol)
|
| 208 |
+
|
| 209 |
+
if conjugate_smiles not in seen_smiles:
|
| 210 |
+
seen_smiles.add(conjugate_smiles)
|
| 211 |
+
conjugates.append(conjugate_smiles)
|
| 212 |
+
except Exception:
|
| 213 |
+
continue
|
| 214 |
+
|
| 215 |
+
if not conjugates:
|
| 216 |
+
raise ValueError("All products failed sanitization")
|
| 217 |
+
|
| 218 |
+
return conjugates
|
| 219 |
+
|
| 220 |
+
def conjugate_to_peptide(self, peptide_smiles, exclude_n_terminus=True, return_all=True):
|
| 221 |
+
"""
|
| 222 |
+
Conjugate Cys-SS-DSEC-Camptothecin to peptide via amide bond at NH2 groups
|
| 223 |
+
|
| 224 |
+
Args:
|
| 225 |
+
peptide_smiles: SMILES string of peptide (must contain Lys with NH2)
|
| 226 |
+
exclude_n_terminus: If True (default), only conjugate at Lys ε-amines
|
| 227 |
+
return_all: If True, return all possible conjugates
|
| 228 |
+
|
| 229 |
+
Returns:
|
| 230 |
+
List of all possible conjugate SMILES
|
| 231 |
+
"""
|
| 232 |
+
peptide_mol = Chem.MolFromSmiles(peptide_smiles)
|
| 233 |
+
if peptide_mol is None:
|
| 234 |
+
raise ValueError(f"Invalid peptide SMILES: {peptide_smiles}")
|
| 235 |
+
|
| 236 |
+
sites = self.find_conjugation_sites(peptide_smiles)
|
| 237 |
+
print(f"Found {len(sites)} Lys ε-amine site(s)")
|
| 238 |
+
|
| 239 |
+
conjugates = self._perform_conjugation(peptide_mol, None, exclude_n_terminus)
|
| 240 |
+
|
| 241 |
+
site_type = "Lys ε-amine only" if exclude_n_terminus else "all NH2 (including N-terminus)"
|
| 242 |
+
print(f"Generated {len(conjugates)} unique conjugate(s) [{site_type}] [DSEC carbonate]")
|
| 243 |
+
|
| 244 |
+
if return_all:
|
| 245 |
+
return conjugates
|
| 246 |
+
else:
|
| 247 |
+
return conjugates[0] if conjugates else None
|
| 248 |
+
|
| 249 |
+
def batch_conjugate(self, peptide_smiles_list, exclude_n_terminus=True, return_all=True):
|
| 250 |
+
"""
|
| 251 |
+
Batch conjugate multiple peptides
|
| 252 |
+
|
| 253 |
+
Args:
|
| 254 |
+
peptide_smiles_list: List of peptide SMILES strings
|
| 255 |
+
exclude_n_terminus: If True (default), only conjugate at Lys ε-amines
|
| 256 |
+
return_all: If True, return all possible conjugates per peptide
|
| 257 |
+
|
| 258 |
+
Returns:
|
| 259 |
+
Dict with 'conjugates', 'successful', 'failed', 'total_conjugates'
|
| 260 |
+
"""
|
| 261 |
+
results = {
|
| 262 |
+
'conjugates': [],
|
| 263 |
+
'successful': [],
|
| 264 |
+
'failed': [],
|
| 265 |
+
'total_conjugates': 0
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
for i, peptide_smiles in enumerate(peptide_smiles_list):
|
| 269 |
+
try:
|
| 270 |
+
conjugates = self.conjugate_to_peptide(
|
| 271 |
+
peptide_smiles,
|
| 272 |
+
exclude_n_terminus=exclude_n_terminus,
|
| 273 |
+
return_all=return_all
|
| 274 |
+
)
|
| 275 |
+
if return_all:
|
| 276 |
+
results['conjugates'].append(conjugates)
|
| 277 |
+
results['total_conjugates'] += len(conjugates)
|
| 278 |
+
else:
|
| 279 |
+
results['conjugates'].append(conjugates)
|
| 280 |
+
results['total_conjugates'] += 1
|
| 281 |
+
results['successful'].append(i)
|
| 282 |
+
except Exception as e:
|
| 283 |
+
print(f" Peptide {i} failed: {e}")
|
| 284 |
+
results['conjugates'].append(None)
|
| 285 |
+
results['failed'].append((i, str(e)))
|
| 286 |
+
|
| 287 |
+
print(f"\n=== Summary ===")
|
| 288 |
+
print(f"Peptides: {len(peptide_smiles_list)}, Success: {len(results['successful'])}, Failed: {len(results['failed'])}")
|
| 289 |
+
print(f"Total conjugates generated: {results['total_conjugates']}")
|
| 290 |
+
|
| 291 |
+
return results
|
| 292 |
+
|
| 293 |
+
def flatten_batch_results(self, results):
|
| 294 |
+
"""Flatten batch conjugation results into a single list"""
|
| 295 |
+
all_conjugates = []
|
| 296 |
+
for conjugate_list in results['conjugates']:
|
| 297 |
+
if conjugate_list is not None:
|
| 298 |
+
if isinstance(conjugate_list, list):
|
| 299 |
+
all_conjugates.extend(conjugate_list)
|
| 300 |
+
else:
|
| 301 |
+
all_conjugates.append(conjugate_list)
|
| 302 |
+
return all_conjugates
|
| 303 |
+
|
| 304 |
+
# ========================================================================
|
| 305 |
+
# Attachment Site Compatibility Checks
|
| 306 |
+
# ========================================================================
|
| 307 |
+
|
| 308 |
+
def has_lys_cys_amide_conjugation(self, mol):
|
| 309 |
+
"""Check if molecule has Lys-Cys amide conjugation bond"""
|
| 310 |
+
lys_cys_amide_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[CH]([CH2][SX2][SX2])[NX3]')
|
| 311 |
+
return mol.HasSubstructMatch(lys_cys_amide_pattern)
|
| 312 |
+
|
| 313 |
+
def has_disulfide_bond(self, mol):
|
| 314 |
+
"""Check if molecule has disulfide bond (S-S)"""
|
| 315 |
+
disulfide_pattern = Chem.MolFromSmarts('[SX2]-[SX2]')
|
| 316 |
+
return mol.HasSubstructMatch(disulfide_pattern)
|
| 317 |
+
|
| 318 |
+
def has_carbonate_bond(self, mol):
|
| 319 |
+
"""
|
| 320 |
+
Check if molecule has carbonate bond (O-C(=O)-O)
|
| 321 |
+
|
| 322 |
+
This specifically checks for the carbonate linkage between linker and drug.
|
| 323 |
+
"""
|
| 324 |
+
carbonate_pattern = Chem.MolFromSmarts('[OX2][CX3](=[OX1])[OX2]')
|
| 325 |
+
return mol.HasSubstructMatch(carbonate_pattern)
|
| 326 |
+
|
| 327 |
+
def has_unreacted_lys_epsilon_amine(self, mol):
|
| 328 |
+
"""Check for unreacted Lys ε-amine (free NH2 on 4-carbon side chain)"""
|
| 329 |
+
lys_amine_pattern = Chem.MolFromSmarts('[CH2][CH2][CH2][CH2][NH2]')
|
| 330 |
+
return mol.HasSubstructMatch(lys_amine_pattern)
|
| 331 |
+
|
| 332 |
+
def has_unreacted_cys_carboxylic_acid(self, mol):
|
| 333 |
+
"""Check for unreacted Cys carboxylic acid from Cys-SS-Linker-Drug"""
|
| 334 |
+
cys_cooh_pattern = Chem.MolFromSmarts('[NH2][CH]([CH2][SX2][SX2])[CX3](=[OX1])[OX2H1]')
|
| 335 |
+
return mol.HasSubstructMatch(cys_cooh_pattern)
|
| 336 |
+
|
| 337 |
+
def find_amide_conjugation_sites(self, mol):
|
| 338 |
+
"""Find all Lys-Cys amide conjugation sites in the molecule"""
|
| 339 |
+
lys_cys_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[CH]([CH2][SX2][SX2])[NX3]')
|
| 340 |
+
matches = mol.GetSubstructMatches(lys_cys_pattern)
|
| 341 |
+
|
| 342 |
+
sites = []
|
| 343 |
+
for match in matches:
|
| 344 |
+
nitrogen_idx = match[0]
|
| 345 |
+
carbonyl_c_idx = match[1]
|
| 346 |
+
cys_alpha_idx = match[3]
|
| 347 |
+
sites.append((nitrogen_idx, carbonyl_c_idx, cys_alpha_idx))
|
| 348 |
+
|
| 349 |
+
return sites
|
| 350 |
+
|
| 351 |
+
def check_valency_at_conjugation_site(self, mol, site_indices):
|
| 352 |
+
"""Check that valency is correct at Lys-Cys conjugation site"""
|
| 353 |
+
nitrogen_idx, carbonyl_c_idx, cys_alpha_idx = site_indices
|
| 354 |
+
|
| 355 |
+
try:
|
| 356 |
+
n_atom = mol.GetAtomWithIdx(nitrogen_idx)
|
| 357 |
+
if n_atom.GetTotalDegree() != 3:
|
| 358 |
+
return False
|
| 359 |
+
|
| 360 |
+
c_atom = mol.GetAtomWithIdx(carbonyl_c_idx)
|
| 361 |
+
if c_atom.GetTotalDegree() != 3:
|
| 362 |
+
return False
|
| 363 |
+
|
| 364 |
+
cys_c_atom = mol.GetAtomWithIdx(cys_alpha_idx)
|
| 365 |
+
if cys_c_atom.GetTotalDegree() != 4:
|
| 366 |
+
return False
|
| 367 |
+
|
| 368 |
+
return True
|
| 369 |
+
except Exception:
|
| 370 |
+
return False
|
| 371 |
+
|
| 372 |
+
def check_attachment_site_compatibility(self, conjugate_smiles):
|
| 373 |
+
"""
|
| 374 |
+
Check attachment site compatibility for Lys-Cys-SS-DSEC-CPT conjugation
|
| 375 |
+
|
| 376 |
+
Validates:
|
| 377 |
+
1. Lys-Cys amide conjugation is present
|
| 378 |
+
2. Disulfide bond (S-S) is intact
|
| 379 |
+
3. Carbonate bond is present (linker-to-drug)
|
| 380 |
+
4. Valency at conjugation sites is correct
|
| 381 |
+
5. No unreacted Cys-COOH (unconjugated linker-drug)
|
| 382 |
+
|
| 383 |
+
Returns:
|
| 384 |
+
Tuple of (passed: bool, details: dict)
|
| 385 |
+
"""
|
| 386 |
+
mol = Chem.MolFromSmiles(conjugate_smiles)
|
| 387 |
+
|
| 388 |
+
if mol is None:
|
| 389 |
+
return False, {'parseable': False, 'error': 'Could not parse SMILES'}
|
| 390 |
+
|
| 391 |
+
details = {
|
| 392 |
+
'parseable': True,
|
| 393 |
+
'validation_method': 'lys_cys_amide_disulfide_carbonate',
|
| 394 |
+
'linker': 'LIN00039 (2-(Disulfanyl)ethyl hydrogen carbonate)',
|
| 395 |
+
'drug': 'DRG00009 (Camptothecin)',
|
| 396 |
+
'pdc_ref': 'PDC_00337'
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
details['has_lys_cys_amide'] = self.has_lys_cys_amide_conjugation(mol)
|
| 400 |
+
details['has_disulfide_bond'] = self.has_disulfide_bond(mol)
|
| 401 |
+
details['has_carbonate_bond'] = self.has_carbonate_bond(mol)
|
| 402 |
+
|
| 403 |
+
conjugation_sites = self.find_amide_conjugation_sites(mol)
|
| 404 |
+
details['conjugation_sites'] = len(conjugation_sites)
|
| 405 |
+
|
| 406 |
+
valency_ok = True
|
| 407 |
+
if conjugation_sites:
|
| 408 |
+
for site in conjugation_sites:
|
| 409 |
+
if not self.check_valency_at_conjugation_site(mol, site):
|
| 410 |
+
valency_ok = False
|
| 411 |
+
break
|
| 412 |
+
details['valency_correct'] = valency_ok
|
| 413 |
+
|
| 414 |
+
details['no_unreacted_cys_cooh'] = not self.has_unreacted_cys_carboxylic_acid(mol)
|
| 415 |
+
details['has_unreacted_lys_amine'] = self.has_unreacted_lys_epsilon_amine(mol)
|
| 416 |
+
|
| 417 |
+
passed = all([
|
| 418 |
+
details['has_lys_cys_amide'],
|
| 419 |
+
details['has_disulfide_bond'],
|
| 420 |
+
details['has_carbonate_bond'],
|
| 421 |
+
details['valency_correct'],
|
| 422 |
+
details['no_unreacted_cys_cooh']
|
| 423 |
+
])
|
| 424 |
+
|
| 425 |
+
return passed, details
|
| 426 |
+
|
| 427 |
+
def _basic_validation(self, mol):
|
| 428 |
+
"""
|
| 429 |
+
Basic validation checks for DSEC-Camptothecin conjugates
|
| 430 |
+
|
| 431 |
+
Checks:
|
| 432 |
+
1. Amide bond present (Lys-Cys conjugation)
|
| 433 |
+
2. Disulfide bond present (S-S linker)
|
| 434 |
+
3. Specific Lys-Cys-SS conjugation pattern
|
| 435 |
+
4. Carbonate bond present (linker-to-drug)
|
| 436 |
+
5. Camptothecin core present
|
| 437 |
+
"""
|
| 438 |
+
checks = {}
|
| 439 |
+
|
| 440 |
+
amide_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[#6]')
|
| 441 |
+
checks['has_amide_bond'] = mol.HasSubstructMatch(amide_pattern)
|
| 442 |
+
|
| 443 |
+
disulfide_pattern = Chem.MolFromSmarts('[#16]-[#16]')
|
| 444 |
+
checks['has_disulfide_bond'] = mol.HasSubstructMatch(disulfide_pattern)
|
| 445 |
+
|
| 446 |
+
lys_cys_ss_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[CH]([CH2][SX2][SX2])[NX3]')
|
| 447 |
+
checks['has_lys_cys_ss_conjugation'] = mol.HasSubstructMatch(lys_cys_ss_pattern)
|
| 448 |
+
|
| 449 |
+
carbonate_pattern = Chem.MolFromSmarts('[OX2][CX3](=[OX1])[OX2]')
|
| 450 |
+
checks['has_carbonate_bond'] = mol.HasSubstructMatch(carbonate_pattern)
|
| 451 |
+
|
| 452 |
+
camptothecin_core = Chem.MolFromSmarts('c1ccc2ncc3C(=O)OCc3c2c1')
|
| 453 |
+
checks['has_camptothecin_core'] = mol.HasSubstructMatch(camptothecin_core)
|
| 454 |
+
|
| 455 |
+
checks['_passed'] = all([
|
| 456 |
+
checks['has_amide_bond'],
|
| 457 |
+
checks['has_disulfide_bond'],
|
| 458 |
+
checks['has_lys_cys_ss_conjugation'],
|
| 459 |
+
checks['has_carbonate_bond']
|
| 460 |
+
])
|
| 461 |
+
|
| 462 |
+
return checks
|
| 463 |
+
|
| 464 |
+
def validate_conjugate(self, conjugate_smiles):
|
| 465 |
+
"""
|
| 466 |
+
Validate that the conjugate is chemically reasonable
|
| 467 |
+
|
| 468 |
+
Returns:
|
| 469 |
+
(passed: bool, checks: dict)
|
| 470 |
+
"""
|
| 471 |
+
mol = Chem.MolFromSmiles(conjugate_smiles)
|
| 472 |
+
|
| 473 |
+
if mol is None:
|
| 474 |
+
return False, {'error': 'Could not parse SMILES'}
|
| 475 |
+
|
| 476 |
+
checks = self._basic_validation(mol)
|
| 477 |
+
checks['parseable'] = True
|
| 478 |
+
|
| 479 |
+
passed = checks.get('_passed', True)
|
| 480 |
+
if '_passed' in checks:
|
| 481 |
+
del checks['_passed']
|
| 482 |
+
|
| 483 |
+
return passed, checks
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
# ============================================================================
|
| 487 |
+
# Main - Test Examples
|
| 488 |
+
# ============================================================================
|
| 489 |
+
|
| 490 |
+
if __name__ == "__main__":
|
| 491 |
+
|
| 492 |
+
print("=" * 70)
|
| 493 |
+
print("Disulfanylethyl Carbonate-Camptothecin PDC Builder - Test")
|
| 494 |
+
print("Reference: PDC_00337 (SMAC-P1 CPT conjugate 7, LIN00039)")
|
| 495 |
+
print("=" * 70 + "\n")
|
| 496 |
+
|
| 497 |
+
# Test peptides
|
| 498 |
+
test_peptides = [
|
| 499 |
+
# Gly-Lys-Gly (has N-terminus NH2 + Lys ε-amine)
|
| 500 |
+
"NCC(=O)N[C@@H](CCCCN)C(=O)NCC(=O)O",
|
| 501 |
+
# Lys-Arg
|
| 502 |
+
"N[C@@H](CCCCN)C(=O)N[C@@H](CCCNC(=N)N)C(=O)O",
|
| 503 |
+
]
|
| 504 |
+
|
| 505 |
+
builder = DisulfanylethylCarbonateCpt_PDC_Builder()
|
| 506 |
+
|
| 507 |
+
for i, peptide in enumerate(test_peptides):
|
| 508 |
+
print(f"\n--- Test peptide {i+1} ---")
|
| 509 |
+
print(f"Input: {peptide}")
|
| 510 |
+
|
| 511 |
+
try:
|
| 512 |
+
conjugates = builder.conjugate_to_peptide(peptide, exclude_n_terminus=True)
|
| 513 |
+
print(f" Generated {len(conjugates)} conjugate(s)")
|
| 514 |
+
|
| 515 |
+
for j, conj in enumerate(conjugates):
|
| 516 |
+
valid, checks = builder.validate_conjugate(conj)
|
| 517 |
+
print(f" Conjugate {j+1}: {'PASSED' if valid else 'FAILED'} ({len(conj)} chars)")
|
| 518 |
+
|
| 519 |
+
except Exception as e:
|
| 520 |
+
print(f" FAILED: {e}")
|
| 521 |
+
import traceback
|
| 522 |
+
traceback.print_exc()
|
smiles/conjugation/DSEPSN38.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
5-(2-Disulfanylethoxy)-5-oxopentanoic Acid - SN-38 PDC Builder (DSEPSN38)
|
| 3 |
+
|
| 4 |
+
Conjugation chemistry: Cysteine thiol + Linker thiol → Disulfide bond (S-S)
|
| 5 |
+
|
| 6 |
+
Reference PDCs:
|
| 7 |
+
- PDC_02101 (SN38(10)-5-CRGDK)
|
| 8 |
+
- PDC_02106 (SN38(10)-5-(Ac)CRGDK)
|
| 9 |
+
|
| 10 |
+
Receptor: NRP1 (Neuropilin-1)
|
| 11 |
+
Linker: LIN00169 - 5-(2-Disulfanylethoxy)-5-oxopentanoic acid
|
| 12 |
+
Payload: 7-Ethyl-10-hydroxycamptothecin (SN-38)
|
| 13 |
+
Architecture: Monomer, Cys thiol conjugation (disulfide bond)
|
| 14 |
+
|
| 15 |
+
Chemistry Note:
|
| 16 |
+
===============
|
| 17 |
+
LIN00169 provides a glutaric acid-based disulfide-ester linkage:
|
| 18 |
+
HOOC─CH₂─CH₂─CH₂─C(═O)─O─CH₂─CH₂─S─SH
|
| 19 |
+
|
| 20 |
+
pentanoic acid ethoxy-disulfanyl
|
| 21 |
+
|
| 22 |
+
Linker-to-Drug Connection:
|
| 23 |
+
SN-38(10-O)─C(═O)─CH₂─CH₂─CH₂─C(═O)─O─CH₂─CH₂─SH
|
| 24 |
+
|
| 25 |
+
Full PDC Architecture:
|
| 26 |
+
Peptide──Cys──S─S──CH₂─CH₂─O─C(═O)─CH₂─CH₂─CH₂─C(═O)─O──SN-38
|
| 27 |
+
└── disulfide ─┘ └── linker ──────┘ └ ester ┘
|
| 28 |
+
|
| 29 |
+
Usage:
|
| 30 |
+
======
|
| 31 |
+
builder = DisulfanylethOxyPentanoicAcidSN38_PDC_Builder()
|
| 32 |
+
conjugates = builder.conjugate_to_peptide(peptide_smiles)
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
from rdkit import Chem
|
| 36 |
+
from rdkit.Chem import AllChem, Descriptors
|
| 37 |
+
|
| 38 |
+
from .pdc_builder import PDC_Builder
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# ============================================================================
|
| 42 |
+
# 5-(2-Disulfanylethoxy)-5-oxopentanoic Acid - SN-38 PDC Builder
|
| 43 |
+
# ============================================================================
|
| 44 |
+
|
| 45 |
+
class DisulfanylethOxyPentanoicAcidSN38_PDC_Builder(PDC_Builder):
|
| 46 |
+
"""
|
| 47 |
+
PDC builder for 5-(2-Disulfanylethoxy)-5-oxopentanoic Acid + SN-38 conjugates
|
| 48 |
+
|
| 49 |
+
Conjugation chemistry (disulfide bond formation):
|
| 50 |
+
- Peptide site: Cysteine thiol (side chain -SH)
|
| 51 |
+
- Linker-payload site: Terminal thiol (-SH) on linker
|
| 52 |
+
- Reaction: Peptide-Cys-SH + HS-Linker-Drug → Peptide-Cys-S-S-Linker-Drug
|
| 53 |
+
|
| 54 |
+
Linker: LIN00169 - 5-(2-Disulfanylethoxy)-5-oxopentanoic acid
|
| 55 |
+
IUPAC: 5-(2-(disulfanyl)ethoxy)-5-oxopentanoic acid
|
| 56 |
+
Connectivity: HOOC-CH2-CH2-CH2-C(=O)-O-CH2-CH2-S-SH
|
| 57 |
+
Glutaric acid monoester with 2-(disulfanyl)ethanol
|
| 58 |
+
|
| 59 |
+
Drug: 7-Ethyl-10-hydroxycamptothecin (SN-38)
|
| 60 |
+
Connection: Ester at C-10 hydroxyl (default) or C-20 hydroxyl
|
| 61 |
+
|
| 62 |
+
Reference: PDC_02101, PDC_02106 (NRP1)
|
| 63 |
+
"""
|
| 64 |
+
|
| 65 |
+
# ========================================================================
|
| 66 |
+
# Linker-Drug SMILES Variants
|
| 67 |
+
# ========================================================================
|
| 68 |
+
|
| 69 |
+
# SN-38 with DSEP ester at C-10 (phenolic OH), free thiol
|
| 70 |
+
# Drug(10-O)-C(=O)-CH2-CH2-CH2-C(=O)-O-CH2-CH2-SH
|
| 71 |
+
POS10_LINKER_SN38_SMILES = (
|
| 72 |
+
"CCC1(O)C(=O)OCc2c1cc1n(c2=O)Cc2c(CC)c3cc(OC(=O)CCCC(=O)OCCS)ccc3nc2-1"
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
# SN-38 with DSEP ester at C-20 (tertiary OH), free thiol
|
| 76 |
+
POS20_LINKER_SN38_SMILES = (
|
| 77 |
+
"CCC1(OC(=O)CCCC(=O)OCCS)C(=O)OCc2c1cc1n(c2=O)Cc2c(CC)c3cc(O)ccc3nc2-1"
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
DEFAULT_LINKER_SN38_SMILES = POS10_LINKER_SN38_SMILES
|
| 81 |
+
|
| 82 |
+
DRUG_POSITIONS = {
|
| 83 |
+
'10': POS10_LINKER_SN38_SMILES,
|
| 84 |
+
'20': POS20_LINKER_SN38_SMILES,
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
def __init__(self, linker_drug_smiles=None, drug_position='all'):
|
| 88 |
+
"""
|
| 89 |
+
Initialize DSEP-SN38 builder
|
| 90 |
+
|
| 91 |
+
Args:
|
| 92 |
+
linker_drug_smiles: Optional custom Linker-Drug SMILES.
|
| 93 |
+
drug_position: 'all' (default), '10', or '20'.
|
| 94 |
+
"""
|
| 95 |
+
self.drug_position = drug_position
|
| 96 |
+
|
| 97 |
+
if linker_drug_smiles is not None:
|
| 98 |
+
self._variants = [linker_drug_smiles]
|
| 99 |
+
self._variant_names = ['custom']
|
| 100 |
+
super().__init__(
|
| 101 |
+
linker_drug_smiles,
|
| 102 |
+
component_name="Custom-DSEP-SN38 (NRP1)"
|
| 103 |
+
)
|
| 104 |
+
elif drug_position == 'all':
|
| 105 |
+
self._variants = [self.POS10_LINKER_SN38_SMILES, self.POS20_LINKER_SN38_SMILES]
|
| 106 |
+
self._variant_names = ['pos10', 'pos20']
|
| 107 |
+
super().__init__(
|
| 108 |
+
self.POS10_LINKER_SN38_SMILES,
|
| 109 |
+
component_name="DSEP-SN38 (LIN00169, all positions)"
|
| 110 |
+
)
|
| 111 |
+
else:
|
| 112 |
+
if drug_position not in self.DRUG_POSITIONS:
|
| 113 |
+
raise ValueError(f"Unknown drug_position '{drug_position}'. "
|
| 114 |
+
f"Choose from: 'all', '10', '20'")
|
| 115 |
+
smiles = self.DRUG_POSITIONS[drug_position]
|
| 116 |
+
self._variants = [smiles]
|
| 117 |
+
self._variant_names = [f'pos{drug_position}']
|
| 118 |
+
super().__init__(
|
| 119 |
+
smiles,
|
| 120 |
+
component_name=f"DSEP-SN38 (LIN00169, C-{drug_position})"
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
def _verify_reactive_group(self):
|
| 124 |
+
"""Verify thiol group is present for disulfide bond formation"""
|
| 125 |
+
for atom in self.linker_payload_mol.GetAtoms():
|
| 126 |
+
if atom.GetAtomicNum() == 16:
|
| 127 |
+
if atom.GetTotalNumHs() >= 1 and atom.GetDegree() == 1:
|
| 128 |
+
print(" DSEP-SN38 loaded with free thiol reactive group")
|
| 129 |
+
break
|
| 130 |
+
else:
|
| 131 |
+
print(" Warning: No free thiol group detected in DSEP-SN38")
|
| 132 |
+
|
| 133 |
+
ester_pattern = Chem.MolFromSmarts('[#6][CX3](=[OX1])[OX2][#6]')
|
| 134 |
+
if self.linker_payload_mol.HasSubstructMatch(ester_pattern):
|
| 135 |
+
print(" Ester bond (linker-to-drug) detected")
|
| 136 |
+
else:
|
| 137 |
+
print(" Warning: No ester bond detected")
|
| 138 |
+
|
| 139 |
+
# ------------------------------------------------------------------
|
| 140 |
+
# Conjugation site finding (Cysteine thiol)
|
| 141 |
+
# ------------------------------------------------------------------
|
| 142 |
+
|
| 143 |
+
def find_conjugation_sites(self, peptide_smiles):
|
| 144 |
+
"""Find all free cysteine thiol groups in peptide"""
|
| 145 |
+
return self.find_peptide_cysteines(peptide_smiles)
|
| 146 |
+
|
| 147 |
+
def find_peptide_cysteines(self, peptide_smiles):
|
| 148 |
+
"""Find all free cysteine residues (thiol groups) in peptide"""
|
| 149 |
+
peptide_mol = Chem.MolFromSmiles(peptide_smiles)
|
| 150 |
+
if peptide_mol is None:
|
| 151 |
+
raise ValueError("Invalid peptide SMILES")
|
| 152 |
+
|
| 153 |
+
cysteines = []
|
| 154 |
+
for atom in peptide_mol.GetAtoms():
|
| 155 |
+
if atom.GetAtomicNum() == 16:
|
| 156 |
+
total_hs = atom.GetTotalNumHs()
|
| 157 |
+
degree = atom.GetDegree()
|
| 158 |
+
if total_hs >= 1 and degree == 1:
|
| 159 |
+
cysteines.append({
|
| 160 |
+
'atom_idx': atom.GetIdx(),
|
| 161 |
+
'num_hydrogens': total_hs,
|
| 162 |
+
'neighbors': [peptide_mol.GetAtomWithIdx(n.GetIdx()).GetSymbol()
|
| 163 |
+
for n in atom.GetNeighbors()],
|
| 164 |
+
'type': 'Cys_thiol'
|
| 165 |
+
})
|
| 166 |
+
return cysteines
|
| 167 |
+
|
| 168 |
+
# ------------------------------------------------------------------
|
| 169 |
+
# Conjugation reaction (disulfide bond formation)
|
| 170 |
+
# ------------------------------------------------------------------
|
| 171 |
+
|
| 172 |
+
def _perform_conjugation(self, peptide_mol, site_info, exclude_n_terminus=True):
|
| 173 |
+
"""Form disulfide bond between peptide Cys thiol and linker-drug thiol."""
|
| 174 |
+
all_conjugates = []
|
| 175 |
+
seen = set()
|
| 176 |
+
|
| 177 |
+
for variant_smiles in self._variants:
|
| 178 |
+
variant_mol = Chem.MolFromSmiles(variant_smiles)
|
| 179 |
+
if variant_mol is None:
|
| 180 |
+
continue
|
| 181 |
+
|
| 182 |
+
disulfide_rxn = AllChem.ReactionFromSmarts(
|
| 183 |
+
'[C:1][SH:2].[SH:3][C:4]>>[C:1][S:2][S:3][C:4]'
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
products = disulfide_rxn.RunReactants((peptide_mol, variant_mol))
|
| 187 |
+
if not products:
|
| 188 |
+
continue
|
| 189 |
+
|
| 190 |
+
for product_tuple in products:
|
| 191 |
+
try:
|
| 192 |
+
conjugate_mol = product_tuple[0]
|
| 193 |
+
Chem.SanitizeMol(conjugate_mol)
|
| 194 |
+
conjugate_smiles = Chem.MolToSmiles(conjugate_mol)
|
| 195 |
+
if conjugate_smiles not in seen:
|
| 196 |
+
seen.add(conjugate_smiles)
|
| 197 |
+
all_conjugates.append(conjugate_smiles)
|
| 198 |
+
except Exception:
|
| 199 |
+
continue
|
| 200 |
+
|
| 201 |
+
if not all_conjugates:
|
| 202 |
+
raise ValueError("Disulfide bond formation failed - no products generated")
|
| 203 |
+
|
| 204 |
+
return all_conjugates
|
| 205 |
+
|
| 206 |
+
def conjugate_to_peptide(self, peptide_smiles, exclude_n_terminus=True, return_all=True):
|
| 207 |
+
"""
|
| 208 |
+
Conjugate DSEP-SN38 to peptide via disulfide bond at Cys thiol.
|
| 209 |
+
|
| 210 |
+
Args:
|
| 211 |
+
peptide_smiles: SMILES string of peptide (must contain Cys)
|
| 212 |
+
exclude_n_terminus: Not used for Cys conjugation (kept for interface)
|
| 213 |
+
return_all: If True, return all possible conjugates
|
| 214 |
+
|
| 215 |
+
Returns:
|
| 216 |
+
List of all possible conjugate SMILES
|
| 217 |
+
"""
|
| 218 |
+
peptide_mol = Chem.MolFromSmiles(peptide_smiles)
|
| 219 |
+
if peptide_mol is None:
|
| 220 |
+
raise ValueError(f"Invalid peptide SMILES: {peptide_smiles}")
|
| 221 |
+
|
| 222 |
+
sites = self.find_conjugation_sites(peptide_smiles)
|
| 223 |
+
print(f"Found {len(sites)} Cys thiol site(s)")
|
| 224 |
+
|
| 225 |
+
conjugates = self._perform_conjugation(peptide_mol, None, exclude_n_terminus)
|
| 226 |
+
|
| 227 |
+
pos_label = f"C-{self.drug_position}" if self.drug_position != 'all' else "C-10 + C-20"
|
| 228 |
+
print(f"Generated {len(conjugates)} unique conjugate(s) [Cys disulfide] [DSEP {pos_label}]")
|
| 229 |
+
|
| 230 |
+
if return_all:
|
| 231 |
+
return conjugates
|
| 232 |
+
else:
|
| 233 |
+
return conjugates[0] if conjugates else None
|
| 234 |
+
|
| 235 |
+
def batch_conjugate(self, peptide_smiles_list, exclude_n_terminus=True, return_all=True):
|
| 236 |
+
"""Batch conjugate multiple peptides."""
|
| 237 |
+
results = {
|
| 238 |
+
'conjugates': [], 'successful': [], 'failed': [], 'total_conjugates': 0
|
| 239 |
+
}
|
| 240 |
+
for i, peptide_smiles in enumerate(peptide_smiles_list):
|
| 241 |
+
try:
|
| 242 |
+
conjugates = self.conjugate_to_peptide(
|
| 243 |
+
peptide_smiles, exclude_n_terminus=exclude_n_terminus, return_all=return_all
|
| 244 |
+
)
|
| 245 |
+
if return_all:
|
| 246 |
+
results['conjugates'].append(conjugates)
|
| 247 |
+
results['total_conjugates'] += len(conjugates)
|
| 248 |
+
else:
|
| 249 |
+
results['conjugates'].append(conjugates)
|
| 250 |
+
results['total_conjugates'] += 1
|
| 251 |
+
results['successful'].append(i)
|
| 252 |
+
except Exception as e:
|
| 253 |
+
print(f" Peptide {i} failed: {e}")
|
| 254 |
+
results['conjugates'].append(None)
|
| 255 |
+
results['failed'].append((i, str(e)))
|
| 256 |
+
|
| 257 |
+
print(f"\n=== Summary ===")
|
| 258 |
+
print(f"Peptides: {len(peptide_smiles_list)}, Success: {len(results['successful'])}, "
|
| 259 |
+
f"Failed: {len(results['failed'])}")
|
| 260 |
+
print(f"Total conjugates generated: {results['total_conjugates']}")
|
| 261 |
+
return results
|
| 262 |
+
|
| 263 |
+
def flatten_batch_results(self, results):
|
| 264 |
+
"""Flatten batch conjugation results into a single list"""
|
| 265 |
+
all_conjugates = []
|
| 266 |
+
for conjugate_list in results['conjugates']:
|
| 267 |
+
if conjugate_list is not None:
|
| 268 |
+
if isinstance(conjugate_list, list):
|
| 269 |
+
all_conjugates.extend(conjugate_list)
|
| 270 |
+
else:
|
| 271 |
+
all_conjugates.append(conjugate_list)
|
| 272 |
+
return all_conjugates
|
| 273 |
+
|
| 274 |
+
# ========================================================================
|
| 275 |
+
# Validation
|
| 276 |
+
# ========================================================================
|
| 277 |
+
|
| 278 |
+
def has_disulfide_bond(self, mol):
|
| 279 |
+
"""Check if molecule has disulfide bond (S-S)"""
|
| 280 |
+
disulfide_pattern = Chem.MolFromSmarts('[SX2]-[SX2]')
|
| 281 |
+
return mol.HasSubstructMatch(disulfide_pattern)
|
| 282 |
+
|
| 283 |
+
def has_free_thiol(self, mol):
|
| 284 |
+
"""Check if molecule has unconjugated (free) thiol groups"""
|
| 285 |
+
for atom in mol.GetAtoms():
|
| 286 |
+
if atom.GetAtomicNum() == 16:
|
| 287 |
+
if atom.GetTotalNumHs() >= 1 and atom.GetDegree() == 1:
|
| 288 |
+
return True
|
| 289 |
+
return False
|
| 290 |
+
|
| 291 |
+
def has_ester_bond_to_drug(self, mol):
|
| 292 |
+
"""Check for ester bond connecting linker to drug"""
|
| 293 |
+
ester_pattern = Chem.MolFromSmarts('[#6][CX3](=[OX1])[OX2][#6]')
|
| 294 |
+
return mol.HasSubstructMatch(ester_pattern)
|
| 295 |
+
|
| 296 |
+
def has_sn38_core(self, mol):
|
| 297 |
+
"""Check for SN-38 core structure"""
|
| 298 |
+
cpt_core = Chem.MolFromSmarts('c1ccc2ncc3C(=O)OCc3c2c1')
|
| 299 |
+
return mol.HasSubstructMatch(cpt_core)
|
| 300 |
+
|
| 301 |
+
def check_attachment_site_compatibility(self, conjugate_smiles):
|
| 302 |
+
"""Check attachment site compatibility for Cys-SS-DSEP-SN38 conjugation."""
|
| 303 |
+
mol = Chem.MolFromSmiles(conjugate_smiles)
|
| 304 |
+
if mol is None:
|
| 305 |
+
return False, {'parseable': False, 'error': 'Could not parse SMILES'}
|
| 306 |
+
|
| 307 |
+
details = {
|
| 308 |
+
'parseable': True,
|
| 309 |
+
'validation_method': 'cys_disulfide_ester',
|
| 310 |
+
'linker': 'LIN00169 (5-(2-Disulfanylethoxy)-5-oxopentanoic acid)',
|
| 311 |
+
'drug': 'SN-38 (7-Ethyl-10-hydroxycamptothecin)',
|
| 312 |
+
}
|
| 313 |
+
details['has_disulfide_bond'] = self.has_disulfide_bond(mol)
|
| 314 |
+
details['no_free_thiol'] = not self.has_free_thiol(mol)
|
| 315 |
+
details['has_ester_bond'] = self.has_ester_bond_to_drug(mol)
|
| 316 |
+
|
| 317 |
+
passed = all([details['has_disulfide_bond'], details['no_free_thiol']])
|
| 318 |
+
return passed, details
|
| 319 |
+
|
| 320 |
+
def _basic_validation(self, mol):
|
| 321 |
+
"""Basic validation checks for DSEP-SN38 conjugates."""
|
| 322 |
+
checks = {}
|
| 323 |
+
checks['has_disulfide_bond'] = mol.HasSubstructMatch(Chem.MolFromSmarts('[#16]-[#16]'))
|
| 324 |
+
checks['no_free_thiol'] = not self.has_free_thiol(mol)
|
| 325 |
+
checks['has_ester_bond'] = self.has_ester_bond_to_drug(mol)
|
| 326 |
+
checks['has_sn38_core'] = self.has_sn38_core(mol)
|
| 327 |
+
checks['_passed'] = all([checks['has_disulfide_bond'], checks['no_free_thiol']])
|
| 328 |
+
return checks
|
| 329 |
+
|
| 330 |
+
def validate_conjugate(self, conjugate_smiles):
|
| 331 |
+
"""Validate that the conjugate is chemically reasonable."""
|
| 332 |
+
mol = Chem.MolFromSmiles(conjugate_smiles)
|
| 333 |
+
if mol is None:
|
| 334 |
+
return False, {'error': 'Could not parse SMILES'}
|
| 335 |
+
checks = self._basic_validation(mol)
|
| 336 |
+
checks['parseable'] = True
|
| 337 |
+
passed = checks.get('_passed', True)
|
| 338 |
+
if '_passed' in checks:
|
| 339 |
+
del checks['_passed']
|
| 340 |
+
return passed, checks
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
# ============================================================================
|
| 344 |
+
# Main - Test Examples
|
| 345 |
+
# ============================================================================
|
| 346 |
+
|
| 347 |
+
if __name__ == "__main__":
|
| 348 |
+
|
| 349 |
+
print("=" * 70)
|
| 350 |
+
print("DSEP-SN38 PDC Builder - Test")
|
| 351 |
+
print("Reference: NRP1 + LIN00169 + SN-38")
|
| 352 |
+
print("=" * 70 + "\n")
|
| 353 |
+
|
| 354 |
+
test_peptides = [
|
| 355 |
+
"NCC(=O)N[C@@H](CS)C(=O)NCC(=O)O", # Gly-Cys-Gly
|
| 356 |
+
]
|
| 357 |
+
|
| 358 |
+
builder = DisulfanylethOxyPentanoicAcidSN38_PDC_Builder()
|
| 359 |
+
|
| 360 |
+
for i, peptide in enumerate(test_peptides):
|
| 361 |
+
print(f"\n--- Test peptide {i+1} ---")
|
| 362 |
+
print(f"Input: {peptide}")
|
| 363 |
+
|
| 364 |
+
try:
|
| 365 |
+
conjugates = builder.conjugate_to_peptide(peptide)
|
| 366 |
+
print(f" Generated {len(conjugates)} conjugate(s)")
|
| 367 |
+
for j, conj in enumerate(conjugates):
|
| 368 |
+
valid, checks = builder.validate_conjugate(conj)
|
| 369 |
+
print(f" Conjugate {j+1}: {'PASSED' if valid else 'FAILED'}")
|
| 370 |
+
except Exception as e:
|
| 371 |
+
print(f" FAILED: {e}")
|
| 372 |
+
import traceback
|
| 373 |
+
traceback.print_exc()
|
smiles/conjugation/DSPACpt.py
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Disulfanylpropanoic Acid - Camptothecin PDC Builder (DSPACpt)
|
| 3 |
+
|
| 4 |
+
Conjugation chemistry: Lysine ε-amine + Cys-SS-Linker-Camptothecin (amide bond formation)
|
| 5 |
+
|
| 6 |
+
Reference PDC:
|
| 7 |
+
- PDC_00335 (SMAC-P1 CPT conjugate 5)
|
| 8 |
+
|
| 9 |
+
Receptor: ERBB2 (Receptor tyrosine-protein kinase erbB-2)
|
| 10 |
+
Linker: LIN00012 - 3-Disulfanylpropanoic Acid (disulfide + ester)
|
| 11 |
+
Payload: DRG00009 - Camptothecin
|
| 12 |
+
Architecture: Monomer, Lys side chain conjugation
|
| 13 |
+
|
| 14 |
+
Chemistry Note:
|
| 15 |
+
===============
|
| 16 |
+
LIN00012 (3-Disulfanylpropanoic Acid) provides a disulfide-ester linkage:
|
| 17 |
+
HS─S─CH₂─CH₂─C(═O)─OH
|
| 18 |
+
|
| 19 |
+
Linker-to-Drug Connection (Ester):
|
| 20 |
+
Cys─S─S─CH₂─CH₂─C(═O)─O─Camptothecin
|
| 21 |
+
│
|
| 22 |
+
ester bond (C-20 OH of CPT)
|
| 23 |
+
|
| 24 |
+
Full PDC Architecture:
|
| 25 |
+
Peptide──Lys──NH──C(═O)──Cys──S─S──CH₂─CH₂─C(═O)─O──Camptothecin
|
| 26 |
+
└── amide ──┘ └── disulfide ──┘ └── ester ──┘
|
| 27 |
+
|
| 28 |
+
Note: This linker chemistry is equivalent to the SSCpt ester variant (Z7/PDC_00294).
|
| 29 |
+
|
| 30 |
+
Usage:
|
| 31 |
+
======
|
| 32 |
+
builder = DisulfanylpropanoicAcidCpt_PDC_Builder()
|
| 33 |
+
conjugates = builder.conjugate_to_peptide(peptide_smiles)
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
from rdkit import Chem
|
| 37 |
+
from rdkit.Chem import AllChem, Descriptors
|
| 38 |
+
|
| 39 |
+
from .pdc_builder import PDC_Builder
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ============================================================================
|
| 43 |
+
# Disulfanylpropanoic Acid - Camptothecin PDC Builder
|
| 44 |
+
# ============================================================================
|
| 45 |
+
|
| 46 |
+
class DisulfanylpropanoicAcidCpt_PDC_Builder(PDC_Builder):
|
| 47 |
+
"""
|
| 48 |
+
PDC builder for 3-Disulfanylpropanoic Acid + Camptothecin conjugates
|
| 49 |
+
|
| 50 |
+
Conjugation chemistry (amide bond formation):
|
| 51 |
+
- Peptide site: Lysine ε-amine (side chain -NH2)
|
| 52 |
+
- Linker-payload site: Carboxylic acid of Cys (Cys-SS-Linker-Drug)
|
| 53 |
+
- Reaction: Lys-NH2 + HOOC-Cys-SS-Linker-Drug → Lys-NH-CO-Cys-SS-Linker-Drug
|
| 54 |
+
|
| 55 |
+
Linker: LIN00012 - 3-Disulfanylpropanoic Acid
|
| 56 |
+
SMILES: C(CSS)C(=O)O
|
| 57 |
+
IUPAC: 3-(disulfanyl)propanoic acid
|
| 58 |
+
Connectivity: HS-S-CH2-CH2-C(=O)-OH
|
| 59 |
+
MW: 138.21, MF: C3H6O2S2
|
| 60 |
+
|
| 61 |
+
Drug: DRG00009 - Camptothecin
|
| 62 |
+
Canonical SMILES: CCC1(O)C(=O)OCc2c1cc1n(c2=O)Cc2cc3ccccc3nc2-1
|
| 63 |
+
Connection: Ester at C-20 hydroxyl
|
| 64 |
+
|
| 65 |
+
Reference: PDC_00335 (SMAC-P1 CPT conjugate 5, ERBB2)
|
| 66 |
+
"""
|
| 67 |
+
|
| 68 |
+
# ========================================================================
|
| 69 |
+
# Cys-SS-Linker-Drug SMILES
|
| 70 |
+
# ========================================================================
|
| 71 |
+
#
|
| 72 |
+
# Pre-formed Cys-Disulfanylpropanoic acid-Camptothecin assembly:
|
| 73 |
+
# HOOC─Cys(NH₂)─CH₂─S─S─CH₂─CH₂─C(═O)─O─CPT(C-20)
|
| 74 |
+
#
|
| 75 |
+
# Cys provides COOH for amide bond with Lys ε-amine
|
| 76 |
+
# Disulfide bond (S-S) is cleavable inside cells
|
| 77 |
+
# Ester bond connects linker to CPT C-20 hydroxyl
|
| 78 |
+
#
|
| 79 |
+
# ========================================================================
|
| 80 |
+
|
| 81 |
+
CYS_SS_LINKER_CPT_SMILES = (
|
| 82 |
+
"N[C@H](CSSCCC(=O)OC1(CC)C(=O)OCc2c1cc1n(c2=O)Cc2cc3ccccc3nc2-1)C(=O)O"
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
def __init__(self, cys_linker_drug_smiles=None):
|
| 86 |
+
"""
|
| 87 |
+
Initialize Disulfanylpropanoic Acid-Camptothecin builder
|
| 88 |
+
|
| 89 |
+
Args:
|
| 90 |
+
cys_linker_drug_smiles: Optional custom Cys-SS-Linker-Drug SMILES.
|
| 91 |
+
If None, uses default LIN00012+CPT assembly.
|
| 92 |
+
Must have free carboxylic acid for conjugation.
|
| 93 |
+
"""
|
| 94 |
+
if cys_linker_drug_smiles is None:
|
| 95 |
+
cys_linker_drug_smiles = self.CYS_SS_LINKER_CPT_SMILES
|
| 96 |
+
|
| 97 |
+
super().__init__(
|
| 98 |
+
cys_linker_drug_smiles,
|
| 99 |
+
component_name="Cys-SS-DisulfanylpropanoicAcid-Camptothecin (LIN00012+DRG00009)"
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
def _verify_reactive_group(self):
|
| 103 |
+
"""Verify carboxylic acid group is present for amide bond formation"""
|
| 104 |
+
# Pattern: Carboxylic acid (COOH)
|
| 105 |
+
carboxylic_acid_pattern = Chem.MolFromSmarts('[CX3](=O)[OX2H1]')
|
| 106 |
+
|
| 107 |
+
if not self.linker_payload_mol.HasSubstructMatch(carboxylic_acid_pattern):
|
| 108 |
+
print(" Warning: No carboxylic acid group detected in Cys-SS-Linker-Drug")
|
| 109 |
+
else:
|
| 110 |
+
print(" Cys-SS-DSPA-Camptothecin loaded with carboxylic acid reactive group")
|
| 111 |
+
|
| 112 |
+
# Verify disulfide bond is present
|
| 113 |
+
disulfide_pattern = Chem.MolFromSmarts('[#16]-[#16]')
|
| 114 |
+
if self.linker_payload_mol.HasSubstructMatch(disulfide_pattern):
|
| 115 |
+
print(" Disulfide bond (S-S) detected")
|
| 116 |
+
else:
|
| 117 |
+
print(" Warning: No disulfide bond detected - check structure")
|
| 118 |
+
|
| 119 |
+
# Verify Cys α-amine is present
|
| 120 |
+
cys_amine_pattern = Chem.MolFromSmarts('[NH2][CH]([CH2]S)C(=O)')
|
| 121 |
+
if self.linker_payload_mol.HasSubstructMatch(cys_amine_pattern):
|
| 122 |
+
print(" Cys α-amine detected (will be free in conjugate)")
|
| 123 |
+
else:
|
| 124 |
+
print(" Warning: Cys α-amine pattern not detected")
|
| 125 |
+
|
| 126 |
+
# Verify ester bond to drug
|
| 127 |
+
ester_pattern = Chem.MolFromSmarts('[#6][CX3](=[OX1])[OX2][#6]')
|
| 128 |
+
if self.linker_payload_mol.HasSubstructMatch(ester_pattern):
|
| 129 |
+
print(" Ester bond (linker-to-drug) detected")
|
| 130 |
+
else:
|
| 131 |
+
print(" Warning: No ester bond detected - check linker-drug connection")
|
| 132 |
+
|
| 133 |
+
def find_conjugation_sites(self, peptide_smiles):
|
| 134 |
+
"""
|
| 135 |
+
Find all lysine ε-amine groups in peptide
|
| 136 |
+
|
| 137 |
+
Returns list of dicts with site information including atom index
|
| 138 |
+
"""
|
| 139 |
+
return self.find_peptide_lysines(peptide_smiles)
|
| 140 |
+
|
| 141 |
+
def find_peptide_lysines(self, peptide_smiles):
|
| 142 |
+
"""
|
| 143 |
+
Find all lysine residues with free ε-amine in peptide
|
| 144 |
+
|
| 145 |
+
Lysine side chain: -CH2-CH2-CH2-CH2-NH2 (CCCCN)
|
| 146 |
+
|
| 147 |
+
Returns:
|
| 148 |
+
List of dicts with 'atom_idx' (nitrogen index) and metadata
|
| 149 |
+
"""
|
| 150 |
+
peptide_mol = Chem.MolFromSmiles(peptide_smiles)
|
| 151 |
+
if peptide_mol is None:
|
| 152 |
+
raise ValueError("Invalid peptide SMILES")
|
| 153 |
+
|
| 154 |
+
lysines = []
|
| 155 |
+
|
| 156 |
+
# SMARTS pattern for Lysine ε-amine: 4 CH2 groups ending in NH2
|
| 157 |
+
lys_pattern = Chem.MolFromSmarts('[CH2][CH2][CH2][CH2][NH2]')
|
| 158 |
+
matches = peptide_mol.GetSubstructMatches(lys_pattern)
|
| 159 |
+
|
| 160 |
+
for match in matches:
|
| 161 |
+
nitrogen_idx = match[4]
|
| 162 |
+
lysines.append({
|
| 163 |
+
'atom_idx': nitrogen_idx,
|
| 164 |
+
'side_chain_atoms': list(match),
|
| 165 |
+
'type': 'Lys_epsilon_amine'
|
| 166 |
+
})
|
| 167 |
+
|
| 168 |
+
# Count N-terminus for informational purposes
|
| 169 |
+
n_terminus_pattern = Chem.MolFromSmarts('[NH2][CH,CH2][C](=O)')
|
| 170 |
+
n_term_matches = peptide_mol.GetSubstructMatches(n_terminus_pattern)
|
| 171 |
+
|
| 172 |
+
if n_term_matches:
|
| 173 |
+
print(f" (Also found {len(n_term_matches)} N-terminus α-amine(s) - excluded by default)")
|
| 174 |
+
|
| 175 |
+
return lysines
|
| 176 |
+
|
| 177 |
+
def _perform_conjugation(self, peptide_mol, site_info, exclude_n_terminus=True):
|
| 178 |
+
"""
|
| 179 |
+
Perform amide bond conjugation between NH2 groups and Cys carboxylic acid
|
| 180 |
+
|
| 181 |
+
Args:
|
| 182 |
+
peptide_mol: RDKit Mol object
|
| 183 |
+
site_info: Not used (kept for compatibility)
|
| 184 |
+
exclude_n_terminus: If True, skip N-terminus conjugation
|
| 185 |
+
|
| 186 |
+
Returns: List of all possible conjugate SMILES
|
| 187 |
+
"""
|
| 188 |
+
if exclude_n_terminus:
|
| 189 |
+
amide_rxn = AllChem.ReactionFromSmarts(
|
| 190 |
+
'[*:5][CH2][CH2][CH2][CH2:4][NH2:1].[C:2](=[O:3])[OH]>>[*:5][CH2][CH2][CH2][CH2:4][NH:1][C:2](=[O:3])'
|
| 191 |
+
)
|
| 192 |
+
else:
|
| 193 |
+
amide_rxn = AllChem.ReactionFromSmarts(
|
| 194 |
+
'[*:4][NH2:1].[C:2](=[O:3])[OH]>>[*:4][NH:1][C:2](=[O:3])'
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
products = amide_rxn.RunReactants((peptide_mol, self.linker_payload_mol))
|
| 198 |
+
|
| 199 |
+
if not products:
|
| 200 |
+
raise ValueError("Amide bond formation reaction failed - no products generated")
|
| 201 |
+
|
| 202 |
+
conjugates = []
|
| 203 |
+
seen_smiles = set()
|
| 204 |
+
|
| 205 |
+
for product_tuple in products:
|
| 206 |
+
try:
|
| 207 |
+
conjugate_mol = product_tuple[0]
|
| 208 |
+
Chem.SanitizeMol(conjugate_mol)
|
| 209 |
+
conjugate_smiles = Chem.MolToSmiles(conjugate_mol)
|
| 210 |
+
|
| 211 |
+
if conjugate_smiles not in seen_smiles:
|
| 212 |
+
seen_smiles.add(conjugate_smiles)
|
| 213 |
+
conjugates.append(conjugate_smiles)
|
| 214 |
+
except Exception:
|
| 215 |
+
continue
|
| 216 |
+
|
| 217 |
+
if not conjugates:
|
| 218 |
+
raise ValueError("All products failed sanitization")
|
| 219 |
+
|
| 220 |
+
return conjugates
|
| 221 |
+
|
| 222 |
+
def conjugate_to_peptide(self, peptide_smiles, exclude_n_terminus=True, return_all=True):
|
| 223 |
+
"""
|
| 224 |
+
Conjugate Cys-SS-DSPA-Camptothecin to peptide via amide bond at NH2 groups
|
| 225 |
+
|
| 226 |
+
Args:
|
| 227 |
+
peptide_smiles: SMILES string of peptide (must contain Lys with NH2)
|
| 228 |
+
exclude_n_terminus: If True (default), only conjugate at Lys ε-amines
|
| 229 |
+
return_all: If True, return all possible conjugates
|
| 230 |
+
|
| 231 |
+
Returns:
|
| 232 |
+
List of all possible conjugate SMILES
|
| 233 |
+
"""
|
| 234 |
+
peptide_mol = Chem.MolFromSmiles(peptide_smiles)
|
| 235 |
+
if peptide_mol is None:
|
| 236 |
+
raise ValueError(f"Invalid peptide SMILES: {peptide_smiles}")
|
| 237 |
+
|
| 238 |
+
sites = self.find_conjugation_sites(peptide_smiles)
|
| 239 |
+
print(f"Found {len(sites)} Lys ε-amine site(s)")
|
| 240 |
+
|
| 241 |
+
conjugates = self._perform_conjugation(peptide_mol, None, exclude_n_terminus)
|
| 242 |
+
|
| 243 |
+
site_type = "Lys ε-amine only" if exclude_n_terminus else "all NH2 (including N-terminus)"
|
| 244 |
+
print(f"Generated {len(conjugates)} unique conjugate(s) [{site_type}] [DSPA ester]")
|
| 245 |
+
|
| 246 |
+
if return_all:
|
| 247 |
+
return conjugates
|
| 248 |
+
else:
|
| 249 |
+
return conjugates[0] if conjugates else None
|
| 250 |
+
|
| 251 |
+
def batch_conjugate(self, peptide_smiles_list, exclude_n_terminus=True, return_all=True):
|
| 252 |
+
"""
|
| 253 |
+
Batch conjugate multiple peptides
|
| 254 |
+
|
| 255 |
+
Args:
|
| 256 |
+
peptide_smiles_list: List of peptide SMILES strings
|
| 257 |
+
exclude_n_terminus: If True (default), only conjugate at Lys ε-amines
|
| 258 |
+
return_all: If True, return all possible conjugates per peptide
|
| 259 |
+
|
| 260 |
+
Returns:
|
| 261 |
+
Dict with 'conjugates', 'successful', 'failed', 'total_conjugates'
|
| 262 |
+
"""
|
| 263 |
+
results = {
|
| 264 |
+
'conjugates': [],
|
| 265 |
+
'successful': [],
|
| 266 |
+
'failed': [],
|
| 267 |
+
'total_conjugates': 0
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
for i, peptide_smiles in enumerate(peptide_smiles_list):
|
| 271 |
+
try:
|
| 272 |
+
conjugates = self.conjugate_to_peptide(
|
| 273 |
+
peptide_smiles,
|
| 274 |
+
exclude_n_terminus=exclude_n_terminus,
|
| 275 |
+
return_all=return_all
|
| 276 |
+
)
|
| 277 |
+
if return_all:
|
| 278 |
+
results['conjugates'].append(conjugates)
|
| 279 |
+
results['total_conjugates'] += len(conjugates)
|
| 280 |
+
else:
|
| 281 |
+
results['conjugates'].append(conjugates)
|
| 282 |
+
results['total_conjugates'] += 1
|
| 283 |
+
results['successful'].append(i)
|
| 284 |
+
except Exception as e:
|
| 285 |
+
print(f" Peptide {i} failed: {e}")
|
| 286 |
+
results['conjugates'].append(None)
|
| 287 |
+
results['failed'].append((i, str(e)))
|
| 288 |
+
|
| 289 |
+
print(f"\n=== Summary ===")
|
| 290 |
+
print(f"Peptides: {len(peptide_smiles_list)}, Success: {len(results['successful'])}, Failed: {len(results['failed'])}")
|
| 291 |
+
print(f"Total conjugates generated: {results['total_conjugates']}")
|
| 292 |
+
|
| 293 |
+
return results
|
| 294 |
+
|
| 295 |
+
def flatten_batch_results(self, results):
|
| 296 |
+
"""Flatten batch conjugation results into a single list"""
|
| 297 |
+
all_conjugates = []
|
| 298 |
+
for conjugate_list in results['conjugates']:
|
| 299 |
+
if conjugate_list is not None:
|
| 300 |
+
if isinstance(conjugate_list, list):
|
| 301 |
+
all_conjugates.extend(conjugate_list)
|
| 302 |
+
else:
|
| 303 |
+
all_conjugates.append(conjugate_list)
|
| 304 |
+
return all_conjugates
|
| 305 |
+
|
| 306 |
+
# ========================================================================
|
| 307 |
+
# Attachment Site Compatibility Checks
|
| 308 |
+
# ========================================================================
|
| 309 |
+
|
| 310 |
+
def has_lys_cys_amide_conjugation(self, mol):
|
| 311 |
+
"""Check if molecule has Lys-Cys amide conjugation bond"""
|
| 312 |
+
lys_cys_amide_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[CH]([CH2][SX2][SX2])[NX3]')
|
| 313 |
+
return mol.HasSubstructMatch(lys_cys_amide_pattern)
|
| 314 |
+
|
| 315 |
+
def has_disulfide_bond(self, mol):
|
| 316 |
+
"""Check if molecule has disulfide bond (S-S)"""
|
| 317 |
+
disulfide_pattern = Chem.MolFromSmarts('[SX2]-[SX2]')
|
| 318 |
+
return mol.HasSubstructMatch(disulfide_pattern)
|
| 319 |
+
|
| 320 |
+
def has_unreacted_lys_epsilon_amine(self, mol):
|
| 321 |
+
"""Check for unreacted Lys ε-amine (free NH2 on 4-carbon side chain)"""
|
| 322 |
+
lys_amine_pattern = Chem.MolFromSmarts('[CH2][CH2][CH2][CH2][NH2]')
|
| 323 |
+
return mol.HasSubstructMatch(lys_amine_pattern)
|
| 324 |
+
|
| 325 |
+
def has_unreacted_cys_carboxylic_acid(self, mol):
|
| 326 |
+
"""Check for unreacted Cys carboxylic acid from Cys-SS-Linker-Drug"""
|
| 327 |
+
cys_cooh_pattern = Chem.MolFromSmarts('[NH2][CH]([CH2][SX2][SX2])[CX3](=[OX1])[OX2H1]')
|
| 328 |
+
return mol.HasSubstructMatch(cys_cooh_pattern)
|
| 329 |
+
|
| 330 |
+
def has_ester_bond_to_drug(self, mol):
|
| 331 |
+
"""Check for ester bond connecting linker to drug"""
|
| 332 |
+
ester_pattern = Chem.MolFromSmarts('[SX2][SX2][CH2][CH2][CX3](=[OX1])[OX2][C]')
|
| 333 |
+
return mol.HasSubstructMatch(ester_pattern)
|
| 334 |
+
|
| 335 |
+
def find_amide_conjugation_sites(self, mol):
|
| 336 |
+
"""Find all Lys-Cys amide conjugation sites in the molecule"""
|
| 337 |
+
lys_cys_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[CH]([CH2][SX2][SX2])[NX3]')
|
| 338 |
+
matches = mol.GetSubstructMatches(lys_cys_pattern)
|
| 339 |
+
|
| 340 |
+
sites = []
|
| 341 |
+
for match in matches:
|
| 342 |
+
nitrogen_idx = match[0]
|
| 343 |
+
carbonyl_c_idx = match[1]
|
| 344 |
+
cys_alpha_idx = match[3]
|
| 345 |
+
sites.append((nitrogen_idx, carbonyl_c_idx, cys_alpha_idx))
|
| 346 |
+
|
| 347 |
+
return sites
|
| 348 |
+
|
| 349 |
+
def check_valency_at_conjugation_site(self, mol, site_indices):
|
| 350 |
+
"""Check that valency is correct at Lys-Cys conjugation site"""
|
| 351 |
+
nitrogen_idx, carbonyl_c_idx, cys_alpha_idx = site_indices
|
| 352 |
+
|
| 353 |
+
try:
|
| 354 |
+
n_atom = mol.GetAtomWithIdx(nitrogen_idx)
|
| 355 |
+
if n_atom.GetTotalDegree() != 3:
|
| 356 |
+
return False
|
| 357 |
+
|
| 358 |
+
c_atom = mol.GetAtomWithIdx(carbonyl_c_idx)
|
| 359 |
+
if c_atom.GetTotalDegree() != 3:
|
| 360 |
+
return False
|
| 361 |
+
|
| 362 |
+
cys_c_atom = mol.GetAtomWithIdx(cys_alpha_idx)
|
| 363 |
+
if cys_c_atom.GetTotalDegree() != 4:
|
| 364 |
+
return False
|
| 365 |
+
|
| 366 |
+
return True
|
| 367 |
+
except Exception:
|
| 368 |
+
return False
|
| 369 |
+
|
| 370 |
+
def check_attachment_site_compatibility(self, conjugate_smiles):
|
| 371 |
+
"""
|
| 372 |
+
Check attachment site compatibility for Lys-Cys-SS-DSPA-CPT conjugation
|
| 373 |
+
|
| 374 |
+
Validates:
|
| 375 |
+
1. Lys-Cys amide conjugation is present
|
| 376 |
+
2. Disulfide bond (S-S) is intact
|
| 377 |
+
3. Valency at conjugation sites is correct
|
| 378 |
+
4. No unreacted Cys-COOH (unconjugated linker-drug)
|
| 379 |
+
5. Ester bond to drug is present
|
| 380 |
+
|
| 381 |
+
Returns:
|
| 382 |
+
Tuple of (passed: bool, details: dict)
|
| 383 |
+
"""
|
| 384 |
+
mol = Chem.MolFromSmiles(conjugate_smiles)
|
| 385 |
+
|
| 386 |
+
if mol is None:
|
| 387 |
+
return False, {'parseable': False, 'error': 'Could not parse SMILES'}
|
| 388 |
+
|
| 389 |
+
details = {
|
| 390 |
+
'parseable': True,
|
| 391 |
+
'validation_method': 'lys_cys_amide_disulfide_ester',
|
| 392 |
+
'linker': 'LIN00012 (3-Disulfanylpropanoic Acid)',
|
| 393 |
+
'drug': 'DRG00009 (Camptothecin)',
|
| 394 |
+
'pdc_ref': 'PDC_00335'
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
details['has_lys_cys_amide'] = self.has_lys_cys_amide_conjugation(mol)
|
| 398 |
+
details['has_disulfide_bond'] = self.has_disulfide_bond(mol)
|
| 399 |
+
|
| 400 |
+
conjugation_sites = self.find_amide_conjugation_sites(mol)
|
| 401 |
+
details['conjugation_sites'] = len(conjugation_sites)
|
| 402 |
+
|
| 403 |
+
valency_ok = True
|
| 404 |
+
if conjugation_sites:
|
| 405 |
+
for site in conjugation_sites:
|
| 406 |
+
if not self.check_valency_at_conjugation_site(mol, site):
|
| 407 |
+
valency_ok = False
|
| 408 |
+
break
|
| 409 |
+
details['valency_correct'] = valency_ok
|
| 410 |
+
|
| 411 |
+
details['no_unreacted_cys_cooh'] = not self.has_unreacted_cys_carboxylic_acid(mol)
|
| 412 |
+
details['has_unreacted_lys_amine'] = self.has_unreacted_lys_epsilon_amine(mol)
|
| 413 |
+
|
| 414 |
+
passed = all([
|
| 415 |
+
details['has_lys_cys_amide'],
|
| 416 |
+
details['has_disulfide_bond'],
|
| 417 |
+
details['valency_correct'],
|
| 418 |
+
details['no_unreacted_cys_cooh']
|
| 419 |
+
])
|
| 420 |
+
|
| 421 |
+
return passed, details
|
| 422 |
+
|
| 423 |
+
def _basic_validation(self, mol):
|
| 424 |
+
"""
|
| 425 |
+
Basic validation checks for DSPA-Camptothecin conjugates
|
| 426 |
+
|
| 427 |
+
Checks:
|
| 428 |
+
1. Amide bond present (Lys-Cys conjugation)
|
| 429 |
+
2. Disulfide bond present (S-S linker)
|
| 430 |
+
3. Specific Lys-Cys-SS conjugation pattern
|
| 431 |
+
4. Camptothecin core present
|
| 432 |
+
5. Ester bond to drug present
|
| 433 |
+
"""
|
| 434 |
+
checks = {}
|
| 435 |
+
|
| 436 |
+
amide_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[#6]')
|
| 437 |
+
checks['has_amide_bond'] = mol.HasSubstructMatch(amide_pattern)
|
| 438 |
+
|
| 439 |
+
disulfide_pattern = Chem.MolFromSmarts('[#16]-[#16]')
|
| 440 |
+
checks['has_disulfide_bond'] = mol.HasSubstructMatch(disulfide_pattern)
|
| 441 |
+
|
| 442 |
+
lys_cys_ss_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[CH]([CH2][SX2][SX2])[NX3]')
|
| 443 |
+
checks['has_lys_cys_ss_conjugation'] = mol.HasSubstructMatch(lys_cys_ss_pattern)
|
| 444 |
+
|
| 445 |
+
camptothecin_core = Chem.MolFromSmarts('c1ccc2ncc3C(=O)OCc3c2c1')
|
| 446 |
+
checks['has_camptothecin_core'] = mol.HasSubstructMatch(camptothecin_core)
|
| 447 |
+
|
| 448 |
+
ester_pattern = Chem.MolFromSmarts('[#6][CX3](=[OX1])[OX2][#6]')
|
| 449 |
+
checks['has_ester_bond'] = mol.HasSubstructMatch(ester_pattern)
|
| 450 |
+
|
| 451 |
+
checks['_passed'] = all([
|
| 452 |
+
checks['has_amide_bond'],
|
| 453 |
+
checks['has_disulfide_bond'],
|
| 454 |
+
checks['has_lys_cys_ss_conjugation']
|
| 455 |
+
])
|
| 456 |
+
|
| 457 |
+
return checks
|
| 458 |
+
|
| 459 |
+
def validate_conjugate(self, conjugate_smiles):
|
| 460 |
+
"""
|
| 461 |
+
Validate that the conjugate is chemically reasonable
|
| 462 |
+
|
| 463 |
+
Returns:
|
| 464 |
+
(passed: bool, checks: dict)
|
| 465 |
+
"""
|
| 466 |
+
mol = Chem.MolFromSmiles(conjugate_smiles)
|
| 467 |
+
|
| 468 |
+
if mol is None:
|
| 469 |
+
return False, {'error': 'Could not parse SMILES'}
|
| 470 |
+
|
| 471 |
+
checks = self._basic_validation(mol)
|
| 472 |
+
checks['parseable'] = True
|
| 473 |
+
|
| 474 |
+
passed = checks.get('_passed', True)
|
| 475 |
+
if '_passed' in checks:
|
| 476 |
+
del checks['_passed']
|
| 477 |
+
|
| 478 |
+
return passed, checks
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
# ============================================================================
|
| 482 |
+
# Main - Test Examples
|
| 483 |
+
# ============================================================================
|
| 484 |
+
|
| 485 |
+
if __name__ == "__main__":
|
| 486 |
+
|
| 487 |
+
print("=" * 70)
|
| 488 |
+
print("Disulfanylpropanoic Acid-Camptothecin PDC Builder - Test")
|
| 489 |
+
print("Reference: PDC_00335 (SMAC-P1 CPT conjugate 5, LIN00012)")
|
| 490 |
+
print("=" * 70 + "\n")
|
| 491 |
+
|
| 492 |
+
# Test peptides
|
| 493 |
+
test_peptides = [
|
| 494 |
+
# Gly-Lys-Gly (has N-terminus NH2 + Lys ε-amine)
|
| 495 |
+
"NCC(=O)N[C@@H](CCCCN)C(=O)NCC(=O)O",
|
| 496 |
+
# Lys-Arg
|
| 497 |
+
"N[C@@H](CCCCN)C(=O)N[C@@H](CCCNC(=N)N)C(=O)O",
|
| 498 |
+
]
|
| 499 |
+
|
| 500 |
+
builder = DisulfanylpropanoicAcidCpt_PDC_Builder()
|
| 501 |
+
|
| 502 |
+
for i, peptide in enumerate(test_peptides):
|
| 503 |
+
print(f"\n--- Test peptide {i+1} ---")
|
| 504 |
+
print(f"Input: {peptide}")
|
| 505 |
+
|
| 506 |
+
try:
|
| 507 |
+
conjugates = builder.conjugate_to_peptide(peptide, exclude_n_terminus=True)
|
| 508 |
+
print(f" Generated {len(conjugates)} conjugate(s)")
|
| 509 |
+
|
| 510 |
+
for j, conj in enumerate(conjugates):
|
| 511 |
+
valid, checks = builder.validate_conjugate(conj)
|
| 512 |
+
print(f" Conjugate {j+1}: {'PASSED' if valid else 'FAILED'} ({len(conj)} chars)")
|
| 513 |
+
|
| 514 |
+
except Exception as e:
|
| 515 |
+
print(f" FAILED: {e}")
|
| 516 |
+
import traceback
|
| 517 |
+
traceback.print_exc()
|
smiles/conjugation/DSPASN38.py
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Disulfanylpropanoic Acid - SN-38 PDC Builder (DSPASN38)
|
| 3 |
+
|
| 4 |
+
Conjugation chemistry: Cysteine thiol + Linker thiol → Disulfide bond (S-S)
|
| 5 |
+
|
| 6 |
+
Reference PDCs:
|
| 7 |
+
- PDC_02099 (SN38(10)-CRGDK)
|
| 8 |
+
- PDC_02104 (SN38(10)-(Ac)CRGDK)
|
| 9 |
+
- PDC_02102 (SN38(10)-(Ac)CGVQIRGDK)
|
| 10 |
+
- PDC_02107 (SN38(10)-CRRGDK)
|
| 11 |
+
- PDC_02110 (SN38(10)-(Ac)CDRGDK)
|
| 12 |
+
- PDC_02112 (SN38(20)-CRGDK)
|
| 13 |
+
- PDC_02113 ((MOM)SN38(20)-CRGDK)
|
| 14 |
+
- PDC_02114 ((MOM)SN38(20)-(Ac)CRGDK)
|
| 15 |
+
- PDC_02115 ((MOM)SN38(20)-(Ac)CGVQIRGDK)
|
| 16 |
+
|
| 17 |
+
Receptor: NRP1 (Neuropilin-1)
|
| 18 |
+
Linker: LIN00149 - 3-Disulfanylpropanoic Acid (disulfide + ester)
|
| 19 |
+
Payload: DRG - 7-Ethyl-10-hydroxycamptothecin (SN-38)
|
| 20 |
+
Architecture: Monomer, Cys thiol conjugation (disulfide bond)
|
| 21 |
+
|
| 22 |
+
Chemistry Note:
|
| 23 |
+
===============
|
| 24 |
+
LIN00149 (3-Disulfanylpropanoic Acid) connects via:
|
| 25 |
+
HS─CH₂─CH₂─C(═O)─OH
|
| 26 |
+
|
| 27 |
+
Linker-to-Drug Connection (Ester):
|
| 28 |
+
SN-38(10-O)─C(═O)─CH₂─CH₂─SH (position 10, default)
|
| 29 |
+
SN-38(20-O)─C(═O)─CH₂─CH₂─SH (position 20)
|
| 30 |
+
|
| 31 |
+
Full PDC Architecture:
|
| 32 |
+
Peptide──Cys──S─S──CH₂─CH₂─C(═O)─O──SN-38
|
| 33 |
+
└── disulfide ──┘ └── ester ──┘
|
| 34 |
+
|
| 35 |
+
Usage:
|
| 36 |
+
======
|
| 37 |
+
builder = DisulfanylpropanoicAcidSN38_PDC_Builder()
|
| 38 |
+
conjugates = builder.conjugate_to_peptide(peptide_smiles)
|
| 39 |
+
"""
|
| 40 |
+
|
| 41 |
+
from rdkit import Chem
|
| 42 |
+
from rdkit.Chem import AllChem, Descriptors
|
| 43 |
+
|
| 44 |
+
from .pdc_builder import PDC_Builder
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ============================================================================
|
| 48 |
+
# Disulfanylpropanoic Acid - SN-38 PDC Builder
|
| 49 |
+
# ============================================================================
|
| 50 |
+
|
| 51 |
+
class DisulfanylpropanoicAcidSN38_PDC_Builder(PDC_Builder):
|
| 52 |
+
"""
|
| 53 |
+
PDC builder for 3-Disulfanylpropanoic Acid + SN-38 conjugates
|
| 54 |
+
|
| 55 |
+
Conjugation chemistry (disulfide bond formation):
|
| 56 |
+
- Peptide site: Cysteine thiol (side chain -SH)
|
| 57 |
+
- Linker-payload site: Terminal thiol (-SH) on DSPA linker
|
| 58 |
+
- Reaction: Peptide-Cys-SH + HS-Linker-Drug → Peptide-Cys-S-S-Linker-Drug
|
| 59 |
+
|
| 60 |
+
Linker: LIN00149 - 3-Disulfanylpropanoic Acid
|
| 61 |
+
SMILES: OC(=O)CCSS
|
| 62 |
+
IUPAC: 3-(disulfanyl)propanoic acid
|
| 63 |
+
Connectivity: HS-S-CH2-CH2-C(=O)-OH
|
| 64 |
+
|
| 65 |
+
Drug: 7-Ethyl-10-hydroxycamptothecin (SN-38)
|
| 66 |
+
Canonical SMILES: CCC1(O)C(=O)OCc2c1cc1n(c2=O)Cc2c(CC)c3cc(O)ccc3nc2-1
|
| 67 |
+
Connection: Ester at C-10 hydroxyl (default) or C-20 hydroxyl
|
| 68 |
+
|
| 69 |
+
Supported drug conjugation positions:
|
| 70 |
+
- '10' (default): Ester at 10-OH (phenolic, most common in PDCdb)
|
| 71 |
+
- '20': Ester at 20-OH (tertiary aliphatic)
|
| 72 |
+
- 'all': Both positions, conjugates pooled
|
| 73 |
+
|
| 74 |
+
Reference: PDC_02099, PDC_02112 (NRP1)
|
| 75 |
+
"""
|
| 76 |
+
|
| 77 |
+
# ========================================================================
|
| 78 |
+
# Linker-Drug SMILES Variants
|
| 79 |
+
# ========================================================================
|
| 80 |
+
#
|
| 81 |
+
# DSPA linker ester to SN-38 with free terminal thiol for Cys conjugation
|
| 82 |
+
#
|
| 83 |
+
# Position 10 (phenolic OH):
|
| 84 |
+
# HS─CH₂─CH₂─C(═O)─O─SN38(10)
|
| 85 |
+
#
|
| 86 |
+
# Position 20 (tertiary OH):
|
| 87 |
+
# HS─CH₂─CH₂─C(═O)─O─SN38(20)
|
| 88 |
+
#
|
| 89 |
+
# ========================================================================
|
| 90 |
+
|
| 91 |
+
# SN-38 with DSPA ester at C-10 (phenolic OH), free thiol
|
| 92 |
+
POS10_LINKER_SN38_SMILES = (
|
| 93 |
+
"CCC1(O)C(=O)OCc2c1cc1n(c2=O)Cc2c(CC)c3cc(OC(=O)CCS)ccc3nc2-1"
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
# SN-38 with DSPA ester at C-20 (tertiary OH), free thiol
|
| 97 |
+
POS20_LINKER_SN38_SMILES = (
|
| 98 |
+
"CCC1(OC(=O)CCS)C(=O)OCc2c1cc1n(c2=O)Cc2c(CC)c3cc(O)ccc3nc2-1"
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
# Default: position 10 (most common)
|
| 102 |
+
DEFAULT_LINKER_SN38_SMILES = POS10_LINKER_SN38_SMILES
|
| 103 |
+
|
| 104 |
+
# Available drug positions
|
| 105 |
+
DRUG_POSITIONS = {
|
| 106 |
+
'10': POS10_LINKER_SN38_SMILES,
|
| 107 |
+
'20': POS20_LINKER_SN38_SMILES,
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
def __init__(self, linker_drug_smiles=None, drug_position='all'):
|
| 111 |
+
"""
|
| 112 |
+
Initialize DSPA-SN38 builder
|
| 113 |
+
|
| 114 |
+
Args:
|
| 115 |
+
linker_drug_smiles: Optional custom Linker-Drug SMILES.
|
| 116 |
+
If provided, overrides drug_position.
|
| 117 |
+
Must have free thiol for Cys disulfide conjugation.
|
| 118 |
+
drug_position: 'all' (default), '10', or '20'.
|
| 119 |
+
'10': ester at C-10 phenolic OH (most common)
|
| 120 |
+
'20': ester at C-20 tertiary OH
|
| 121 |
+
'all': both positions, conjugates pooled
|
| 122 |
+
Ignored if linker_drug_smiles is provided.
|
| 123 |
+
"""
|
| 124 |
+
self.drug_position = drug_position
|
| 125 |
+
|
| 126 |
+
if linker_drug_smiles is not None:
|
| 127 |
+
self._variants = [linker_drug_smiles]
|
| 128 |
+
self._variant_names = ['custom']
|
| 129 |
+
super().__init__(
|
| 130 |
+
linker_drug_smiles,
|
| 131 |
+
component_name="Custom-DSPA-SN38 (NRP1)"
|
| 132 |
+
)
|
| 133 |
+
elif drug_position == 'all':
|
| 134 |
+
self._variants = [self.POS10_LINKER_SN38_SMILES, self.POS20_LINKER_SN38_SMILES]
|
| 135 |
+
self._variant_names = ['pos10', 'pos20']
|
| 136 |
+
super().__init__(
|
| 137 |
+
self.POS10_LINKER_SN38_SMILES,
|
| 138 |
+
component_name="DSPA-SN38 (LIN00149, all positions)"
|
| 139 |
+
)
|
| 140 |
+
else:
|
| 141 |
+
if drug_position not in self.DRUG_POSITIONS:
|
| 142 |
+
raise ValueError(f"Unknown drug_position '{drug_position}'. "
|
| 143 |
+
f"Choose from: 'all', '10', '20'")
|
| 144 |
+
smiles = self.DRUG_POSITIONS[drug_position]
|
| 145 |
+
self._variants = [smiles]
|
| 146 |
+
self._variant_names = [f'pos{drug_position}']
|
| 147 |
+
super().__init__(
|
| 148 |
+
smiles,
|
| 149 |
+
component_name=f"DSPA-SN38 (LIN00149, C-{drug_position})"
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
def _verify_reactive_group(self):
|
| 153 |
+
"""Verify thiol group is present for disulfide bond formation"""
|
| 154 |
+
# Check for free thiol (SH)
|
| 155 |
+
for atom in self.linker_payload_mol.GetAtoms():
|
| 156 |
+
if atom.GetAtomicNum() == 16: # Sulfur
|
| 157 |
+
if atom.GetTotalNumHs() >= 1 and atom.GetDegree() == 1:
|
| 158 |
+
print(" DSPA-SN38 loaded with free thiol reactive group")
|
| 159 |
+
break
|
| 160 |
+
else:
|
| 161 |
+
print(" Warning: No free thiol group detected in DSPA-SN38")
|
| 162 |
+
|
| 163 |
+
# Verify ester bond to drug
|
| 164 |
+
ester_pattern = Chem.MolFromSmarts('[#6][CX3](=[OX1])[OX2][c]')
|
| 165 |
+
if self.linker_payload_mol.HasSubstructMatch(ester_pattern):
|
| 166 |
+
print(" Ester bond (linker-to-drug) detected")
|
| 167 |
+
else:
|
| 168 |
+
# Try broader ester pattern
|
| 169 |
+
ester_broad = Chem.MolFromSmarts('[#6][CX3](=[OX1])[OX2][#6]')
|
| 170 |
+
if self.linker_payload_mol.HasSubstructMatch(ester_broad):
|
| 171 |
+
print(" Ester bond (linker-to-drug) detected")
|
| 172 |
+
else:
|
| 173 |
+
print(" Warning: No ester bond detected - check linker-drug connection")
|
| 174 |
+
|
| 175 |
+
# ------------------------------------------------------------------
|
| 176 |
+
# Conjugation site finding (Cysteine thiol)
|
| 177 |
+
# ------------------------------------------------------------------
|
| 178 |
+
|
| 179 |
+
def find_conjugation_sites(self, peptide_smiles):
|
| 180 |
+
"""
|
| 181 |
+
Find all free cysteine thiol groups in peptide
|
| 182 |
+
|
| 183 |
+
Returns list of dicts with site information including atom index
|
| 184 |
+
"""
|
| 185 |
+
return self.find_peptide_cysteines(peptide_smiles)
|
| 186 |
+
|
| 187 |
+
def find_peptide_cysteines(self, peptide_smiles):
|
| 188 |
+
"""
|
| 189 |
+
Find all free cysteine residues (thiol groups) in peptide
|
| 190 |
+
|
| 191 |
+
Iterates over all atoms to find S with free H (thiol, not disulfide).
|
| 192 |
+
|
| 193 |
+
Returns:
|
| 194 |
+
List of dicts with 'atom_idx' (sulfur index) and metadata
|
| 195 |
+
"""
|
| 196 |
+
peptide_mol = Chem.MolFromSmiles(peptide_smiles)
|
| 197 |
+
if peptide_mol is None:
|
| 198 |
+
raise ValueError("Invalid peptide SMILES")
|
| 199 |
+
|
| 200 |
+
cysteines = []
|
| 201 |
+
|
| 202 |
+
for atom in peptide_mol.GetAtoms():
|
| 203 |
+
if atom.GetAtomicNum() == 16: # Sulfur
|
| 204 |
+
total_hs = atom.GetTotalNumHs()
|
| 205 |
+
degree = atom.GetDegree()
|
| 206 |
+
|
| 207 |
+
# Thiol: S with 1 H and bonded to 1 carbon (not disulfide S-S)
|
| 208 |
+
if total_hs >= 1 and degree == 1:
|
| 209 |
+
cysteines.append({
|
| 210 |
+
'atom_idx': atom.GetIdx(),
|
| 211 |
+
'num_hydrogens': total_hs,
|
| 212 |
+
'neighbors': [peptide_mol.GetAtomWithIdx(n.GetIdx()).GetSymbol()
|
| 213 |
+
for n in atom.GetNeighbors()],
|
| 214 |
+
'type': 'Cys_thiol'
|
| 215 |
+
})
|
| 216 |
+
|
| 217 |
+
return cysteines
|
| 218 |
+
|
| 219 |
+
# ------------------------------------------------------------------
|
| 220 |
+
# Conjugation reaction (disulfide bond formation)
|
| 221 |
+
# ------------------------------------------------------------------
|
| 222 |
+
|
| 223 |
+
def _perform_conjugation(self, peptide_mol, site_info, exclude_n_terminus=True):
|
| 224 |
+
"""
|
| 225 |
+
Form disulfide bond between peptide Cys thiol and linker-drug thiol.
|
| 226 |
+
|
| 227 |
+
Args:
|
| 228 |
+
peptide_mol: RDKit Mol object
|
| 229 |
+
site_info: Not used (kept for compatibility)
|
| 230 |
+
exclude_n_terminus: Not used for Cys conjugation (kept for interface)
|
| 231 |
+
|
| 232 |
+
Returns: List of all possible conjugate SMILES
|
| 233 |
+
"""
|
| 234 |
+
all_conjugates = []
|
| 235 |
+
seen = set()
|
| 236 |
+
|
| 237 |
+
for variant_smiles in self._variants:
|
| 238 |
+
variant_mol = Chem.MolFromSmiles(variant_smiles)
|
| 239 |
+
if variant_mol is None:
|
| 240 |
+
continue
|
| 241 |
+
|
| 242 |
+
# SMARTS: form disulfide between any two thiols
|
| 243 |
+
# Reactant 0 = peptide (Cys-CH2-SH), Reactant 1 = linker-drug (-CH2-SH)
|
| 244 |
+
disulfide_rxn = AllChem.ReactionFromSmarts(
|
| 245 |
+
'[C:1][SH:2].[SH:3][C:4]>>[C:1][S:2][S:3][C:4]'
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
products = disulfide_rxn.RunReactants((peptide_mol, variant_mol))
|
| 249 |
+
|
| 250 |
+
if not products:
|
| 251 |
+
continue
|
| 252 |
+
|
| 253 |
+
for product_tuple in products:
|
| 254 |
+
try:
|
| 255 |
+
conjugate_mol = product_tuple[0]
|
| 256 |
+
Chem.SanitizeMol(conjugate_mol)
|
| 257 |
+
conjugate_smiles = Chem.MolToSmiles(conjugate_mol)
|
| 258 |
+
|
| 259 |
+
if conjugate_smiles not in seen:
|
| 260 |
+
seen.add(conjugate_smiles)
|
| 261 |
+
all_conjugates.append(conjugate_smiles)
|
| 262 |
+
except Exception:
|
| 263 |
+
continue
|
| 264 |
+
|
| 265 |
+
if not all_conjugates:
|
| 266 |
+
raise ValueError("Disulfide bond formation failed - no products generated")
|
| 267 |
+
|
| 268 |
+
return all_conjugates
|
| 269 |
+
|
| 270 |
+
def conjugate_to_peptide(self, peptide_smiles, exclude_n_terminus=True, return_all=True):
|
| 271 |
+
"""
|
| 272 |
+
Conjugate DSPA-SN38 to peptide via disulfide bond at Cys thiol.
|
| 273 |
+
|
| 274 |
+
Args:
|
| 275 |
+
peptide_smiles: SMILES string of peptide (must contain Cys with free thiol)
|
| 276 |
+
exclude_n_terminus: Not used for Cys conjugation (kept for interface)
|
| 277 |
+
return_all: If True, return all possible conjugates
|
| 278 |
+
|
| 279 |
+
Returns:
|
| 280 |
+
List of all possible conjugate SMILES
|
| 281 |
+
"""
|
| 282 |
+
peptide_mol = Chem.MolFromSmiles(peptide_smiles)
|
| 283 |
+
if peptide_mol is None:
|
| 284 |
+
raise ValueError(f"Invalid peptide SMILES: {peptide_smiles}")
|
| 285 |
+
|
| 286 |
+
sites = self.find_conjugation_sites(peptide_smiles)
|
| 287 |
+
print(f"Found {len(sites)} Cys thiol site(s)")
|
| 288 |
+
|
| 289 |
+
conjugates = self._perform_conjugation(peptide_mol, None, exclude_n_terminus)
|
| 290 |
+
|
| 291 |
+
pos_label = f"C-{self.drug_position}" if self.drug_position != 'all' else "C-10 + C-20"
|
| 292 |
+
print(f"Generated {len(conjugates)} unique conjugate(s) [Cys disulfide] [DSPA {pos_label}]")
|
| 293 |
+
|
| 294 |
+
if return_all:
|
| 295 |
+
return conjugates
|
| 296 |
+
else:
|
| 297 |
+
return conjugates[0] if conjugates else None
|
| 298 |
+
|
| 299 |
+
def batch_conjugate(self, peptide_smiles_list, exclude_n_terminus=True, return_all=True):
|
| 300 |
+
"""
|
| 301 |
+
Batch conjugate multiple peptides.
|
| 302 |
+
|
| 303 |
+
Returns:
|
| 304 |
+
Dict with 'conjugates', 'successful', 'failed', 'total_conjugates'
|
| 305 |
+
"""
|
| 306 |
+
results = {
|
| 307 |
+
'conjugates': [],
|
| 308 |
+
'successful': [],
|
| 309 |
+
'failed': [],
|
| 310 |
+
'total_conjugates': 0
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
for i, peptide_smiles in enumerate(peptide_smiles_list):
|
| 314 |
+
try:
|
| 315 |
+
conjugates = self.conjugate_to_peptide(
|
| 316 |
+
peptide_smiles,
|
| 317 |
+
exclude_n_terminus=exclude_n_terminus,
|
| 318 |
+
return_all=return_all
|
| 319 |
+
)
|
| 320 |
+
if return_all:
|
| 321 |
+
results['conjugates'].append(conjugates)
|
| 322 |
+
results['total_conjugates'] += len(conjugates)
|
| 323 |
+
else:
|
| 324 |
+
results['conjugates'].append(conjugates)
|
| 325 |
+
results['total_conjugates'] += 1
|
| 326 |
+
results['successful'].append(i)
|
| 327 |
+
except Exception as e:
|
| 328 |
+
print(f" Peptide {i} failed: {e}")
|
| 329 |
+
results['conjugates'].append(None)
|
| 330 |
+
results['failed'].append((i, str(e)))
|
| 331 |
+
|
| 332 |
+
print(f"\n=== Summary ===")
|
| 333 |
+
print(f"Peptides: {len(peptide_smiles_list)}, Success: {len(results['successful'])}, "
|
| 334 |
+
f"Failed: {len(results['failed'])}")
|
| 335 |
+
print(f"Total conjugates generated: {results['total_conjugates']}")
|
| 336 |
+
|
| 337 |
+
return results
|
| 338 |
+
|
| 339 |
+
def flatten_batch_results(self, results):
|
| 340 |
+
"""Flatten batch conjugation results into a single list"""
|
| 341 |
+
all_conjugates = []
|
| 342 |
+
for conjugate_list in results['conjugates']:
|
| 343 |
+
if conjugate_list is not None:
|
| 344 |
+
if isinstance(conjugate_list, list):
|
| 345 |
+
all_conjugates.extend(conjugate_list)
|
| 346 |
+
else:
|
| 347 |
+
all_conjugates.append(conjugate_list)
|
| 348 |
+
return all_conjugates
|
| 349 |
+
|
| 350 |
+
# ========================================================================
|
| 351 |
+
# Attachment Site Compatibility Checks (Cys disulfide)
|
| 352 |
+
# ========================================================================
|
| 353 |
+
|
| 354 |
+
def has_disulfide_bond(self, mol):
|
| 355 |
+
"""Check if molecule has disulfide bond (S-S)"""
|
| 356 |
+
disulfide_pattern = Chem.MolFromSmarts('[SX2]-[SX2]')
|
| 357 |
+
return mol.HasSubstructMatch(disulfide_pattern)
|
| 358 |
+
|
| 359 |
+
def has_free_thiol(self, mol):
|
| 360 |
+
"""Check if molecule has unconjugated (free) thiol groups"""
|
| 361 |
+
for atom in mol.GetAtoms():
|
| 362 |
+
if atom.GetAtomicNum() == 16:
|
| 363 |
+
if atom.GetTotalNumHs() >= 1 and atom.GetDegree() == 1:
|
| 364 |
+
return True
|
| 365 |
+
return False
|
| 366 |
+
|
| 367 |
+
def has_ester_bond_to_drug(self, mol):
|
| 368 |
+
"""Check for ester bond connecting linker to drug"""
|
| 369 |
+
ester_pattern = Chem.MolFromSmarts('[#6][CX3](=[OX1])[OX2][#6]')
|
| 370 |
+
return mol.HasSubstructMatch(ester_pattern)
|
| 371 |
+
|
| 372 |
+
def has_sn38_core(self, mol):
|
| 373 |
+
"""Check for SN-38 core structure (camptothecin with 7-ethyl)"""
|
| 374 |
+
# Camptothecin-like core: fused ring system with lactone
|
| 375 |
+
cpt_core = Chem.MolFromSmarts('c1ccc2ncc3C(=O)OCc3c2c1')
|
| 376 |
+
return mol.HasSubstructMatch(cpt_core)
|
| 377 |
+
|
| 378 |
+
def check_attachment_site_compatibility(self, conjugate_smiles):
|
| 379 |
+
"""
|
| 380 |
+
Check attachment site compatibility for Cys-SS-DSPA-SN38 conjugation.
|
| 381 |
+
|
| 382 |
+
Validates:
|
| 383 |
+
1. Disulfide bond (S-S) is present
|
| 384 |
+
2. No free thiol remaining
|
| 385 |
+
3. Ester bond to drug is present
|
| 386 |
+
|
| 387 |
+
Returns:
|
| 388 |
+
Tuple of (passed: bool, details: dict)
|
| 389 |
+
"""
|
| 390 |
+
mol = Chem.MolFromSmiles(conjugate_smiles)
|
| 391 |
+
|
| 392 |
+
if mol is None:
|
| 393 |
+
return False, {'parseable': False, 'error': 'Could not parse SMILES'}
|
| 394 |
+
|
| 395 |
+
details = {
|
| 396 |
+
'parseable': True,
|
| 397 |
+
'validation_method': 'cys_disulfide_ester',
|
| 398 |
+
'linker': 'LIN00149 (3-Disulfanylpropanoic Acid)',
|
| 399 |
+
'drug': 'SN-38 (7-Ethyl-10-hydroxycamptothecin)',
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
details['has_disulfide_bond'] = self.has_disulfide_bond(mol)
|
| 403 |
+
details['no_free_thiol'] = not self.has_free_thiol(mol)
|
| 404 |
+
details['has_ester_bond'] = self.has_ester_bond_to_drug(mol)
|
| 405 |
+
|
| 406 |
+
passed = all([
|
| 407 |
+
details['has_disulfide_bond'],
|
| 408 |
+
details['no_free_thiol'],
|
| 409 |
+
])
|
| 410 |
+
|
| 411 |
+
return passed, details
|
| 412 |
+
|
| 413 |
+
def _basic_validation(self, mol):
|
| 414 |
+
"""
|
| 415 |
+
Basic validation checks for DSPA-SN38 conjugates.
|
| 416 |
+
|
| 417 |
+
Checks:
|
| 418 |
+
1. Disulfide bond present (Cys-Linker conjugation)
|
| 419 |
+
2. No free thiol remaining
|
| 420 |
+
3. Ester bond to drug present
|
| 421 |
+
4. SN-38 core present
|
| 422 |
+
"""
|
| 423 |
+
checks = {}
|
| 424 |
+
|
| 425 |
+
disulfide_pattern = Chem.MolFromSmarts('[#16]-[#16]')
|
| 426 |
+
checks['has_disulfide_bond'] = mol.HasSubstructMatch(disulfide_pattern)
|
| 427 |
+
|
| 428 |
+
checks['no_free_thiol'] = not self.has_free_thiol(mol)
|
| 429 |
+
|
| 430 |
+
ester_pattern = Chem.MolFromSmarts('[#6][CX3](=[OX1])[OX2][#6]')
|
| 431 |
+
checks['has_ester_bond'] = mol.HasSubstructMatch(ester_pattern)
|
| 432 |
+
|
| 433 |
+
checks['has_sn38_core'] = self.has_sn38_core(mol)
|
| 434 |
+
|
| 435 |
+
checks['_passed'] = all([
|
| 436 |
+
checks['has_disulfide_bond'],
|
| 437 |
+
checks['no_free_thiol'],
|
| 438 |
+
])
|
| 439 |
+
|
| 440 |
+
return checks
|
| 441 |
+
|
| 442 |
+
def validate_conjugate(self, conjugate_smiles):
|
| 443 |
+
"""
|
| 444 |
+
Validate that the conjugate is chemically reasonable.
|
| 445 |
+
|
| 446 |
+
Returns:
|
| 447 |
+
(passed: bool, checks: dict)
|
| 448 |
+
"""
|
| 449 |
+
mol = Chem.MolFromSmiles(conjugate_smiles)
|
| 450 |
+
|
| 451 |
+
if mol is None:
|
| 452 |
+
return False, {'error': 'Could not parse SMILES'}
|
| 453 |
+
|
| 454 |
+
checks = self._basic_validation(mol)
|
| 455 |
+
checks['parseable'] = True
|
| 456 |
+
|
| 457 |
+
passed = checks.get('_passed', True)
|
| 458 |
+
if '_passed' in checks:
|
| 459 |
+
del checks['_passed']
|
| 460 |
+
|
| 461 |
+
return passed, checks
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
# ============================================================================
|
| 465 |
+
# Main - Test Examples
|
| 466 |
+
# ============================================================================
|
| 467 |
+
|
| 468 |
+
if __name__ == "__main__":
|
| 469 |
+
|
| 470 |
+
print("=" * 70)
|
| 471 |
+
print("DSPA-SN38 PDC Builder - Test")
|
| 472 |
+
print("Reference: NRP1 + LIN00149 + SN-38")
|
| 473 |
+
print("=" * 70 + "\n")
|
| 474 |
+
|
| 475 |
+
# Test peptides with free cysteine (NRP1-targeting sequences)
|
| 476 |
+
test_peptides = [
|
| 477 |
+
# Cys-Arg-Gly-Asp-Lys (CRGDK) - reference NRP1 peptide
|
| 478 |
+
"N[C@@H](CS)C(=O)N[C@@H](CCCNC(=N)N)C(=O)NCC(=O)N[C@@H](CC(=O)O)C(=O)N[C@@H](CCCCN)C(=O)O",
|
| 479 |
+
# Simple Gly-Cys-Gly
|
| 480 |
+
"NCC(=O)N[C@@H](CS)C(=O)NCC(=O)O",
|
| 481 |
+
]
|
| 482 |
+
|
| 483 |
+
print("Test 1: Default mode (all positions: C-10 + C-20)")
|
| 484 |
+
print("-" * 50)
|
| 485 |
+
|
| 486 |
+
builder = DisulfanylpropanoicAcidSN38_PDC_Builder()
|
| 487 |
+
|
| 488 |
+
for i, peptide in enumerate(test_peptides):
|
| 489 |
+
print(f"\n--- Test peptide {i+1} ---")
|
| 490 |
+
print(f"Input: {peptide[:60]}...")
|
| 491 |
+
|
| 492 |
+
try:
|
| 493 |
+
conjugates = builder.conjugate_to_peptide(peptide)
|
| 494 |
+
print(f" Generated {len(conjugates)} conjugate(s)")
|
| 495 |
+
|
| 496 |
+
for j, conj in enumerate(conjugates):
|
| 497 |
+
valid, checks = builder.validate_conjugate(conj)
|
| 498 |
+
print(f" Conjugate {j+1}: {'PASSED' if valid else 'FAILED'} "
|
| 499 |
+
f"({len(conj)} chars)")
|
| 500 |
+
|
| 501 |
+
except Exception as e:
|
| 502 |
+
print(f" FAILED: {e}")
|
| 503 |
+
import traceback
|
| 504 |
+
traceback.print_exc()
|
| 505 |
+
|
| 506 |
+
print("\n\nTest 2: Position-10 only")
|
| 507 |
+
print("-" * 50)
|
| 508 |
+
|
| 509 |
+
builder_10 = DisulfanylpropanoicAcidSN38_PDC_Builder(drug_position='10')
|
| 510 |
+
|
| 511 |
+
try:
|
| 512 |
+
conjugates = builder_10.conjugate_to_peptide(test_peptides[1])
|
| 513 |
+
print(f" Generated {len(conjugates)} conjugate(s) [C-10 only]")
|
| 514 |
+
except Exception as e:
|
| 515 |
+
print(f" FAILED: {e}")
|
smiles/conjugation/DauFactory.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Daunorubicin PDC Factory - Drug-level builder with linker sampling
|
| 3 |
+
|
| 4 |
+
Instead of specifying a particular linker-drug combo (e.g. AoaDau, ValAlaDau),
|
| 5 |
+
the user specifies only the drug (Daunorubicin) and a linker is sampled
|
| 6 |
+
according to GNRHR+Dau entry frequencies from PDCdb.
|
| 7 |
+
|
| 8 |
+
Linker frequencies (GNRHR + Daunorubicin):
|
| 9 |
+
===========================================
|
| 10 |
+
Aminooxyacetic acid (Aoa) (AoaDau) - 21 entries
|
| 11 |
+
Val-Ala-PABC + glutaryl spacer (ValAlaDau) - 2 entries
|
| 12 |
+
Val-Cit-PABC + glutaryl spacer (ValCitDau) - 2 entries
|
| 13 |
+
Glutaric acid (GlutarylDau) - 2 entries
|
| 14 |
+
Total: 27 entries
|
| 15 |
+
|
| 16 |
+
Sampling probabilities:
|
| 17 |
+
AoaDau: 21/27 ≈ 0.778
|
| 18 |
+
ValAlaDau: 2/27 ≈ 0.074
|
| 19 |
+
ValCitDau: 2/27 ≈ 0.074
|
| 20 |
+
GlutarylDau: 2/27 ≈ 0.074
|
| 21 |
+
|
| 22 |
+
Usage:
|
| 23 |
+
======
|
| 24 |
+
# Drug-only mode - linker is sampled per peptide
|
| 25 |
+
factory = DaunorubicinPDC_Factory()
|
| 26 |
+
conjugates = factory.conjugate_to_peptide(peptide_smiles)
|
| 27 |
+
|
| 28 |
+
# Fixed linker mode - use a specific linker
|
| 29 |
+
factory = DaunorubicinPDC_Factory(linker='AoaDau')
|
| 30 |
+
conjugates = factory.conjugate_to_peptide(peptide_smiles)
|
| 31 |
+
|
| 32 |
+
# All linkers mode - generate conjugates with every linker
|
| 33 |
+
factory = DaunorubicinPDC_Factory(linker='all')
|
| 34 |
+
conjugates = factory.conjugate_to_peptide(peptide_smiles)
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
import numpy as np
|
| 38 |
+
import contextlib
|
| 39 |
+
import io
|
| 40 |
+
|
| 41 |
+
from .AoaDau import AoaDaunorubicin_PDC_Builder
|
| 42 |
+
from .ValAlaDau import ValAlaPABCDaunorubicin_PDC_Builder
|
| 43 |
+
from .ValCitDau import ValCitPABCDaunorubicin_PDC_Builder
|
| 44 |
+
from .GlutarylDau import GlutarylDaunorubicin_PDC_Builder
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ============================================================================
|
| 48 |
+
# Linker registry: name → (builder_class, PDCdb frequency, description)
|
| 49 |
+
# ============================================================================
|
| 50 |
+
|
| 51 |
+
GNRHR_DAU_LINKER_REGISTRY = {
|
| 52 |
+
'AoaDau': {
|
| 53 |
+
'class': AoaDaunorubicin_PDC_Builder,
|
| 54 |
+
'frequency': 21,
|
| 55 |
+
'linker_id': 'LIN00019',
|
| 56 |
+
'description': 'Aminooxyacetic acid (Aoa) - oxime bond to Dau ketone',
|
| 57 |
+
'pdc_refs': ['PDC_00011', 'PDC_00208'],
|
| 58 |
+
},
|
| 59 |
+
'ValAlaDau': {
|
| 60 |
+
'class': ValAlaPABCDaunorubicin_PDC_Builder,
|
| 61 |
+
'frequency': 2,
|
| 62 |
+
'linker_id': 'LIN00028',
|
| 63 |
+
'description': 'Val-Ala dipeptide + PABC self-immolative + glutaryl spacer',
|
| 64 |
+
'pdc_refs': ['PDC_00185', 'PDC_00193'],
|
| 65 |
+
},
|
| 66 |
+
'ValCitDau': {
|
| 67 |
+
'class': ValCitPABCDaunorubicin_PDC_Builder,
|
| 68 |
+
'frequency': 2,
|
| 69 |
+
'linker_id': 'LIN00005',
|
| 70 |
+
'description': 'Val-Cit dipeptide + PABC self-immolative + glutaryl spacer',
|
| 71 |
+
'pdc_refs': ['PDC_00189', 'PDC_00194'],
|
| 72 |
+
},
|
| 73 |
+
'GlutarylDau': {
|
| 74 |
+
'class': GlutarylDaunorubicin_PDC_Builder,
|
| 75 |
+
'frequency': 2,
|
| 76 |
+
'linker_id': 'LIN00033',
|
| 77 |
+
'description': 'Glutaric acid - simple dicarboxylic acid spacer',
|
| 78 |
+
'pdc_refs': ['PDC_00190', 'PDC_00195'],
|
| 79 |
+
},
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _compute_linker_probabilities(registry=None):
|
| 84 |
+
"""Compute sampling probabilities from frequency counts."""
|
| 85 |
+
if registry is None:
|
| 86 |
+
registry = GNRHR_DAU_LINKER_REGISTRY
|
| 87 |
+
names = list(registry.keys())
|
| 88 |
+
freqs = np.array([registry[n]['frequency'] for n in names], dtype=np.float64)
|
| 89 |
+
probs = freqs / freqs.sum()
|
| 90 |
+
return names, probs
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class DaunorubicinPDC_Factory:
|
| 94 |
+
"""
|
| 95 |
+
Drug-level PDC factory for Daunorubicin conjugates.
|
| 96 |
+
|
| 97 |
+
Wraps all four GNRHR+Dau linker builders and provides:
|
| 98 |
+
- 'sample' mode (default): randomly pick a linker per call according to
|
| 99 |
+
PDCdb entry frequencies
|
| 100 |
+
- 'all' mode: generate conjugates with every linker
|
| 101 |
+
- fixed linker mode: always use a specific linker (e.g. 'AoaDau')
|
| 102 |
+
|
| 103 |
+
The interface mirrors the individual builder's `conjugate_to_peptide` so it
|
| 104 |
+
can be used as a drop-in replacement in the Conjugation classifier.
|
| 105 |
+
"""
|
| 106 |
+
|
| 107 |
+
def __init__(self, linker='sample', rng_seed=None):
|
| 108 |
+
"""
|
| 109 |
+
Args:
|
| 110 |
+
linker: One of:
|
| 111 |
+
- 'sample' (default): sample linker per peptide according to
|
| 112 |
+
GNRHR+Dau frequency weights
|
| 113 |
+
- 'all': use every linker and pool conjugates
|
| 114 |
+
- A specific linker name ('AoaDau', 'ValAlaDau', 'ValCitDau',
|
| 115 |
+
'GlutarylDau')
|
| 116 |
+
rng_seed: Optional seed for reproducible linker sampling
|
| 117 |
+
"""
|
| 118 |
+
self.linker_mode = linker
|
| 119 |
+
self.registry = GNRHR_DAU_LINKER_REGISTRY
|
| 120 |
+
self.rng = np.random.default_rng(rng_seed)
|
| 121 |
+
|
| 122 |
+
# Pre-compute probabilities
|
| 123 |
+
self._linker_names, self._linker_probs = _compute_linker_probabilities(self.registry)
|
| 124 |
+
|
| 125 |
+
# Validate fixed linker
|
| 126 |
+
if linker not in ('sample', 'all') and linker not in self.registry:
|
| 127 |
+
raise ValueError(
|
| 128 |
+
f"Unknown linker '{linker}'. "
|
| 129 |
+
f"Choose from: 'sample', 'all', {list(self.registry.keys())}"
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
# Pre-instantiate builders (suppress verbose init output)
|
| 133 |
+
self._builders = {}
|
| 134 |
+
for name, entry in self.registry.items():
|
| 135 |
+
with contextlib.redirect_stdout(io.StringIO()):
|
| 136 |
+
self._builders[name] = entry['class']()
|
| 137 |
+
|
| 138 |
+
# Track which linker was last sampled (useful for logging)
|
| 139 |
+
self.last_sampled_linker = None
|
| 140 |
+
|
| 141 |
+
# ------------------------------------------------------------------
|
| 142 |
+
# Linker selection
|
| 143 |
+
# ------------------------------------------------------------------
|
| 144 |
+
|
| 145 |
+
def sample_linker(self):
|
| 146 |
+
"""Sample a linker name according to frequency weights."""
|
| 147 |
+
idx = self.rng.choice(len(self._linker_names), p=self._linker_probs)
|
| 148 |
+
return self._linker_names[idx]
|
| 149 |
+
|
| 150 |
+
def get_builder(self, linker_name):
|
| 151 |
+
"""Return the pre-instantiated builder for a given linker name."""
|
| 152 |
+
return self._builders[linker_name]
|
| 153 |
+
|
| 154 |
+
@property
|
| 155 |
+
def linker_probabilities(self):
|
| 156 |
+
"""Return dict of linker name → sampling probability."""
|
| 157 |
+
return dict(zip(self._linker_names, self._linker_probs.tolist()))
|
| 158 |
+
|
| 159 |
+
# ------------------------------------------------------------------
|
| 160 |
+
# Conjugation interface (mirrors individual builders)
|
| 161 |
+
# ------------------------------------------------------------------
|
| 162 |
+
|
| 163 |
+
def conjugate_to_peptide(self, peptide_smiles, exclude_n_terminus=True, return_all=True):
|
| 164 |
+
"""
|
| 165 |
+
Conjugate Daunorubicin to peptide, selecting linker by mode.
|
| 166 |
+
|
| 167 |
+
Args:
|
| 168 |
+
peptide_smiles: SMILES string of peptide (must contain Lys -NH2)
|
| 169 |
+
exclude_n_terminus: If True, only conjugate at Lys ε-amines
|
| 170 |
+
return_all: If True, return all positional conjugates
|
| 171 |
+
|
| 172 |
+
Returns:
|
| 173 |
+
List of conjugate SMILES.
|
| 174 |
+
In 'sample' mode: conjugates from the sampled linker.
|
| 175 |
+
In 'all' mode: conjugates from every linker (pooled).
|
| 176 |
+
In fixed mode: conjugates from the specified linker.
|
| 177 |
+
"""
|
| 178 |
+
if self.linker_mode == 'all':
|
| 179 |
+
self.last_sampled_linker = 'all'
|
| 180 |
+
return self._conjugate_all_linkers(peptide_smiles, exclude_n_terminus, return_all)
|
| 181 |
+
elif self.linker_mode == 'sample':
|
| 182 |
+
chosen = self.sample_linker()
|
| 183 |
+
self.last_sampled_linker = chosen
|
| 184 |
+
return self._conjugate_with_linker(chosen, peptide_smiles, exclude_n_terminus, return_all)
|
| 185 |
+
else:
|
| 186 |
+
self.last_sampled_linker = self.linker_mode
|
| 187 |
+
return self._conjugate_with_linker(self.linker_mode, peptide_smiles, exclude_n_terminus, return_all)
|
| 188 |
+
|
| 189 |
+
def _conjugate_with_linker(self, linker_name, peptide_smiles, exclude_n_terminus, return_all):
|
| 190 |
+
"""Run conjugation with a single, specific linker."""
|
| 191 |
+
builder = self._builders[linker_name]
|
| 192 |
+
with contextlib.redirect_stdout(io.StringIO()):
|
| 193 |
+
conjugates = builder.conjugate_to_peptide(
|
| 194 |
+
peptide_smiles,
|
| 195 |
+
exclude_n_terminus=exclude_n_terminus,
|
| 196 |
+
return_all=return_all,
|
| 197 |
+
)
|
| 198 |
+
# Normalize to list
|
| 199 |
+
if not isinstance(conjugates, list):
|
| 200 |
+
conjugates = [conjugates] if conjugates else []
|
| 201 |
+
return conjugates
|
| 202 |
+
|
| 203 |
+
def _conjugate_all_linkers(self, peptide_smiles, exclude_n_terminus, return_all):
|
| 204 |
+
"""Generate conjugates with every linker and pool them."""
|
| 205 |
+
all_conjugates = []
|
| 206 |
+
seen = set()
|
| 207 |
+
for name in self._linker_names:
|
| 208 |
+
try:
|
| 209 |
+
conjs = self._conjugate_with_linker(name, peptide_smiles, exclude_n_terminus, return_all)
|
| 210 |
+
for c in conjs:
|
| 211 |
+
if c not in seen:
|
| 212 |
+
seen.add(c)
|
| 213 |
+
all_conjugates.append(c)
|
| 214 |
+
except Exception:
|
| 215 |
+
continue
|
| 216 |
+
if not all_conjugates:
|
| 217 |
+
raise ValueError("No conjugates generated with any linker")
|
| 218 |
+
return all_conjugates
|
| 219 |
+
|
| 220 |
+
# ------------------------------------------------------------------
|
| 221 |
+
# Validation (delegates to the appropriate builder)
|
| 222 |
+
# ------------------------------------------------------------------
|
| 223 |
+
|
| 224 |
+
def validate_conjugate(self, conjugate_smiles, linker_name=None):
|
| 225 |
+
"""
|
| 226 |
+
Validate a conjugate SMILES.
|
| 227 |
+
|
| 228 |
+
If linker_name is given, validate with that builder.
|
| 229 |
+
Otherwise, try all builders and return the best result.
|
| 230 |
+
"""
|
| 231 |
+
if linker_name and linker_name in self._builders:
|
| 232 |
+
return self._builders[linker_name].validate_conjugate(conjugate_smiles)
|
| 233 |
+
|
| 234 |
+
# Try each builder; return first passing result
|
| 235 |
+
for name, builder in self._builders.items():
|
| 236 |
+
passed, checks = builder.validate_conjugate(conjugate_smiles)
|
| 237 |
+
if passed:
|
| 238 |
+
checks['matched_linker'] = name
|
| 239 |
+
return True, checks
|
| 240 |
+
|
| 241 |
+
# None passed - return last failure
|
| 242 |
+
return False, checks
|
| 243 |
+
|
| 244 |
+
def check_attachment_site_compatibility(self, conjugate_smiles, linker_name=None):
|
| 245 |
+
"""Delegate to appropriate builder's attachment check."""
|
| 246 |
+
if linker_name and linker_name in self._builders:
|
| 247 |
+
return self._builders[linker_name].check_attachment_site_compatibility(conjugate_smiles)
|
| 248 |
+
|
| 249 |
+
for name, builder in self._builders.items():
|
| 250 |
+
passed, details = builder.check_attachment_site_compatibility(conjugate_smiles)
|
| 251 |
+
if passed:
|
| 252 |
+
details['matched_linker'] = name
|
| 253 |
+
return True, details
|
| 254 |
+
return False, details
|
| 255 |
+
|
| 256 |
+
# ------------------------------------------------------------------
|
| 257 |
+
# Batch interface
|
| 258 |
+
# ------------------------------------------------------------------
|
| 259 |
+
|
| 260 |
+
def batch_conjugate(self, peptide_smiles_list, exclude_n_terminus=True, return_all=True):
|
| 261 |
+
"""
|
| 262 |
+
Batch conjugate multiple peptides.
|
| 263 |
+
|
| 264 |
+
In 'sample' mode each peptide gets a freshly sampled linker.
|
| 265 |
+
|
| 266 |
+
Returns:
|
| 267 |
+
Dict with 'conjugates', 'successful', 'failed', 'total_conjugates',
|
| 268 |
+
and 'linkers_used' (list of linker names, one per peptide).
|
| 269 |
+
"""
|
| 270 |
+
results = {
|
| 271 |
+
'conjugates': [],
|
| 272 |
+
'successful': [],
|
| 273 |
+
'failed': [],
|
| 274 |
+
'total_conjugates': 0,
|
| 275 |
+
'linkers_used': [],
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
for i, peptide_smiles in enumerate(peptide_smiles_list):
|
| 279 |
+
try:
|
| 280 |
+
conjugates = self.conjugate_to_peptide(
|
| 281 |
+
peptide_smiles,
|
| 282 |
+
exclude_n_terminus=exclude_n_terminus,
|
| 283 |
+
return_all=return_all,
|
| 284 |
+
)
|
| 285 |
+
if return_all:
|
| 286 |
+
results['conjugates'].append(conjugates)
|
| 287 |
+
results['total_conjugates'] += len(conjugates)
|
| 288 |
+
else:
|
| 289 |
+
results['conjugates'].append(conjugates)
|
| 290 |
+
results['total_conjugates'] += 1
|
| 291 |
+
results['successful'].append(i)
|
| 292 |
+
results['linkers_used'].append(self.last_sampled_linker)
|
| 293 |
+
except Exception as e:
|
| 294 |
+
results['conjugates'].append(None)
|
| 295 |
+
results['failed'].append((i, str(e)))
|
| 296 |
+
results['linkers_used'].append(None)
|
| 297 |
+
|
| 298 |
+
return results
|
smiles/conjugation/GlutarylDau.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Glutaryl-Daunorubicin PDC Builder
|
| 3 |
+
|
| 4 |
+
Conjugation chemistry: Lysine ε-amine + Glutaryl-Daunorubicin (amide bond formation)
|
| 5 |
+
|
| 6 |
+
Reference PDCs:
|
| 7 |
+
- PDC_00190 (GnRH-III-[2ΔHis-3D-Tic,8Lys(glutaryl-Val-Cit-PABC-diamine-PTX)])
|
| 8 |
+
- PDC_00195 (GnRH-III-[2His-3Trp,8Lys(glutaryl-Val-Cit-PABC-diamine-PTX)])
|
| 9 |
+
- Receptor: GNRHR
|
| 10 |
+
- Linker: Glutaric acid (LIN00033)
|
| 11 |
+
- Payload: Daunorubicin (DRG00010)
|
| 12 |
+
|
| 13 |
+
Architecture:
|
| 14 |
+
Peptide──Lys──NH──C(=O)──(CH₂)₃──C(=O)──NH──Daunorubicin
|
| 15 |
+
│ │
|
| 16 |
+
amide bond glutaryl spacer amide bond (pre-formed)
|
| 17 |
+
|
| 18 |
+
The glutaric acid acts as a simple dicarboxylic acid spacer:
|
| 19 |
+
- One COOH forms amide with Dau 3'-amine (pre-formed in linker-payload)
|
| 20 |
+
- Other COOH forms amide with Lys ε-amine (conjugation reaction)
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from rdkit import Chem
|
| 24 |
+
from rdkit.Chem import RWMol, AllChem
|
| 25 |
+
|
| 26 |
+
from .pdc_builder import PDC_Builder
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class GlutarylDaunorubicin_PDC_Builder(PDC_Builder):
|
| 30 |
+
"""
|
| 31 |
+
PDC builder for Glutaric acid (Glutaryl) - Daunorubicin conjugates
|
| 32 |
+
|
| 33 |
+
Conjugation chemistry (amide bond formation):
|
| 34 |
+
- Peptide site: Lysine ε-amine (side chain -NH2)
|
| 35 |
+
- Linker-payload site: Carboxylic acid of glutaryl spacer (already conjugated
|
| 36 |
+
to Dau 3'-amine via amide)
|
| 37 |
+
- Reaction: Amide bond formation (Lys-NH2 + HOOC-glutaryl-Dau → Lys-NH-CO-glutaryl-Dau)
|
| 38 |
+
|
| 39 |
+
Architecture:
|
| 40 |
+
Peptide──Lys──NH──C(=O)──(CH₂)₃──C(=O)──NH──Daunorubicin
|
| 41 |
+
└── amide ──┘ └── glutaryl ──┘ └── amide (pre-formed) ──┘
|
| 42 |
+
|
| 43 |
+
Reference: PDC_00190, PDC_00195 (GnRH-III conjugates, GNRHR)
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
# Glutaryl-Daunorubicin SMILES with free carboxylic acid
|
| 47 |
+
# Structure: HOOC-(CH2)3-CO-NH-Daunorubicin
|
| 48 |
+
# The amide bond between glutaric acid and Dau 3'-amine is pre-formed
|
| 49 |
+
DEFAULT_GLUTARYL_DAU_SMILES = (
|
| 50 |
+
"C[C@H]1[C@H]([C@H](C[C@@H](O1)O[C@H]2C[C@@](CC3=C2C(=C4C(=C3O)"
|
| 51 |
+
"C(=O)C5=C(C4=O)C(=CC=C5)OC)O)(C(=O)C)O)NC(=O)CCCC(=O)O)O"
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
def __init__(self, glutaryl_dau_smiles=None):
|
| 55 |
+
"""
|
| 56 |
+
Initialize Glutaryl-Daunorubicin builder
|
| 57 |
+
|
| 58 |
+
Args:
|
| 59 |
+
glutaryl_dau_smiles: Optional custom Glutaryl-Dau SMILES.
|
| 60 |
+
If None, uses default.
|
| 61 |
+
Must have free carboxylic acid for conjugation.
|
| 62 |
+
"""
|
| 63 |
+
if glutaryl_dau_smiles is None:
|
| 64 |
+
glutaryl_dau_smiles = self.DEFAULT_GLUTARYL_DAU_SMILES
|
| 65 |
+
|
| 66 |
+
super().__init__(glutaryl_dau_smiles, component_name="Glutaryl-Daunorubicin (linker-payload)")
|
| 67 |
+
|
| 68 |
+
def _verify_reactive_group(self):
|
| 69 |
+
"""Verify carboxylic acid group is present for amide bond formation"""
|
| 70 |
+
carboxylic_acid_pattern = Chem.MolFromSmarts('[CX3](=O)[OX2H1]')
|
| 71 |
+
|
| 72 |
+
if not self.linker_payload_mol.HasSubstructMatch(carboxylic_acid_pattern):
|
| 73 |
+
print(" Warning: No carboxylic acid group detected in Glutaryl-Dau")
|
| 74 |
+
else:
|
| 75 |
+
print(" Glutaryl-Daunorubicin loaded with carboxylic acid reactive group")
|
| 76 |
+
|
| 77 |
+
# Verify Dau 3'-amide bond is present (glutaryl-Dau linkage)
|
| 78 |
+
amide_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[CH2]')
|
| 79 |
+
if self.linker_payload_mol.HasSubstructMatch(amide_pattern):
|
| 80 |
+
print(" Amide bond (Glutaryl-Dau linkage) detected")
|
| 81 |
+
else:
|
| 82 |
+
print(" Warning: No amide bond detected - check Glutaryl-Dau structure")
|
| 83 |
+
|
| 84 |
+
def find_conjugation_sites(self, peptide_smiles):
|
| 85 |
+
"""
|
| 86 |
+
Find all lysine ε-amine groups in peptide
|
| 87 |
+
|
| 88 |
+
Returns list of dicts with site information including atom index
|
| 89 |
+
"""
|
| 90 |
+
return self._find_peptide_lysines(peptide_smiles)
|
| 91 |
+
|
| 92 |
+
def _find_peptide_lysines(self, peptide_smiles):
|
| 93 |
+
"""
|
| 94 |
+
Find all lysine residues with free ε-amine in peptide
|
| 95 |
+
|
| 96 |
+
Lysine side chain: -CH2-CH2-CH2-CH2-NH2 (CCCCN)
|
| 97 |
+
|
| 98 |
+
Returns:
|
| 99 |
+
List of dicts with 'atom_idx' (nitrogen index) and metadata
|
| 100 |
+
"""
|
| 101 |
+
peptide_mol = Chem.MolFromSmiles(peptide_smiles)
|
| 102 |
+
if peptide_mol is None:
|
| 103 |
+
raise ValueError("Invalid peptide SMILES")
|
| 104 |
+
|
| 105 |
+
lysines = []
|
| 106 |
+
|
| 107 |
+
lys_pattern = Chem.MolFromSmarts('[CH2][CH2][CH2][CH2][NH2]')
|
| 108 |
+
matches = peptide_mol.GetSubstructMatches(lys_pattern)
|
| 109 |
+
|
| 110 |
+
for match in matches:
|
| 111 |
+
nitrogen_idx = match[4]
|
| 112 |
+
lysines.append({
|
| 113 |
+
'atom_idx': nitrogen_idx,
|
| 114 |
+
'side_chain_atoms': list(match),
|
| 115 |
+
'type': 'Lys_epsilon_amine'
|
| 116 |
+
})
|
| 117 |
+
|
| 118 |
+
n_terminus_pattern = Chem.MolFromSmarts('[NH2][CH,CH2][C](=O)')
|
| 119 |
+
n_term_matches = peptide_mol.GetSubstructMatches(n_terminus_pattern)
|
| 120 |
+
if n_term_matches:
|
| 121 |
+
print(f" (Also found {len(n_term_matches)} N-terminus α-amine(s) - excluded by default)")
|
| 122 |
+
|
| 123 |
+
return lysines
|
| 124 |
+
|
| 125 |
+
def _perform_conjugation(self, peptide_mol, site_info, exclude_n_terminus=True):
|
| 126 |
+
"""
|
| 127 |
+
Perform amide bond conjugation between NH2 groups and glutaryl carboxylic acid
|
| 128 |
+
|
| 129 |
+
Args:
|
| 130 |
+
peptide_mol: RDKit Mol object
|
| 131 |
+
site_info: Not used (kept for compatibility)
|
| 132 |
+
exclude_n_terminus: If True, skip N-terminus conjugation
|
| 133 |
+
|
| 134 |
+
Returns: List of all possible conjugate SMILES
|
| 135 |
+
"""
|
| 136 |
+
return self._manual_conjugation(peptide_mol, site_info, exclude_n_terminus)
|
| 137 |
+
|
| 138 |
+
def _manual_conjugation(self, peptide_mol, lysine_info, exclude_n_terminus=True):
|
| 139 |
+
"""
|
| 140 |
+
Create ALL possible conjugates by forming amide bond at NH2 sites
|
| 141 |
+
|
| 142 |
+
Chemistry:
|
| 143 |
+
- Any-NH2 + HOOC-glutaryl-Dau → Any-NH-C(=O)-glutaryl-Dau + H2O
|
| 144 |
+
"""
|
| 145 |
+
if exclude_n_terminus:
|
| 146 |
+
amide_rxn = AllChem.ReactionFromSmarts(
|
| 147 |
+
'[*:5][CH2][CH2][CH2][CH2:4][NH2:1].[C:2](=[O:3])[OH]>>'
|
| 148 |
+
'[*:5][CH2][CH2][CH2][CH2:4][NH:1][C:2](=[O:3])'
|
| 149 |
+
)
|
| 150 |
+
else:
|
| 151 |
+
amide_rxn = AllChem.ReactionFromSmarts(
|
| 152 |
+
'[*:4][NH2:1].[C:2](=[O:3])[OH]>>[*:4][NH:1][C:2](=[O:3])'
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
products = amide_rxn.RunReactants((peptide_mol, self.linker_payload_mol))
|
| 156 |
+
|
| 157 |
+
if not products:
|
| 158 |
+
raise ValueError("Amide bond formation reaction failed - no products generated")
|
| 159 |
+
|
| 160 |
+
conjugates = []
|
| 161 |
+
seen_smiles = set()
|
| 162 |
+
|
| 163 |
+
for product_tuple in products:
|
| 164 |
+
try:
|
| 165 |
+
conjugate_mol = product_tuple[0]
|
| 166 |
+
Chem.SanitizeMol(conjugate_mol)
|
| 167 |
+
conjugate_smiles = Chem.MolToSmiles(conjugate_mol)
|
| 168 |
+
|
| 169 |
+
if conjugate_smiles not in seen_smiles:
|
| 170 |
+
seen_smiles.add(conjugate_smiles)
|
| 171 |
+
conjugates.append(conjugate_smiles)
|
| 172 |
+
except Exception:
|
| 173 |
+
continue
|
| 174 |
+
|
| 175 |
+
if not conjugates:
|
| 176 |
+
raise ValueError("All products failed sanitization")
|
| 177 |
+
|
| 178 |
+
return conjugates
|
| 179 |
+
|
| 180 |
+
def conjugate_to_peptide(self, peptide_smiles, exclude_n_terminus=True, return_all=True):
|
| 181 |
+
"""
|
| 182 |
+
Conjugate Glutaryl-Daunorubicin to peptide via amide bond at NH2 groups
|
| 183 |
+
|
| 184 |
+
Args:
|
| 185 |
+
peptide_smiles: SMILES string of peptide (must contain NH2)
|
| 186 |
+
exclude_n_terminus: If True (default), only conjugate at Lys ε-amines
|
| 187 |
+
return_all: If True, return all possible conjugates
|
| 188 |
+
|
| 189 |
+
Returns:
|
| 190 |
+
If return_all=True: List of all possible conjugate SMILES
|
| 191 |
+
If return_all=False: Single conjugate SMILES (first one)
|
| 192 |
+
"""
|
| 193 |
+
peptide_mol = Chem.MolFromSmiles(peptide_smiles)
|
| 194 |
+
if peptide_mol is None:
|
| 195 |
+
raise ValueError(f"Invalid peptide SMILES: {peptide_smiles}")
|
| 196 |
+
|
| 197 |
+
sites = self.find_conjugation_sites(peptide_smiles)
|
| 198 |
+
print(f"Found {len(sites)} Lys ε-amine site(s)")
|
| 199 |
+
|
| 200 |
+
conjugates = self._perform_conjugation(peptide_mol, None, exclude_n_terminus)
|
| 201 |
+
|
| 202 |
+
site_type = "Lys ε-amine only" if exclude_n_terminus else "all NH2 (including N-terminus)"
|
| 203 |
+
print(f"Generated {len(conjugates)} unique conjugate(s) [{site_type}]")
|
| 204 |
+
|
| 205 |
+
if return_all:
|
| 206 |
+
return conjugates
|
| 207 |
+
else:
|
| 208 |
+
return conjugates[0] if conjugates else None
|
| 209 |
+
|
| 210 |
+
def batch_conjugate(self, peptide_smiles_list, exclude_n_terminus=True, return_all=True):
|
| 211 |
+
"""Batch conjugate multiple peptides"""
|
| 212 |
+
results = {
|
| 213 |
+
'conjugates': [],
|
| 214 |
+
'successful': [],
|
| 215 |
+
'failed': [],
|
| 216 |
+
'total_conjugates': 0
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
for i, peptide_smiles in enumerate(peptide_smiles_list):
|
| 220 |
+
try:
|
| 221 |
+
conjugates = self.conjugate_to_peptide(
|
| 222 |
+
peptide_smiles,
|
| 223 |
+
exclude_n_terminus=exclude_n_terminus,
|
| 224 |
+
return_all=return_all
|
| 225 |
+
)
|
| 226 |
+
if return_all:
|
| 227 |
+
results['conjugates'].append(conjugates)
|
| 228 |
+
results['total_conjugates'] += len(conjugates)
|
| 229 |
+
else:
|
| 230 |
+
results['conjugates'].append(conjugates)
|
| 231 |
+
results['total_conjugates'] += 1
|
| 232 |
+
results['successful'].append(i)
|
| 233 |
+
except Exception as e:
|
| 234 |
+
print(f" Peptide {i} failed: {e}")
|
| 235 |
+
results['conjugates'].append(None)
|
| 236 |
+
results['failed'].append((i, str(e)))
|
| 237 |
+
|
| 238 |
+
return results
|
| 239 |
+
|
| 240 |
+
# =========================================================================
|
| 241 |
+
# Attachment Site Compatibility Checks
|
| 242 |
+
# =========================================================================
|
| 243 |
+
|
| 244 |
+
def has_glutaryl_amide_conjugation(self, mol):
|
| 245 |
+
"""
|
| 246 |
+
Check if molecule has the glutaryl conjugation pattern
|
| 247 |
+
|
| 248 |
+
Pattern: N-C(=O)-(CH2)3-C(=O)-N (amide-glutaryl-amide linkage)
|
| 249 |
+
This is the signature of successful Lys-Glutaryl-Dau conjugation.
|
| 250 |
+
"""
|
| 251 |
+
glutaryl_pattern = Chem.MolFromSmarts(
|
| 252 |
+
'[NX3][CX3](=[OX1])[CH2][CH2][CH2][CX3](=[OX1])[NX3]'
|
| 253 |
+
)
|
| 254 |
+
if glutaryl_pattern:
|
| 255 |
+
return mol.HasSubstructMatch(glutaryl_pattern)
|
| 256 |
+
return False
|
| 257 |
+
|
| 258 |
+
def has_dau_amide_bond(self, mol):
|
| 259 |
+
"""
|
| 260 |
+
Check if molecule has the glutaryl-Dau amide bond intact
|
| 261 |
+
|
| 262 |
+
The pre-formed amide: -(CH2)3-C(=O)-NH-Dau
|
| 263 |
+
"""
|
| 264 |
+
amide_pattern = Chem.MolFromSmarts('[CH2][CX3](=[OX1])[NX3]')
|
| 265 |
+
if amide_pattern:
|
| 266 |
+
return mol.HasSubstructMatch(amide_pattern)
|
| 267 |
+
return False
|
| 268 |
+
|
| 269 |
+
def has_unreacted_glutaryl_cooh(self, mol):
|
| 270 |
+
"""
|
| 271 |
+
Check if molecule has unreacted glutaryl carboxylic acid
|
| 272 |
+
|
| 273 |
+
Specific pattern: Free COOH on the glutaryl chain ending
|
| 274 |
+
"""
|
| 275 |
+
unreacted_pattern = Chem.MolFromSmarts(
|
| 276 |
+
'[CX3](=[OX1])[OX2H1][CH2][CH2][CH2][CX3](=[OX1])[NX3]'
|
| 277 |
+
)
|
| 278 |
+
if unreacted_pattern:
|
| 279 |
+
return mol.HasSubstructMatch(unreacted_pattern)
|
| 280 |
+
return False
|
| 281 |
+
|
| 282 |
+
def check_attachment_site_compatibility(self, mol):
|
| 283 |
+
"""
|
| 284 |
+
Check if Lys-Glutaryl-Dau attachment site chemistry is valid
|
| 285 |
+
|
| 286 |
+
Validates:
|
| 287 |
+
1. Has glutaryl bis-amide conjugation pattern
|
| 288 |
+
2. Has Dau amide bond intact
|
| 289 |
+
3. No unreacted glutaryl-COOH
|
| 290 |
+
"""
|
| 291 |
+
if not self.has_glutaryl_amide_conjugation(mol):
|
| 292 |
+
return False
|
| 293 |
+
|
| 294 |
+
if not self.has_dau_amide_bond(mol):
|
| 295 |
+
return False
|
| 296 |
+
|
| 297 |
+
if self.has_unreacted_glutaryl_cooh(mol):
|
| 298 |
+
return False
|
| 299 |
+
|
| 300 |
+
return True
|
| 301 |
+
|
| 302 |
+
def _basic_validation(self, mol):
|
| 303 |
+
"""Basic validation checks for Glutaryl-Daunorubicin conjugates"""
|
| 304 |
+
checks = {}
|
| 305 |
+
|
| 306 |
+
amide_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[#6]')
|
| 307 |
+
checks['has_amide_bond'] = mol.HasSubstructMatch(amide_pattern)
|
| 308 |
+
|
| 309 |
+
checks['has_glutaryl_conjugation'] = self.has_glutaryl_amide_conjugation(mol)
|
| 310 |
+
checks['has_dau_amide'] = self.has_dau_amide_bond(mol)
|
| 311 |
+
checks['no_unreacted_cooh'] = not self.has_unreacted_glutaryl_cooh(mol)
|
| 312 |
+
checks['attachment_site_valid'] = self.check_attachment_site_compatibility(mol)
|
| 313 |
+
|
| 314 |
+
anthraquinone = Chem.MolFromSmarts(
|
| 315 |
+
'[#6]1[#6][#6][#6]2[#6](=[#8])[#6]3[#6][#6][#6][#6][#6]3[#6](=[#8])[#6]2[#6]1'
|
| 316 |
+
)
|
| 317 |
+
checks['has_daunorubicin_core'] = mol.HasSubstructMatch(anthraquinone)
|
| 318 |
+
|
| 319 |
+
aminosugar = Chem.MolFromSmarts('[OX2]1[#6][#6][#6]([NX3])[#6][#6]1')
|
| 320 |
+
checks['has_aminosugar'] = mol.HasSubstructMatch(aminosugar)
|
| 321 |
+
|
| 322 |
+
checks['_passed'] = checks['attachment_site_valid']
|
| 323 |
+
|
| 324 |
+
return checks
|
| 325 |
+
|
| 326 |
+
def validate_conjugate(self, conjugate_smiles):
|
| 327 |
+
"""Validate that the conjugate is chemically reasonable"""
|
| 328 |
+
mol = Chem.MolFromSmiles(conjugate_smiles)
|
| 329 |
+
|
| 330 |
+
if mol is None:
|
| 331 |
+
return False, {'error': 'Could not parse SMILES'}
|
| 332 |
+
|
| 333 |
+
checks = self._basic_validation(mol)
|
| 334 |
+
checks['parseable'] = True
|
| 335 |
+
|
| 336 |
+
passed = checks.get('_passed', True)
|
| 337 |
+
if '_passed' in checks:
|
| 338 |
+
del checks['_passed']
|
| 339 |
+
|
| 340 |
+
return passed, checks
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
if __name__ == "__main__":
|
| 344 |
+
print("=" * 70)
|
| 345 |
+
print("Glutaryl-Daunorubicin PDC Builder - Test")
|
| 346 |
+
print("=" * 70 + "\n")
|
| 347 |
+
|
| 348 |
+
builder = GlutarylDaunorubicin_PDC_Builder()
|
| 349 |
+
|
| 350 |
+
test_peptides = [
|
| 351 |
+
"NCC(=O)N[C@@H](CCCCN)C(=O)NCC(=O)O", # Gly-Lys-Gly
|
| 352 |
+
]
|
| 353 |
+
|
| 354 |
+
for pep in test_peptides:
|
| 355 |
+
try:
|
| 356 |
+
conjugates = builder.conjugate_to_peptide(pep, exclude_n_terminus=True)
|
| 357 |
+
print(f" {len(conjugates)} conjugate(s)")
|
| 358 |
+
for c in conjugates:
|
| 359 |
+
valid, checks = builder.validate_conjugate(c)
|
| 360 |
+
print(f" Valid: {valid}")
|
| 361 |
+
except Exception as e:
|
| 362 |
+
print(f" Failed: {e}")
|
smiles/conjugation/MalHexCpt.py
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Maleimide-Hexanoate-Camptothecin PDC Builder (MalHexCpt)
|
| 3 |
+
|
| 4 |
+
Conjugation chemistry: Lysine ε-amine + Cys-S-Linker-Camptothecin (amide bond formation)
|
| 5 |
+
|
| 6 |
+
Reference PDC:
|
| 7 |
+
- PDC_00336 (SMAC-P1 CPT conjugate 6)
|
| 8 |
+
|
| 9 |
+
Receptor: ERBB2 (Receptor tyrosine-protein kinase erbB-2)
|
| 10 |
+
Linker: LIN00031 - 6-(2,5-dioxo-3-sulfanylpyrrolidin-1-yl)hexanoic acid
|
| 11 |
+
Payload: DRG00009 - Camptothecin
|
| 12 |
+
Architecture: Monomer, Lys side chain conjugation
|
| 13 |
+
|
| 14 |
+
Chemistry Note:
|
| 15 |
+
===============
|
| 16 |
+
LIN00031 is a maleimide-thiol adduct (succinimide-thioether) with a hexanoic acid spacer:
|
| 17 |
+
|
| 18 |
+
Succinimide ring Hexanoic spacer
|
| 19 |
+
┌──────────────┐ ┌─────────────────────┐
|
| 20 |
+
O═C─CH₂─CH(SH)─C═O─N─CH₂─CH₂─CH₂─CH₂─CH₂─C(═O)─OH
|
| 21 |
+
└─────────────┘
|
| 22 |
+
|
| 23 |
+
Linker-to-Drug Connection (Ester via hexanoic acid):
|
| 24 |
+
Cys─S─C(succinimide)─N─(CH₂)₅─C(═O)─O─Camptothecin
|
| 25 |
+
│
|
| 26 |
+
ester bond (C-20 OH of CPT)
|
| 27 |
+
|
| 28 |
+
Full PDC Architecture:
|
| 29 |
+
Peptide──Lys──NH──C(═O)──Cys──S──[Succinimide]──N──(CH₂)₅──C(═O)─O──CPT
|
| 30 |
+
└── amide ──┘ └── thioether ──┘ └── hexanoic spacer ──┘
|
| 31 |
+
|
| 32 |
+
This is a NOVEL linker chemistry distinct from the simple disulfide-ester/carbonate
|
| 33 |
+
linkers in SSCpt. The maleimide reacts with Cys thiol via Michael addition to form
|
| 34 |
+
a thioether (C-S) bond. The succinimide ring provides structural rigidity and the
|
| 35 |
+
hexanoic spacer provides distance between the conjugation site and the drug.
|
| 36 |
+
|
| 37 |
+
Usage:
|
| 38 |
+
======
|
| 39 |
+
builder = MaleimideHexanoateCpt_PDC_Builder()
|
| 40 |
+
conjugates = builder.conjugate_to_peptide(peptide_smiles)
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
from rdkit import Chem
|
| 44 |
+
from rdkit.Chem import AllChem, Descriptors
|
| 45 |
+
|
| 46 |
+
from .pdc_builder import PDC_Builder
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# ============================================================================
|
| 50 |
+
# Maleimide-Hexanoate-Camptothecin PDC Builder
|
| 51 |
+
# ============================================================================
|
| 52 |
+
|
| 53 |
+
class MaleimideHexanoateCpt_PDC_Builder(PDC_Builder):
|
| 54 |
+
"""
|
| 55 |
+
PDC builder for Maleimide-Hexanoate + Camptothecin conjugates
|
| 56 |
+
|
| 57 |
+
Conjugation chemistry (amide bond formation):
|
| 58 |
+
- Peptide site: Lysine ε-amine (side chain -NH2)
|
| 59 |
+
- Linker-payload site: Carboxylic acid of Cys (Cys-S-Linker-Drug)
|
| 60 |
+
- Reaction: Lys-NH2 + HOOC-Cys-S-Linker-Drug → Lys-NH-CO-Cys-S-Linker-Drug
|
| 61 |
+
|
| 62 |
+
Linker: LIN00031 - 6-(2,5-dioxo-3-sulfanylpyrrolidin-1-yl)hexanoic acid
|
| 63 |
+
SMILES: C1C(C(=O)N(C1=O)CCCCCC(=O)O)S
|
| 64 |
+
IUPAC: 6-(2,5-dioxo-3-sulfanylpyrrolidin-1-yl)hexanoic acid
|
| 65 |
+
MW: 245.3, MF: C10H15NO4S
|
| 66 |
+
Features: Succinimide ring (maleimide-thiol adduct) + hexanoic acid spacer
|
| 67 |
+
|
| 68 |
+
Drug: DRG00009 - Camptothecin
|
| 69 |
+
Canonical SMILES: CCC1(O)C(=O)OCc2c1cc1n(c2=O)Cc2cc3ccccc3nc2-1
|
| 70 |
+
Connection: Ester at C-20 hydroxyl
|
| 71 |
+
|
| 72 |
+
Reference: PDC_00336 (SMAC-P1 CPT conjugate 6, ERBB2)
|
| 73 |
+
"""
|
| 74 |
+
|
| 75 |
+
# ========================================================================
|
| 76 |
+
# Cys-S-Linker-Drug SMILES (thioether via Michael addition)
|
| 77 |
+
# ========================================================================
|
| 78 |
+
#
|
| 79 |
+
# Pre-formed Cys-MaleimideHexanoate-Camptothecin assembly:
|
| 80 |
+
#
|
| 81 |
+
# HOOC─Cys(NH₂)─CH₂─S─C(succinimide)─N─(CH₂)₅─C(═O)─O─CPT(C-20)
|
| 82 |
+
#
|
| 83 |
+
# Construction from components:
|
| 84 |
+
# Cys: N[C@H](CS...)C(=O)O
|
| 85 |
+
# S-C: ...S... (thioether, Michael addition of Cys-SH to maleimide)
|
| 86 |
+
# LIN00031 ring: ...C4CC(=O)N(...)C4=O (succinimide from maleimide-thiol adduct)
|
| 87 |
+
# Spacer: N(CCCCCC(=O)O...) (hexanoic acid, COOH → ester to CPT)
|
| 88 |
+
# CPT: ...OC1(CC)C(=O)OCc2c1cc1n(c2=O)Cc2cc3ccccc3nc2-1
|
| 89 |
+
#
|
| 90 |
+
# ========================================================================
|
| 91 |
+
|
| 92 |
+
CYS_S_LINKER_CPT_SMILES = (
|
| 93 |
+
"N[C@H](CSC4CC(=O)N(CCCCCC(=O)OC1(CC)C(=O)OCc2c1cc1n(c2=O)Cc2cc3ccccc3nc2-1)C4=O)C(=O)O"
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
def __init__(self, cys_linker_drug_smiles=None):
|
| 97 |
+
"""
|
| 98 |
+
Initialize Maleimide-Hexanoate-Camptothecin builder
|
| 99 |
+
|
| 100 |
+
Args:
|
| 101 |
+
cys_linker_drug_smiles: Optional custom Cys-S-Linker-Drug SMILES.
|
| 102 |
+
If None, uses default LIN00031+CPT assembly.
|
| 103 |
+
Must have free carboxylic acid for conjugation.
|
| 104 |
+
"""
|
| 105 |
+
if cys_linker_drug_smiles is None:
|
| 106 |
+
cys_linker_drug_smiles = self.CYS_S_LINKER_CPT_SMILES
|
| 107 |
+
|
| 108 |
+
super().__init__(
|
| 109 |
+
cys_linker_drug_smiles,
|
| 110 |
+
component_name="Cys-S-MaleimideHexanoate-Camptothecin (LIN00031+DRG00009)"
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
def _verify_reactive_group(self):
|
| 114 |
+
"""Verify carboxylic acid group is present for amide bond formation"""
|
| 115 |
+
# Pattern: Carboxylic acid (COOH)
|
| 116 |
+
carboxylic_acid_pattern = Chem.MolFromSmarts('[CX3](=O)[OX2H1]')
|
| 117 |
+
|
| 118 |
+
if not self.linker_payload_mol.HasSubstructMatch(carboxylic_acid_pattern):
|
| 119 |
+
print(" Warning: No carboxylic acid group detected in Cys-S-Linker-Drug")
|
| 120 |
+
else:
|
| 121 |
+
print(" Cys-S-MalHex-Camptothecin loaded with carboxylic acid reactive group")
|
| 122 |
+
|
| 123 |
+
# Verify thioether bond is present (Cys-S-C, from Michael addition)
|
| 124 |
+
thioether_pattern = Chem.MolFromSmarts('[CH2][SX2][CH]')
|
| 125 |
+
if self.linker_payload_mol.HasSubstructMatch(thioether_pattern):
|
| 126 |
+
print(" Thioether bond (C-S-C) detected (maleimide-thiol Michael addition)")
|
| 127 |
+
else:
|
| 128 |
+
print(" Warning: No thioether bond detected - check structure")
|
| 129 |
+
|
| 130 |
+
# Verify Cys α-amine is present
|
| 131 |
+
cys_amine_pattern = Chem.MolFromSmarts('[NH2][CH]([CH2][SX2])C(=O)')
|
| 132 |
+
if self.linker_payload_mol.HasSubstructMatch(cys_amine_pattern):
|
| 133 |
+
print(" Cys α-amine detected (will be free in conjugate)")
|
| 134 |
+
else:
|
| 135 |
+
print(" Warning: Cys α-amine pattern not detected")
|
| 136 |
+
|
| 137 |
+
# Verify succinimide ring (maleimide-thiol adduct)
|
| 138 |
+
succinimide_pattern = Chem.MolFromSmarts('[CH2]C(=O)N(*)C(=O)')
|
| 139 |
+
if self.linker_payload_mol.HasSubstructMatch(succinimide_pattern):
|
| 140 |
+
print(" Succinimide ring (maleimide-thiol adduct) detected")
|
| 141 |
+
else:
|
| 142 |
+
print(" Warning: No succinimide ring detected - check linker structure")
|
| 143 |
+
|
| 144 |
+
# Verify ester bond to drug
|
| 145 |
+
ester_pattern = Chem.MolFromSmarts('[#6][CX3](=[OX1])[OX2][#6]')
|
| 146 |
+
if self.linker_payload_mol.HasSubstructMatch(ester_pattern):
|
| 147 |
+
print(" Ester bond (linker-to-drug) detected")
|
| 148 |
+
else:
|
| 149 |
+
print(" Warning: No ester bond detected - check linker-drug connection")
|
| 150 |
+
|
| 151 |
+
def find_conjugation_sites(self, peptide_smiles):
|
| 152 |
+
"""
|
| 153 |
+
Find all lysine ε-amine groups in peptide
|
| 154 |
+
|
| 155 |
+
Returns list of dicts with site information including atom index
|
| 156 |
+
"""
|
| 157 |
+
return self.find_peptide_lysines(peptide_smiles)
|
| 158 |
+
|
| 159 |
+
def find_peptide_lysines(self, peptide_smiles):
|
| 160 |
+
"""
|
| 161 |
+
Find all lysine residues with free ε-amine in peptide
|
| 162 |
+
|
| 163 |
+
Lysine side chain: -CH2-CH2-CH2-CH2-NH2 (CCCCN)
|
| 164 |
+
|
| 165 |
+
Returns:
|
| 166 |
+
List of dicts with 'atom_idx' (nitrogen index) and metadata
|
| 167 |
+
"""
|
| 168 |
+
peptide_mol = Chem.MolFromSmiles(peptide_smiles)
|
| 169 |
+
if peptide_mol is None:
|
| 170 |
+
raise ValueError("Invalid peptide SMILES")
|
| 171 |
+
|
| 172 |
+
lysines = []
|
| 173 |
+
|
| 174 |
+
lys_pattern = Chem.MolFromSmarts('[CH2][CH2][CH2][CH2][NH2]')
|
| 175 |
+
matches = peptide_mol.GetSubstructMatches(lys_pattern)
|
| 176 |
+
|
| 177 |
+
for match in matches:
|
| 178 |
+
nitrogen_idx = match[4]
|
| 179 |
+
lysines.append({
|
| 180 |
+
'atom_idx': nitrogen_idx,
|
| 181 |
+
'side_chain_atoms': list(match),
|
| 182 |
+
'type': 'Lys_epsilon_amine'
|
| 183 |
+
})
|
| 184 |
+
|
| 185 |
+
n_terminus_pattern = Chem.MolFromSmarts('[NH2][CH,CH2][C](=O)')
|
| 186 |
+
n_term_matches = peptide_mol.GetSubstructMatches(n_terminus_pattern)
|
| 187 |
+
|
| 188 |
+
if n_term_matches:
|
| 189 |
+
print(f" (Also found {len(n_term_matches)} N-terminus α-amine(s) - excluded by default)")
|
| 190 |
+
|
| 191 |
+
return lysines
|
| 192 |
+
|
| 193 |
+
def _perform_conjugation(self, peptide_mol, site_info, exclude_n_terminus=True):
|
| 194 |
+
"""
|
| 195 |
+
Perform amide bond conjugation between NH2 groups and Cys carboxylic acid
|
| 196 |
+
|
| 197 |
+
Args:
|
| 198 |
+
peptide_mol: RDKit Mol object
|
| 199 |
+
site_info: Not used (kept for compatibility)
|
| 200 |
+
exclude_n_terminus: If True, skip N-terminus conjugation
|
| 201 |
+
|
| 202 |
+
Returns: List of all possible conjugate SMILES
|
| 203 |
+
"""
|
| 204 |
+
if exclude_n_terminus:
|
| 205 |
+
amide_rxn = AllChem.ReactionFromSmarts(
|
| 206 |
+
'[*:5][CH2][CH2][CH2][CH2:4][NH2:1].[C:2](=[O:3])[OH]>>[*:5][CH2][CH2][CH2][CH2:4][NH:1][C:2](=[O:3])'
|
| 207 |
+
)
|
| 208 |
+
else:
|
| 209 |
+
amide_rxn = AllChem.ReactionFromSmarts(
|
| 210 |
+
'[*:4][NH2:1].[C:2](=[O:3])[OH]>>[*:4][NH:1][C:2](=[O:3])'
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
products = amide_rxn.RunReactants((peptide_mol, self.linker_payload_mol))
|
| 214 |
+
|
| 215 |
+
if not products:
|
| 216 |
+
raise ValueError("Amide bond formation reaction failed - no products generated")
|
| 217 |
+
|
| 218 |
+
conjugates = []
|
| 219 |
+
seen_smiles = set()
|
| 220 |
+
|
| 221 |
+
for product_tuple in products:
|
| 222 |
+
try:
|
| 223 |
+
conjugate_mol = product_tuple[0]
|
| 224 |
+
Chem.SanitizeMol(conjugate_mol)
|
| 225 |
+
conjugate_smiles = Chem.MolToSmiles(conjugate_mol)
|
| 226 |
+
|
| 227 |
+
if conjugate_smiles not in seen_smiles:
|
| 228 |
+
seen_smiles.add(conjugate_smiles)
|
| 229 |
+
conjugates.append(conjugate_smiles)
|
| 230 |
+
except Exception:
|
| 231 |
+
continue
|
| 232 |
+
|
| 233 |
+
if not conjugates:
|
| 234 |
+
raise ValueError("All products failed sanitization")
|
| 235 |
+
|
| 236 |
+
return conjugates
|
| 237 |
+
|
| 238 |
+
def conjugate_to_peptide(self, peptide_smiles, exclude_n_terminus=True, return_all=True):
|
| 239 |
+
"""
|
| 240 |
+
Conjugate Cys-S-MalHex-Camptothecin to peptide via amide bond at NH2 groups
|
| 241 |
+
|
| 242 |
+
Args:
|
| 243 |
+
peptide_smiles: SMILES string of peptide (must contain Lys with NH2)
|
| 244 |
+
exclude_n_terminus: If True (default), only conjugate at Lys ε-amines
|
| 245 |
+
return_all: If True, return all possible conjugates
|
| 246 |
+
|
| 247 |
+
Returns:
|
| 248 |
+
List of all possible conjugate SMILES
|
| 249 |
+
"""
|
| 250 |
+
peptide_mol = Chem.MolFromSmiles(peptide_smiles)
|
| 251 |
+
if peptide_mol is None:
|
| 252 |
+
raise ValueError(f"Invalid peptide SMILES: {peptide_smiles}")
|
| 253 |
+
|
| 254 |
+
sites = self.find_conjugation_sites(peptide_smiles)
|
| 255 |
+
print(f"Found {len(sites)} Lys ε-amine site(s)")
|
| 256 |
+
|
| 257 |
+
conjugates = self._perform_conjugation(peptide_mol, None, exclude_n_terminus)
|
| 258 |
+
|
| 259 |
+
site_type = "Lys ε-amine only" if exclude_n_terminus else "all NH2 (including N-terminus)"
|
| 260 |
+
print(f"Generated {len(conjugates)} unique conjugate(s) [{site_type}] [maleimide-hexanoate ester]")
|
| 261 |
+
|
| 262 |
+
if return_all:
|
| 263 |
+
return conjugates
|
| 264 |
+
else:
|
| 265 |
+
return conjugates[0] if conjugates else None
|
| 266 |
+
|
| 267 |
+
def batch_conjugate(self, peptide_smiles_list, exclude_n_terminus=True, return_all=True):
|
| 268 |
+
"""
|
| 269 |
+
Batch conjugate multiple peptides
|
| 270 |
+
|
| 271 |
+
Args:
|
| 272 |
+
peptide_smiles_list: List of peptide SMILES strings
|
| 273 |
+
exclude_n_terminus: If True (default), only conjugate at Lys ε-amines
|
| 274 |
+
return_all: If True, return all possible conjugates per peptide
|
| 275 |
+
|
| 276 |
+
Returns:
|
| 277 |
+
Dict with 'conjugates', 'successful', 'failed', 'total_conjugates'
|
| 278 |
+
"""
|
| 279 |
+
results = {
|
| 280 |
+
'conjugates': [],
|
| 281 |
+
'successful': [],
|
| 282 |
+
'failed': [],
|
| 283 |
+
'total_conjugates': 0
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
for i, peptide_smiles in enumerate(peptide_smiles_list):
|
| 287 |
+
try:
|
| 288 |
+
conjugates = self.conjugate_to_peptide(
|
| 289 |
+
peptide_smiles,
|
| 290 |
+
exclude_n_terminus=exclude_n_terminus,
|
| 291 |
+
return_all=return_all
|
| 292 |
+
)
|
| 293 |
+
if return_all:
|
| 294 |
+
results['conjugates'].append(conjugates)
|
| 295 |
+
results['total_conjugates'] += len(conjugates)
|
| 296 |
+
else:
|
| 297 |
+
results['conjugates'].append(conjugates)
|
| 298 |
+
results['total_conjugates'] += 1
|
| 299 |
+
results['successful'].append(i)
|
| 300 |
+
except Exception as e:
|
| 301 |
+
print(f" Peptide {i} failed: {e}")
|
| 302 |
+
results['conjugates'].append(None)
|
| 303 |
+
results['failed'].append((i, str(e)))
|
| 304 |
+
|
| 305 |
+
print(f"\n=== Summary ===")
|
| 306 |
+
print(f"Peptides: {len(peptide_smiles_list)}, Success: {len(results['successful'])}, Failed: {len(results['failed'])}")
|
| 307 |
+
print(f"Total conjugates generated: {results['total_conjugates']}")
|
| 308 |
+
|
| 309 |
+
return results
|
| 310 |
+
|
| 311 |
+
def flatten_batch_results(self, results):
|
| 312 |
+
"""Flatten batch conjugation results into a single list"""
|
| 313 |
+
all_conjugates = []
|
| 314 |
+
for conjugate_list in results['conjugates']:
|
| 315 |
+
if conjugate_list is not None:
|
| 316 |
+
if isinstance(conjugate_list, list):
|
| 317 |
+
all_conjugates.extend(conjugate_list)
|
| 318 |
+
else:
|
| 319 |
+
all_conjugates.append(conjugate_list)
|
| 320 |
+
return all_conjugates
|
| 321 |
+
|
| 322 |
+
# ========================================================================
|
| 323 |
+
# Attachment Site Compatibility Checks
|
| 324 |
+
# ========================================================================
|
| 325 |
+
|
| 326 |
+
def has_lys_cys_amide_conjugation(self, mol):
|
| 327 |
+
"""Check if molecule has Lys-Cys amide conjugation bond"""
|
| 328 |
+
lys_cys_amide_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[CH]([CH2][SX2])[NX3]')
|
| 329 |
+
return mol.HasSubstructMatch(lys_cys_amide_pattern)
|
| 330 |
+
|
| 331 |
+
def has_thioether_bond(self, mol):
|
| 332 |
+
"""Check if molecule has thioether bond (C-S-C, from maleimide-thiol Michael addition)"""
|
| 333 |
+
thioether_pattern = Chem.MolFromSmarts('[CH2][SX2][CH]')
|
| 334 |
+
return mol.HasSubstructMatch(thioether_pattern)
|
| 335 |
+
|
| 336 |
+
def has_succinimide_ring(self, mol):
|
| 337 |
+
"""
|
| 338 |
+
Check if molecule has succinimide ring (maleimide-thiol adduct)
|
| 339 |
+
|
| 340 |
+
The succinimide ring is the product of maleimide-thiol Michael addition.
|
| 341 |
+
Pattern: 5-membered ring with N and two C=O groups.
|
| 342 |
+
"""
|
| 343 |
+
succinimide_pattern = Chem.MolFromSmarts('C1CC(=O)N(*)C1=O')
|
| 344 |
+
return mol.HasSubstructMatch(succinimide_pattern)
|
| 345 |
+
|
| 346 |
+
def has_hexanoic_spacer(self, mol):
|
| 347 |
+
"""
|
| 348 |
+
Check if molecule has hexanoic acid spacer chain
|
| 349 |
+
|
| 350 |
+
Pattern: N-CH2-CH2-CH2-CH2-CH2-C(=O)-O (6-carbon chain from succinimide N)
|
| 351 |
+
"""
|
| 352 |
+
hexanoic_pattern = Chem.MolFromSmarts('[NX3]CCCCCC(=O)[OX2]')
|
| 353 |
+
return mol.HasSubstructMatch(hexanoic_pattern)
|
| 354 |
+
|
| 355 |
+
def has_unreacted_lys_epsilon_amine(self, mol):
|
| 356 |
+
"""Check for unreacted Lys ε-amine (free NH2 on 4-carbon side chain)"""
|
| 357 |
+
lys_amine_pattern = Chem.MolFromSmarts('[CH2][CH2][CH2][CH2][NH2]')
|
| 358 |
+
return mol.HasSubstructMatch(lys_amine_pattern)
|
| 359 |
+
|
| 360 |
+
def has_unreacted_cys_carboxylic_acid(self, mol):
|
| 361 |
+
"""Check for unreacted Cys carboxylic acid from Cys-S-Linker-Drug"""
|
| 362 |
+
cys_cooh_pattern = Chem.MolFromSmarts('[NH2][CH]([CH2][SX2])[CX3](=[OX1])[OX2H1]')
|
| 363 |
+
return mol.HasSubstructMatch(cys_cooh_pattern)
|
| 364 |
+
|
| 365 |
+
def find_amide_conjugation_sites(self, mol):
|
| 366 |
+
"""Find all Lys-Cys amide conjugation sites in the molecule"""
|
| 367 |
+
lys_cys_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[CH]([CH2][SX2])[NX3]')
|
| 368 |
+
matches = mol.GetSubstructMatches(lys_cys_pattern)
|
| 369 |
+
|
| 370 |
+
sites = []
|
| 371 |
+
for match in matches:
|
| 372 |
+
nitrogen_idx = match[0]
|
| 373 |
+
carbonyl_c_idx = match[1]
|
| 374 |
+
cys_alpha_idx = match[3]
|
| 375 |
+
sites.append((nitrogen_idx, carbonyl_c_idx, cys_alpha_idx))
|
| 376 |
+
|
| 377 |
+
return sites
|
| 378 |
+
|
| 379 |
+
def check_valency_at_conjugation_site(self, mol, site_indices):
|
| 380 |
+
"""Check that valency is correct at Lys-Cys conjugation site"""
|
| 381 |
+
nitrogen_idx, carbonyl_c_idx, cys_alpha_idx = site_indices
|
| 382 |
+
|
| 383 |
+
try:
|
| 384 |
+
n_atom = mol.GetAtomWithIdx(nitrogen_idx)
|
| 385 |
+
if n_atom.GetTotalDegree() != 3:
|
| 386 |
+
return False
|
| 387 |
+
|
| 388 |
+
c_atom = mol.GetAtomWithIdx(carbonyl_c_idx)
|
| 389 |
+
if c_atom.GetTotalDegree() != 3:
|
| 390 |
+
return False
|
| 391 |
+
|
| 392 |
+
cys_c_atom = mol.GetAtomWithIdx(cys_alpha_idx)
|
| 393 |
+
if cys_c_atom.GetTotalDegree() != 4:
|
| 394 |
+
return False
|
| 395 |
+
|
| 396 |
+
return True
|
| 397 |
+
except Exception:
|
| 398 |
+
return False
|
| 399 |
+
|
| 400 |
+
def check_attachment_site_compatibility(self, conjugate_smiles):
|
| 401 |
+
"""
|
| 402 |
+
Check attachment site compatibility for Lys-Cys-S-MalHex-CPT conjugation
|
| 403 |
+
|
| 404 |
+
Validates:
|
| 405 |
+
1. Lys-Cys amide conjugation is present
|
| 406 |
+
2. Thioether bond (C-S-C) is intact (maleimide-thiol Michael addition)
|
| 407 |
+
3. Succinimide ring is present (maleimide-thiol adduct)
|
| 408 |
+
4. Hexanoic spacer is present
|
| 409 |
+
5. Valency at conjugation sites is correct
|
| 410 |
+
6. No unreacted Cys-COOH (unconjugated linker-drug)
|
| 411 |
+
|
| 412 |
+
Returns:
|
| 413 |
+
Tuple of (passed: bool, details: dict)
|
| 414 |
+
"""
|
| 415 |
+
mol = Chem.MolFromSmiles(conjugate_smiles)
|
| 416 |
+
|
| 417 |
+
if mol is None:
|
| 418 |
+
return False, {'parseable': False, 'error': 'Could not parse SMILES'}
|
| 419 |
+
|
| 420 |
+
details = {
|
| 421 |
+
'parseable': True,
|
| 422 |
+
'validation_method': 'lys_cys_amide_thioether_maleimide_hexanoate_ester',
|
| 423 |
+
'linker': 'LIN00031 (6-(2,5-dioxo-3-sulfanylpyrrolidin-1-yl)hexanoic acid)',
|
| 424 |
+
'drug': 'DRG00009 (Camptothecin)',
|
| 425 |
+
'pdc_ref': 'PDC_00336'
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
details['has_lys_cys_amide'] = self.has_lys_cys_amide_conjugation(mol)
|
| 429 |
+
details['has_thioether_bond'] = self.has_thioether_bond(mol)
|
| 430 |
+
details['has_succinimide_ring'] = self.has_succinimide_ring(mol)
|
| 431 |
+
details['has_hexanoic_spacer'] = self.has_hexanoic_spacer(mol)
|
| 432 |
+
|
| 433 |
+
conjugation_sites = self.find_amide_conjugation_sites(mol)
|
| 434 |
+
details['conjugation_sites'] = len(conjugation_sites)
|
| 435 |
+
|
| 436 |
+
valency_ok = True
|
| 437 |
+
if conjugation_sites:
|
| 438 |
+
for site in conjugation_sites:
|
| 439 |
+
if not self.check_valency_at_conjugation_site(mol, site):
|
| 440 |
+
valency_ok = False
|
| 441 |
+
break
|
| 442 |
+
details['valency_correct'] = valency_ok
|
| 443 |
+
|
| 444 |
+
details['no_unreacted_cys_cooh'] = not self.has_unreacted_cys_carboxylic_acid(mol)
|
| 445 |
+
details['has_unreacted_lys_amine'] = self.has_unreacted_lys_epsilon_amine(mol)
|
| 446 |
+
|
| 447 |
+
passed = all([
|
| 448 |
+
details['has_lys_cys_amide'],
|
| 449 |
+
details['has_thioether_bond'],
|
| 450 |
+
details['has_succinimide_ring'],
|
| 451 |
+
details['valency_correct'],
|
| 452 |
+
details['no_unreacted_cys_cooh']
|
| 453 |
+
])
|
| 454 |
+
|
| 455 |
+
return passed, details
|
| 456 |
+
|
| 457 |
+
def _basic_validation(self, mol):
|
| 458 |
+
"""
|
| 459 |
+
Basic validation checks for MaleimideHexanoate-Camptothecin conjugates
|
| 460 |
+
|
| 461 |
+
Checks:
|
| 462 |
+
1. Amide bond present (Lys-Cys conjugation)
|
| 463 |
+
2. Thioether bond present (C-S-C, maleimide-thiol Michael addition)
|
| 464 |
+
3. Specific Lys-Cys-S conjugation pattern
|
| 465 |
+
4. Succinimide ring present (maleimide-thiol adduct)
|
| 466 |
+
5. Camptothecin core present
|
| 467 |
+
6. Ester bond to drug present
|
| 468 |
+
"""
|
| 469 |
+
checks = {}
|
| 470 |
+
|
| 471 |
+
amide_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[#6]')
|
| 472 |
+
checks['has_amide_bond'] = mol.HasSubstructMatch(amide_pattern)
|
| 473 |
+
|
| 474 |
+
thioether_pattern = Chem.MolFromSmarts('[CH2][SX2][CH]')
|
| 475 |
+
checks['has_thioether_bond'] = mol.HasSubstructMatch(thioether_pattern)
|
| 476 |
+
|
| 477 |
+
lys_cys_s_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[CH]([CH2][SX2])[NX3]')
|
| 478 |
+
checks['has_lys_cys_s_conjugation'] = mol.HasSubstructMatch(lys_cys_s_pattern)
|
| 479 |
+
|
| 480 |
+
succinimide_pattern = Chem.MolFromSmarts('C1CC(=O)N(*)C1=O')
|
| 481 |
+
checks['has_succinimide_ring'] = mol.HasSubstructMatch(succinimide_pattern)
|
| 482 |
+
|
| 483 |
+
camptothecin_core = Chem.MolFromSmarts('c1ccc2ncc3C(=O)OCc3c2c1')
|
| 484 |
+
checks['has_camptothecin_core'] = mol.HasSubstructMatch(camptothecin_core)
|
| 485 |
+
|
| 486 |
+
ester_pattern = Chem.MolFromSmarts('[#6][CX3](=[OX1])[OX2][#6]')
|
| 487 |
+
checks['has_ester_bond'] = mol.HasSubstructMatch(ester_pattern)
|
| 488 |
+
|
| 489 |
+
checks['_passed'] = all([
|
| 490 |
+
checks['has_amide_bond'],
|
| 491 |
+
checks['has_thioether_bond'],
|
| 492 |
+
checks['has_lys_cys_s_conjugation'],
|
| 493 |
+
checks['has_succinimide_ring']
|
| 494 |
+
])
|
| 495 |
+
|
| 496 |
+
return checks
|
| 497 |
+
|
| 498 |
+
def validate_conjugate(self, conjugate_smiles):
|
| 499 |
+
"""
|
| 500 |
+
Validate that the conjugate is chemically reasonable
|
| 501 |
+
|
| 502 |
+
Returns:
|
| 503 |
+
(passed: bool, checks: dict)
|
| 504 |
+
"""
|
| 505 |
+
mol = Chem.MolFromSmiles(conjugate_smiles)
|
| 506 |
+
|
| 507 |
+
if mol is None:
|
| 508 |
+
return False, {'error': 'Could not parse SMILES'}
|
| 509 |
+
|
| 510 |
+
checks = self._basic_validation(mol)
|
| 511 |
+
checks['parseable'] = True
|
| 512 |
+
|
| 513 |
+
passed = checks.get('_passed', True)
|
| 514 |
+
if '_passed' in checks:
|
| 515 |
+
del checks['_passed']
|
| 516 |
+
|
| 517 |
+
return passed, checks
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
# ============================================================================
|
| 521 |
+
# Main - Test Examples
|
| 522 |
+
# ============================================================================
|
| 523 |
+
|
| 524 |
+
if __name__ == "__main__":
|
| 525 |
+
|
| 526 |
+
print("=" * 70)
|
| 527 |
+
print("Maleimide-Hexanoate-Camptothecin PDC Builder - Test")
|
| 528 |
+
print("Reference: PDC_00336 (SMAC-P1 CPT conjugate 6, LIN00031)")
|
| 529 |
+
print("=" * 70 + "\n")
|
| 530 |
+
|
| 531 |
+
# Test peptides
|
| 532 |
+
test_peptides = [
|
| 533 |
+
# Gly-Lys-Gly (has N-terminus NH2 + Lys ε-amine)
|
| 534 |
+
"NCC(=O)N[C@@H](CCCCN)C(=O)NCC(=O)O",
|
| 535 |
+
# Lys-Arg
|
| 536 |
+
"N[C@@H](CCCCN)C(=O)N[C@@H](CCCNC(=N)N)C(=O)O",
|
| 537 |
+
]
|
| 538 |
+
|
| 539 |
+
builder = MaleimideHexanoateCpt_PDC_Builder()
|
| 540 |
+
|
| 541 |
+
for i, peptide in enumerate(test_peptides):
|
| 542 |
+
print(f"\n--- Test peptide {i+1} ---")
|
| 543 |
+
print(f"Input: {peptide}")
|
| 544 |
+
|
| 545 |
+
try:
|
| 546 |
+
conjugates = builder.conjugate_to_peptide(peptide, exclude_n_terminus=True)
|
| 547 |
+
print(f" Generated {len(conjugates)} conjugate(s)")
|
| 548 |
+
|
| 549 |
+
for j, conj in enumerate(conjugates):
|
| 550 |
+
valid, checks = builder.validate_conjugate(conj)
|
| 551 |
+
print(f" Conjugate {j+1}: {'PASSED' if valid else 'FAILED'} ({len(conj)} chars)")
|
| 552 |
+
if not valid:
|
| 553 |
+
for k, v in checks.items():
|
| 554 |
+
if v is False:
|
| 555 |
+
print(f" {k}")
|
| 556 |
+
|
| 557 |
+
except Exception as e:
|
| 558 |
+
print(f" FAILED: {e}")
|
| 559 |
+
import traceback
|
| 560 |
+
traceback.print_exc()
|
smiles/conjugation/SN38Factory.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SN-38 PDC Factory - Drug-level builder with linker sampling
|
| 3 |
+
|
| 4 |
+
Instead of specifying a particular linker-drug combo (e.g. DSPASN38, DSEBSN38),
|
| 5 |
+
the user specifies only the drug (SN-38) and a linker is sampled
|
| 6 |
+
according to NRP1+SN38 entry frequencies from PDCdb.
|
| 7 |
+
|
| 8 |
+
Linker frequencies (NRP1 + SN-38):
|
| 9 |
+
===========================================
|
| 10 |
+
3-Disulfanylpropanoic acid (DSPASN38) - 9 entries
|
| 11 |
+
4-(2-Disulfanylethoxy)-4-oxobutanoic acid (DSEBSN38) - 6 entries
|
| 12 |
+
5-(2-Disulfanylethoxy)-5-oxopentanoic acid (DSEPSN38) - 2 entries
|
| 13 |
+
Total: 17 entries
|
| 14 |
+
|
| 15 |
+
Sampling probabilities:
|
| 16 |
+
DSPASN38: 9/17 ≈ 0.529
|
| 17 |
+
DSEBSN38: 6/17 ≈ 0.353
|
| 18 |
+
DSEPSN38: 2/17 ≈ 0.118
|
| 19 |
+
|
| 20 |
+
Usage:
|
| 21 |
+
======
|
| 22 |
+
# Drug-only mode - linker is sampled per peptide
|
| 23 |
+
factory = SN38PDC_Factory()
|
| 24 |
+
conjugates = factory.conjugate_to_peptide(peptide_smiles)
|
| 25 |
+
|
| 26 |
+
# Fixed linker mode - use a specific linker
|
| 27 |
+
factory = SN38PDC_Factory(linker='DSPASN38')
|
| 28 |
+
conjugates = factory.conjugate_to_peptide(peptide_smiles)
|
| 29 |
+
|
| 30 |
+
# All linkers mode - generate conjugates with every linker
|
| 31 |
+
factory = SN38PDC_Factory(linker='all')
|
| 32 |
+
conjugates = factory.conjugate_to_peptide(peptide_smiles)
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
import numpy as np
|
| 36 |
+
import contextlib
|
| 37 |
+
import io
|
| 38 |
+
|
| 39 |
+
from .DSPASN38 import DisulfanylpropanoicAcidSN38_PDC_Builder
|
| 40 |
+
from .DSEBSN38 import DisulfanylethOxyButanoicAcidSN38_PDC_Builder
|
| 41 |
+
from .DSEPSN38 import DisulfanylethOxyPentanoicAcidSN38_PDC_Builder
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ============================================================================
|
| 45 |
+
# Linker registry: name → (builder_class, PDCdb frequency, description)
|
| 46 |
+
# ============================================================================
|
| 47 |
+
|
| 48 |
+
NRP1_SN38_LINKER_REGISTRY = {
|
| 49 |
+
'DSPASN38': {
|
| 50 |
+
'class': DisulfanylpropanoicAcidSN38_PDC_Builder,
|
| 51 |
+
'frequency': 9,
|
| 52 |
+
'linker_id': 'LIN00149',
|
| 53 |
+
'description': '3-Disulfanylpropanoic acid (disulfide + ester)',
|
| 54 |
+
'pdc_refs': ['PDC_02099', 'PDC_02104', 'PDC_02102', 'PDC_02107',
|
| 55 |
+
'PDC_02110', 'PDC_02112', 'PDC_02113', 'PDC_02114', 'PDC_02115'],
|
| 56 |
+
},
|
| 57 |
+
'DSEBSN38': {
|
| 58 |
+
'class': DisulfanylethOxyButanoicAcidSN38_PDC_Builder,
|
| 59 |
+
'frequency': 6,
|
| 60 |
+
'linker_id': 'LIN00168',
|
| 61 |
+
'description': '4-(2-Disulfanylethoxy)-4-oxobutanoic acid (succinic spacer)',
|
| 62 |
+
'pdc_refs': ['PDC_02100', 'PDC_02103', 'PDC_02105',
|
| 63 |
+
'PDC_02108', 'PDC_02109', 'PDC_02111'],
|
| 64 |
+
},
|
| 65 |
+
'DSEPSN38': {
|
| 66 |
+
'class': DisulfanylethOxyPentanoicAcidSN38_PDC_Builder,
|
| 67 |
+
'frequency': 2,
|
| 68 |
+
'linker_id': 'LIN00169',
|
| 69 |
+
'description': '5-(2-Disulfanylethoxy)-5-oxopentanoic acid (glutaric spacer)',
|
| 70 |
+
'pdc_refs': ['PDC_02101', 'PDC_02106'],
|
| 71 |
+
},
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _compute_linker_probabilities(registry=None):
|
| 76 |
+
"""Compute sampling probabilities from frequency counts."""
|
| 77 |
+
if registry is None:
|
| 78 |
+
registry = NRP1_SN38_LINKER_REGISTRY
|
| 79 |
+
names = list(registry.keys())
|
| 80 |
+
freqs = np.array([registry[n]['frequency'] for n in names], dtype=np.float64)
|
| 81 |
+
probs = freqs / freqs.sum()
|
| 82 |
+
return names, probs
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class SN38PDC_Factory:
|
| 86 |
+
"""
|
| 87 |
+
Drug-level PDC factory for SN-38 (7-Ethyl-10-hydroxycamptothecin) conjugates.
|
| 88 |
+
|
| 89 |
+
Wraps all three NRP1+SN38 linker builders and provides:
|
| 90 |
+
- 'sample' mode (default): randomly pick a linker per call according to
|
| 91 |
+
PDCdb entry frequencies
|
| 92 |
+
- 'all' mode: generate conjugates with every linker
|
| 93 |
+
- fixed linker mode: always use a specific linker (e.g. 'DSPASN38')
|
| 94 |
+
|
| 95 |
+
Conjugation chemistry: Cysteine thiol + Linker thiol → Disulfide bond
|
| 96 |
+
Target receptor: NRP1 (Neuropilin-1)
|
| 97 |
+
|
| 98 |
+
The interface mirrors the individual builder's `conjugate_to_peptide` so it
|
| 99 |
+
can be used as a drop-in replacement in the Conjugation classifier.
|
| 100 |
+
"""
|
| 101 |
+
|
| 102 |
+
def __init__(self, linker='sample', rng_seed=None):
|
| 103 |
+
"""
|
| 104 |
+
Args:
|
| 105 |
+
linker: One of:
|
| 106 |
+
- 'sample' (default): sample linker per peptide according to
|
| 107 |
+
NRP1+SN38 frequency weights
|
| 108 |
+
- 'all': use every linker and pool conjugates
|
| 109 |
+
- A specific linker name ('DSPASN38', 'DSEBSN38', 'DSEPSN38')
|
| 110 |
+
rng_seed: Optional seed for reproducible linker sampling
|
| 111 |
+
"""
|
| 112 |
+
self.linker_mode = linker
|
| 113 |
+
self.registry = NRP1_SN38_LINKER_REGISTRY
|
| 114 |
+
self.rng = np.random.default_rng(rng_seed)
|
| 115 |
+
|
| 116 |
+
# Pre-compute probabilities
|
| 117 |
+
self._linker_names, self._linker_probs = _compute_linker_probabilities(self.registry)
|
| 118 |
+
|
| 119 |
+
# Validate fixed linker
|
| 120 |
+
if linker not in ('sample', 'all') and linker not in self.registry:
|
| 121 |
+
raise ValueError(
|
| 122 |
+
f"Unknown linker '{linker}'. "
|
| 123 |
+
f"Choose from: 'sample', 'all', {list(self.registry.keys())}"
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
# Pre-instantiate builders (suppress verbose init output)
|
| 127 |
+
self._builders = {}
|
| 128 |
+
for name, entry in self.registry.items():
|
| 129 |
+
with contextlib.redirect_stdout(io.StringIO()):
|
| 130 |
+
self._builders[name] = entry['class']()
|
| 131 |
+
|
| 132 |
+
# Track which linker was last sampled (useful for logging)
|
| 133 |
+
self.last_sampled_linker = None
|
| 134 |
+
|
| 135 |
+
# ------------------------------------------------------------------
|
| 136 |
+
# Linker selection
|
| 137 |
+
# ------------------------------------------------------------------
|
| 138 |
+
|
| 139 |
+
def sample_linker(self):
|
| 140 |
+
"""Sample a linker name according to frequency weights."""
|
| 141 |
+
idx = self.rng.choice(len(self._linker_names), p=self._linker_probs)
|
| 142 |
+
return self._linker_names[idx]
|
| 143 |
+
|
| 144 |
+
def get_builder(self, linker_name):
|
| 145 |
+
"""Return the pre-instantiated builder for a given linker name."""
|
| 146 |
+
return self._builders[linker_name]
|
| 147 |
+
|
| 148 |
+
@property
|
| 149 |
+
def linker_probabilities(self):
|
| 150 |
+
"""Return dict of linker name → sampling probability."""
|
| 151 |
+
return dict(zip(self._linker_names, self._linker_probs.tolist()))
|
| 152 |
+
|
| 153 |
+
# ------------------------------------------------------------------
|
| 154 |
+
# Conjugation interface (mirrors individual builders)
|
| 155 |
+
# ------------------------------------------------------------------
|
| 156 |
+
|
| 157 |
+
def conjugate_to_peptide(self, peptide_smiles, exclude_n_terminus=True, return_all=True):
|
| 158 |
+
"""
|
| 159 |
+
Conjugate SN-38 to peptide, selecting linker by mode.
|
| 160 |
+
|
| 161 |
+
Args:
|
| 162 |
+
peptide_smiles: SMILES string of peptide (must contain Cys thiol)
|
| 163 |
+
exclude_n_terminus: Not used for Cys conjugation (kept for interface)
|
| 164 |
+
return_all: If True, return all positional conjugates
|
| 165 |
+
|
| 166 |
+
Returns:
|
| 167 |
+
List of conjugate SMILES.
|
| 168 |
+
In 'sample' mode: conjugates from the sampled linker.
|
| 169 |
+
In 'all' mode: conjugates from every linker (pooled).
|
| 170 |
+
In fixed mode: conjugates from the specified linker.
|
| 171 |
+
"""
|
| 172 |
+
if self.linker_mode == 'all':
|
| 173 |
+
self.last_sampled_linker = 'all'
|
| 174 |
+
return self._conjugate_all_linkers(peptide_smiles, exclude_n_terminus, return_all)
|
| 175 |
+
elif self.linker_mode == 'sample':
|
| 176 |
+
chosen = self.sample_linker()
|
| 177 |
+
self.last_sampled_linker = chosen
|
| 178 |
+
return self._conjugate_with_linker(chosen, peptide_smiles, exclude_n_terminus, return_all)
|
| 179 |
+
else:
|
| 180 |
+
self.last_sampled_linker = self.linker_mode
|
| 181 |
+
return self._conjugate_with_linker(self.linker_mode, peptide_smiles, exclude_n_terminus, return_all)
|
| 182 |
+
|
| 183 |
+
def _conjugate_with_linker(self, linker_name, peptide_smiles, exclude_n_terminus, return_all):
|
| 184 |
+
"""Run conjugation with a single, specific linker."""
|
| 185 |
+
builder = self._builders[linker_name]
|
| 186 |
+
with contextlib.redirect_stdout(io.StringIO()):
|
| 187 |
+
conjugates = builder.conjugate_to_peptide(
|
| 188 |
+
peptide_smiles,
|
| 189 |
+
exclude_n_terminus=exclude_n_terminus,
|
| 190 |
+
return_all=return_all,
|
| 191 |
+
)
|
| 192 |
+
# Normalize to list
|
| 193 |
+
if not isinstance(conjugates, list):
|
| 194 |
+
conjugates = [conjugates] if conjugates else []
|
| 195 |
+
return conjugates
|
| 196 |
+
|
| 197 |
+
def _conjugate_all_linkers(self, peptide_smiles, exclude_n_terminus, return_all):
|
| 198 |
+
"""Generate conjugates with every linker and pool them."""
|
| 199 |
+
all_conjugates = []
|
| 200 |
+
seen = set()
|
| 201 |
+
for name in self._linker_names:
|
| 202 |
+
try:
|
| 203 |
+
conjs = self._conjugate_with_linker(name, peptide_smiles, exclude_n_terminus, return_all)
|
| 204 |
+
for c in conjs:
|
| 205 |
+
if c not in seen:
|
| 206 |
+
seen.add(c)
|
| 207 |
+
all_conjugates.append(c)
|
| 208 |
+
except Exception:
|
| 209 |
+
continue
|
| 210 |
+
if not all_conjugates:
|
| 211 |
+
raise ValueError("No conjugates generated with any linker")
|
| 212 |
+
return all_conjugates
|
| 213 |
+
|
| 214 |
+
# ------------------------------------------------------------------
|
| 215 |
+
# Validation (delegates to the appropriate builder)
|
| 216 |
+
# ------------------------------------------------------------------
|
| 217 |
+
|
| 218 |
+
def validate_conjugate(self, conjugate_smiles, linker_name=None):
|
| 219 |
+
"""
|
| 220 |
+
Validate a conjugate SMILES.
|
| 221 |
+
|
| 222 |
+
If linker_name is given, validate with that builder.
|
| 223 |
+
Otherwise, try all builders and return the best result.
|
| 224 |
+
"""
|
| 225 |
+
if linker_name and linker_name in self._builders:
|
| 226 |
+
return self._builders[linker_name].validate_conjugate(conjugate_smiles)
|
| 227 |
+
|
| 228 |
+
# Try each builder; return first passing result
|
| 229 |
+
checks = None
|
| 230 |
+
for name, builder in self._builders.items():
|
| 231 |
+
passed, checks = builder.validate_conjugate(conjugate_smiles)
|
| 232 |
+
if passed:
|
| 233 |
+
checks['matched_linker'] = name
|
| 234 |
+
return True, checks
|
| 235 |
+
|
| 236 |
+
# None passed - return last failure
|
| 237 |
+
return False, checks
|
| 238 |
+
|
| 239 |
+
def check_attachment_site_compatibility(self, conjugate_smiles, linker_name=None):
|
| 240 |
+
"""Delegate to appropriate builder's attachment check."""
|
| 241 |
+
if linker_name and linker_name in self._builders:
|
| 242 |
+
return self._builders[linker_name].check_attachment_site_compatibility(conjugate_smiles)
|
| 243 |
+
|
| 244 |
+
details = None
|
| 245 |
+
for name, builder in self._builders.items():
|
| 246 |
+
passed, details = builder.check_attachment_site_compatibility(conjugate_smiles)
|
| 247 |
+
if passed:
|
| 248 |
+
details['matched_linker'] = name
|
| 249 |
+
return True, details
|
| 250 |
+
return False, details
|
| 251 |
+
|
| 252 |
+
# ------------------------------------------------------------------
|
| 253 |
+
# Batch interface
|
| 254 |
+
# ------------------------------------------------------------------
|
| 255 |
+
|
| 256 |
+
def batch_conjugate(self, peptide_smiles_list, exclude_n_terminus=True, return_all=True):
|
| 257 |
+
"""
|
| 258 |
+
Batch conjugate multiple peptides.
|
| 259 |
+
|
| 260 |
+
In 'sample' mode each peptide gets a freshly sampled linker.
|
| 261 |
+
|
| 262 |
+
Returns:
|
| 263 |
+
Dict with 'conjugates', 'successful', 'failed', 'total_conjugates',
|
| 264 |
+
and 'linkers_used' (list of linker names, one per peptide).
|
| 265 |
+
"""
|
| 266 |
+
results = {
|
| 267 |
+
'conjugates': [],
|
| 268 |
+
'successful': [],
|
| 269 |
+
'failed': [],
|
| 270 |
+
'total_conjugates': 0,
|
| 271 |
+
'linkers_used': [],
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
for i, peptide_smiles in enumerate(peptide_smiles_list):
|
| 275 |
+
try:
|
| 276 |
+
conjugates = self.conjugate_to_peptide(
|
| 277 |
+
peptide_smiles,
|
| 278 |
+
exclude_n_terminus=exclude_n_terminus,
|
| 279 |
+
return_all=return_all,
|
| 280 |
+
)
|
| 281 |
+
if return_all:
|
| 282 |
+
results['conjugates'].append(conjugates)
|
| 283 |
+
results['total_conjugates'] += len(conjugates)
|
| 284 |
+
else:
|
| 285 |
+
results['conjugates'].append(conjugates)
|
| 286 |
+
results['total_conjugates'] += 1
|
| 287 |
+
results['successful'].append(i)
|
| 288 |
+
results['linkers_used'].append(
|
| 289 |
+
self.linker_mode if self.linker_mode not in ('sample', 'all') else None
|
| 290 |
+
)
|
| 291 |
+
except Exception as e:
|
| 292 |
+
results['conjugates'].append(None)
|
| 293 |
+
results['failed'].append((i, str(e)))
|
| 294 |
+
results['linkers_used'].append(None)
|
| 295 |
+
|
| 296 |
+
return results
|
| 297 |
+
|
| 298 |
+
# ------------------------------------------------------------------
|
| 299 |
+
# Informational
|
| 300 |
+
# ------------------------------------------------------------------
|
| 301 |
+
|
| 302 |
+
def __repr__(self):
|
| 303 |
+
probs = self.linker_probabilities
|
| 304 |
+
lines = [f"SN38PDC_Factory(mode='{self.linker_mode}')"]
|
| 305 |
+
for name, prob in probs.items():
|
| 306 |
+
desc = self.registry[name]['description']
|
| 307 |
+
lines.append(f" {name:12s} p={prob:.3f} ({desc})")
|
| 308 |
+
return '\n'.join(lines)
|
smiles/conjugation/SSCpt.py
ADDED
|
@@ -0,0 +1,891 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Disulfide-Camptothecin PDC Builder (SSCpt)
|
| 3 |
+
|
| 4 |
+
Conjugation chemistry: Lysine ε-amine + Cys-SS-Linker-Camptothecin (amide bond formation)
|
| 5 |
+
|
| 6 |
+
Reference PDCs:
|
| 7 |
+
- PDC_00294 (Z7): Ester linker chemistry
|
| 8 |
+
- PDC_00295 (Z8): Carbonate linker chemistry
|
| 9 |
+
|
| 10 |
+
Receptor: ERBB2
|
| 11 |
+
Linker: Disulfide bond (S-S) - cleavable inside cells
|
| 12 |
+
Payload: Camptothecin
|
| 13 |
+
Architecture: Monomer, Lys side chain conjugation
|
| 14 |
+
|
| 15 |
+
Supported Linker-to-Drug Chemistries:
|
| 16 |
+
======================================
|
| 17 |
+
|
| 18 |
+
ESTER (Z7):
|
| 19 |
+
Cys─S─S─CH2─CH2─C(═O)─O─Camptothecin
|
| 20 |
+
│
|
| 21 |
+
ester
|
| 22 |
+
|
| 23 |
+
CARBONATE (Z8):
|
| 24 |
+
Cys─S─S─CH2─CH2─O─C(═O)─O─Camptothecin
|
| 25 |
+
│
|
| 26 |
+
carbonate
|
| 27 |
+
|
| 28 |
+
Usage:
|
| 29 |
+
======
|
| 30 |
+
# DEFAULT: Both chemistries (returns combined list)
|
| 31 |
+
builder = DisulfideCamptothecin_PDC_Builder()
|
| 32 |
+
conjugates = builder.conjugate_to_peptide(peptide_smiles)
|
| 33 |
+
# Returns: [ester_conj1, ester_conj2, ..., carbonate_conj1, carbonate_conj2, ...]
|
| 34 |
+
|
| 35 |
+
# Single chemistry only
|
| 36 |
+
builder = DisulfideCamptothecin_PDC_Builder(linker_chemistry='ester')
|
| 37 |
+
builder = DisulfideCamptothecin_PDC_Builder(linker_chemistry='carbonate')
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
from rdkit import Chem
|
| 41 |
+
from rdkit.Chem import AllChem, Descriptors
|
| 42 |
+
|
| 43 |
+
from .pdc_builder import PDC_Builder
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# ============================================================================
|
| 47 |
+
# Disulfide-Camptothecin PDC Builder
|
| 48 |
+
# ============================================================================
|
| 49 |
+
|
| 50 |
+
class DisulfideCamptothecin_PDC_Builder(PDC_Builder):
|
| 51 |
+
"""
|
| 52 |
+
PDC builder for Disulfide-Camptothecin conjugates
|
| 53 |
+
|
| 54 |
+
Conjugation chemistry (amide bond formation):
|
| 55 |
+
- Peptide site: Lysine ε-amine (side chain -NH2)
|
| 56 |
+
- Linker-payload site: Carboxylic acid of Cys (Cys-SS-Linker-Drug)
|
| 57 |
+
- Reaction: Lys-NH2 + HOOC-Cys-SS-Linker-Drug → Lys-NH-CO-Cys-SS-Linker-Drug
|
| 58 |
+
|
| 59 |
+
Supported linker-to-drug chemistries:
|
| 60 |
+
- 'ester' (Z7, PDC_00294): Cys-S-S-CH2-CH2-C(=O)-O-CPT
|
| 61 |
+
- 'carbonate' (Z8, PDC_00295): Cys-S-S-CH2-CH2-O-C(=O)-O-CPT
|
| 62 |
+
|
| 63 |
+
Architecture:
|
| 64 |
+
Peptide──Lys──NH──C(=O)──Cys──S─S──Linker──Camptothecin
|
| 65 |
+
└── amide ──┘ └── disulfide ──┘
|
| 66 |
+
|
| 67 |
+
Usage:
|
| 68 |
+
# Default: BOTH chemistries (returns combined list)
|
| 69 |
+
builder = DisulfideCamptothecin_PDC_Builder()
|
| 70 |
+
conjugates = builder.conjugate_to_peptide(peptide_smiles)
|
| 71 |
+
# For 1 Lys: returns 2 conjugates (1 ester + 1 carbonate)
|
| 72 |
+
|
| 73 |
+
# Single chemistry only
|
| 74 |
+
builder = DisulfideCamptothecin_PDC_Builder(linker_chemistry='ester')
|
| 75 |
+
"""
|
| 76 |
+
|
| 77 |
+
# ========================================================================
|
| 78 |
+
# Linker Chemistry Variants
|
| 79 |
+
# ========================================================================
|
| 80 |
+
#
|
| 81 |
+
# Two linker-to-drug chemistries are supported:
|
| 82 |
+
#
|
| 83 |
+
# ESTER (Z7, PDC_00294):
|
| 84 |
+
# Cys─S─S─CH2─CH2─C(═O)─O─Camptothecin
|
| 85 |
+
# │
|
| 86 |
+
# ester
|
| 87 |
+
#
|
| 88 |
+
# CARBONATE (Z8, PDC_00295):
|
| 89 |
+
# Cys─S─S─CH2─CH2─O─C(═O)─O─Camptothecin
|
| 90 |
+
# │
|
| 91 |
+
# carbonate
|
| 92 |
+
#
|
| 93 |
+
# ========================================================================
|
| 94 |
+
|
| 95 |
+
# Ester variant (Z7, PDC_00294)
|
| 96 |
+
# Structure: HOOC-Cys(α-amine)-CH2-S-S-CH2-CH2-C(=O)-O-Camptothecin
|
| 97 |
+
ESTER_CYS_SS_LINKER_CPT_SMILES = (
|
| 98 |
+
"N[C@H](CSSCCC(=O)OC1(CC)C(=O)OCc2c1cc1n(c2=O)Cc2cc3ccccc3nc2-1)C(=O)O"
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
# Carbonate variant (Z8, PDC_00295)
|
| 102 |
+
# Structure: HOOC-Cys(α-amine)-CH2-S-S-CH2-CH2-O-C(=O)-O-Camptothecin
|
| 103 |
+
CARBONATE_CYS_SS_LINKER_CPT_SMILES = (
|
| 104 |
+
"N[C@H](CSSCCOC(=O)OC1(CC)C(=O)OCc2c1cc1n(c2=O)Cc2cc3ccccc3nc2-1)C(=O)O"
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
# Default (backward compatibility)
|
| 108 |
+
DEFAULT_CYS_SS_LINKER_CPT_SMILES = ESTER_CYS_SS_LINKER_CPT_SMILES
|
| 109 |
+
|
| 110 |
+
# Available linker chemistries
|
| 111 |
+
LINKER_CHEMISTRIES = {
|
| 112 |
+
'ester': ESTER_CYS_SS_LINKER_CPT_SMILES,
|
| 113 |
+
'carbonate': CARBONATE_CYS_SS_LINKER_CPT_SMILES,
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
def __init__(self, cys_linker_drug_smiles=None, linker_chemistry='all'):
|
| 117 |
+
"""
|
| 118 |
+
Initialize Disulfide-Camptothecin builder
|
| 119 |
+
|
| 120 |
+
Args:
|
| 121 |
+
cys_linker_drug_smiles: Optional custom Cys-SS-Linker-Drug SMILES.
|
| 122 |
+
If provided, overrides linker_chemistry.
|
| 123 |
+
Must have free carboxylic acid for conjugation.
|
| 124 |
+
linker_chemistry: 'all' (default), 'ester' (Z7), or 'carbonate' (Z8).
|
| 125 |
+
'all' generates conjugates with both chemistries.
|
| 126 |
+
Ignored if cys_linker_drug_smiles is provided.
|
| 127 |
+
"""
|
| 128 |
+
self.linker_chemistry = linker_chemistry
|
| 129 |
+
|
| 130 |
+
if cys_linker_drug_smiles is not None:
|
| 131 |
+
# Custom SMILES provided - use single chemistry mode
|
| 132 |
+
self.linker_chemistry = 'custom'
|
| 133 |
+
super().__init__(cys_linker_drug_smiles, component_name="Cys-SS-Linker-Camptothecin (custom)")
|
| 134 |
+
elif linker_chemistry == 'all':
|
| 135 |
+
# All chemistries mode - initialize with ester as base, but will use both
|
| 136 |
+
super().__init__(self.ESTER_CYS_SS_LINKER_CPT_SMILES, component_name="Cys-SS-Linker-Camptothecin (all)")
|
| 137 |
+
else:
|
| 138 |
+
# Single chemistry mode
|
| 139 |
+
if linker_chemistry not in self.LINKER_CHEMISTRIES:
|
| 140 |
+
raise ValueError(f"Unknown linker_chemistry: {linker_chemistry}. "
|
| 141 |
+
f"Choose from: 'all', {list(self.LINKER_CHEMISTRIES.keys())}")
|
| 142 |
+
cys_linker_drug_smiles = self.LINKER_CHEMISTRIES[linker_chemistry]
|
| 143 |
+
super().__init__(cys_linker_drug_smiles, component_name=f"Cys-SS-Linker-Camptothecin ({linker_chemistry})")
|
| 144 |
+
|
| 145 |
+
@classmethod
|
| 146 |
+
def generate_all_chemistry_variants(cls, peptide_smiles, exclude_n_terminus=True):
|
| 147 |
+
"""
|
| 148 |
+
Generate conjugates with ALL available linker chemistries (Ester + Carbonate)
|
| 149 |
+
|
| 150 |
+
This is useful for expanding candidate diversity.
|
| 151 |
+
|
| 152 |
+
Args:
|
| 153 |
+
peptide_smiles: SMILES string of peptide
|
| 154 |
+
exclude_n_terminus: If True, only conjugate at Lys ε-amines
|
| 155 |
+
|
| 156 |
+
Returns:
|
| 157 |
+
Dict with:
|
| 158 |
+
'conjugates': List of all conjugate SMILES
|
| 159 |
+
'by_chemistry': Dict mapping chemistry name to list of conjugates
|
| 160 |
+
'by_site': Dict mapping site index to list of (chemistry, smiles) tuples
|
| 161 |
+
'total': Total number of conjugates
|
| 162 |
+
"""
|
| 163 |
+
results = {
|
| 164 |
+
'conjugates': [],
|
| 165 |
+
'by_chemistry': {},
|
| 166 |
+
'by_site': {},
|
| 167 |
+
'total': 0
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
for chemistry_name in cls.LINKER_CHEMISTRIES.keys():
|
| 171 |
+
builder = cls(linker_chemistry=chemistry_name)
|
| 172 |
+
|
| 173 |
+
try:
|
| 174 |
+
conjugates = builder.conjugate_to_peptide(
|
| 175 |
+
peptide_smiles,
|
| 176 |
+
exclude_n_terminus=exclude_n_terminus
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
results['by_chemistry'][chemistry_name] = conjugates
|
| 180 |
+
|
| 181 |
+
for i, conj in enumerate(conjugates):
|
| 182 |
+
results['conjugates'].append(conj)
|
| 183 |
+
|
| 184 |
+
# Track by site
|
| 185 |
+
if i not in results['by_site']:
|
| 186 |
+
results['by_site'][i] = []
|
| 187 |
+
results['by_site'][i].append((chemistry_name, conj))
|
| 188 |
+
|
| 189 |
+
except Exception as e:
|
| 190 |
+
print(f" {chemistry_name} variant failed: {e}")
|
| 191 |
+
results['by_chemistry'][chemistry_name] = []
|
| 192 |
+
|
| 193 |
+
results['total'] = len(results['conjugates'])
|
| 194 |
+
|
| 195 |
+
print(f"\n=== All Chemistry Variants ===")
|
| 196 |
+
print(f"Total conjugates: {results['total']}")
|
| 197 |
+
for chem, conjs in results['by_chemistry'].items():
|
| 198 |
+
print(f" {chem}: {len(conjs)} conjugate(s)")
|
| 199 |
+
|
| 200 |
+
return results
|
| 201 |
+
|
| 202 |
+
@classmethod
|
| 203 |
+
def batch_generate_all_variants(cls, peptide_smiles_list, exclude_n_terminus=True):
|
| 204 |
+
"""
|
| 205 |
+
Batch generate all chemistry variants for multiple peptides
|
| 206 |
+
|
| 207 |
+
Args:
|
| 208 |
+
peptide_smiles_list: List of peptide SMILES strings
|
| 209 |
+
exclude_n_terminus: If True, only conjugate at Lys ε-amines
|
| 210 |
+
|
| 211 |
+
Returns:
|
| 212 |
+
Dict with:
|
| 213 |
+
'all_conjugates': Flat list of all conjugate SMILES
|
| 214 |
+
'by_peptide': List of results dicts (one per peptide)
|
| 215 |
+
'summary': Overall statistics
|
| 216 |
+
"""
|
| 217 |
+
results = {
|
| 218 |
+
'all_conjugates': [],
|
| 219 |
+
'by_peptide': [],
|
| 220 |
+
'summary': {
|
| 221 |
+
'peptides': len(peptide_smiles_list),
|
| 222 |
+
'successful': 0,
|
| 223 |
+
'failed': 0,
|
| 224 |
+
'total_conjugates': 0
|
| 225 |
+
}
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
for i, peptide_smiles in enumerate(peptide_smiles_list):
|
| 229 |
+
print(f"\n--- Peptide {i+1}/{len(peptide_smiles_list)} ---")
|
| 230 |
+
try:
|
| 231 |
+
pep_results = cls.generate_all_chemistry_variants(
|
| 232 |
+
peptide_smiles,
|
| 233 |
+
exclude_n_terminus=exclude_n_terminus
|
| 234 |
+
)
|
| 235 |
+
results['by_peptide'].append(pep_results)
|
| 236 |
+
results['all_conjugates'].extend(pep_results['conjugates'])
|
| 237 |
+
results['summary']['successful'] += 1
|
| 238 |
+
results['summary']['total_conjugates'] += pep_results['total']
|
| 239 |
+
except Exception as e:
|
| 240 |
+
print(f" Peptide {i} failed completely: {e}")
|
| 241 |
+
results['by_peptide'].append(None)
|
| 242 |
+
results['summary']['failed'] += 1
|
| 243 |
+
|
| 244 |
+
print(f"\n=== Batch Summary ===")
|
| 245 |
+
print(f"Peptides: {results['summary']['peptides']}")
|
| 246 |
+
print(f"Successful: {results['summary']['successful']}")
|
| 247 |
+
print(f"Failed: {results['summary']['failed']}")
|
| 248 |
+
print(f"Total conjugates: {results['summary']['total_conjugates']}")
|
| 249 |
+
|
| 250 |
+
return results
|
| 251 |
+
|
| 252 |
+
def _verify_reactive_group(self):
|
| 253 |
+
"""Verify carboxylic acid group is present for amide bond formation"""
|
| 254 |
+
# Pattern: Carboxylic acid (COOH)
|
| 255 |
+
carboxylic_acid_pattern = Chem.MolFromSmarts('[CX3](=O)[OX2H1]')
|
| 256 |
+
|
| 257 |
+
if not self.linker_payload_mol.HasSubstructMatch(carboxylic_acid_pattern):
|
| 258 |
+
print(" Warning: No carboxylic acid group detected in Cys-SS-Linker-Drug")
|
| 259 |
+
else:
|
| 260 |
+
print(" Cys-SS-Linker-Camptothecin loaded with carboxylic acid reactive group")
|
| 261 |
+
|
| 262 |
+
# Verify disulfide bond is present
|
| 263 |
+
disulfide_pattern = Chem.MolFromSmarts('[#16]-[#16]')
|
| 264 |
+
if self.linker_payload_mol.HasSubstructMatch(disulfide_pattern):
|
| 265 |
+
print(" Disulfide bond (S-S) detected")
|
| 266 |
+
else:
|
| 267 |
+
print(" Warning: No disulfide bond detected - check structure")
|
| 268 |
+
|
| 269 |
+
# Verify Cys α-amine is present
|
| 270 |
+
cys_amine_pattern = Chem.MolFromSmarts('[NH2][CH]([CH2]S)C(=O)')
|
| 271 |
+
if self.linker_payload_mol.HasSubstructMatch(cys_amine_pattern):
|
| 272 |
+
print(" Cys α-amine detected (will be free in conjugate)")
|
| 273 |
+
else:
|
| 274 |
+
print(" Warning: Cys α-amine pattern not detected")
|
| 275 |
+
|
| 276 |
+
def find_conjugation_sites(self, peptide_smiles):
|
| 277 |
+
"""
|
| 278 |
+
Find all lysine ε-amine groups in peptide
|
| 279 |
+
|
| 280 |
+
Returns list of dicts with site information including atom index
|
| 281 |
+
"""
|
| 282 |
+
return self.find_peptide_lysines(peptide_smiles)
|
| 283 |
+
|
| 284 |
+
def find_peptide_lysines(self, peptide_smiles):
|
| 285 |
+
"""
|
| 286 |
+
Find all lysine residues with free ε-amine in peptide
|
| 287 |
+
|
| 288 |
+
Lysine side chain: -CH2-CH2-CH2-CH2-NH2 (CCCCN)
|
| 289 |
+
|
| 290 |
+
Returns:
|
| 291 |
+
List of dicts with 'atom_idx' (nitrogen index) and metadata
|
| 292 |
+
"""
|
| 293 |
+
peptide_mol = Chem.MolFromSmiles(peptide_smiles)
|
| 294 |
+
if peptide_mol is None:
|
| 295 |
+
raise ValueError("Invalid peptide SMILES")
|
| 296 |
+
|
| 297 |
+
lysines = []
|
| 298 |
+
|
| 299 |
+
# SMARTS pattern for Lysine ε-amine: 4 CH2 groups ending in NH2
|
| 300 |
+
lys_pattern = Chem.MolFromSmarts('[CH2][CH2][CH2][CH2][NH2]')
|
| 301 |
+
matches = peptide_mol.GetSubstructMatches(lys_pattern)
|
| 302 |
+
|
| 303 |
+
for match in matches:
|
| 304 |
+
nitrogen_idx = match[4]
|
| 305 |
+
|
| 306 |
+
lysines.append({
|
| 307 |
+
'atom_idx': nitrogen_idx,
|
| 308 |
+
'side_chain_atoms': list(match),
|
| 309 |
+
'type': 'Lys_epsilon_amine'
|
| 310 |
+
})
|
| 311 |
+
|
| 312 |
+
# Also count N-terminus for informational purposes
|
| 313 |
+
n_terminus_pattern = Chem.MolFromSmarts('[NH2][CH,CH2][C](=O)')
|
| 314 |
+
n_term_matches = peptide_mol.GetSubstructMatches(n_terminus_pattern)
|
| 315 |
+
|
| 316 |
+
if n_term_matches:
|
| 317 |
+
print(f" (Also found {len(n_term_matches)} N-terminus α-amine(s) - excluded by default)")
|
| 318 |
+
|
| 319 |
+
return lysines
|
| 320 |
+
|
| 321 |
+
def count_all_nh2_sites(self, peptide_smiles):
|
| 322 |
+
"""
|
| 323 |
+
Count all NH2 sites in peptide (both Lys ε-amines and N-terminus)
|
| 324 |
+
|
| 325 |
+
Returns:
|
| 326 |
+
Dict with counts: {'lys_epsilon': n, 'n_terminus': n, 'other': n, 'total': n}
|
| 327 |
+
"""
|
| 328 |
+
peptide_mol = Chem.MolFromSmiles(peptide_smiles)
|
| 329 |
+
if peptide_mol is None:
|
| 330 |
+
raise ValueError("Invalid peptide SMILES")
|
| 331 |
+
|
| 332 |
+
counts = {'lys_epsilon': 0, 'n_terminus': 0, 'other': 0, 'total': 0}
|
| 333 |
+
|
| 334 |
+
# Lys ε-amine: CH2-CH2-CH2-CH2-NH2 (4-carbon side chain)
|
| 335 |
+
lys_pattern = Chem.MolFromSmarts('[CH2][CH2][CH2][CH2][NH2]')
|
| 336 |
+
counts['lys_epsilon'] = len(peptide_mol.GetSubstructMatches(lys_pattern))
|
| 337 |
+
|
| 338 |
+
# N-terminus: NH2 attached to backbone carbon
|
| 339 |
+
n_term_pattern = Chem.MolFromSmarts('[NH2][CH,CH2][C](=O)')
|
| 340 |
+
counts['n_terminus'] = len(peptide_mol.GetSubstructMatches(n_term_pattern))
|
| 341 |
+
|
| 342 |
+
# Total NH2 (any primary amine)
|
| 343 |
+
all_nh2_pattern = Chem.MolFromSmarts('[NH2]')
|
| 344 |
+
total_nh2 = len(peptide_mol.GetSubstructMatches(all_nh2_pattern))
|
| 345 |
+
|
| 346 |
+
counts['other'] = total_nh2 - counts['lys_epsilon'] - counts['n_terminus']
|
| 347 |
+
counts['total'] = total_nh2
|
| 348 |
+
|
| 349 |
+
return counts
|
| 350 |
+
|
| 351 |
+
def _perform_conjugation(self, peptide_mol, site_info, exclude_n_terminus=True):
|
| 352 |
+
"""
|
| 353 |
+
Perform amide bond conjugation between NH2 groups and Cys carboxylic acid
|
| 354 |
+
|
| 355 |
+
Args:
|
| 356 |
+
peptide_mol: RDKit Mol object
|
| 357 |
+
site_info: Not used (kept for compatibility)
|
| 358 |
+
exclude_n_terminus: If True, skip N-terminus conjugation
|
| 359 |
+
|
| 360 |
+
Returns: List of all possible conjugate SMILES
|
| 361 |
+
"""
|
| 362 |
+
return self._manual_conjugation(peptide_mol, site_info, exclude_n_terminus)
|
| 363 |
+
|
| 364 |
+
def _manual_conjugation(self, peptide_mol, lysine_info, exclude_n_terminus=True):
|
| 365 |
+
"""
|
| 366 |
+
Create ALL possible conjugates by forming amide bond at NH2 sites
|
| 367 |
+
|
| 368 |
+
Chemistry:
|
| 369 |
+
- Lys-NH2 + HOOC-Cys-SS-Linker-Drug → Lys-NH-C(=O)-Cys-SS-Linker-Drug + H2O
|
| 370 |
+
|
| 371 |
+
Args:
|
| 372 |
+
peptide_mol: RDKit Mol object of peptide
|
| 373 |
+
lysine_info: Not used (kept for interface compatibility)
|
| 374 |
+
exclude_n_terminus: If True, only conjugate at Lys ε-amines (4-carbon chain),
|
| 375 |
+
not at N-terminus α-amine. Default: True
|
| 376 |
+
|
| 377 |
+
Returns:
|
| 378 |
+
List of all possible conjugate SMILES
|
| 379 |
+
"""
|
| 380 |
+
if exclude_n_terminus:
|
| 381 |
+
# SMARTS that matches only Lys ε-amine: requires 4 CH2 chain before NH2
|
| 382 |
+
# Include wildcard (*) to preserve connection to peptide backbone
|
| 383 |
+
amide_rxn = AllChem.ReactionFromSmarts(
|
| 384 |
+
'[*:5][CH2][CH2][CH2][CH2:4][NH2:1].[C:2](=[O:3])[OH]>>[*:5][CH2][CH2][CH2][CH2:4][NH:1][C:2](=[O:3])'
|
| 385 |
+
)
|
| 386 |
+
else:
|
| 387 |
+
# Match ANY primary amine (includes N-terminus)
|
| 388 |
+
# Include wildcard to preserve the rest of the molecule
|
| 389 |
+
amide_rxn = AllChem.ReactionFromSmarts(
|
| 390 |
+
'[*:4][NH2:1].[C:2](=[O:3])[OH]>>[*:4][NH:1][C:2](=[O:3])'
|
| 391 |
+
)
|
| 392 |
+
|
| 393 |
+
# Run the reaction - returns all possible products
|
| 394 |
+
products = amide_rxn.RunReactants((peptide_mol, self.linker_payload_mol))
|
| 395 |
+
|
| 396 |
+
if not products:
|
| 397 |
+
raise ValueError("Amide bond formation reaction failed - no products generated")
|
| 398 |
+
|
| 399 |
+
# Collect ALL valid conjugates
|
| 400 |
+
conjugates = []
|
| 401 |
+
seen_smiles = set() # Deduplicate
|
| 402 |
+
|
| 403 |
+
for product_tuple in products:
|
| 404 |
+
try:
|
| 405 |
+
conjugate_mol = product_tuple[0]
|
| 406 |
+
Chem.SanitizeMol(conjugate_mol)
|
| 407 |
+
conjugate_smiles = Chem.MolToSmiles(conjugate_mol)
|
| 408 |
+
|
| 409 |
+
# Deduplicate (same SMILES = same molecule)
|
| 410 |
+
if conjugate_smiles not in seen_smiles:
|
| 411 |
+
seen_smiles.add(conjugate_smiles)
|
| 412 |
+
conjugates.append(conjugate_smiles)
|
| 413 |
+
except Exception as e:
|
| 414 |
+
# Skip invalid products
|
| 415 |
+
continue
|
| 416 |
+
|
| 417 |
+
if not conjugates:
|
| 418 |
+
raise ValueError("All products failed sanitization")
|
| 419 |
+
|
| 420 |
+
return conjugates
|
| 421 |
+
|
| 422 |
+
def conjugate_to_peptide(self, peptide_smiles, exclude_n_terminus=True, return_all=True):
|
| 423 |
+
"""
|
| 424 |
+
Conjugate Cys-SS-Linker-Camptothecin to peptide via amide bond at NH2 groups
|
| 425 |
+
|
| 426 |
+
If linker_chemistry='all' (default), generates conjugates with BOTH ester and
|
| 427 |
+
carbonate chemistries, returning a combined list.
|
| 428 |
+
|
| 429 |
+
Args:
|
| 430 |
+
peptide_smiles: SMILES string of peptide (must contain Lys with NH2)
|
| 431 |
+
exclude_n_terminus: If True (default), only conjugate at Lys ε-amines,
|
| 432 |
+
not at N-terminus. Set to False to include N-terminus.
|
| 433 |
+
return_all: If True, return all possible conjugates. If False, return first only.
|
| 434 |
+
|
| 435 |
+
Returns:
|
| 436 |
+
List of all possible conjugate SMILES (from all chemistries if mode='all')
|
| 437 |
+
"""
|
| 438 |
+
peptide_mol = Chem.MolFromSmiles(peptide_smiles)
|
| 439 |
+
if peptide_mol is None:
|
| 440 |
+
raise ValueError(f"Invalid peptide SMILES: {peptide_smiles}")
|
| 441 |
+
|
| 442 |
+
sites = self.find_conjugation_sites(peptide_smiles)
|
| 443 |
+
print(f"Found {len(sites)} Lys ε-amine site(s)")
|
| 444 |
+
|
| 445 |
+
# If 'all' mode, generate conjugates with both chemistries
|
| 446 |
+
if self.linker_chemistry == 'all':
|
| 447 |
+
all_conjugates = []
|
| 448 |
+
seen_smiles = set()
|
| 449 |
+
|
| 450 |
+
for chem_name, chem_smiles in self.LINKER_CHEMISTRIES.items():
|
| 451 |
+
# Temporarily set linker_payload_mol to this chemistry
|
| 452 |
+
temp_mol = Chem.MolFromSmiles(chem_smiles)
|
| 453 |
+
original_mol = self.linker_payload_mol
|
| 454 |
+
self.linker_payload_mol = temp_mol
|
| 455 |
+
|
| 456 |
+
try:
|
| 457 |
+
conjugates = self._perform_conjugation(peptide_mol, None, exclude_n_terminus)
|
| 458 |
+
|
| 459 |
+
for conj in conjugates:
|
| 460 |
+
if conj not in seen_smiles:
|
| 461 |
+
seen_smiles.add(conj)
|
| 462 |
+
all_conjugates.append(conj)
|
| 463 |
+
|
| 464 |
+
print(f" {chem_name}: {len(conjugates)} conjugate(s)")
|
| 465 |
+
|
| 466 |
+
except Exception as e:
|
| 467 |
+
print(f" {chem_name}: failed - {e}")
|
| 468 |
+
finally:
|
| 469 |
+
# Restore original
|
| 470 |
+
self.linker_payload_mol = original_mol
|
| 471 |
+
|
| 472 |
+
site_type = "Lys ε-amine only" if exclude_n_terminus else "all NH2"
|
| 473 |
+
print(f"Generated {len(all_conjugates)} total unique conjugate(s) [{site_type}] [all chemistries]")
|
| 474 |
+
|
| 475 |
+
if not all_conjugates:
|
| 476 |
+
raise ValueError("No conjugates generated - no valid conjugation sites found")
|
| 477 |
+
|
| 478 |
+
if return_all:
|
| 479 |
+
return all_conjugates
|
| 480 |
+
else:
|
| 481 |
+
return all_conjugates[0] if all_conjugates else None
|
| 482 |
+
|
| 483 |
+
else:
|
| 484 |
+
# Single chemistry mode
|
| 485 |
+
conjugates = self._perform_conjugation(peptide_mol, None, exclude_n_terminus)
|
| 486 |
+
|
| 487 |
+
site_type = "Lys ε-amine only" if exclude_n_terminus else "all NH2 (including N-terminus)"
|
| 488 |
+
print(f"Generated {len(conjugates)} unique conjugate(s) [{site_type}] [{self.linker_chemistry}]")
|
| 489 |
+
|
| 490 |
+
if return_all:
|
| 491 |
+
return conjugates
|
| 492 |
+
else:
|
| 493 |
+
return conjugates[0] if conjugates else None
|
| 494 |
+
|
| 495 |
+
def batch_conjugate(self, peptide_smiles_list, exclude_n_terminus=True, return_all=True):
|
| 496 |
+
"""
|
| 497 |
+
Batch conjugate multiple peptides
|
| 498 |
+
|
| 499 |
+
Args:
|
| 500 |
+
peptide_smiles_list: List of peptide SMILES strings
|
| 501 |
+
exclude_n_terminus: If True (default), only conjugate at Lys ε-amines
|
| 502 |
+
return_all: If True, return all possible conjugates per peptide
|
| 503 |
+
|
| 504 |
+
Returns:
|
| 505 |
+
Dict with:
|
| 506 |
+
'conjugates': List of lists (if return_all=True) or list of SMILES
|
| 507 |
+
'successful': List of successful peptide indices
|
| 508 |
+
'failed': List of (index, error) tuples
|
| 509 |
+
'total_conjugates': Total number of conjugates generated
|
| 510 |
+
"""
|
| 511 |
+
results = {
|
| 512 |
+
'conjugates': [],
|
| 513 |
+
'successful': [],
|
| 514 |
+
'failed': [],
|
| 515 |
+
'total_conjugates': 0
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
for i, peptide_smiles in enumerate(peptide_smiles_list):
|
| 519 |
+
try:
|
| 520 |
+
conjugates = self.conjugate_to_peptide(
|
| 521 |
+
peptide_smiles,
|
| 522 |
+
exclude_n_terminus=exclude_n_terminus,
|
| 523 |
+
return_all=return_all
|
| 524 |
+
)
|
| 525 |
+
if return_all:
|
| 526 |
+
results['conjugates'].append(conjugates)
|
| 527 |
+
results['total_conjugates'] += len(conjugates)
|
| 528 |
+
else:
|
| 529 |
+
results['conjugates'].append(conjugates)
|
| 530 |
+
results['total_conjugates'] += 1
|
| 531 |
+
results['successful'].append(i)
|
| 532 |
+
except Exception as e:
|
| 533 |
+
print(f" Peptide {i} failed: {e}")
|
| 534 |
+
results['conjugates'].append(None)
|
| 535 |
+
results['failed'].append((i, str(e)))
|
| 536 |
+
|
| 537 |
+
print(f"\n=== Summary ===")
|
| 538 |
+
print(f"Peptides: {len(peptide_smiles_list)}, Success: {len(results['successful'])}, Failed: {len(results['failed'])}")
|
| 539 |
+
print(f"Total conjugates generated: {results['total_conjugates']}")
|
| 540 |
+
|
| 541 |
+
return results
|
| 542 |
+
|
| 543 |
+
def flatten_batch_results(self, results):
|
| 544 |
+
"""
|
| 545 |
+
Flatten batch conjugation results into a single list of all conjugates
|
| 546 |
+
|
| 547 |
+
Args:
|
| 548 |
+
results: Dict returned by batch_conjugate()
|
| 549 |
+
|
| 550 |
+
Returns:
|
| 551 |
+
List of all conjugate SMILES (flattened)
|
| 552 |
+
"""
|
| 553 |
+
all_conjugates = []
|
| 554 |
+
for conjugate_list in results['conjugates']:
|
| 555 |
+
if conjugate_list is not None:
|
| 556 |
+
if isinstance(conjugate_list, list):
|
| 557 |
+
all_conjugates.extend(conjugate_list)
|
| 558 |
+
else:
|
| 559 |
+
all_conjugates.append(conjugate_list)
|
| 560 |
+
return all_conjugates
|
| 561 |
+
|
| 562 |
+
# ========================================================================
|
| 563 |
+
# Attachment Site Compatibility Checks (Lys ε-amine + Cys-COOH amide bond)
|
| 564 |
+
# ========================================================================
|
| 565 |
+
|
| 566 |
+
def has_lys_cys_amide_conjugation(self, mol):
|
| 567 |
+
"""
|
| 568 |
+
Check if molecule has Lys-Cys amide conjugation bond
|
| 569 |
+
|
| 570 |
+
Pattern: Lys-ε-N-C(=O)-Cα(Cys)-CH2-S-S
|
| 571 |
+
The amide bond connects Lys side chain to Cys α-carbon
|
| 572 |
+
|
| 573 |
+
Returns:
|
| 574 |
+
bool: True if Lys-Cys amide conjugation is present
|
| 575 |
+
"""
|
| 576 |
+
# Pattern: N-C(=O)-C(N)(C-S-S) - amide to Cys with disulfide
|
| 577 |
+
lys_cys_amide_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[CH]([CH2][SX2][SX2])[NX3]')
|
| 578 |
+
return mol.HasSubstructMatch(lys_cys_amide_pattern)
|
| 579 |
+
|
| 580 |
+
def has_disulfide_bond(self, mol):
|
| 581 |
+
"""
|
| 582 |
+
Check if molecule has disulfide bond (S-S)
|
| 583 |
+
|
| 584 |
+
Returns:
|
| 585 |
+
bool: True if disulfide bond is present
|
| 586 |
+
"""
|
| 587 |
+
disulfide_pattern = Chem.MolFromSmarts('[SX2]-[SX2]')
|
| 588 |
+
return mol.HasSubstructMatch(disulfide_pattern)
|
| 589 |
+
|
| 590 |
+
def has_unreacted_lys_epsilon_amine(self, mol):
|
| 591 |
+
"""
|
| 592 |
+
Check for unreacted Lys ε-amine (free NH2 on 4-carbon side chain)
|
| 593 |
+
|
| 594 |
+
In a fully conjugated PDC, all Lys ε-amines should be consumed.
|
| 595 |
+
This checks for remaining free NH2 groups on Lys side chains.
|
| 596 |
+
|
| 597 |
+
Returns:
|
| 598 |
+
bool: True if unreacted Lys ε-amine is present (indicates incomplete conjugation)
|
| 599 |
+
"""
|
| 600 |
+
# Lys ε-amine: 4 CH2 chain ending in NH2
|
| 601 |
+
lys_amine_pattern = Chem.MolFromSmarts('[CH2][CH2][CH2][CH2][NH2]')
|
| 602 |
+
return mol.HasSubstructMatch(lys_amine_pattern)
|
| 603 |
+
|
| 604 |
+
def has_unreacted_cys_carboxylic_acid(self, mol):
|
| 605 |
+
"""
|
| 606 |
+
Check for unreacted Cys carboxylic acid (from Cys-SS-Linker-Drug)
|
| 607 |
+
|
| 608 |
+
Pattern: Free COOH on Cys α-carbon adjacent to CH2-S-S
|
| 609 |
+
|
| 610 |
+
Returns:
|
| 611 |
+
bool: True if unreacted Cys-COOH is present (indicates unconjugated linker-drug)
|
| 612 |
+
"""
|
| 613 |
+
# Cys with free COOH: H2N-CH(CH2-S-S)-COOH
|
| 614 |
+
cys_cooh_pattern = Chem.MolFromSmarts('[NH2][CH]([CH2][SX2][SX2])[CX3](=[OX1])[OX2H1]')
|
| 615 |
+
return mol.HasSubstructMatch(cys_cooh_pattern)
|
| 616 |
+
|
| 617 |
+
def find_amide_conjugation_sites(self, mol):
|
| 618 |
+
"""
|
| 619 |
+
Find all Lys-Cys amide conjugation sites in the molecule
|
| 620 |
+
|
| 621 |
+
Returns:
|
| 622 |
+
List of tuples: (nitrogen_idx, carbonyl_carbon_idx, cys_alpha_carbon_idx)
|
| 623 |
+
"""
|
| 624 |
+
# Pattern: N-C(=O)-C(N)(C-S-S)
|
| 625 |
+
lys_cys_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[CH]([CH2][SX2][SX2])[NX3]')
|
| 626 |
+
matches = mol.GetSubstructMatches(lys_cys_pattern)
|
| 627 |
+
|
| 628 |
+
sites = []
|
| 629 |
+
for match in matches:
|
| 630 |
+
nitrogen_idx = match[0] # Lys nitrogen
|
| 631 |
+
carbonyl_c_idx = match[1] # Carbonyl carbon
|
| 632 |
+
cys_alpha_idx = match[3] # Cys α-carbon
|
| 633 |
+
sites.append((nitrogen_idx, carbonyl_c_idx, cys_alpha_idx))
|
| 634 |
+
|
| 635 |
+
return sites
|
| 636 |
+
|
| 637 |
+
def check_valency_at_conjugation_site(self, mol, site_indices):
|
| 638 |
+
"""
|
| 639 |
+
Check that valency is correct at Lys-Cys conjugation site
|
| 640 |
+
|
| 641 |
+
Args:
|
| 642 |
+
mol: RDKit Mol object
|
| 643 |
+
site_indices: Tuple of (nitrogen_idx, carbonyl_c_idx, cys_alpha_idx)
|
| 644 |
+
|
| 645 |
+
Returns:
|
| 646 |
+
bool: True if valency is correct
|
| 647 |
+
"""
|
| 648 |
+
nitrogen_idx, carbonyl_c_idx, cys_alpha_idx = site_indices
|
| 649 |
+
|
| 650 |
+
try:
|
| 651 |
+
# Lys nitrogen should have 3 bonds (NH connected to amide)
|
| 652 |
+
n_atom = mol.GetAtomWithIdx(nitrogen_idx)
|
| 653 |
+
if n_atom.GetTotalDegree() != 3:
|
| 654 |
+
return False
|
| 655 |
+
|
| 656 |
+
# Carbonyl carbon should have 3 bonds (C=O and two single bonds)
|
| 657 |
+
c_atom = mol.GetAtomWithIdx(carbonyl_c_idx)
|
| 658 |
+
if c_atom.GetTotalDegree() != 3:
|
| 659 |
+
return False
|
| 660 |
+
|
| 661 |
+
# Cys α-carbon should have 4 bonds
|
| 662 |
+
cys_c_atom = mol.GetAtomWithIdx(cys_alpha_idx)
|
| 663 |
+
if cys_c_atom.GetTotalDegree() != 4:
|
| 664 |
+
return False
|
| 665 |
+
|
| 666 |
+
return True
|
| 667 |
+
|
| 668 |
+
except Exception:
|
| 669 |
+
return False
|
| 670 |
+
|
| 671 |
+
def check_attachment_site_compatibility(self, conjugate_smiles):
|
| 672 |
+
"""
|
| 673 |
+
Check attachment site compatibility for Lys-Cys-SS-Linker-Drug conjugation
|
| 674 |
+
|
| 675 |
+
Validates:
|
| 676 |
+
1. Lys-Cys amide conjugation is present
|
| 677 |
+
2. Disulfide bond (S-S) is intact
|
| 678 |
+
3. Valency at conjugation sites is correct
|
| 679 |
+
4. No unreacted Cys-COOH (unconjugated linker-drug)
|
| 680 |
+
|
| 681 |
+
Args:
|
| 682 |
+
conjugate_smiles: SMILES string of the conjugate
|
| 683 |
+
|
| 684 |
+
Returns:
|
| 685 |
+
Tuple of (passed: bool, details: dict)
|
| 686 |
+
"""
|
| 687 |
+
mol = Chem.MolFromSmiles(conjugate_smiles)
|
| 688 |
+
|
| 689 |
+
if mol is None:
|
| 690 |
+
return False, {
|
| 691 |
+
'parseable': False,
|
| 692 |
+
'error': 'Could not parse SMILES'
|
| 693 |
+
}
|
| 694 |
+
|
| 695 |
+
details = {
|
| 696 |
+
'parseable': True,
|
| 697 |
+
'validation_method': 'lys_cys_amide_disulfide'
|
| 698 |
+
}
|
| 699 |
+
|
| 700 |
+
# Check 1: Lys-Cys amide conjugation present
|
| 701 |
+
details['has_lys_cys_amide'] = self.has_lys_cys_amide_conjugation(mol)
|
| 702 |
+
|
| 703 |
+
# Check 2: Disulfide bond intact
|
| 704 |
+
details['has_disulfide_bond'] = self.has_disulfide_bond(mol)
|
| 705 |
+
|
| 706 |
+
# Check 3: Find conjugation sites and validate valency
|
| 707 |
+
conjugation_sites = self.find_amide_conjugation_sites(mol)
|
| 708 |
+
details['conjugation_sites'] = len(conjugation_sites)
|
| 709 |
+
|
| 710 |
+
valency_ok = True
|
| 711 |
+
if conjugation_sites:
|
| 712 |
+
for site in conjugation_sites:
|
| 713 |
+
if not self.check_valency_at_conjugation_site(mol, site):
|
| 714 |
+
valency_ok = False
|
| 715 |
+
break
|
| 716 |
+
details['valency_correct'] = valency_ok
|
| 717 |
+
|
| 718 |
+
# Check 4: No unreacted Cys-COOH (would indicate unconjugated linker-drug)
|
| 719 |
+
details['no_unreacted_cys_cooh'] = not self.has_unreacted_cys_carboxylic_acid(mol)
|
| 720 |
+
|
| 721 |
+
# Optional info: Check for unreacted Lys ε-amines (for multi-Lys peptides)
|
| 722 |
+
details['has_unreacted_lys_amine'] = self.has_unreacted_lys_epsilon_amine(mol)
|
| 723 |
+
|
| 724 |
+
# Overall pass/fail
|
| 725 |
+
passed = all([
|
| 726 |
+
details['has_lys_cys_amide'],
|
| 727 |
+
details['has_disulfide_bond'],
|
| 728 |
+
details['valency_correct'],
|
| 729 |
+
details['no_unreacted_cys_cooh']
|
| 730 |
+
])
|
| 731 |
+
|
| 732 |
+
return passed, details
|
| 733 |
+
|
| 734 |
+
def _basic_validation(self, mol):
|
| 735 |
+
"""
|
| 736 |
+
Basic validation checks for Disulfide-Camptothecin conjugates
|
| 737 |
+
|
| 738 |
+
Checks:
|
| 739 |
+
1. Amide bond present (Lys-Cys conjugation)
|
| 740 |
+
2. Disulfide bond present (S-S linker)
|
| 741 |
+
3. Specific Lys-Cys-SS conjugation pattern
|
| 742 |
+
4. Camptothecin core present
|
| 743 |
+
5. Ester bond to drug present
|
| 744 |
+
"""
|
| 745 |
+
checks = {}
|
| 746 |
+
|
| 747 |
+
# Check 1: Has amide bond (general check)
|
| 748 |
+
amide_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[#6]')
|
| 749 |
+
checks['has_amide_bond'] = mol.HasSubstructMatch(amide_pattern)
|
| 750 |
+
|
| 751 |
+
# Check 2: Disulfide bond present (S-S)
|
| 752 |
+
disulfide_pattern = Chem.MolFromSmarts('[#16]-[#16]')
|
| 753 |
+
checks['has_disulfide_bond'] = mol.HasSubstructMatch(disulfide_pattern)
|
| 754 |
+
|
| 755 |
+
# Check 3: Specific Lys-Cys-SS conjugation pattern
|
| 756 |
+
# Looking for: N-C(=O)-C-C-S-S (amide to Cys to disulfide)
|
| 757 |
+
lys_cys_ss_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[CH]([CH2][SX2][SX2])[NX3]')
|
| 758 |
+
checks['has_lys_cys_ss_conjugation'] = mol.HasSubstructMatch(lys_cys_ss_pattern)
|
| 759 |
+
|
| 760 |
+
# Check 4: Camptothecin core (quinoline fused ring system)
|
| 761 |
+
# Simplified pattern for the fused ring system
|
| 762 |
+
camptothecin_core = Chem.MolFromSmarts('c1ccc2ncc3C(=O)OCc3c2c1')
|
| 763 |
+
checks['has_camptothecin_core'] = mol.HasSubstructMatch(camptothecin_core)
|
| 764 |
+
|
| 765 |
+
# Check 5: Ester bond (linker to drug)
|
| 766 |
+
ester_pattern = Chem.MolFromSmarts('[#6][CX3](=[OX1])[OX2][#6]')
|
| 767 |
+
checks['has_ester_bond'] = mol.HasSubstructMatch(ester_pattern)
|
| 768 |
+
|
| 769 |
+
# Overall pass/fail - based on essential conjugation checks
|
| 770 |
+
checks['_passed'] = all([
|
| 771 |
+
checks['has_amide_bond'],
|
| 772 |
+
checks['has_disulfide_bond'],
|
| 773 |
+
checks['has_lys_cys_ss_conjugation']
|
| 774 |
+
])
|
| 775 |
+
|
| 776 |
+
return checks
|
| 777 |
+
|
| 778 |
+
def validate_conjugate(self, conjugate_smiles):
|
| 779 |
+
"""
|
| 780 |
+
Validate that the conjugate is chemically reasonable
|
| 781 |
+
|
| 782 |
+
Returns:
|
| 783 |
+
(passed: bool, checks: dict)
|
| 784 |
+
"""
|
| 785 |
+
mol = Chem.MolFromSmiles(conjugate_smiles)
|
| 786 |
+
|
| 787 |
+
if mol is None:
|
| 788 |
+
return False, {'error': 'Could not parse SMILES'}
|
| 789 |
+
|
| 790 |
+
checks = self._basic_validation(mol)
|
| 791 |
+
checks['parseable'] = True
|
| 792 |
+
|
| 793 |
+
passed = checks.get('_passed', True)
|
| 794 |
+
if '_passed' in checks:
|
| 795 |
+
del checks['_passed']
|
| 796 |
+
|
| 797 |
+
return passed, checks
|
| 798 |
+
|
| 799 |
+
|
| 800 |
+
# ============================================================================
|
| 801 |
+
# Main - Test Examples
|
| 802 |
+
# ============================================================================
|
| 803 |
+
|
| 804 |
+
if __name__ == "__main__":
|
| 805 |
+
|
| 806 |
+
print("=" * 70)
|
| 807 |
+
print("Disulfide-Camptothecin PDC Builder - Test")
|
| 808 |
+
print("Reference: PDC_00294 (Z7, Ester) & PDC_00295 (Z8, Carbonate)")
|
| 809 |
+
print("=" * 70 + "\n")
|
| 810 |
+
|
| 811 |
+
# Test peptides
|
| 812 |
+
test_peptides = [
|
| 813 |
+
# Simple test: Gly-Lys-Gly (has N-terminus NH2 + Lys ε-amine)
|
| 814 |
+
"NCC(=O)N[C@@H](CCCCN)C(=O)NCC(=O)O",
|
| 815 |
+
# Lys-Arg
|
| 816 |
+
"N[C@@H](CCCCN)C(=O)N[C@@H](CCCNC(=N)N)C(=O)O",
|
| 817 |
+
]
|
| 818 |
+
|
| 819 |
+
print("\n" + "=" * 70)
|
| 820 |
+
print("Test 1: DEFAULT MODE (all chemistries: Ester + Carbonate)")
|
| 821 |
+
print("=" * 70 + "\n")
|
| 822 |
+
|
| 823 |
+
# Default: linker_chemistry='all'
|
| 824 |
+
builder = DisulfideCamptothecin_PDC_Builder()
|
| 825 |
+
|
| 826 |
+
peptide = test_peptides[0]
|
| 827 |
+
print(f"Input peptide (Gly-Lys-Gly): {peptide}")
|
| 828 |
+
|
| 829 |
+
try:
|
| 830 |
+
conjugates = builder.conjugate_to_peptide(peptide, exclude_n_terminus=True)
|
| 831 |
+
print(f"\n Total conjugates: {len(conjugates)} (should be 2: 1 ester + 1 carbonate)")
|
| 832 |
+
|
| 833 |
+
for i, conj in enumerate(conjugates):
|
| 834 |
+
valid, checks = builder.validate_conjugate(conj)
|
| 835 |
+
print(f" Conjugate {i+1}: {'PASSED' if valid else 'FAILED'} ({len(conj)} chars)")
|
| 836 |
+
|
| 837 |
+
except Exception as e:
|
| 838 |
+
print(f"\n FAILED: {e}")
|
| 839 |
+
import traceback
|
| 840 |
+
traceback.print_exc()
|
| 841 |
+
|
| 842 |
+
print("\n" + "=" * 70)
|
| 843 |
+
print("Test 2: Single Chemistry Mode (Ester only)")
|
| 844 |
+
print("=" * 70 + "\n")
|
| 845 |
+
|
| 846 |
+
builder_ester = DisulfideCamptothecin_PDC_Builder(linker_chemistry='ester')
|
| 847 |
+
|
| 848 |
+
print(f"Input peptide (Gly-Lys-Gly): {peptide}")
|
| 849 |
+
|
| 850 |
+
try:
|
| 851 |
+
conjugates = builder_ester.conjugate_to_peptide(peptide, exclude_n_terminus=True)
|
| 852 |
+
print(f"\n Ester only: {len(conjugates)} conjugate(s)")
|
| 853 |
+
|
| 854 |
+
except Exception as e:
|
| 855 |
+
print(f"\n FAILED: {e}")
|
| 856 |
+
|
| 857 |
+
print("\n" + "=" * 70)
|
| 858 |
+
print("Test 3: Single Chemistry Mode (Carbonate only)")
|
| 859 |
+
print("=" * 70 + "\n")
|
| 860 |
+
|
| 861 |
+
builder_carbonate = DisulfideCamptothecin_PDC_Builder(linker_chemistry='carbonate')
|
| 862 |
+
|
| 863 |
+
print(f"Input peptide (Gly-Lys-Gly): {peptide}")
|
| 864 |
+
|
| 865 |
+
try:
|
| 866 |
+
conjugates = builder_carbonate.conjugate_to_peptide(peptide, exclude_n_terminus=True)
|
| 867 |
+
print(f"\n Carbonate only: {len(conjugates)} conjugate(s)")
|
| 868 |
+
|
| 869 |
+
except Exception as e:
|
| 870 |
+
print(f"\n FAILED: {e}")
|
| 871 |
+
|
| 872 |
+
print("\n" + "=" * 70)
|
| 873 |
+
print("Test 4: Batch Conjugation (default: all chemistries)")
|
| 874 |
+
print("=" * 70 + "\n")
|
| 875 |
+
|
| 876 |
+
builder = DisulfideCamptothecin_PDC_Builder() # default: all
|
| 877 |
+
|
| 878 |
+
try:
|
| 879 |
+
results = builder.batch_conjugate(test_peptides, exclude_n_terminus=True)
|
| 880 |
+
|
| 881 |
+
print(f"\nResults breakdown:")
|
| 882 |
+
for i in results['successful']:
|
| 883 |
+
conjugates = results['conjugates'][i]
|
| 884 |
+
num_valid = sum(1 for c in conjugates if builder.validate_conjugate(c)[0])
|
| 885 |
+
name = ["Gly-Lys-Gly", "Lys-Arg"][i]
|
| 886 |
+
print(f" {name}: {len(conjugates)} conjugate(s), {num_valid} valid")
|
| 887 |
+
|
| 888 |
+
except Exception as e:
|
| 889 |
+
print(f"\n FAILED: {e}")
|
| 890 |
+
import traceback
|
| 891 |
+
traceback.print_exc()
|
smiles/conjugation/ValAlaDau.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Val-Ala-PABC-Daunorubicin PDC Builder
|
| 3 |
+
|
| 4 |
+
Conjugation chemistry: Lysine ε-amine + Glutaryl-Val-Ala-PABC-Daunorubicin (amide bond formation)
|
| 5 |
+
|
| 6 |
+
Reference PDCs:
|
| 7 |
+
- PDC_00185 (GnRH-III-[2ΔHis-3D-Tic, 8Lys(glutaryl-Val-Ala-PABC-Dau)])
|
| 8 |
+
- PDC_00193 (GnRH-III-[2His-3Trp,8Lys(glutaryl-Val-Ala-PABC-Dau)])
|
| 9 |
+
- Receptor: GNRHR
|
| 10 |
+
- Linker: Val-Ala (LIN00028)
|
| 11 |
+
- Payload: Daunorubicin (DRG00010)
|
| 12 |
+
|
| 13 |
+
Architecture:
|
| 14 |
+
Peptide──Lys──NH──C(=O)──(CH₂)₃──C(=O)──Val──Ala──PABC──O──C(=O)──NH──Dau
|
| 15 |
+
│ │ │ │
|
| 16 |
+
amide bond glutaryl dipeptide carbamate
|
| 17 |
+
(reaction) spacer cleavable self-immolative
|
| 18 |
+
|
| 19 |
+
The cleavable dipeptide Val-Ala is recognized by cathepsin B (lysosomal protease).
|
| 20 |
+
After cleavage, PABC self-immolation releases free Daunorubicin.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from rdkit import Chem
|
| 24 |
+
from rdkit.Chem import RWMol, AllChem
|
| 25 |
+
|
| 26 |
+
from .pdc_builder import PDC_Builder
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class ValAlaPABCDaunorubicin_PDC_Builder(PDC_Builder):
|
| 30 |
+
"""
|
| 31 |
+
PDC builder for Glutaryl-Val-Ala-PABC-Daunorubicin conjugates
|
| 32 |
+
|
| 33 |
+
Conjugation chemistry (amide bond formation):
|
| 34 |
+
- Peptide site: Lysine ε-amine (side chain -NH2)
|
| 35 |
+
- Linker-payload site: Free carboxylic acid on glutaryl spacer
|
| 36 |
+
- Reaction: Amide bond (Lys-NH2 + HOOC-glutaryl-Val-Ala-PABC-Dau)
|
| 37 |
+
|
| 38 |
+
Architecture:
|
| 39 |
+
Peptide──Lys──NH──CO──(CH₂)₃──CO──Val──Ala──PABC──OCO──NH──Dau
|
| 40 |
+
└── amide ──┘ └── cleavable ──┘ └─ carbamate ─┘
|
| 41 |
+
|
| 42 |
+
Reference: PDC_00185, PDC_00193 (GnRH-III conjugates, GNRHR)
|
| 43 |
+
"""
|
| 44 |
+
|
| 45 |
+
# Val-Ala-PABC-Daunorubicin with free glutaryl COOH
|
| 46 |
+
# Structure: HOOC-(CH2)3-CO-Val-Ala-PABC-OCO-NH-Dau
|
| 47 |
+
# - Glutaryl spacer connects to Val N-terminus via amide
|
| 48 |
+
# - Val-Ala dipeptide (cathepsin B cleavable)
|
| 49 |
+
# - PABC (para-aminobenzylcarbamate) self-immolative spacer
|
| 50 |
+
# - Carbamate bond from PABC to Dau 3'-amine
|
| 51 |
+
DEFAULT_VALA_PABC_DAU_SMILES = (
|
| 52 |
+
"C[C@H]1[C@H]([C@H](C[C@@H](O1)O[C@H]2C[C@@](CC3=C2C(=C4C(=C3O)"
|
| 53 |
+
"C(=O)C5=C(C4=O)C(=CC=C5)OC)O)(C(=O)C)O)NC(=O)OCc6ccc(NC(=O)"
|
| 54 |
+
"[C@@H](C)NC(=O)[C@@H](C(C)C)NC(=O)CCCC(=O)O)cc6)O"
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
def __init__(self, vala_pabc_dau_smiles=None):
|
| 58 |
+
"""
|
| 59 |
+
Initialize Val-Ala-PABC-Daunorubicin builder
|
| 60 |
+
|
| 61 |
+
Args:
|
| 62 |
+
vala_pabc_dau_smiles: Optional custom SMILES. If None, uses default.
|
| 63 |
+
Must have free carboxylic acid for conjugation.
|
| 64 |
+
"""
|
| 65 |
+
if vala_pabc_dau_smiles is None:
|
| 66 |
+
vala_pabc_dau_smiles = self.DEFAULT_VALA_PABC_DAU_SMILES
|
| 67 |
+
|
| 68 |
+
super().__init__(vala_pabc_dau_smiles,
|
| 69 |
+
component_name="Val-Ala-PABC-Daunorubicin (linker-payload)")
|
| 70 |
+
|
| 71 |
+
def _verify_reactive_group(self):
|
| 72 |
+
"""Verify carboxylic acid group is present for amide bond formation"""
|
| 73 |
+
carboxylic_acid_pattern = Chem.MolFromSmarts('[CX3](=O)[OX2H1]')
|
| 74 |
+
|
| 75 |
+
if not self.linker_payload_mol.HasSubstructMatch(carboxylic_acid_pattern):
|
| 76 |
+
print(" Warning: No carboxylic acid group detected in Val-Ala-PABC-Dau")
|
| 77 |
+
else:
|
| 78 |
+
print(" Val-Ala-PABC-Daunorubicin loaded with carboxylic acid reactive group")
|
| 79 |
+
|
| 80 |
+
# Verify PABC benzyl carbamate
|
| 81 |
+
pabc_pattern = Chem.MolFromSmarts('c1ccc(COC(=O)N)cc1')
|
| 82 |
+
if self.linker_payload_mol.HasSubstructMatch(pabc_pattern):
|
| 83 |
+
print(" PABC self-immolative spacer detected")
|
| 84 |
+
else:
|
| 85 |
+
print(" Warning: No PABC pattern detected")
|
| 86 |
+
|
| 87 |
+
# Verify dipeptide amide bonds
|
| 88 |
+
dipeptide_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[CH][NX3][CX3](=[OX1])')
|
| 89 |
+
if self.linker_payload_mol.HasSubstructMatch(dipeptide_pattern):
|
| 90 |
+
print(" Dipeptide (Val-Ala) linkage detected")
|
| 91 |
+
|
| 92 |
+
def find_conjugation_sites(self, peptide_smiles):
|
| 93 |
+
"""Find all lysine ε-amine groups in peptide"""
|
| 94 |
+
return self._find_peptide_lysines(peptide_smiles)
|
| 95 |
+
|
| 96 |
+
def _find_peptide_lysines(self, peptide_smiles):
|
| 97 |
+
"""Find all lysine residues with free ε-amine in peptide"""
|
| 98 |
+
peptide_mol = Chem.MolFromSmiles(peptide_smiles)
|
| 99 |
+
if peptide_mol is None:
|
| 100 |
+
raise ValueError("Invalid peptide SMILES")
|
| 101 |
+
|
| 102 |
+
lysines = []
|
| 103 |
+
|
| 104 |
+
lys_pattern = Chem.MolFromSmarts('[CH2][CH2][CH2][CH2][NH2]')
|
| 105 |
+
matches = peptide_mol.GetSubstructMatches(lys_pattern)
|
| 106 |
+
|
| 107 |
+
for match in matches:
|
| 108 |
+
nitrogen_idx = match[4]
|
| 109 |
+
lysines.append({
|
| 110 |
+
'atom_idx': nitrogen_idx,
|
| 111 |
+
'side_chain_atoms': list(match),
|
| 112 |
+
'type': 'Lys_epsilon_amine'
|
| 113 |
+
})
|
| 114 |
+
|
| 115 |
+
n_terminus_pattern = Chem.MolFromSmarts('[NH2][CH,CH2][C](=O)')
|
| 116 |
+
n_term_matches = peptide_mol.GetSubstructMatches(n_terminus_pattern)
|
| 117 |
+
if n_term_matches:
|
| 118 |
+
print(f" (Also found {len(n_term_matches)} N-terminus α-amine(s) - excluded by default)")
|
| 119 |
+
|
| 120 |
+
return lysines
|
| 121 |
+
|
| 122 |
+
def _perform_conjugation(self, peptide_mol, site_info, exclude_n_terminus=True):
|
| 123 |
+
"""Perform amide bond conjugation"""
|
| 124 |
+
return self._manual_conjugation(peptide_mol, site_info, exclude_n_terminus)
|
| 125 |
+
|
| 126 |
+
def _manual_conjugation(self, peptide_mol, lysine_info, exclude_n_terminus=True):
|
| 127 |
+
"""
|
| 128 |
+
Create ALL possible conjugates by forming amide bond at NH2 sites
|
| 129 |
+
|
| 130 |
+
Chemistry:
|
| 131 |
+
- Any-NH2 + HOOC-glutaryl-Val-Ala-PABC-Dau → amide bond + H2O
|
| 132 |
+
"""
|
| 133 |
+
if exclude_n_terminus:
|
| 134 |
+
amide_rxn = AllChem.ReactionFromSmarts(
|
| 135 |
+
'[*:5][CH2][CH2][CH2][CH2:4][NH2:1].[C:2](=[O:3])[OH]>>'
|
| 136 |
+
'[*:5][CH2][CH2][CH2][CH2:4][NH:1][C:2](=[O:3])'
|
| 137 |
+
)
|
| 138 |
+
else:
|
| 139 |
+
amide_rxn = AllChem.ReactionFromSmarts(
|
| 140 |
+
'[*:4][NH2:1].[C:2](=[O:3])[OH]>>[*:4][NH:1][C:2](=[O:3])'
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
products = amide_rxn.RunReactants((peptide_mol, self.linker_payload_mol))
|
| 144 |
+
|
| 145 |
+
if not products:
|
| 146 |
+
raise ValueError("Amide bond formation reaction failed - no products generated")
|
| 147 |
+
|
| 148 |
+
conjugates = []
|
| 149 |
+
seen_smiles = set()
|
| 150 |
+
|
| 151 |
+
for product_tuple in products:
|
| 152 |
+
try:
|
| 153 |
+
conjugate_mol = product_tuple[0]
|
| 154 |
+
Chem.SanitizeMol(conjugate_mol)
|
| 155 |
+
conjugate_smiles = Chem.MolToSmiles(conjugate_mol)
|
| 156 |
+
|
| 157 |
+
if conjugate_smiles not in seen_smiles:
|
| 158 |
+
seen_smiles.add(conjugate_smiles)
|
| 159 |
+
conjugates.append(conjugate_smiles)
|
| 160 |
+
except Exception:
|
| 161 |
+
continue
|
| 162 |
+
|
| 163 |
+
if not conjugates:
|
| 164 |
+
raise ValueError("All products failed sanitization")
|
| 165 |
+
|
| 166 |
+
return conjugates
|
| 167 |
+
|
| 168 |
+
def conjugate_to_peptide(self, peptide_smiles, exclude_n_terminus=True, return_all=True):
|
| 169 |
+
"""
|
| 170 |
+
Conjugate Val-Ala-PABC-Daunorubicin to peptide via amide bond
|
| 171 |
+
|
| 172 |
+
Args:
|
| 173 |
+
peptide_smiles: SMILES string of peptide (must contain NH2)
|
| 174 |
+
exclude_n_terminus: If True (default), only conjugate at Lys ε-amines
|
| 175 |
+
return_all: If True, return all possible conjugates
|
| 176 |
+
|
| 177 |
+
Returns:
|
| 178 |
+
If return_all=True: List of all possible conjugate SMILES
|
| 179 |
+
If return_all=False: Single conjugate SMILES
|
| 180 |
+
"""
|
| 181 |
+
peptide_mol = Chem.MolFromSmiles(peptide_smiles)
|
| 182 |
+
if peptide_mol is None:
|
| 183 |
+
raise ValueError(f"Invalid peptide SMILES: {peptide_smiles}")
|
| 184 |
+
|
| 185 |
+
sites = self.find_conjugation_sites(peptide_smiles)
|
| 186 |
+
print(f"Found {len(sites)} Lys ε-amine site(s)")
|
| 187 |
+
|
| 188 |
+
conjugates = self._perform_conjugation(peptide_mol, None, exclude_n_terminus)
|
| 189 |
+
|
| 190 |
+
site_type = "Lys ε-amine only" if exclude_n_terminus else "all NH2 (including N-terminus)"
|
| 191 |
+
print(f"Generated {len(conjugates)} unique conjugate(s) [{site_type}]")
|
| 192 |
+
|
| 193 |
+
if return_all:
|
| 194 |
+
return conjugates
|
| 195 |
+
else:
|
| 196 |
+
return conjugates[0] if conjugates else None
|
| 197 |
+
|
| 198 |
+
def batch_conjugate(self, peptide_smiles_list, exclude_n_terminus=True, return_all=True):
|
| 199 |
+
"""Batch conjugate multiple peptides"""
|
| 200 |
+
results = {
|
| 201 |
+
'conjugates': [],
|
| 202 |
+
'successful': [],
|
| 203 |
+
'failed': [],
|
| 204 |
+
'total_conjugates': 0
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
for i, peptide_smiles in enumerate(peptide_smiles_list):
|
| 208 |
+
try:
|
| 209 |
+
conjugates = self.conjugate_to_peptide(
|
| 210 |
+
peptide_smiles,
|
| 211 |
+
exclude_n_terminus=exclude_n_terminus,
|
| 212 |
+
return_all=return_all
|
| 213 |
+
)
|
| 214 |
+
if return_all:
|
| 215 |
+
results['conjugates'].append(conjugates)
|
| 216 |
+
results['total_conjugates'] += len(conjugates)
|
| 217 |
+
else:
|
| 218 |
+
results['conjugates'].append(conjugates)
|
| 219 |
+
results['total_conjugates'] += 1
|
| 220 |
+
results['successful'].append(i)
|
| 221 |
+
except Exception as e:
|
| 222 |
+
print(f" Peptide {i} failed: {e}")
|
| 223 |
+
results['conjugates'].append(None)
|
| 224 |
+
results['failed'].append((i, str(e)))
|
| 225 |
+
|
| 226 |
+
return results
|
| 227 |
+
|
| 228 |
+
# =========================================================================
|
| 229 |
+
# Attachment Site Compatibility Checks
|
| 230 |
+
# =========================================================================
|
| 231 |
+
|
| 232 |
+
def has_pabc_carbamate(self, mol):
|
| 233 |
+
"""Check for PABC benzyl carbamate: Ar-CH2-O-C(=O)-N"""
|
| 234 |
+
pabc_pattern = Chem.MolFromSmarts('c1ccc(COC(=O)[NX3])cc1')
|
| 235 |
+
if pabc_pattern:
|
| 236 |
+
return mol.HasSubstructMatch(pabc_pattern)
|
| 237 |
+
return False
|
| 238 |
+
|
| 239 |
+
def has_val_ala_dipeptide(self, mol):
|
| 240 |
+
"""
|
| 241 |
+
Check for Val-Ala dipeptide fragment
|
| 242 |
+
|
| 243 |
+
Pattern: isopropyl-CH-C(=O)-NH-CH(CH3)-C(=O)
|
| 244 |
+
"""
|
| 245 |
+
# General dipeptide amide pattern
|
| 246 |
+
dipeptide = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[CH][NX3][CX3](=[OX1])')
|
| 247 |
+
if dipeptide:
|
| 248 |
+
return mol.HasSubstructMatch(dipeptide)
|
| 249 |
+
return False
|
| 250 |
+
|
| 251 |
+
def has_glutaryl_spacer(self, mol):
|
| 252 |
+
"""Check for glutaryl spacer: -C(=O)-(CH2)3-C(=O)-"""
|
| 253 |
+
glutaryl = Chem.MolFromSmarts('[CX3](=[OX1])[CH2][CH2][CH2][CX3](=[OX1])')
|
| 254 |
+
if glutaryl:
|
| 255 |
+
return mol.HasSubstructMatch(glutaryl)
|
| 256 |
+
return False
|
| 257 |
+
|
| 258 |
+
def has_unreacted_glutaryl_cooh(self, mol):
|
| 259 |
+
"""Check if free COOH on glutaryl end remains (should be consumed)"""
|
| 260 |
+
unreacted = Chem.MolFromSmarts(
|
| 261 |
+
'[OX2H1][CX3](=[OX1])[CH2][CH2][CH2][CX3](=[OX1])[NX3]'
|
| 262 |
+
)
|
| 263 |
+
if unreacted:
|
| 264 |
+
return mol.HasSubstructMatch(unreacted)
|
| 265 |
+
return False
|
| 266 |
+
|
| 267 |
+
def check_attachment_site_compatibility(self, mol):
|
| 268 |
+
"""
|
| 269 |
+
Check if attachment site chemistry is valid
|
| 270 |
+
|
| 271 |
+
Validates:
|
| 272 |
+
1. Has PABC carbamate (intact self-immolative spacer)
|
| 273 |
+
2. Has glutaryl spacer
|
| 274 |
+
3. No unreacted glutaryl-COOH
|
| 275 |
+
"""
|
| 276 |
+
if not self.has_pabc_carbamate(mol):
|
| 277 |
+
return False
|
| 278 |
+
|
| 279 |
+
if not self.has_glutaryl_spacer(mol):
|
| 280 |
+
return False
|
| 281 |
+
|
| 282 |
+
if self.has_unreacted_glutaryl_cooh(mol):
|
| 283 |
+
return False
|
| 284 |
+
|
| 285 |
+
return True
|
| 286 |
+
|
| 287 |
+
def _basic_validation(self, mol):
|
| 288 |
+
"""Basic validation checks for Val-Ala-PABC-Daunorubicin conjugates"""
|
| 289 |
+
checks = {}
|
| 290 |
+
|
| 291 |
+
amide_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[#6]')
|
| 292 |
+
checks['has_amide_bond'] = mol.HasSubstructMatch(amide_pattern)
|
| 293 |
+
|
| 294 |
+
checks['has_pabc_carbamate'] = self.has_pabc_carbamate(mol)
|
| 295 |
+
checks['has_val_ala_dipeptide'] = self.has_val_ala_dipeptide(mol)
|
| 296 |
+
checks['has_glutaryl_spacer'] = self.has_glutaryl_spacer(mol)
|
| 297 |
+
checks['no_unreacted_cooh'] = not self.has_unreacted_glutaryl_cooh(mol)
|
| 298 |
+
checks['attachment_site_valid'] = self.check_attachment_site_compatibility(mol)
|
| 299 |
+
|
| 300 |
+
anthraquinone = Chem.MolFromSmarts(
|
| 301 |
+
'[#6]1[#6][#6][#6]2[#6](=[#8])[#6]3[#6][#6][#6][#6][#6]3[#6](=[#8])[#6]2[#6]1'
|
| 302 |
+
)
|
| 303 |
+
checks['has_daunorubicin_core'] = mol.HasSubstructMatch(anthraquinone)
|
| 304 |
+
|
| 305 |
+
aminosugar = Chem.MolFromSmarts('[OX2]1[#6][#6][#6]([NX3])[#6][#6]1')
|
| 306 |
+
checks['has_aminosugar'] = mol.HasSubstructMatch(aminosugar)
|
| 307 |
+
|
| 308 |
+
checks['_passed'] = checks['attachment_site_valid']
|
| 309 |
+
|
| 310 |
+
return checks
|
| 311 |
+
|
| 312 |
+
def validate_conjugate(self, conjugate_smiles):
|
| 313 |
+
"""Validate that the conjugate is chemically reasonable"""
|
| 314 |
+
mol = Chem.MolFromSmiles(conjugate_smiles)
|
| 315 |
+
|
| 316 |
+
if mol is None:
|
| 317 |
+
return False, {'error': 'Could not parse SMILES'}
|
| 318 |
+
|
| 319 |
+
checks = self._basic_validation(mol)
|
| 320 |
+
checks['parseable'] = True
|
| 321 |
+
|
| 322 |
+
passed = checks.get('_passed', True)
|
| 323 |
+
if '_passed' in checks:
|
| 324 |
+
del checks['_passed']
|
| 325 |
+
|
| 326 |
+
return passed, checks
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
if __name__ == "__main__":
|
| 330 |
+
print("=" * 70)
|
| 331 |
+
print("Val-Ala-PABC-Daunorubicin PDC Builder - Test")
|
| 332 |
+
print("=" * 70 + "\n")
|
| 333 |
+
|
| 334 |
+
builder = ValAlaPABCDaunorubicin_PDC_Builder()
|
| 335 |
+
|
| 336 |
+
test_peptides = [
|
| 337 |
+
"NCC(=O)N[C@@H](CCCCN)C(=O)NCC(=O)O", # Gly-Lys-Gly
|
| 338 |
+
]
|
| 339 |
+
|
| 340 |
+
for pep in test_peptides:
|
| 341 |
+
try:
|
| 342 |
+
conjugates = builder.conjugate_to_peptide(pep, exclude_n_terminus=True)
|
| 343 |
+
print(f" {len(conjugates)} conjugate(s)")
|
| 344 |
+
for c in conjugates:
|
| 345 |
+
valid, checks = builder.validate_conjugate(c)
|
| 346 |
+
print(f" Valid: {valid}")
|
| 347 |
+
except Exception as e:
|
| 348 |
+
print(f" Failed: {e}")
|
smiles/conjugation/ValCitDau.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Val-Cit-PABC-Daunorubicin PDC Builder
|
| 3 |
+
|
| 4 |
+
Conjugation chemistry: Lysine ε-amine + Glutaryl-Val-Cit-PABC-Daunorubicin (amide bond formation)
|
| 5 |
+
|
| 6 |
+
Reference PDCs:
|
| 7 |
+
- PDC_00189 (GnRH-III-[2ΔHis-3D-Tic,8Lys(glutaryl-Val-Cit-PABC-Dau)])
|
| 8 |
+
- PDC_00194 (GnRH-III-[2His-3Trp,8Lys(glutaryl-Val-Cit-PABC-Dau)])
|
| 9 |
+
- Receptor: GNRHR
|
| 10 |
+
- Linker: Val-Cit (LIN00005)
|
| 11 |
+
- Payload: Daunorubicin (DRG00010)
|
| 12 |
+
|
| 13 |
+
Architecture:
|
| 14 |
+
Peptide──Lys──NH──C(=O)──(CH₂)₃──C(=O)──Val──Cit──PABC──O──C(=O)──NH──Dau
|
| 15 |
+
│ │ │ │
|
| 16 |
+
amide bond glutaryl dipeptide carbamate
|
| 17 |
+
(reaction) spacer cleavable self-immolative
|
| 18 |
+
|
| 19 |
+
The cleavable dipeptide Val-Cit (valine-citrulline) is recognized by
|
| 20 |
+
cathepsin B (lysosomal protease). After cleavage, PABC self-immolation
|
| 21 |
+
releases free Daunorubicin.
|
| 22 |
+
|
| 23 |
+
Citrulline (Cit) is a non-coded amino acid with a ureido side chain:
|
| 24 |
+
-CH₂-CH₂-CH₂-NH-C(=O)-NH₂
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from rdkit import Chem
|
| 28 |
+
from rdkit.Chem import RWMol, AllChem
|
| 29 |
+
|
| 30 |
+
from .pdc_builder import PDC_Builder
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class ValCitPABCDaunorubicin_PDC_Builder(PDC_Builder):
|
| 34 |
+
"""
|
| 35 |
+
PDC builder for Glutaryl-Val-Cit-PABC-Daunorubicin conjugates
|
| 36 |
+
|
| 37 |
+
Conjugation chemistry (amide bond formation):
|
| 38 |
+
- Peptide site: Lysine ε-amine (side chain -NH2)
|
| 39 |
+
- Linker-payload site: Free carboxylic acid on glutaryl spacer
|
| 40 |
+
- Reaction: Amide bond (Lys-NH2 + HOOC-glutaryl-Val-Cit-PABC-Dau)
|
| 41 |
+
|
| 42 |
+
Architecture:
|
| 43 |
+
Peptide──Lys──NH──CO──(CH₂)₃──CO──Val──Cit──PABC──OCO──NH──Dau
|
| 44 |
+
└── amide ──┘ └── cleavable ──┘ └─ carbamate ─┘
|
| 45 |
+
|
| 46 |
+
Reference: PDC_00189, PDC_00194 (GnRH-III conjugates, GNRHR)
|
| 47 |
+
"""
|
| 48 |
+
|
| 49 |
+
# Val-Cit-PABC-Daunorubicin with free glutaryl COOH
|
| 50 |
+
# Structure: HOOC-(CH2)3-CO-Val-Cit-PABC-OCO-NH-Dau
|
| 51 |
+
# - Glutaryl spacer connects to Val N-terminus via amide
|
| 52 |
+
# - Val-Cit dipeptide (cathepsin B cleavable, Cit has ureido side chain)
|
| 53 |
+
# - PABC (para-aminobenzylcarbamate) self-immolative spacer
|
| 54 |
+
# - Carbamate bond from PABC to Dau 3'-amine
|
| 55 |
+
DEFAULT_VALCIT_PABC_DAU_SMILES = (
|
| 56 |
+
"C[C@H]1[C@H]([C@H](C[C@@H](O1)O[C@H]2C[C@@](CC3=C2C(=C4C(=C3O)"
|
| 57 |
+
"C(=O)C5=C(C4=O)C(=CC=C5)OC)O)(C(=O)C)O)NC(=O)OCc6ccc(NC(=O)"
|
| 58 |
+
"[C@@H](CCCNC(=O)N)NC(=O)[C@@H](C(C)C)NC(=O)CCCC(=O)O)cc6)O"
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
def __init__(self, valcit_pabc_dau_smiles=None):
|
| 62 |
+
"""
|
| 63 |
+
Initialize Val-Cit-PABC-Daunorubicin builder
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
valcit_pabc_dau_smiles: Optional custom SMILES. If None, uses default.
|
| 67 |
+
Must have free carboxylic acid for conjugation.
|
| 68 |
+
"""
|
| 69 |
+
if valcit_pabc_dau_smiles is None:
|
| 70 |
+
valcit_pabc_dau_smiles = self.DEFAULT_VALCIT_PABC_DAU_SMILES
|
| 71 |
+
|
| 72 |
+
super().__init__(valcit_pabc_dau_smiles,
|
| 73 |
+
component_name="Val-Cit-PABC-Daunorubicin (linker-payload)")
|
| 74 |
+
|
| 75 |
+
def _verify_reactive_group(self):
|
| 76 |
+
"""Verify carboxylic acid group is present for amide bond formation"""
|
| 77 |
+
carboxylic_acid_pattern = Chem.MolFromSmarts('[CX3](=O)[OX2H1]')
|
| 78 |
+
|
| 79 |
+
if not self.linker_payload_mol.HasSubstructMatch(carboxylic_acid_pattern):
|
| 80 |
+
print(" Warning: No carboxylic acid group detected in Val-Cit-PABC-Dau")
|
| 81 |
+
else:
|
| 82 |
+
print(" Val-Cit-PABC-Daunorubicin loaded with carboxylic acid reactive group")
|
| 83 |
+
|
| 84 |
+
# Verify PABC benzyl carbamate
|
| 85 |
+
pabc_pattern = Chem.MolFromSmarts('c1ccc(COC(=O)N)cc1')
|
| 86 |
+
if self.linker_payload_mol.HasSubstructMatch(pabc_pattern):
|
| 87 |
+
print(" PABC self-immolative spacer detected")
|
| 88 |
+
else:
|
| 89 |
+
print(" Warning: No PABC pattern detected")
|
| 90 |
+
|
| 91 |
+
# Verify citrulline ureido group
|
| 92 |
+
urea_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[NX3]')
|
| 93 |
+
if self.linker_payload_mol.HasSubstructMatch(urea_pattern):
|
| 94 |
+
print(" Citrulline ureido group detected")
|
| 95 |
+
else:
|
| 96 |
+
print(" Warning: No citrulline ureido group detected")
|
| 97 |
+
|
| 98 |
+
def find_conjugation_sites(self, peptide_smiles):
|
| 99 |
+
"""Find all lysine ε-amine groups in peptide"""
|
| 100 |
+
return self._find_peptide_lysines(peptide_smiles)
|
| 101 |
+
|
| 102 |
+
def _find_peptide_lysines(self, peptide_smiles):
|
| 103 |
+
"""Find all lysine residues with free ε-amine in peptide"""
|
| 104 |
+
peptide_mol = Chem.MolFromSmiles(peptide_smiles)
|
| 105 |
+
if peptide_mol is None:
|
| 106 |
+
raise ValueError("Invalid peptide SMILES")
|
| 107 |
+
|
| 108 |
+
lysines = []
|
| 109 |
+
|
| 110 |
+
lys_pattern = Chem.MolFromSmarts('[CH2][CH2][CH2][CH2][NH2]')
|
| 111 |
+
matches = peptide_mol.GetSubstructMatches(lys_pattern)
|
| 112 |
+
|
| 113 |
+
for match in matches:
|
| 114 |
+
nitrogen_idx = match[4]
|
| 115 |
+
lysines.append({
|
| 116 |
+
'atom_idx': nitrogen_idx,
|
| 117 |
+
'side_chain_atoms': list(match),
|
| 118 |
+
'type': 'Lys_epsilon_amine'
|
| 119 |
+
})
|
| 120 |
+
|
| 121 |
+
n_terminus_pattern = Chem.MolFromSmarts('[NH2][CH,CH2][C](=O)')
|
| 122 |
+
n_term_matches = peptide_mol.GetSubstructMatches(n_terminus_pattern)
|
| 123 |
+
if n_term_matches:
|
| 124 |
+
print(f" (Also found {len(n_term_matches)} N-terminus α-amine(s) - excluded by default)")
|
| 125 |
+
|
| 126 |
+
return lysines
|
| 127 |
+
|
| 128 |
+
def _perform_conjugation(self, peptide_mol, site_info, exclude_n_terminus=True):
|
| 129 |
+
"""Perform amide bond conjugation"""
|
| 130 |
+
return self._manual_conjugation(peptide_mol, site_info, exclude_n_terminus)
|
| 131 |
+
|
| 132 |
+
def _manual_conjugation(self, peptide_mol, lysine_info, exclude_n_terminus=True):
|
| 133 |
+
"""
|
| 134 |
+
Create ALL possible conjugates by forming amide bond at NH2 sites
|
| 135 |
+
|
| 136 |
+
Chemistry:
|
| 137 |
+
- Any-NH2 + HOOC-glutaryl-Val-Cit-PABC-Dau → amide bond + H2O
|
| 138 |
+
"""
|
| 139 |
+
if exclude_n_terminus:
|
| 140 |
+
amide_rxn = AllChem.ReactionFromSmarts(
|
| 141 |
+
'[*:5][CH2][CH2][CH2][CH2:4][NH2:1].[C:2](=[O:3])[OH]>>'
|
| 142 |
+
'[*:5][CH2][CH2][CH2][CH2:4][NH:1][C:2](=[O:3])'
|
| 143 |
+
)
|
| 144 |
+
else:
|
| 145 |
+
amide_rxn = AllChem.ReactionFromSmarts(
|
| 146 |
+
'[*:4][NH2:1].[C:2](=[O:3])[OH]>>[*:4][NH:1][C:2](=[O:3])'
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
products = amide_rxn.RunReactants((peptide_mol, self.linker_payload_mol))
|
| 150 |
+
|
| 151 |
+
if not products:
|
| 152 |
+
raise ValueError("Amide bond formation reaction failed - no products generated")
|
| 153 |
+
|
| 154 |
+
conjugates = []
|
| 155 |
+
seen_smiles = set()
|
| 156 |
+
|
| 157 |
+
for product_tuple in products:
|
| 158 |
+
try:
|
| 159 |
+
conjugate_mol = product_tuple[0]
|
| 160 |
+
Chem.SanitizeMol(conjugate_mol)
|
| 161 |
+
conjugate_smiles = Chem.MolToSmiles(conjugate_mol)
|
| 162 |
+
|
| 163 |
+
if conjugate_smiles not in seen_smiles:
|
| 164 |
+
seen_smiles.add(conjugate_smiles)
|
| 165 |
+
conjugates.append(conjugate_smiles)
|
| 166 |
+
except Exception:
|
| 167 |
+
continue
|
| 168 |
+
|
| 169 |
+
if not conjugates:
|
| 170 |
+
raise ValueError("All products failed sanitization")
|
| 171 |
+
|
| 172 |
+
return conjugates
|
| 173 |
+
|
| 174 |
+
def conjugate_to_peptide(self, peptide_smiles, exclude_n_terminus=True, return_all=True):
|
| 175 |
+
"""
|
| 176 |
+
Conjugate Val-Cit-PABC-Daunorubicin to peptide via amide bond
|
| 177 |
+
|
| 178 |
+
Args:
|
| 179 |
+
peptide_smiles: SMILES string of peptide (must contain NH2)
|
| 180 |
+
exclude_n_terminus: If True (default), only conjugate at Lys ε-amines
|
| 181 |
+
return_all: If True, return all possible conjugates
|
| 182 |
+
|
| 183 |
+
Returns:
|
| 184 |
+
If return_all=True: List of all possible conjugate SMILES
|
| 185 |
+
If return_all=False: Single conjugate SMILES
|
| 186 |
+
"""
|
| 187 |
+
peptide_mol = Chem.MolFromSmiles(peptide_smiles)
|
| 188 |
+
if peptide_mol is None:
|
| 189 |
+
raise ValueError(f"Invalid peptide SMILES: {peptide_smiles}")
|
| 190 |
+
|
| 191 |
+
sites = self.find_conjugation_sites(peptide_smiles)
|
| 192 |
+
print(f"Found {len(sites)} Lys ε-amine site(s)")
|
| 193 |
+
|
| 194 |
+
conjugates = self._perform_conjugation(peptide_mol, None, exclude_n_terminus)
|
| 195 |
+
|
| 196 |
+
site_type = "Lys ε-amine only" if exclude_n_terminus else "all NH2 (including N-terminus)"
|
| 197 |
+
print(f"Generated {len(conjugates)} unique conjugate(s) [{site_type}]")
|
| 198 |
+
|
| 199 |
+
if return_all:
|
| 200 |
+
return conjugates
|
| 201 |
+
else:
|
| 202 |
+
return conjugates[0] if conjugates else None
|
| 203 |
+
|
| 204 |
+
def batch_conjugate(self, peptide_smiles_list, exclude_n_terminus=True, return_all=True):
|
| 205 |
+
"""Batch conjugate multiple peptides"""
|
| 206 |
+
results = {
|
| 207 |
+
'conjugates': [],
|
| 208 |
+
'successful': [],
|
| 209 |
+
'failed': [],
|
| 210 |
+
'total_conjugates': 0
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
for i, peptide_smiles in enumerate(peptide_smiles_list):
|
| 214 |
+
try:
|
| 215 |
+
conjugates = self.conjugate_to_peptide(
|
| 216 |
+
peptide_smiles,
|
| 217 |
+
exclude_n_terminus=exclude_n_terminus,
|
| 218 |
+
return_all=return_all
|
| 219 |
+
)
|
| 220 |
+
if return_all:
|
| 221 |
+
results['conjugates'].append(conjugates)
|
| 222 |
+
results['total_conjugates'] += len(conjugates)
|
| 223 |
+
else:
|
| 224 |
+
results['conjugates'].append(conjugates)
|
| 225 |
+
results['total_conjugates'] += 1
|
| 226 |
+
results['successful'].append(i)
|
| 227 |
+
except Exception as e:
|
| 228 |
+
print(f" Peptide {i} failed: {e}")
|
| 229 |
+
results['conjugates'].append(None)
|
| 230 |
+
results['failed'].append((i, str(e)))
|
| 231 |
+
|
| 232 |
+
return results
|
| 233 |
+
|
| 234 |
+
# =========================================================================
|
| 235 |
+
# Attachment Site Compatibility Checks
|
| 236 |
+
# =========================================================================
|
| 237 |
+
|
| 238 |
+
def has_pabc_carbamate(self, mol):
|
| 239 |
+
"""Check for PABC benzyl carbamate: Ar-CH2-O-C(=O)-N"""
|
| 240 |
+
pabc_pattern = Chem.MolFromSmarts('c1ccc(COC(=O)[NX3])cc1')
|
| 241 |
+
if pabc_pattern:
|
| 242 |
+
return mol.HasSubstructMatch(pabc_pattern)
|
| 243 |
+
return False
|
| 244 |
+
|
| 245 |
+
def has_val_cit_dipeptide(self, mol):
|
| 246 |
+
"""
|
| 247 |
+
Check for Val-Cit dipeptide fragment
|
| 248 |
+
|
| 249 |
+
Citrulline has a ureido side chain: -CH₂CH₂CH₂-NH-C(=O)-NH₂
|
| 250 |
+
"""
|
| 251 |
+
# Check for ureido (citrulline marker)
|
| 252 |
+
urea_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[NX3]')
|
| 253 |
+
if urea_pattern:
|
| 254 |
+
return mol.HasSubstructMatch(urea_pattern)
|
| 255 |
+
return False
|
| 256 |
+
|
| 257 |
+
def has_glutaryl_spacer(self, mol):
|
| 258 |
+
"""Check for glutaryl spacer: -C(=O)-(CH2)3-C(=O)-"""
|
| 259 |
+
glutaryl = Chem.MolFromSmarts('[CX3](=[OX1])[CH2][CH2][CH2][CX3](=[OX1])')
|
| 260 |
+
if glutaryl:
|
| 261 |
+
return mol.HasSubstructMatch(glutaryl)
|
| 262 |
+
return False
|
| 263 |
+
|
| 264 |
+
def has_unreacted_glutaryl_cooh(self, mol):
|
| 265 |
+
"""Check if free COOH on glutaryl end remains (should be consumed)"""
|
| 266 |
+
unreacted = Chem.MolFromSmarts(
|
| 267 |
+
'[OX2H1][CX3](=[OX1])[CH2][CH2][CH2][CX3](=[OX1])[NX3]'
|
| 268 |
+
)
|
| 269 |
+
if unreacted:
|
| 270 |
+
return mol.HasSubstructMatch(unreacted)
|
| 271 |
+
return False
|
| 272 |
+
|
| 273 |
+
def check_attachment_site_compatibility(self, mol):
|
| 274 |
+
"""
|
| 275 |
+
Check if attachment site chemistry is valid
|
| 276 |
+
|
| 277 |
+
Validates:
|
| 278 |
+
1. Has PABC carbamate
|
| 279 |
+
2. Has glutaryl spacer
|
| 280 |
+
3. Has citrulline ureido group
|
| 281 |
+
4. No unreacted glutaryl-COOH
|
| 282 |
+
"""
|
| 283 |
+
if not self.has_pabc_carbamate(mol):
|
| 284 |
+
return False
|
| 285 |
+
|
| 286 |
+
if not self.has_glutaryl_spacer(mol):
|
| 287 |
+
return False
|
| 288 |
+
|
| 289 |
+
if not self.has_val_cit_dipeptide(mol):
|
| 290 |
+
return False
|
| 291 |
+
|
| 292 |
+
if self.has_unreacted_glutaryl_cooh(mol):
|
| 293 |
+
return False
|
| 294 |
+
|
| 295 |
+
return True
|
| 296 |
+
|
| 297 |
+
def _basic_validation(self, mol):
|
| 298 |
+
"""Basic validation checks for Val-Cit-PABC-Daunorubicin conjugates"""
|
| 299 |
+
checks = {}
|
| 300 |
+
|
| 301 |
+
amide_pattern = Chem.MolFromSmarts('[NX3][CX3](=[OX1])[#6]')
|
| 302 |
+
checks['has_amide_bond'] = mol.HasSubstructMatch(amide_pattern)
|
| 303 |
+
|
| 304 |
+
checks['has_pabc_carbamate'] = self.has_pabc_carbamate(mol)
|
| 305 |
+
checks['has_val_cit_dipeptide'] = self.has_val_cit_dipeptide(mol)
|
| 306 |
+
checks['has_glutaryl_spacer'] = self.has_glutaryl_spacer(mol)
|
| 307 |
+
checks['no_unreacted_cooh'] = not self.has_unreacted_glutaryl_cooh(mol)
|
| 308 |
+
checks['attachment_site_valid'] = self.check_attachment_site_compatibility(mol)
|
| 309 |
+
|
| 310 |
+
anthraquinone = Chem.MolFromSmarts(
|
| 311 |
+
'[#6]1[#6][#6][#6]2[#6](=[#8])[#6]3[#6][#6][#6][#6][#6]3[#6](=[#8])[#6]2[#6]1'
|
| 312 |
+
)
|
| 313 |
+
checks['has_daunorubicin_core'] = mol.HasSubstructMatch(anthraquinone)
|
| 314 |
+
|
| 315 |
+
aminosugar = Chem.MolFromSmarts('[OX2]1[#6][#6][#6]([NX3])[#6][#6]1')
|
| 316 |
+
checks['has_aminosugar'] = mol.HasSubstructMatch(aminosugar)
|
| 317 |
+
|
| 318 |
+
checks['_passed'] = checks['attachment_site_valid']
|
| 319 |
+
|
| 320 |
+
return checks
|
| 321 |
+
|
| 322 |
+
def validate_conjugate(self, conjugate_smiles):
|
| 323 |
+
"""Validate that the conjugate is chemically reasonable"""
|
| 324 |
+
mol = Chem.MolFromSmiles(conjugate_smiles)
|
| 325 |
+
|
| 326 |
+
if mol is None:
|
| 327 |
+
return False, {'error': 'Could not parse SMILES'}
|
| 328 |
+
|
| 329 |
+
checks = self._basic_validation(mol)
|
| 330 |
+
checks['parseable'] = True
|
| 331 |
+
|
| 332 |
+
passed = checks.get('_passed', True)
|
| 333 |
+
if '_passed' in checks:
|
| 334 |
+
del checks['_passed']
|
| 335 |
+
|
| 336 |
+
return passed, checks
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
if __name__ == "__main__":
|
| 340 |
+
print("=" * 70)
|
| 341 |
+
print("Val-Cit-PABC-Daunorubicin PDC Builder - Test")
|
| 342 |
+
print("=" * 70 + "\n")
|
| 343 |
+
|
| 344 |
+
builder = ValCitPABCDaunorubicin_PDC_Builder()
|
| 345 |
+
|
| 346 |
+
test_peptides = [
|
| 347 |
+
"NCC(=O)N[C@@H](CCCCN)C(=O)NCC(=O)O", # Gly-Lys-Gly
|
| 348 |
+
]
|
| 349 |
+
|
| 350 |
+
for pep in test_peptides:
|
| 351 |
+
try:
|
| 352 |
+
conjugates = builder.conjugate_to_peptide(pep, exclude_n_terminus=True)
|
| 353 |
+
print(f" {len(conjugates)} conjugate(s)")
|
| 354 |
+
for c in conjugates:
|
| 355 |
+
valid, checks = builder.validate_conjugate(c)
|
| 356 |
+
print(f" Valid: {valid}")
|
| 357 |
+
except Exception as e:
|
| 358 |
+
print(f" Failed: {e}")
|
smiles/conjugation/VcMMAE.py
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from rdkit import Chem
|
| 2 |
+
from rdkit.Chem import RWMol
|
| 3 |
+
|
| 4 |
+
from .pdc_builder import PDC_Builder
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class VcMMAE_PDC_Builder(PDC_Builder):
|
| 8 |
+
"""
|
| 9 |
+
Specialized builder for vcMMAE (valine-citrulline-MMAE) conjugates
|
| 10 |
+
|
| 11 |
+
vcMMAE consists of:
|
| 12 |
+
- Linker: Val-Cit-PABC (valine-citrulline-para-aminobenzylcarbamate) - cleavable dipeptide
|
| 13 |
+
- Payload: MMAE (monomethyl auristatin E) - cytotoxic drug
|
| 14 |
+
- Reactive group: Maleimide for cysteine conjugation
|
| 15 |
+
|
| 16 |
+
Conjugation chemistry (thiol-maleimide Michael addition):
|
| 17 |
+
- Peptide site: Free cysteine thiol (-SH)
|
| 18 |
+
- vcMMAE site: Maleimide group (C=C in 5-membered ring)
|
| 19 |
+
- Reaction: Thiol adds to maleimide C=C, forming stable thioether bond
|
| 20 |
+
- Product: Peptide-S-vcMMAE conjugate
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
def __init__(self, vcmmae_smiles=None):
|
| 24 |
+
"""
|
| 25 |
+
Initialize vcMMAE builder
|
| 26 |
+
|
| 27 |
+
Args:
|
| 28 |
+
vcmmae_smiles: Optional custom vcMMAE (linker-payload) SMILES. If None, uses default.
|
| 29 |
+
"""
|
| 30 |
+
# Default vcMMAE SMILES: maleimide-(6-carbon spacer)-Val-Cit-PABC-MMAE
|
| 31 |
+
# Structure: Maleimide-C6-Val-Cit-PABC-MMAE
|
| 32 |
+
if vcmmae_smiles is None:
|
| 33 |
+
vcmmae_smiles = "O[C@@H](C1=CC=CC=C1)[C@@H](C)NC([C@H](C)[C@H]([C@@]2([H])N(CCC2)C(C[C@@H](OC)[C@H]([C@@H](C)CC)N(C)C([C@H](C(C)C)NC([C@H](C(C)C)N(C)C(OCC3=CC=C(C=C3)NC([C@H](CCCNC(N)=O)NC([C@H](C(C)C)NC(CCCCCN4C(C=CC4=O)=O)=O)=O)=O)=O)=O)=O)=O)OC)=O"
|
| 34 |
+
|
| 35 |
+
# Initialize base class
|
| 36 |
+
super().__init__(vcmmae_smiles, component_name="vcMMAE (linker-payload)")
|
| 37 |
+
|
| 38 |
+
def _verify_reactive_group(self):
|
| 39 |
+
"""Verify maleimide group is present"""
|
| 40 |
+
maleimide_pattern = Chem.MolFromSmarts('[#7]1[#6](=[#8])[#6]=[#6][#6]1=[#8]')
|
| 41 |
+
if not self.linker_payload_mol.HasSubstructMatch(maleimide_pattern):
|
| 42 |
+
print(" Warning: No maleimide group detected")
|
| 43 |
+
else:
|
| 44 |
+
print(" vcMMAE (linker-payload) loaded with maleimide reactive group")
|
| 45 |
+
|
| 46 |
+
def find_conjugation_sites(self, peptide_smiles):
|
| 47 |
+
"""
|
| 48 |
+
Find all free cysteine residues (thiol groups) in peptide
|
| 49 |
+
|
| 50 |
+
Returns list of dicts with site information including atom index
|
| 51 |
+
"""
|
| 52 |
+
return self.find_peptide_cysteines(peptide_smiles)
|
| 53 |
+
|
| 54 |
+
def find_peptide_cysteines(self, peptide_smiles):
|
| 55 |
+
"""
|
| 56 |
+
Find all free cysteine residues in peptide
|
| 57 |
+
|
| 58 |
+
FIX: Iterate over all atoms instead of using restrictive SMARTS
|
| 59 |
+
"""
|
| 60 |
+
peptide_mol = Chem.MolFromSmiles(peptide_smiles)
|
| 61 |
+
if peptide_mol is None:
|
| 62 |
+
raise ValueError("Invalid peptide SMILES")
|
| 63 |
+
|
| 64 |
+
cysteines = []
|
| 65 |
+
|
| 66 |
+
# FIX: Directly check each sulfur atom
|
| 67 |
+
for atom in peptide_mol.GetAtoms():
|
| 68 |
+
if atom.GetAtomicNum() == 16: # Sulfur
|
| 69 |
+
# Check if it's a thiol (has hydrogen attached)
|
| 70 |
+
total_hs = atom.GetTotalNumHs() # Includes implicit H
|
| 71 |
+
degree = atom.GetDegree() # Number of bonds to other heavy atoms
|
| 72 |
+
|
| 73 |
+
# Thiol: S with 1 H and bonded to 1 carbon
|
| 74 |
+
# (vs disulfide: S-S with no H)
|
| 75 |
+
if total_hs >= 1 and degree == 1:
|
| 76 |
+
cysteines.append({
|
| 77 |
+
'atom_idx': atom.GetIdx(),
|
| 78 |
+
'num_hydrogens': total_hs,
|
| 79 |
+
'neighbors': [peptide_mol.GetAtomWithIdx(n.GetIdx()).GetSymbol()
|
| 80 |
+
for n in atom.GetNeighbors()]
|
| 81 |
+
})
|
| 82 |
+
|
| 83 |
+
return cysteines
|
| 84 |
+
|
| 85 |
+
def _find_maleimide_atoms(self, mol):
|
| 86 |
+
"""
|
| 87 |
+
Find the maleimide C=C double bond atoms
|
| 88 |
+
|
| 89 |
+
Returns: (carbon1_idx, carbon2_idx) of the C=C in maleimide
|
| 90 |
+
"""
|
| 91 |
+
# Pattern: N in ring, with two C=O, and C=C
|
| 92 |
+
# O=C-C=C-C=O with N closing the ring
|
| 93 |
+
maleimide_pattern = Chem.MolFromSmarts('[#7]1[#6](=[#8])[#6]=[#6][#6]1=[#8]')
|
| 94 |
+
matches = mol.GetSubstructMatches(maleimide_pattern)
|
| 95 |
+
|
| 96 |
+
if not matches:
|
| 97 |
+
return None, None
|
| 98 |
+
|
| 99 |
+
# Pattern indices: 0=N, 1=C(=O), 2=C(of C=C), 3=C(of C=C), 4=C(=O)
|
| 100 |
+
match = matches[0]
|
| 101 |
+
return match[2], match[3]
|
| 102 |
+
|
| 103 |
+
def _perform_conjugation(self, peptide_mol, site_info):
|
| 104 |
+
"""
|
| 105 |
+
Perform thiol-maleimide conjugation
|
| 106 |
+
|
| 107 |
+
Uses manual bond formation for reliability with complex molecules
|
| 108 |
+
"""
|
| 109 |
+
return self._manual_conjugation(peptide_mol, site_info)
|
| 110 |
+
|
| 111 |
+
def conjugate_to_peptide(self, peptide_smiles, cys_position=0):
|
| 112 |
+
"""
|
| 113 |
+
Conjugate vcMMAE to peptide via thiol-maleimide Michael addition
|
| 114 |
+
|
| 115 |
+
Compatibility wrapper - calls parent class method with renamed parameter
|
| 116 |
+
"""
|
| 117 |
+
return super().conjugate_to_peptide(peptide_smiles, site_position=cys_position)
|
| 118 |
+
|
| 119 |
+
def batch_conjugate(self, peptide_smiles_list, cys_position=0):
|
| 120 |
+
"""
|
| 121 |
+
Batch conjugate multiple peptides
|
| 122 |
+
|
| 123 |
+
Compatibility wrapper - calls parent class method with renamed parameter
|
| 124 |
+
"""
|
| 125 |
+
return super().batch_conjugate(peptide_smiles_list, site_position=cys_position)
|
| 126 |
+
|
| 127 |
+
# =========================================================================
|
| 128 |
+
# Attachment Site Compatibility Checks (thiol-maleimide specific)
|
| 129 |
+
# =========================================================================
|
| 130 |
+
|
| 131 |
+
def has_free_thiol(self, mol):
|
| 132 |
+
"""
|
| 133 |
+
Check if molecule has unconjugated (free) thiol groups
|
| 134 |
+
|
| 135 |
+
A valid PDC should NOT have free thiols - they should be conjugated
|
| 136 |
+
"""
|
| 137 |
+
# SMARTS pattern check
|
| 138 |
+
free_thiol_pattern = Chem.MolFromSmarts('[CH2][SH1]')
|
| 139 |
+
free_thiol_alt = Chem.MolFromSmarts('[CH2][S;H1;v2]')
|
| 140 |
+
|
| 141 |
+
if free_thiol_pattern and mol.HasSubstructMatch(free_thiol_pattern):
|
| 142 |
+
return True
|
| 143 |
+
if free_thiol_alt and mol.HasSubstructMatch(free_thiol_alt):
|
| 144 |
+
return True
|
| 145 |
+
|
| 146 |
+
# Also check by iterating atoms (more reliable)
|
| 147 |
+
for atom in mol.GetAtoms():
|
| 148 |
+
if atom.GetAtomicNum() == 16: # Sulfur
|
| 149 |
+
if atom.GetTotalNumHs() >= 1 and atom.GetDegree() == 1:
|
| 150 |
+
# Sulfur with H and only one bond = free thiol
|
| 151 |
+
return True
|
| 152 |
+
|
| 153 |
+
return False
|
| 154 |
+
|
| 155 |
+
def has_unreacted_maleimide(self, mol):
|
| 156 |
+
"""
|
| 157 |
+
Check if molecule has unreacted maleimide (C=C in ring)
|
| 158 |
+
|
| 159 |
+
After thiol-maleimide conjugation, the C=C should become C-C
|
| 160 |
+
"""
|
| 161 |
+
maleimide_unreacted = Chem.MolFromSmarts('[#7]1[#6](=[#8])[#6]=[#6][#6]1=[#8]')
|
| 162 |
+
if maleimide_unreacted:
|
| 163 |
+
return mol.HasSubstructMatch(maleimide_unreacted)
|
| 164 |
+
return False
|
| 165 |
+
|
| 166 |
+
def find_thioether_conjugation_sites(self, mol):
|
| 167 |
+
"""
|
| 168 |
+
Identify thioether (C-S-C) conjugation sites in the molecule
|
| 169 |
+
|
| 170 |
+
For thiol-maleimide conjugation, the sulfur should be bonded to:
|
| 171 |
+
- One carbon from the peptide cysteine
|
| 172 |
+
- One carbon from the (former) maleimide ring
|
| 173 |
+
|
| 174 |
+
Returns list of sulfur atom indices that are conjugation sites
|
| 175 |
+
"""
|
| 176 |
+
thioether_pattern = Chem.MolFromSmarts('[#6][#16][#6]')
|
| 177 |
+
conjugation_sites = []
|
| 178 |
+
|
| 179 |
+
if thioether_pattern:
|
| 180 |
+
matches = mol.GetSubstructMatches(thioether_pattern)
|
| 181 |
+
for match in matches:
|
| 182 |
+
sulfur_idx = match[1] # S is the middle atom in C-S-C
|
| 183 |
+
sulfur_atom = mol.GetAtomWithIdx(sulfur_idx)
|
| 184 |
+
|
| 185 |
+
# Check if one neighbor is in a 5-membered ring (succinimide)
|
| 186 |
+
neighbors = [mol.GetAtomWithIdx(n.GetIdx()) for n in sulfur_atom.GetNeighbors()]
|
| 187 |
+
for neighbor in neighbors:
|
| 188 |
+
if neighbor.IsInRingSize(5):
|
| 189 |
+
conjugation_sites.append(sulfur_idx)
|
| 190 |
+
break
|
| 191 |
+
else:
|
| 192 |
+
# Even if not clearly in succinimide, thioether is likely conjugation
|
| 193 |
+
conjugation_sites.append(sulfur_idx)
|
| 194 |
+
|
| 195 |
+
return list(set(conjugation_sites)) # Remove duplicates
|
| 196 |
+
|
| 197 |
+
def check_valency_at_conjugation_site(self, mol, conjugation_atom_idx):
|
| 198 |
+
"""
|
| 199 |
+
Check valency specifically at a sulfur conjugation site atom
|
| 200 |
+
|
| 201 |
+
Valid thioether states for thiol-maleimide conjugation:
|
| 202 |
+
- Thioether: S with 2 single bonds to C, no H (C-S-C)
|
| 203 |
+
- NOT: free thiol, sulfonium, hypervalent sulfur
|
| 204 |
+
"""
|
| 205 |
+
atom = mol.GetAtomWithIdx(conjugation_atom_idx)
|
| 206 |
+
|
| 207 |
+
if atom.GetSymbol() != 'S':
|
| 208 |
+
return True # Not sulfur, skip this check
|
| 209 |
+
|
| 210 |
+
degree = atom.GetDegree() # Number of bonds to other heavy atoms
|
| 211 |
+
total_hs = atom.GetTotalNumHs()
|
| 212 |
+
formal_charge = atom.GetFormalCharge()
|
| 213 |
+
|
| 214 |
+
# Get neighbor information
|
| 215 |
+
neighbors = [mol.GetAtomWithIdx(n.GetIdx()) for n in atom.GetNeighbors()]
|
| 216 |
+
neighbor_symbols = [n.GetSymbol() for n in neighbors]
|
| 217 |
+
|
| 218 |
+
# Count double bonds to oxygen (for sulfoxide/sulfone)
|
| 219 |
+
double_bonded_oxygens = 0
|
| 220 |
+
for neighbor in neighbors:
|
| 221 |
+
if neighbor.GetSymbol() == 'O':
|
| 222 |
+
bond = mol.GetBondBetweenAtoms(conjugation_atom_idx, neighbor.GetIdx())
|
| 223 |
+
if bond and bond.GetBondType() == Chem.BondType.DOUBLE:
|
| 224 |
+
double_bonded_oxygens += 1
|
| 225 |
+
|
| 226 |
+
# ========== Valid Sulfur States ==========
|
| 227 |
+
|
| 228 |
+
# 1. Thioether: S with 2 single bonds to C, no H
|
| 229 |
+
# C-S-C (degree=2, no O, no charge)
|
| 230 |
+
if degree == 2 and 'O' not in neighbor_symbols and formal_charge == 0:
|
| 231 |
+
if total_hs == 0:
|
| 232 |
+
return True # Valid thioether
|
| 233 |
+
else:
|
| 234 |
+
return False # Has H = still partially a thiol
|
| 235 |
+
|
| 236 |
+
# 2. Sulfoxide: S with 1 double-bonded O
|
| 237 |
+
# C-S(=O)-C (degree=3, one =O)
|
| 238 |
+
if degree == 3 and double_bonded_oxygens == 1 and formal_charge == 0:
|
| 239 |
+
return True
|
| 240 |
+
|
| 241 |
+
# 3. Sulfone: S with 2 double-bonded O
|
| 242 |
+
# C-S(=O)(=O)-C (degree=4, two =O)
|
| 243 |
+
if degree == 4 and double_bonded_oxygens == 2 and formal_charge == 0:
|
| 244 |
+
return True
|
| 245 |
+
|
| 246 |
+
# ========== Invalid Sulfur States ==========
|
| 247 |
+
|
| 248 |
+
# Sulfonium (positively charged S with 3+ bonds) - INVALID
|
| 249 |
+
if degree >= 3 and formal_charge > 0:
|
| 250 |
+
return False
|
| 251 |
+
|
| 252 |
+
# Hypervalent sulfur without proper oxygen - INVALID
|
| 253 |
+
if degree > 2 and double_bonded_oxygens == 0:
|
| 254 |
+
return False
|
| 255 |
+
|
| 256 |
+
# If none of the valid patterns matched, it's invalid
|
| 257 |
+
return False
|
| 258 |
+
|
| 259 |
+
def check_attachment_site_compatibility(self, mol):
|
| 260 |
+
"""
|
| 261 |
+
Check if thiol-maleimide attachment site chemistry is valid
|
| 262 |
+
|
| 263 |
+
For vcMMAE conjugation, validates:
|
| 264 |
+
1. No free thiols (conjugation must have happened)
|
| 265 |
+
2. Has thioether bond (C-S-C)
|
| 266 |
+
3. No unreacted maleimide (C=C should be reduced)
|
| 267 |
+
4. Valid valency at conjugation sites
|
| 268 |
+
|
| 269 |
+
Args:
|
| 270 |
+
mol: RDKit Mol object of the conjugate
|
| 271 |
+
|
| 272 |
+
Returns:
|
| 273 |
+
bool: True if attachment site is valid, False otherwise
|
| 274 |
+
"""
|
| 275 |
+
# ===== CRITICAL CHECK: Reject if free thiol present =====
|
| 276 |
+
# A valid PDC should have the thiol CONSUMED by conjugation
|
| 277 |
+
if self.has_free_thiol(mol):
|
| 278 |
+
return False
|
| 279 |
+
|
| 280 |
+
# ===== Check for thioether (C-S-C) =====
|
| 281 |
+
thioether_pattern = Chem.MolFromSmarts('[#6][#16][#6]')
|
| 282 |
+
if not thioether_pattern or not mol.HasSubstructMatch(thioether_pattern):
|
| 283 |
+
return False # No thioether = no conjugation
|
| 284 |
+
|
| 285 |
+
# ===== Check valency at conjugation sites =====
|
| 286 |
+
conjugation_sites = self.find_thioether_conjugation_sites(mol)
|
| 287 |
+
|
| 288 |
+
for site_idx in conjugation_sites:
|
| 289 |
+
if not self.check_valency_at_conjugation_site(mol, site_idx):
|
| 290 |
+
return False
|
| 291 |
+
|
| 292 |
+
return True
|
| 293 |
+
|
| 294 |
+
# =========================================================================
|
| 295 |
+
# Manual Conjugation
|
| 296 |
+
# =========================================================================
|
| 297 |
+
|
| 298 |
+
def _manual_conjugation(self, peptide_mol, cysteine_info):
|
| 299 |
+
"""
|
| 300 |
+
Create conjugate by manually forming bonds
|
| 301 |
+
|
| 302 |
+
Chemistry:
|
| 303 |
+
- Maleimide C=C becomes C-C (single bond)
|
| 304 |
+
- Sulfur bonds to one of these carbons
|
| 305 |
+
- Proton transfers (S loses H, C gains H)
|
| 306 |
+
|
| 307 |
+
Result: Peptide-Cys-S-(linker-payload)
|
| 308 |
+
"""
|
| 309 |
+
|
| 310 |
+
# Find maleimide C=C in vcMMAE linker-payload
|
| 311 |
+
maleimide_c1, maleimide_c2 = self._find_maleimide_atoms(self.linker_payload_mol)
|
| 312 |
+
|
| 313 |
+
if maleimide_c1 is None:
|
| 314 |
+
raise ValueError("Could not locate maleimide in vcMMAE linker-payload")
|
| 315 |
+
|
| 316 |
+
sulfur_idx = cysteine_info['atom_idx']
|
| 317 |
+
|
| 318 |
+
# Step 1: Combine molecules
|
| 319 |
+
combined = Chem.CombineMols(self.linker_payload_mol, peptide_mol)
|
| 320 |
+
combined_rw = RWMol(combined)
|
| 321 |
+
|
| 322 |
+
# Step 2: Adjust indices (peptide atoms are shifted)
|
| 323 |
+
linker_payload_atom_count = self.linker_payload_mol.GetNumAtoms()
|
| 324 |
+
new_sulfur_idx = sulfur_idx + linker_payload_atom_count
|
| 325 |
+
|
| 326 |
+
# Step 3: Change maleimide C=C to C-C
|
| 327 |
+
bond = combined_rw.GetBondBetweenAtoms(maleimide_c1, maleimide_c2)
|
| 328 |
+
if bond is not None:
|
| 329 |
+
bond.SetBondType(Chem.BondType.SINGLE)
|
| 330 |
+
|
| 331 |
+
# Step 4: Add S-C bond (sulfur to one maleimide carbon)
|
| 332 |
+
combined_rw.AddBond(new_sulfur_idx, maleimide_c2, Chem.BondType.SINGLE)
|
| 333 |
+
|
| 334 |
+
# Step 5: Sanitize and clean up
|
| 335 |
+
try:
|
| 336 |
+
mol = combined_rw.GetMol()
|
| 337 |
+
Chem.SanitizeMol(mol)
|
| 338 |
+
conjugate_smiles = Chem.MolToSmiles(mol)
|
| 339 |
+
return conjugate_smiles
|
| 340 |
+
|
| 341 |
+
except Exception as e:
|
| 342 |
+
# Try alternative sanitization
|
| 343 |
+
try:
|
| 344 |
+
mol = combined_rw.GetMol()
|
| 345 |
+
mol = Chem.RemoveAllHs(mol)
|
| 346 |
+
Chem.SanitizeMol(mol)
|
| 347 |
+
conjugate_smiles = Chem.MolToSmiles(mol)
|
| 348 |
+
return conjugate_smiles
|
| 349 |
+
except Exception as e2:
|
| 350 |
+
raise ValueError(f"Sanitization failed: {e2}")
|
| 351 |
+
|
| 352 |
+
def _basic_validation(self, mol):
|
| 353 |
+
"""
|
| 354 |
+
Basic validation checks for thiol-maleimide conjugates
|
| 355 |
+
|
| 356 |
+
Includes attachment site compatibility check with valency validation.
|
| 357 |
+
|
| 358 |
+
Returns dict of check_name: bool pairs
|
| 359 |
+
"""
|
| 360 |
+
checks = {}
|
| 361 |
+
|
| 362 |
+
# Check 1: Has thioether (the new C-S-C bond)
|
| 363 |
+
thioether = Chem.MolFromSmarts('[#6]-[#16]-[#6]')
|
| 364 |
+
checks['has_thioether'] = mol.HasSubstructMatch(thioether)
|
| 365 |
+
|
| 366 |
+
# Check 2: Maleimide C=C is gone (should be succinimide now)
|
| 367 |
+
checks['maleimide_consumed'] = not self.has_unreacted_maleimide(mol)
|
| 368 |
+
|
| 369 |
+
# Check 3: Has succinimide (reduced maleimide)
|
| 370 |
+
succinimide = Chem.MolFromSmarts('[#7]1[#6](=[#8])[#6][#6][#6]1=[#8]')
|
| 371 |
+
checks['has_succinimide'] = mol.HasSubstructMatch(succinimide)
|
| 372 |
+
|
| 373 |
+
# Check 4: No free thiol remaining
|
| 374 |
+
checks['no_free_thiol'] = not self.has_free_thiol(mol)
|
| 375 |
+
|
| 376 |
+
# Check 5: Full attachment site compatibility (includes valency)
|
| 377 |
+
checks['attachment_site_valid'] = self.check_attachment_site_compatibility(mol)
|
| 378 |
+
|
| 379 |
+
# Find and report conjugation sites for diagnostics
|
| 380 |
+
conjugation_sites = self.find_thioether_conjugation_sites(mol)
|
| 381 |
+
checks['conjugation_sites'] = conjugation_sites
|
| 382 |
+
|
| 383 |
+
# Overall pass/fail - attachment_site_valid includes thioether and no_free_thiol checks
|
| 384 |
+
checks['_passed'] = checks['attachment_site_valid']
|
| 385 |
+
|
| 386 |
+
return checks
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
# ============================================================================
|
| 391 |
+
# Main - Test Examples
|
| 392 |
+
# ============================================================================
|
| 393 |
+
|
| 394 |
+
if __name__ == "__main__":
|
| 395 |
+
|
| 396 |
+
print("=" * 70)
|
| 397 |
+
print("PDC Builder Framework - vcMMAE Example")
|
| 398 |
+
print("=" * 70 + "\n")
|
| 399 |
+
|
| 400 |
+
builder = VcMMAE_PDC_Builder()
|
| 401 |
+
|
| 402 |
+
# Test peptides with free cysteine
|
| 403 |
+
test_peptides = [
|
| 404 |
+
"NCC(=O)N[C@@H](CS)C(=O)NCC(=O)O", # Gly-Cys-Gly
|
| 405 |
+
"N[C@@H](CS)C(=O)N[C@@H](CCCNC(=N)N)C(=O)O", # Cys-Arg
|
| 406 |
+
"N[C@@H](CCCNC(=N)N)C(=O)N[C@@H](CS)C(=O)N[C@@H](CC(C)C)C(=O)O", # Arg-Cys-Leu
|
| 407 |
+
]
|
| 408 |
+
|
| 409 |
+
print("\n" + "=" * 70)
|
| 410 |
+
print("Testing Single Conjugation")
|
| 411 |
+
print("=" * 70 + "\n")
|
| 412 |
+
|
| 413 |
+
peptide = test_peptides[0]
|
| 414 |
+
print(f"Input peptide: {peptide}")
|
| 415 |
+
|
| 416 |
+
try:
|
| 417 |
+
conjugate = builder.conjugate_to_peptide(peptide)
|
| 418 |
+
print(f"\n SUCCESS!")
|
| 419 |
+
print(f"Conjugate SMILES: {conjugate[:80]}...")
|
| 420 |
+
|
| 421 |
+
valid, checks = builder.validate_conjugate(conjugate, use_analyzer=False)
|
| 422 |
+
print(f"\n--- Basic Validation ---")
|
| 423 |
+
print(f"Result: {'PASSED' if valid else 'FAILED'}")
|
| 424 |
+
for k, v in checks.items():
|
| 425 |
+
print(f" {k}: {v}")
|
| 426 |
+
|
| 427 |
+
except Exception as e:
|
| 428 |
+
print(f"\n FAILED: {e}")
|
| 429 |
+
import traceback
|
| 430 |
+
traceback.print_exc()
|
| 431 |
+
|
| 432 |
+
print("\n" + "=" * 70)
|
| 433 |
+
print("Batch Conjugation & Validation")
|
| 434 |
+
print("=" * 70 + "\n")
|
| 435 |
+
|
| 436 |
+
# Batch conjugate
|
| 437 |
+
results = builder.batch_conjugate(test_peptides)
|
| 438 |
+
|
| 439 |
+
# Get successful conjugates
|
| 440 |
+
successful_conjugates = [c for c in results['conjugates'] if c is not None]
|
| 441 |
+
|
| 442 |
+
if len(successful_conjugates) > 0:
|
| 443 |
+
print(f"\nValidating {len(successful_conjugates)} conjugates...")
|
| 444 |
+
|
| 445 |
+
# Basic validation
|
| 446 |
+
print("\n--- Basic Validation ---")
|
| 447 |
+
basic_results = builder.batch_validate(successful_conjugates, use_analyzer=False)
|
| 448 |
+
|
| 449 |
+
for i, (passed, checks) in enumerate(basic_results):
|
| 450 |
+
status = " VALID" if passed else " INVALID"
|
| 451 |
+
print(f" Conjugate {i+1}: {status}")
|
| 452 |
+
|
| 453 |
+
if not passed:
|
| 454 |
+
# Show which checks failed
|
| 455 |
+
for check_name, check_val in checks.items():
|
| 456 |
+
if check_name not in ['validation_method', 'parseable', 'conjugation_sites'] and not check_val:
|
| 457 |
+
print(f" Failed: {check_name}")
|
smiles/conjugation/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""PDC conjugation builders."""
|
smiles/conjugation/pdc_builder.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PDC Builders - General and Specialized
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from rdkit import Chem
|
| 6 |
+
from rdkit.Chem import Descriptors
|
| 7 |
+
from abc import ABC, abstractmethod
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class PDC_Builder(ABC):
|
| 11 |
+
"""
|
| 12 |
+
Base class for Peptide-Drug Conjugate (PDC) builders
|
| 13 |
+
|
| 14 |
+
This abstract class defines the interface for building PDCs with different
|
| 15 |
+
conjugation chemistries. Subclasses must implement specific conjugation rules.
|
| 16 |
+
|
| 17 |
+
The conjugation component can be:
|
| 18 |
+
- Just a drug payload with reactive group
|
| 19 |
+
- A linker-payload combination (e.g., vcMMAE = Val-Cit linker + MMAE payload)
|
| 20 |
+
- A linker alone (for later payload attachment)
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
def __init__(self, linker_payload_smiles, component_name="Drug-Linker"):
|
| 24 |
+
"""
|
| 25 |
+
Initialize PDC builder with a linker-payload component
|
| 26 |
+
|
| 27 |
+
Args:
|
| 28 |
+
linker_payload_smiles: SMILES string of the linker-payload component
|
| 29 |
+
component_name: Name of the component (for display)
|
| 30 |
+
"""
|
| 31 |
+
self.linker_payload_smiles = linker_payload_smiles
|
| 32 |
+
self.component_name = component_name
|
| 33 |
+
self.linker_payload_mol = Chem.MolFromSmiles(linker_payload_smiles)
|
| 34 |
+
|
| 35 |
+
if self.linker_payload_mol is None:
|
| 36 |
+
raise ValueError(f"Could not parse {component_name} SMILES: {linker_payload_smiles}")
|
| 37 |
+
|
| 38 |
+
# Verify reactive group is present
|
| 39 |
+
self._verify_reactive_group()
|
| 40 |
+
|
| 41 |
+
@abstractmethod
|
| 42 |
+
def _verify_reactive_group(self):
|
| 43 |
+
"""Verify that the linker-payload component has the expected reactive group"""
|
| 44 |
+
pass
|
| 45 |
+
|
| 46 |
+
@abstractmethod
|
| 47 |
+
def find_conjugation_sites(self, peptide_smiles):
|
| 48 |
+
"""
|
| 49 |
+
Find conjugation sites in the peptide
|
| 50 |
+
|
| 51 |
+
Args:
|
| 52 |
+
peptide_smiles: SMILES string of the peptide
|
| 53 |
+
|
| 54 |
+
Returns:
|
| 55 |
+
List of dicts with conjugation site information
|
| 56 |
+
"""
|
| 57 |
+
pass
|
| 58 |
+
|
| 59 |
+
@abstractmethod
|
| 60 |
+
def _perform_conjugation(self, peptide_mol, site_info):
|
| 61 |
+
"""
|
| 62 |
+
Perform the actual conjugation chemistry
|
| 63 |
+
|
| 64 |
+
Args:
|
| 65 |
+
peptide_mol: RDKit Mol object of the peptide
|
| 66 |
+
site_info: Dict with conjugation site information
|
| 67 |
+
|
| 68 |
+
Returns:
|
| 69 |
+
SMILES string of the conjugate
|
| 70 |
+
"""
|
| 71 |
+
pass
|
| 72 |
+
|
| 73 |
+
@abstractmethod
|
| 74 |
+
def check_attachment_site_compatibility(self, mol):
|
| 75 |
+
"""
|
| 76 |
+
Check if attachment site chemistry is valid for this conjugation type
|
| 77 |
+
|
| 78 |
+
This is conjugation-method specific and must be implemented by subclasses.
|
| 79 |
+
Checks should include:
|
| 80 |
+
- Presence of expected conjugation bond (e.g., thioether for thiol-maleimide)
|
| 81 |
+
- Absence of unreacted precursors (e.g., free thiol, unreacted maleimide)
|
| 82 |
+
- Valency at conjugation sites
|
| 83 |
+
|
| 84 |
+
Args:
|
| 85 |
+
mol: RDKit Mol object of the conjugate
|
| 86 |
+
|
| 87 |
+
Returns:
|
| 88 |
+
bool: True if attachment site is valid, False otherwise
|
| 89 |
+
"""
|
| 90 |
+
pass
|
| 91 |
+
|
| 92 |
+
def conjugate_to_peptide(self, peptide_smiles, site_position=0):
|
| 93 |
+
"""
|
| 94 |
+
Conjugate linker-payload to peptide at specified conjugation site
|
| 95 |
+
|
| 96 |
+
Args:
|
| 97 |
+
peptide_smiles: SMILES string of the peptide
|
| 98 |
+
site_position: Index of the conjugation site to use (default: 0)
|
| 99 |
+
|
| 100 |
+
Returns:
|
| 101 |
+
SMILES string of the PDC conjugate
|
| 102 |
+
"""
|
| 103 |
+
peptide_mol = Chem.MolFromSmiles(peptide_smiles)
|
| 104 |
+
if peptide_mol is None:
|
| 105 |
+
raise ValueError(f"Invalid peptide SMILES: {peptide_smiles}")
|
| 106 |
+
|
| 107 |
+
sites = self.find_conjugation_sites(peptide_smiles)
|
| 108 |
+
|
| 109 |
+
if len(sites) == 0:
|
| 110 |
+
raise ValueError(f"No conjugation sites found in peptide")
|
| 111 |
+
|
| 112 |
+
if site_position >= len(sites):
|
| 113 |
+
raise ValueError(f"Site position {site_position} out of range (found {len(sites)} sites)")
|
| 114 |
+
|
| 115 |
+
print(f"Found {len(sites)} conjugation site(s) in peptide")
|
| 116 |
+
print(f"Conjugating at site position {site_position}")
|
| 117 |
+
|
| 118 |
+
target_site = sites[site_position]
|
| 119 |
+
return self._perform_conjugation(peptide_mol, target_site)
|
| 120 |
+
|
| 121 |
+
def batch_conjugate(self, peptide_smiles_list, site_position=0):
|
| 122 |
+
"""
|
| 123 |
+
Conjugate multiple peptides in batch
|
| 124 |
+
|
| 125 |
+
Args:
|
| 126 |
+
peptide_smiles_list: List of peptide SMILES strings
|
| 127 |
+
site_position: Index of conjugation site to use
|
| 128 |
+
|
| 129 |
+
Returns:
|
| 130 |
+
Dict with 'conjugates', 'successful', and 'failed' keys
|
| 131 |
+
"""
|
| 132 |
+
results = {
|
| 133 |
+
'conjugates': [],
|
| 134 |
+
'successful': [],
|
| 135 |
+
'failed': [],
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
for i, peptide_smiles in enumerate(peptide_smiles_list):
|
| 139 |
+
try:
|
| 140 |
+
conjugate = self.conjugate_to_peptide(peptide_smiles, site_position)
|
| 141 |
+
results['conjugates'].append(conjugate)
|
| 142 |
+
results['successful'].append(i)
|
| 143 |
+
except Exception as e:
|
| 144 |
+
print(f" Peptide {i} failed: {e}")
|
| 145 |
+
results['conjugates'].append(None)
|
| 146 |
+
results['failed'].append((i, str(e)))
|
| 147 |
+
|
| 148 |
+
print(f"\n=== Summary ===")
|
| 149 |
+
print(f"Total: {len(peptide_smiles_list)}, Success: {len(results['successful'])}, Failed: {len(results['failed'])}")
|
| 150 |
+
|
| 151 |
+
return results
|
| 152 |
+
|
| 153 |
+
def get_conjugate_properties(self, conjugate_smiles):
|
| 154 |
+
"""Calculate molecular properties of the conjugate"""
|
| 155 |
+
mol = Chem.MolFromSmiles(conjugate_smiles)
|
| 156 |
+
if mol is None:
|
| 157 |
+
return None
|
| 158 |
+
|
| 159 |
+
return {
|
| 160 |
+
'molecular_weight': Descriptors.MolWt(mol),
|
| 161 |
+
'num_heavy_atoms': mol.GetNumHeavyAtoms(),
|
| 162 |
+
'logp': Descriptors.MolLogP(mol),
|
| 163 |
+
'tpsa': Descriptors.TPSA(mol), # Topological Polar Surface Area
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
def validate_conjugate(self, conjugate_smiles):
|
| 167 |
+
"""
|
| 168 |
+
Validate that the conjugate is chemically reasonable
|
| 169 |
+
|
| 170 |
+
Args:
|
| 171 |
+
conjugate_smiles: SMILES string of the conjugate
|
| 172 |
+
|
| 173 |
+
Returns:
|
| 174 |
+
(passed: bool, checks: dict)
|
| 175 |
+
"""
|
| 176 |
+
mol = Chem.MolFromSmiles(conjugate_smiles)
|
| 177 |
+
|
| 178 |
+
if mol is None:
|
| 179 |
+
return False, {'error': 'Could not parse SMILES'}
|
| 180 |
+
|
| 181 |
+
# Basic validation (delegated to subclass)
|
| 182 |
+
checks = self._basic_validation(mol)
|
| 183 |
+
checks['validation_method'] = 'basic'
|
| 184 |
+
checks['parseable'] = True
|
| 185 |
+
|
| 186 |
+
# Get pass/fail from subclass implementation
|
| 187 |
+
passed = checks.get('_passed', True)
|
| 188 |
+
if '_passed' in checks:
|
| 189 |
+
del checks['_passed'] # Remove internal flag from output
|
| 190 |
+
|
| 191 |
+
return passed, checks
|
| 192 |
+
|
| 193 |
+
@abstractmethod
|
| 194 |
+
def _basic_validation(self, mol):
|
| 195 |
+
"""
|
| 196 |
+
Perform basic validation checks specific to conjugation type
|
| 197 |
+
|
| 198 |
+
Should include check_attachment_site_compatibility() and any other
|
| 199 |
+
conjugation-specific checks.
|
| 200 |
+
|
| 201 |
+
Args:
|
| 202 |
+
mol: RDKit Mol object of the conjugate
|
| 203 |
+
|
| 204 |
+
Returns:
|
| 205 |
+
Dict of check_name: bool pairs. Include '_passed': bool for overall result.
|
| 206 |
+
"""
|
| 207 |
+
pass
|
| 208 |
+
|
| 209 |
+
def batch_validate(self, conjugate_smiles_list):
|
| 210 |
+
"""Validate multiple conjugates"""
|
| 211 |
+
results = []
|
| 212 |
+
for smiles in conjugate_smiles_list:
|
| 213 |
+
if smiles is None:
|
| 214 |
+
results.append((False, {'error': 'None SMILES'}))
|
| 215 |
+
else:
|
| 216 |
+
passed, checks = self.validate_conjugate(smiles)
|
| 217 |
+
results.append((passed, checks))
|
| 218 |
+
|
| 219 |
+
num_passed = sum(1 for passed, _ in results if passed)
|
| 220 |
+
print(f"\nValidation Summary: {num_passed}/{len(results)} passed")
|
| 221 |
+
|
| 222 |
+
return results
|
| 223 |
+
|
smiles/evaluation/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""PDC evaluation and structure-analysis utilities."""
|
smiles/evaluation/metrics.py
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import warnings
|
| 3 |
+
import numpy as np
|
| 4 |
+
import pandas as pd
|
| 5 |
+
from rdkit import Chem, rdBase, DataStructs
|
| 6 |
+
from rdkit.Chem import AllChem
|
| 7 |
+
import os
|
| 8 |
+
import sys
|
| 9 |
+
|
| 10 |
+
# Add current directory to path for imports
|
| 11 |
+
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
| 12 |
+
|
| 13 |
+
# Import custom analyzers and conjugation builders
|
| 14 |
+
from smiles.evaluation.peptide_analyzer import PeptideAnalyzer
|
| 15 |
+
from smiles.evaluation.pdc_analyzer import PDCAnalyzer
|
| 16 |
+
from smiles.conjugation.AoaDau import AoaDaunorubicin_PDC_Builder
|
| 17 |
+
from smiles.conjugation.SSCpt import DisulfideCamptothecin_PDC_Builder
|
| 18 |
+
from smiles.conjugation.DSPASN38 import DisulfanylpropanoicAcidSN38_PDC_Builder
|
| 19 |
+
from smiles.scoring.classifiers import BindingAffinity
|
| 20 |
+
|
| 21 |
+
rdBase.DisableLog('rdApp.error')
|
| 22 |
+
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
| 23 |
+
warnings.filterwarnings("ignore", category=UserWarning)
|
| 24 |
+
warnings.filterwarnings("ignore", category=FutureWarning)
|
| 25 |
+
|
| 26 |
+
def fingerprints_from_mol(molecule, radius=3, size=2048, hashed=False):
|
| 27 |
+
""" Create ECFP fingerprint of a molecule """
|
| 28 |
+
if hashed:
|
| 29 |
+
fp_bits = AllChem.GetHashedMorganFingerprint(molecule, radius, nBits=size)
|
| 30 |
+
else:
|
| 31 |
+
fp_bits = AllChem.GetMorganFingerprintAsBitVect(molecule, radius, nBits=size)
|
| 32 |
+
fp_np = np.zeros((1,))
|
| 33 |
+
DataStructs.ConvertToNumpyArray(fp_bits, fp_np)
|
| 34 |
+
return fp_np.reshape(1, -1)
|
| 35 |
+
|
| 36 |
+
def average_agg_tanimoto(stock_vecs, gen_vecs, batch_size=5000, agg='max', device='cuda', p=1):
|
| 37 |
+
"""
|
| 38 |
+
For each molecule in gen_vecs finds closest molecule in stock_vecs.
|
| 39 |
+
Returns average tanimoto score for between these molecules
|
| 40 |
+
|
| 41 |
+
Parameters:
|
| 42 |
+
stock_vecs: numpy array <n_vectors x dim>
|
| 43 |
+
gen_vecs: numpy array <n_vectors' x dim>
|
| 44 |
+
agg: max or mean
|
| 45 |
+
p: power for averaging: (mean x^p)^(1/p)
|
| 46 |
+
"""
|
| 47 |
+
assert agg in ['max', 'mean'], "Can aggregate only max or mean"
|
| 48 |
+
agg_tanimoto = np.zeros(len(gen_vecs))
|
| 49 |
+
total = np.zeros(len(gen_vecs))
|
| 50 |
+
for j in range(0, stock_vecs.shape[0], batch_size):
|
| 51 |
+
x_stock = torch.tensor(stock_vecs[j:j + batch_size]).to(device).float()
|
| 52 |
+
for i in range(0, gen_vecs.shape[0], batch_size):
|
| 53 |
+
y_gen = torch.tensor(gen_vecs[i:i + batch_size]).to(device).float()
|
| 54 |
+
y_gen = y_gen.transpose(0, 1)
|
| 55 |
+
tp = torch.mm(x_stock, y_gen)
|
| 56 |
+
jac = (tp / (x_stock.sum(1, keepdim=True) + y_gen.sum(0, keepdim=True) - tp)).cpu().numpy()
|
| 57 |
+
jac[np.isnan(jac)] = 1
|
| 58 |
+
if p != 1:
|
| 59 |
+
jac = jac**p
|
| 60 |
+
if agg == 'max':
|
| 61 |
+
agg_tanimoto[i:i + y_gen.shape[1]] = np.maximum(agg_tanimoto[i:i + y_gen.shape[1]], jac.max(0))
|
| 62 |
+
elif agg == 'mean':
|
| 63 |
+
agg_tanimoto[i:i + y_gen.shape[1]] += jac.sum(0)
|
| 64 |
+
total[i:i + y_gen.shape[1]] += jac.shape[0]
|
| 65 |
+
if agg == 'mean':
|
| 66 |
+
agg_tanimoto /= total
|
| 67 |
+
if p != 1:
|
| 68 |
+
agg_tanimoto = (agg_tanimoto)**(1/p)
|
| 69 |
+
return np.mean(agg_tanimoto)
|
| 70 |
+
|
| 71 |
+
def get_mol(smiles_or_mol):
|
| 72 |
+
'''
|
| 73 |
+
Loads SMILES/molecule into RDKit's object
|
| 74 |
+
'''
|
| 75 |
+
if isinstance(smiles_or_mol, str):
|
| 76 |
+
if len(smiles_or_mol) == 0:
|
| 77 |
+
return None
|
| 78 |
+
mol = Chem.MolFromSmiles(smiles_or_mol)
|
| 79 |
+
if mol is None:
|
| 80 |
+
return None
|
| 81 |
+
try:
|
| 82 |
+
Chem.SanitizeMol(mol)
|
| 83 |
+
except ValueError:
|
| 84 |
+
return None
|
| 85 |
+
return mol
|
| 86 |
+
return smiles_or_mol
|
| 87 |
+
|
| 88 |
+
def canonic_smiles(smiles_or_mol):
|
| 89 |
+
mol = get_mol(smiles_or_mol)
|
| 90 |
+
if mol is None:
|
| 91 |
+
return None
|
| 92 |
+
return Chem.MolToSmiles(mol)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class Metrics:
|
| 96 |
+
|
| 97 |
+
def __init__(self, prior_path='./data/smiles/30K_all.csv', n_jobs=100, input_type='smiles', device='cuda'):
|
| 98 |
+
self.device = device
|
| 99 |
+
self.input_type = input_type # 'smiles', 'peptide', or 'conjugate'
|
| 100 |
+
self.n_jobs = n_jobs
|
| 101 |
+
|
| 102 |
+
# Initialize analyzers
|
| 103 |
+
self.peptide_analyzer = PeptideAnalyzer()
|
| 104 |
+
self.pdc_analyzer = PDCAnalyzer(device)
|
| 105 |
+
|
| 106 |
+
# Initialize conjugation builders
|
| 107 |
+
self.aoa_dau_builder = AoaDaunorubicin_PDC_Builder()
|
| 108 |
+
self.ss_cpt_builder = DisulfideCamptothecin_PDC_Builder()
|
| 109 |
+
self.dspa_sn38_builder = DisulfanylpropanoicAcidSN38_PDC_Builder()
|
| 110 |
+
|
| 111 |
+
# Load reference set for similarity calculations
|
| 112 |
+
train_set_cano_smi = pd.read_csv(prior_path)['SMILES'].astype(str).tolist()
|
| 113 |
+
|
| 114 |
+
for smi in train_set_cano_smi:
|
| 115 |
+
mol = Chem.MolFromSmiles(smi)
|
| 116 |
+
|
| 117 |
+
if mol is None:
|
| 118 |
+
print(f"Invalid SMILES: {smi}")
|
| 119 |
+
|
| 120 |
+
self.train_set_cano_smi = train_set_cano_smi
|
| 121 |
+
|
| 122 |
+
self.ref_fps = np.vstack([
|
| 123 |
+
fingerprints_from_mol(Chem.MolFromSmiles(smi))
|
| 124 |
+
for smi in train_set_cano_smi
|
| 125 |
+
if Chem.MolFromSmiles(smi) is not None
|
| 126 |
+
])
|
| 127 |
+
|
| 128 |
+
def _determine_target_from_filename(self, generated_path):
|
| 129 |
+
"""Determine target protein from filename"""
|
| 130 |
+
filename = os.path.basename(generated_path).upper()
|
| 131 |
+
if 'GNRHR' in filename:
|
| 132 |
+
return 'GNRHR'
|
| 133 |
+
elif 'ERBB2' in filename:
|
| 134 |
+
return 'ERBB2'
|
| 135 |
+
elif 'NRP1' in filename:
|
| 136 |
+
return 'NRP1'
|
| 137 |
+
else:
|
| 138 |
+
raise ValueError(f"Cannot determine target from filename: {filename}. Expected GNRHR / ERBB2 / NRP1 in filename.")
|
| 139 |
+
|
| 140 |
+
def _get_target_sequence(self, target):
|
| 141 |
+
"""Get target protein sequence for binding affinity calculation"""
|
| 142 |
+
# Define target sequences (you may need to update these)
|
| 143 |
+
target_sequences = {
|
| 144 |
+
'GNRHR': 'MANSASPEQNQNHCSAINNSIPLMQGNLPTLTLSGKIRVTVTFFLFLLSATFNASFLLKLQKWTQKKEKGKKLSRMKLLLKHLTLANLLETLIVMPLDGMWNITVQWYAGELLCKVLSYLKLFSMYAPAFMMVVISLDRSLAITRPLALKSNSKVGQSMVGLAWILSSVFAGPQLYIFRMIHLADSSGQTKVFSQCVTHCSFSQWWHQAFYNFFTFSCLFIIPLFIMLICNAKIIFTLTRVLHQDPHELQLNQSKNNIPRARLKTLKMTVAFATSFTVCWTPYYVLGIWYWFDPEMLNRLSDPVNHFFFLFAFLNPCFDPLIYGYFSL',
|
| 145 |
+
'ERBB2': 'MELAALCRWGLLLALLPPGAASTQVCTGTDMKLRLPASPETHLDMLRHLYQGCQVVQGNLELTYLPTNASLSFLQDIQEVQGYVLIAHNQVRQVPLQRLRIVRGTQLFEDNYALAVLDNGDPLNNTTPVTGASPGGLRELQLRSLTEILKGGVLIQRNPQLCYQDTILWKDIFHKNNQLALTLIDTNRSRACHPCSPMCKGSRCWGESSEDCQSLTRTVCAGGCARCKGPLPTDCCHEQCAAGCTGPKHSDCLACLHFNHSGICELHCPALVTYNTDTFESMPNPEGRYTFGASCVTACPYNYLSTDVGSCTLVCPLHNQEVTAEDGTQRCEKCSKPCARVCYGLGMEHLREVRAVTSANIQEFAGCKKIFGSLAFLPESFDGDPASNTAPLQPEQLQVFETLEEITGYLYISAWPDSLPDLSVFQNLQVIRGRILHNGAYSLTLQGLGISWLGLRSLRELGSGLALIHHNTHLCFVHTVPWDQLFRNPHQALLHTANRPEDECVGEGLACHQLCARGHCWGPGPTQCVNCSQFLRGQECVEECRVLQGLPREYVNARHCLPCHPECQPQNGSVTCFGPEADQCVACAHYKDPPFCVARCPSGVKPDLSYMPIWKFPDEEGACQPCPINCTHSCVDLDDKGCPAEQRASPLTSIISAVVGILLVVVLGVVFGILIKRRQQKIRKYTMRRLLQETELVEPLTPSGAMPNQAQMRILKETELRKVKVLGSGAFGTVYKGIWIPDGENVKIPVAIKVLRENTSPKANKEILDEAYVMAGVGSPYVSRLLGICLTSTVQLVTQLMPYGCLLDHVRENRGRLGSQDLLNWCMQIAKGMSYLEDVRLVHRDLAARNVLVKSPNHVKITDFGLARLLDIDETEYHADGGKVPIKWMALESILRRRFTHQSDVWSYGVTVWELMTFGAKPYDGIPAREIPDLLEKGERLPQPPICTIDVYMIMVKCWMIDSECRPRFRELVSEFSRMARDPQRFVVIQNEDLGPASPLDSTFYRSLLEDDDMGDLVDAEEYLVPQQGFFCPDPAPGAGGMVHHRHRSSSTRSGGGDLTLGLEPSEEEAPRSPLAPSEGAGSDVFDGDLGMGAAKGLQSLPTHDPSPLQRYSEDPTVPLPSETDGYVAPLTCSPQPEYVNQPDVRPQPPSPREGPLPAARPAGATLERPKTLSPGKNGVVKDVFAFGGAVENPEYLTPQGGAAPQPHPPPAFSPAFDNLYYWDQDPPERGAPPSTFKGTPTAENPEYLGLDVPV',
|
| 146 |
+
'NRP1': 'MERGLPLLCAVLALVLAPAGAFRNDKCGDTIKIESPGYLTSPGYPHSYHPSEKCEWLIQAPDPYQRIMINFNPHFDLEDRDCKYDYVEVFDGENENGHFRGKFCGKIAPPPVVSSGPFLFIKFVSDYETHGAGFSIRYEIFKRGPECSQNYTTPSGVIKSPGFPEKYPNSLECTYIVFVPKMSEIILEFESFDLEPDSNPPGGMFCRYDRLEIWDGFPDVGPHIGRYCGQKTPGRIRSSSGILSMVFYTDSAIAKEGFSANYSVLQSSVSEDFKCMEALGMESGEIHSDQITASSQYSTNWSAERSRLNYPENGWTPGEDSYREWIQVDLGLLRFVTAVGTQGAISKETKKKYYVKTYKIDVSSNGEDWITIKEGNKPVLFQGNTNPTDVVVAVFPKPLITRFVRIKPATWETGISMRFEVYGCKITDYPCSGMLGMVSGLISDSQITSSNQGDRNWMPENIRLVTSRSGWALPPAPHSYINEWLQIDLGEEKIVRGIIIQGGKHRENKVFMRKFKIGYSNNGSDWKMIMDDSKRKAKSFEGNNNYDTPELRTFPALSTRFIRIYPERATHGGLGLRMELLGCEVEAPTAGPTTPNGNLVDECDDDQANCHSGTGDDFQLTGGTTVLATEKPTVIDSTIQSEFPTYGFNCEFGWGSHKTFCHWEHDNHVQLKWSVLTSKTGPIQDHTGDGNFIYSQADENQKGKVARLVSPVVYSQNSAHCMTFWYHMSGSHVGTLRVKLRYQKPEEYDQLVWMAIGHQGDHWKEGRVLLHKSLKLYQVIFEGEIGKGNLGGIAVDDISINNHISQEDCAKPADLDKKNPEIKIDETGSTPGYEGEGEGDKNISRKPGNVLKTLDPILITIIAMSALGVLLGAVCGVVLYCACWHNGMSERNLSALENYNFELVDGVKLKKDKLNTQSTYSEA'
|
| 147 |
+
}
|
| 148 |
+
return target_sequences.get(target, '')
|
| 149 |
+
|
| 150 |
+
def get_metrics_for_peptides(self, generated_path, save_csv=True):
|
| 151 |
+
"""
|
| 152 |
+
Analyze peptide sequences with conjugation, validity, binding affinity, and drug-likeness
|
| 153 |
+
"""
|
| 154 |
+
print(f"Analyzing peptides from: {generated_path}")
|
| 155 |
+
|
| 156 |
+
# Determine target from filename
|
| 157 |
+
target = self._determine_target_from_filename(generated_path)
|
| 158 |
+
print(f"Detected target: {target}")
|
| 159 |
+
|
| 160 |
+
# Load data
|
| 161 |
+
df = pd.read_csv(generated_path)
|
| 162 |
+
if 'sequence' in df.columns:
|
| 163 |
+
peptide_sequences = df['sequence'].astype(str).tolist()
|
| 164 |
+
elif 'SMILES' in df.columns:
|
| 165 |
+
peptide_sequences = df['SMILES'].astype(str).tolist()
|
| 166 |
+
else:
|
| 167 |
+
raise ValueError("No 'sequence' or 'SMILES' column found in the input file")
|
| 168 |
+
|
| 169 |
+
total_sequences = len(peptide_sequences)
|
| 170 |
+
print(f"Total sequences: {total_sequences}")
|
| 171 |
+
|
| 172 |
+
# Step 1: Filter valid peptides
|
| 173 |
+
print("Step 1: Filtering valid peptides...")
|
| 174 |
+
valid_peptides = []
|
| 175 |
+
valid_indices = []
|
| 176 |
+
|
| 177 |
+
for i, seq in enumerate(peptide_sequences):
|
| 178 |
+
if self.peptide_analyzer.is_peptide(seq):
|
| 179 |
+
valid_peptides.append(seq)
|
| 180 |
+
valid_indices.append(i)
|
| 181 |
+
|
| 182 |
+
print(f"Valid peptides: {len(valid_peptides)}/{total_sequences}")
|
| 183 |
+
|
| 184 |
+
if len(valid_peptides) == 0:
|
| 185 |
+
print("No valid peptides found!")
|
| 186 |
+
return {
|
| 187 |
+
'total_sequences': total_sequences,
|
| 188 |
+
'valid_peptides': 0,
|
| 189 |
+
'validity_rate': 0.0,
|
| 190 |
+
'conjugation_success_rate': 0.0,
|
| 191 |
+
'binding_affinity_mean': 0.0,
|
| 192 |
+
'binding_affinity_std': 0.0,
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
# Step 2: Conjugate peptides
|
| 196 |
+
print("Step 2: Conjugating peptides...")
|
| 197 |
+
if target == 'GNRHR':
|
| 198 |
+
print("Using Aoa-Dau conjugation builder for GNRHR")
|
| 199 |
+
builder = self.aoa_dau_builder
|
| 200 |
+
elif target == 'ERBB2':
|
| 201 |
+
builder = self.ss_cpt_builder
|
| 202 |
+
elif target == 'NRP1':
|
| 203 |
+
builder = self.dspa_sn38_builder
|
| 204 |
+
|
| 205 |
+
conjugate_results = []
|
| 206 |
+
|
| 207 |
+
for i, peptide in enumerate(valid_peptides):
|
| 208 |
+
try:
|
| 209 |
+
print(f" Conjugating peptide {i+1}/{len(valid_peptides)}: {peptide[:50]}...")
|
| 210 |
+
# Try different methods to get conjugates
|
| 211 |
+
try:
|
| 212 |
+
conjugates = builder.conjugate_to_peptide(peptide, exclude_n_terminus=True)
|
| 213 |
+
if conjugates and len(conjugates) > 0:
|
| 214 |
+
print(f" Success: Generated {len(conjugates)} conjugate(s)")
|
| 215 |
+
first_conjugate = conjugates[0]
|
| 216 |
+
print(f" DEBUG: First conjugate length: {len(first_conjugate)} chars")
|
| 217 |
+
print(f" DEBUG: First conjugate preview: {first_conjugate[:100]}...")
|
| 218 |
+
print(f" DEBUG: Expected peptide in conjugate: {peptide[:30] in first_conjugate}")
|
| 219 |
+
conjugate_results.append(first_conjugate) # Take first conjugate
|
| 220 |
+
else:
|
| 221 |
+
print(f" No conjugates generated")
|
| 222 |
+
conjugate_results.append(None)
|
| 223 |
+
except AttributeError:
|
| 224 |
+
# Fallback to batch_conjugate without return_all parameter
|
| 225 |
+
result = builder.batch_conjugate([peptide])
|
| 226 |
+
if result and 'conjugates' in result and len(result['conjugates']) > 0:
|
| 227 |
+
conjugates = result['conjugates'][0] # First peptide's conjugates
|
| 228 |
+
if conjugates:
|
| 229 |
+
print(f" Success: Generated {len(conjugates)} conjugate(s)")
|
| 230 |
+
first_conjugate = conjugates[0]
|
| 231 |
+
print(f" DEBUG: Fallback conjugate length: {len(first_conjugate)} chars")
|
| 232 |
+
print(f" DEBUG: Fallback conjugate preview: {first_conjugate[:100]}...")
|
| 233 |
+
conjugate_results.append(first_conjugate) # Take first conjugate
|
| 234 |
+
else:
|
| 235 |
+
print(f" No conjugates generated")
|
| 236 |
+
conjugate_results.append(None)
|
| 237 |
+
else:
|
| 238 |
+
print(f" Batch conjugation failed")
|
| 239 |
+
conjugate_results.append(None)
|
| 240 |
+
|
| 241 |
+
except Exception as e:
|
| 242 |
+
print(f" Conjugation failed for peptide {i+1}: {e}")
|
| 243 |
+
conjugate_results.append(None)
|
| 244 |
+
|
| 245 |
+
print(f"Total conjugation attempts: {len(conjugate_results)}")
|
| 246 |
+
print(f"Successful conjugations: {sum(1 for c in conjugate_results if c is not None)}")
|
| 247 |
+
|
| 248 |
+
# Step 3: Analyze conjugates with PDC analyzer
|
| 249 |
+
print("Step 3: Analyzing conjugates...")
|
| 250 |
+
valid_conjugates = []
|
| 251 |
+
conjugate_properties = []
|
| 252 |
+
|
| 253 |
+
for i, conjugate in enumerate(conjugate_results):
|
| 254 |
+
if conjugate is not None:
|
| 255 |
+
print(f" Analyzing conjugate {i+1}: {conjugate[:60]}...")
|
| 256 |
+
# Check if SMILES is valid
|
| 257 |
+
mol = Chem.MolFromSmiles(conjugate)
|
| 258 |
+
if mol is not None:
|
| 259 |
+
print(f" Valid SMILES structure")
|
| 260 |
+
|
| 261 |
+
# Try PDC analyzer validation
|
| 262 |
+
try:
|
| 263 |
+
linker_feasible = self.pdc_analyzer.check_linker_feasibility(mol)
|
| 264 |
+
repr_valid = self.pdc_analyzer.check_representational_validity(mol)
|
| 265 |
+
print(f" PDC checks - Linker feasible: {linker_feasible}, Repr valid: {repr_valid}")
|
| 266 |
+
|
| 267 |
+
is_valid = linker_feasible and repr_valid
|
| 268 |
+
if not is_valid:
|
| 269 |
+
print(f" PDC validation failed, dropped this molecule")
|
| 270 |
+
is_valid = False
|
| 271 |
+
|
| 272 |
+
except Exception as e:
|
| 273 |
+
print(f" PDC validation error: {e}, dropped this molecule")
|
| 274 |
+
is_valid = False
|
| 275 |
+
|
| 276 |
+
if is_valid:
|
| 277 |
+
valid_conjugates.append(conjugate)
|
| 278 |
+
try:
|
| 279 |
+
# DEBUG: Check the molecule being analyzed
|
| 280 |
+
print(f" DEBUG: SMILES to analyze: {conjugate[:100]}...")
|
| 281 |
+
|
| 282 |
+
# CRITICAL FIX: Create molecule from conjugate SMILES
|
| 283 |
+
conjugate_mol = Chem.MolFromSmiles(conjugate)
|
| 284 |
+
conjugate_formula = Chem.rdMolDescriptors.CalcMolFormula(conjugate_mol)
|
| 285 |
+
conjugate_mw = Chem.Descriptors.MolWt(conjugate_mol)
|
| 286 |
+
print(f" DEBUG: Analyzing molecule - Formula: {conjugate_formula}, MW: {conjugate_mw:.2f}")
|
| 287 |
+
print(f" DEBUG: SMILES length: {len(conjugate)} chars")
|
| 288 |
+
|
| 289 |
+
# DEBUG: Double-check we're analyzing the right molecule
|
| 290 |
+
if conjugate_formula == 'C33H40N3O11':
|
| 291 |
+
print(f" WARNING: This is the linker-drug formula, not full conjugate!")
|
| 292 |
+
else:
|
| 293 |
+
print(f" This appears to be the full conjugate formula")
|
| 294 |
+
|
| 295 |
+
# Use the correctly created molecule for property calculation
|
| 296 |
+
props = self.pdc_analyzer.calculate_molecular_properties(conjugate_mol)
|
| 297 |
+
# Ensure molecular weight is included
|
| 298 |
+
if 'molecular_weight' not in props:
|
| 299 |
+
from rdkit.Chem import Descriptors
|
| 300 |
+
props['molecular_weight'] = Descriptors.MolWt(conjugate_mol)
|
| 301 |
+
print(f" Calculated properties successfully: {props.get('mf', 'No MF')}, MW: {props.get('molecular_weight', 'N/A'):.2f}")
|
| 302 |
+
except Exception as e:
|
| 303 |
+
print(f" Property calculation failed: {e}, using basic properties")
|
| 304 |
+
# Fallback to basic RDKit properties - use conjugate molecule
|
| 305 |
+
conjugate_mol = Chem.MolFromSmiles(conjugate)
|
| 306 |
+
if conjugate_mol:
|
| 307 |
+
from rdkit.Chem import Descriptors
|
| 308 |
+
props = {
|
| 309 |
+
'molecular_weight': Descriptors.MolWt(conjugate_mol),
|
| 310 |
+
'logp': Descriptors.MolLogP(conjugate_mol),
|
| 311 |
+
'num_atoms': conjugate_mol.GetNumHeavyAtoms(),
|
| 312 |
+
'num_bonds': conjugate_mol.GetNumBonds()
|
| 313 |
+
}
|
| 314 |
+
else:
|
| 315 |
+
props = {
|
| 316 |
+
'molecular_weight': 0,
|
| 317 |
+
'logp': 0,
|
| 318 |
+
'num_atoms': 0,
|
| 319 |
+
'num_bonds': 0
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
# Get amino acid sequence from peptide analyzer
|
| 323 |
+
try:
|
| 324 |
+
aa_seq, seq_length = self.peptide_analyzer.analyze_structure(valid_peptides[i])
|
| 325 |
+
except Exception as e:
|
| 326 |
+
print(f" Failed to analyze structure: {e}")
|
| 327 |
+
aa_seq, seq_length = None, None
|
| 328 |
+
|
| 329 |
+
conjugate_properties.append({
|
| 330 |
+
'peptide_index': valid_indices[i],
|
| 331 |
+
'peptide_smiles': valid_peptides[i],
|
| 332 |
+
'aa_seq': aa_seq,
|
| 333 |
+
'seq_length': seq_length,
|
| 334 |
+
'conjugate_smiles': conjugate,
|
| 335 |
+
**props
|
| 336 |
+
})
|
| 337 |
+
print(f" Added to valid conjugates")
|
| 338 |
+
else:
|
| 339 |
+
print(f" Failed validation")
|
| 340 |
+
else:
|
| 341 |
+
print(f" Invalid SMILES structure")
|
| 342 |
+
else:
|
| 343 |
+
print(f" Conjugate {i+1}: None (conjugation failed)")
|
| 344 |
+
|
| 345 |
+
print(f"Valid conjugates: {len(valid_conjugates)}")
|
| 346 |
+
if len(valid_conjugates) > 0:
|
| 347 |
+
print(f"First few valid conjugates:")
|
| 348 |
+
for i, conj in enumerate(valid_conjugates[:3]):
|
| 349 |
+
print(f" {i+1}: {conj[:80]}...")
|
| 350 |
+
else:
|
| 351 |
+
print("No valid conjugates found!")
|
| 352 |
+
|
| 353 |
+
# Step 4: Calculate binding affinity
|
| 354 |
+
print("Step 4: Calculating binding affinity...")
|
| 355 |
+
target_seq = self._get_target_sequence(target)
|
| 356 |
+
if not target_seq:
|
| 357 |
+
print(f"Warning: No target sequence found for {target}")
|
| 358 |
+
affinity_scores_all = []
|
| 359 |
+
affinity_scores_conjugatable = []
|
| 360 |
+
# Add None for binding affinity when no target sequence
|
| 361 |
+
for prop in conjugate_properties:
|
| 362 |
+
prop['binding_affinity'] = None
|
| 363 |
+
else:
|
| 364 |
+
print(f"Using target sequence (length: {len(target_seq)})")
|
| 365 |
+
try:
|
| 366 |
+
# Initialize binding affinity model
|
| 367 |
+
from smiles.smiles_tokenizer.my_tokenizers import SMILES_SPE_Tokenizer
|
| 368 |
+
tokenizer = SMILES_SPE_Tokenizer('./smiles/smiles_tokenizer/new_vocab.txt', './smiles/smiles_tokenizer/new_splits.txt')
|
| 369 |
+
binding_model = BindingAffinity(target_seq, self.device)
|
| 370 |
+
print(f"Binding model initialized successfully")
|
| 371 |
+
|
| 372 |
+
# Tokenize valid peptides
|
| 373 |
+
valid_peptide_tokens = []
|
| 374 |
+
for peptide in valid_peptides:
|
| 375 |
+
tokens = tokenizer(peptide)
|
| 376 |
+
# Handle BatchEncoding object from tokenizer
|
| 377 |
+
if hasattr(tokens, 'input_ids'):
|
| 378 |
+
tokens = tokens.input_ids # Extract token IDs from BatchEncoding
|
| 379 |
+
if isinstance(tokens, list):
|
| 380 |
+
tokens = torch.tensor(tokens)
|
| 381 |
+
valid_peptide_tokens.append(tokens)
|
| 382 |
+
|
| 383 |
+
print(f"Tokenized {len(valid_peptide_tokens)} valid peptides")
|
| 384 |
+
|
| 385 |
+
# Calculate affinity for all valid peptides
|
| 386 |
+
if valid_peptide_tokens:
|
| 387 |
+
# Pad sequences
|
| 388 |
+
max_len = max(len(tokens) for tokens in valid_peptide_tokens)
|
| 389 |
+
padded_tokens = torch.zeros((len(valid_peptide_tokens), max_len), dtype=torch.long)
|
| 390 |
+
for i, tokens in enumerate(valid_peptide_tokens):
|
| 391 |
+
padded_tokens[i, :len(tokens)] = tokens
|
| 392 |
+
|
| 393 |
+
print(f"Calculating affinity for {len(valid_peptide_tokens)} peptides...")
|
| 394 |
+
affinity_scores_all = binding_model(padded_tokens.to(self.device)).cpu().numpy()
|
| 395 |
+
print(f"Calculated {len(affinity_scores_all)} affinity scores")
|
| 396 |
+
else:
|
| 397 |
+
affinity_scores_all = []
|
| 398 |
+
|
| 399 |
+
# ===== NEW CODE STARTS HERE =====
|
| 400 |
+
# Create mapping from valid_indices to affinity scores
|
| 401 |
+
affinity_map = {}
|
| 402 |
+
for i, valid_idx in enumerate(valid_indices):
|
| 403 |
+
if i < len(affinity_scores_all):
|
| 404 |
+
affinity_map[valid_idx] = float(affinity_scores_all[i])
|
| 405 |
+
|
| 406 |
+
# Add binding affinity to conjugate_properties
|
| 407 |
+
print(f"Adding binding affinity to conjugate properties...")
|
| 408 |
+
for prop in conjugate_properties:
|
| 409 |
+
peptide_idx = prop['peptide_index']
|
| 410 |
+
if peptide_idx in affinity_map:
|
| 411 |
+
prop['binding_affinity'] = affinity_map[peptide_idx]
|
| 412 |
+
print(f" Added affinity {affinity_map[peptide_idx]:.4f} for peptide index {peptide_idx}")
|
| 413 |
+
else:
|
| 414 |
+
prop['binding_affinity'] = None
|
| 415 |
+
print(f" No affinity score for peptide index {peptide_idx}")
|
| 416 |
+
|
| 417 |
+
# Calculate affinity for peptides with valid conjugates
|
| 418 |
+
# NOTE: pre-initialize so the variable always exists even when
|
| 419 |
+
# conjugate_properties is empty (zero valid conjugates).
|
| 420 |
+
# Also: this block runs ONCE here, not once per prop above.
|
| 421 |
+
affinity_scores_conjugatable = []
|
| 422 |
+
conjugatable_indices = [prop['peptide_index'] for prop in conjugate_properties]
|
| 423 |
+
valid_conjugatable_indices = [
|
| 424 |
+
valid_indices.index(idx)
|
| 425 |
+
for idx in conjugatable_indices
|
| 426 |
+
if idx in valid_indices
|
| 427 |
+
]
|
| 428 |
+
|
| 429 |
+
print(f"Found {len(valid_conjugatable_indices)} conjugatable peptides")
|
| 430 |
+
|
| 431 |
+
if valid_conjugatable_indices:
|
| 432 |
+
conjugatable_tokens = [valid_peptide_tokens[i] for i in valid_conjugatable_indices]
|
| 433 |
+
max_len = max(len(tokens) for tokens in conjugatable_tokens)
|
| 434 |
+
padded_tokens = torch.zeros((len(conjugatable_tokens), max_len), dtype=torch.long)
|
| 435 |
+
for i, tokens in enumerate(conjugatable_tokens):
|
| 436 |
+
padded_tokens[i, :len(tokens)] = tokens
|
| 437 |
+
|
| 438 |
+
affinity_scores_conjugatable = binding_model(padded_tokens.to(self.device)).cpu().numpy()
|
| 439 |
+
print(f"Calculated {len(affinity_scores_conjugatable)} conjugatable affinity scores")
|
| 440 |
+
|
| 441 |
+
except Exception as e:
|
| 442 |
+
print(f"Binding affinity calculation failed: {e}")
|
| 443 |
+
import traceback
|
| 444 |
+
traceback.print_exc()
|
| 445 |
+
affinity_scores_all = []
|
| 446 |
+
affinity_scores_conjugatable = []
|
| 447 |
+
# ===== NEW CODE: Handle errors =====
|
| 448 |
+
# Still add None values for binding affinity
|
| 449 |
+
for prop in conjugate_properties:
|
| 450 |
+
prop['binding_affinity'] = None
|
| 451 |
+
# ===== NEW CODE ENDS =====
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
# Save detailed results to CSV
|
| 455 |
+
csv_path = None
|
| 456 |
+
if save_csv and conjugate_properties:
|
| 457 |
+
output_dir = os.path.dirname(generated_path)
|
| 458 |
+
base_name = os.path.splitext(os.path.basename(generated_path))[0]
|
| 459 |
+
csv_path = os.path.join(output_dir, f"{base_name}_conjugate_analysis.csv")
|
| 460 |
+
|
| 461 |
+
df_results = pd.DataFrame(conjugate_properties)
|
| 462 |
+
df_results.to_csv(csv_path, index=False)
|
| 463 |
+
print(f"Detailed results saved to: {csv_path}")
|
| 464 |
+
|
| 465 |
+
# Calculate summary statistics
|
| 466 |
+
validity_rate = len(valid_peptides) / total_sequences
|
| 467 |
+
conjugation_success_rate = len(valid_conjugates) / total_sequences
|
| 468 |
+
|
| 469 |
+
results = {
|
| 470 |
+
'total_sequences': total_sequences,
|
| 471 |
+
'valid_peptides': len(valid_peptides),
|
| 472 |
+
'validity_rate': validity_rate,
|
| 473 |
+
'valid_conjugates': len(valid_conjugates),
|
| 474 |
+
'conjugation_success_rate': conjugation_success_rate,
|
| 475 |
+
'target': target,
|
| 476 |
+
'csv_path': csv_path, # Add CSV path to results
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
# Binding affinity statistics
|
| 480 |
+
if len(affinity_scores_all) > 0:
|
| 481 |
+
results.update({
|
| 482 |
+
'binding_affinity_all_mean': float(np.mean(affinity_scores_all)),
|
| 483 |
+
'binding_affinity_all_std': float(np.std(affinity_scores_all)),
|
| 484 |
+
'binding_affinity_all_min': float(np.min(affinity_scores_all)),
|
| 485 |
+
'binding_affinity_all_max': float(np.max(affinity_scores_all)),
|
| 486 |
+
})
|
| 487 |
+
|
| 488 |
+
if len(affinity_scores_conjugatable) > 0:
|
| 489 |
+
results.update({
|
| 490 |
+
'binding_affinity_conjugatable_mean': float(np.mean(affinity_scores_conjugatable)),
|
| 491 |
+
'binding_affinity_conjugatable_std': float(np.std(affinity_scores_conjugatable)),
|
| 492 |
+
'binding_affinity_conjugatable_min': float(np.min(affinity_scores_conjugatable)),
|
| 493 |
+
'binding_affinity_conjugatable_max': float(np.max(affinity_scores_conjugatable)),
|
| 494 |
+
})
|
| 495 |
+
|
| 496 |
+
|
| 497 |
+
# Print summary
|
| 498 |
+
print("\n" + "="*60)
|
| 499 |
+
print("PEPTIDE ANALYSIS SUMMARY")
|
| 500 |
+
print("="*60)
|
| 501 |
+
print(f"Target: {target}")
|
| 502 |
+
print(f"Total sequences: {total_sequences}")
|
| 503 |
+
print(f"Valid peptides: {len(valid_peptides)} ({validity_rate:.1%})")
|
| 504 |
+
print(f"Valid conjugates: {len(valid_conjugates)} ({conjugation_success_rate:.1%})")
|
| 505 |
+
|
| 506 |
+
print(f"\nBINDING AFFINITY ANALYSIS:")
|
| 507 |
+
if len(affinity_scores_all) > 0:
|
| 508 |
+
print(f" All valid peptides ({len(affinity_scores_all)} peptides):")
|
| 509 |
+
print(f" Mean: {np.mean(affinity_scores_all):.4f} ± {np.std(affinity_scores_all):.4f}")
|
| 510 |
+
print(f" Range: {np.min(affinity_scores_all):.4f} to {np.max(affinity_scores_all):.4f}")
|
| 511 |
+
else:
|
| 512 |
+
print(f" All valid peptides: No scores calculated (binding affinity model failed)")
|
| 513 |
+
|
| 514 |
+
if len(affinity_scores_conjugatable) > 0:
|
| 515 |
+
print(f" Conjugatable peptides ({len(affinity_scores_conjugatable)} peptides):")
|
| 516 |
+
print(f" Mean: {np.mean(affinity_scores_conjugatable):.4f} ± {np.std(affinity_scores_conjugatable):.4f}")
|
| 517 |
+
print(f" Range: {np.min(affinity_scores_conjugatable):.4f} to {np.max(affinity_scores_conjugatable):.4f}")
|
| 518 |
+
else:
|
| 519 |
+
print(f" Conjugatable peptides: No scores calculated")
|
| 520 |
+
|
| 521 |
+
return results
|
| 522 |
+
|
| 523 |
+
def get_metrics(self, generated_path):
|
| 524 |
+
"""Main method to get metrics based on input type"""
|
| 525 |
+
if self.input_type == 'peptide':
|
| 526 |
+
return self.get_metrics_for_peptides(generated_path)
|
| 527 |
+
elif self.input_type == 'conjugate':
|
| 528 |
+
# Placeholder for future conjugate analysis
|
| 529 |
+
print("Conjugate analysis not yet implemented")
|
| 530 |
+
return self._get_basic_metrics(generated_path)
|
| 531 |
+
else:
|
| 532 |
+
# Default SMILES analysis
|
| 533 |
+
return self._get_basic_metrics(generated_path)
|
| 534 |
+
|
| 535 |
+
def _get_basic_metrics(self, generated_path):
|
| 536 |
+
"""Original metrics calculation for general SMILES"""
|
| 537 |
+
generated_smi = pd.read_csv(generated_path)['SMILES'].astype(str).tolist()
|
| 538 |
+
#generated_smi = pd.read_csv(generated_path, usecols=['Generated SMILES'])['Generated SMILES'].tolist()
|
| 539 |
+
#mols = [Chem.MolFromSmiles(smi) if smi else None for smi in generated_smi]
|
| 540 |
+
mols = [Chem.MolFromSmiles(smi) if smi else None for smi in generated_smi]
|
| 541 |
+
is_valid = [1 if mol else 0 for mol in mols]
|
| 542 |
+
validity = sum(is_valid) / len(is_valid)
|
| 543 |
+
|
| 544 |
+
valid_canon_smiles = [Chem.MolToSmiles(mol) for mol in mols if mol]
|
| 545 |
+
uniqueness = len(set(valid_canon_smiles)) / len(valid_canon_smiles)
|
| 546 |
+
|
| 547 |
+
uniq_smis = list(set(valid_canon_smiles))
|
| 548 |
+
uniq_mols = [Chem.MolFromSmiles(smi) for smi in uniq_smis]
|
| 549 |
+
|
| 550 |
+
fps = np.vstack([fingerprints_from_mol(mol) for mol in uniq_mols])
|
| 551 |
+
diversity = 1 - (average_agg_tanimoto(fps, fps, agg='mean', p=1)).mean()
|
| 552 |
+
|
| 553 |
+
snn = average_agg_tanimoto(self.ref_fps, fps, agg='max', p=1)
|
| 554 |
+
|
| 555 |
+
# gen_smiles = mapper(self.n_jobs)(canonic_smiles, valid_canon_smiles)
|
| 556 |
+
# gen_smiles_set = set(gen_smiles) - {None}
|
| 557 |
+
# train_set = set(self.train_set_cano_smi)
|
| 558 |
+
# novelty = len(gen_smiles_set - train_set) / len(gen_smiles_set)
|
| 559 |
+
|
| 560 |
+
# print(f"validity\tuniqueness\tdiversity\tsnn\tnovelty")
|
| 561 |
+
# print(f"{validity:.3f}\t{uniqueness:.3f}\t{diversity:.3f}\t{snn:.3f}\t{novelty:.3f}")
|
| 562 |
+
print(f"validity\tuniqueness\tdiversity\tsnn")
|
| 563 |
+
print(f"{validity:.3f}\t{uniqueness:.3f}\t{diversity:.3f}\t{snn:.3f}")
|
| 564 |
+
return {
|
| 565 |
+
"validity": validity,
|
| 566 |
+
"uniqueness": uniqueness,
|
| 567 |
+
"diversity": diversity,
|
| 568 |
+
"snn": snn, # "structural novelty"
|
| 569 |
+
# "novelty": novelty,
|
| 570 |
+
}
|
smiles/evaluation/pdc_analyzer.py
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PDC Analyzer
|
| 3 |
+
Analyzes Peptide-Drug Conjugates (PDCs) for structural validity
|
| 4 |
+
|
| 5 |
+
Hard constraints (validity = 1 if all pass, 0 if any fails):
|
| 6 |
+
1. Linker feasibility: ring-closure constraints, long carbon chains
|
| 7 |
+
2. Representational validity: SMILES parsing, sanitization, chemistry problems
|
| 8 |
+
|
| 9 |
+
Note: Attachment/conjugation site checks are conjugation-method specific
|
| 10 |
+
and should be handled in the conjugation PDC builder (e.g., pdc_builder.py)
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import torch
|
| 14 |
+
from rdkit import Chem
|
| 15 |
+
from rdkit.Chem import Descriptors, AllChem, rdMolDescriptors
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class PDCAnalyzer:
|
| 19 |
+
"""
|
| 20 |
+
Analyzes Peptide-Drug Conjugates (PDCs) for structural validity
|
| 21 |
+
|
| 22 |
+
Hard constraint checks (validity = 1 if all pass, 0 if any fails):
|
| 23 |
+
1. Linker feasibility: ring-closure constraints, long carbon chains
|
| 24 |
+
2. Representational validity: SMILES parsing, sanitization, chemistry problems
|
| 25 |
+
|
| 26 |
+
Optional molecular properties (toggle on/off):
|
| 27 |
+
- mf: molecular formula
|
| 28 |
+
- polararea: topological polar surface area
|
| 29 |
+
- complexity: molecular complexity (BertzCT)
|
| 30 |
+
- xlogp: calculated LogP (Wildman-Crippen)
|
| 31 |
+
- heavycnt: heavy atom count
|
| 32 |
+
- hbonddonor: hydrogen bond donors
|
| 33 |
+
- hbondacc: hydrogen bond acceptors
|
| 34 |
+
- rotbonds: rotatable bonds
|
| 35 |
+
|
| 36 |
+
Note: Attachment/conjugation site checks are conjugation-method specific
|
| 37 |
+
and should be handled in the conjugation PDC builder.
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
def __init__(self, device,
|
| 41 |
+
max_linker_chain_length=16,
|
| 42 |
+
calculate_properties=True):
|
| 43 |
+
self.device = device
|
| 44 |
+
self.max_linker_chain_length = max_linker_chain_length
|
| 45 |
+
self.calculate_properties = calculate_properties
|
| 46 |
+
|
| 47 |
+
# Property calculation flags (all on by default)
|
| 48 |
+
self.property_flags = {
|
| 49 |
+
'mf': True, # molecular formula
|
| 50 |
+
'polararea': True, # topological polar surface area
|
| 51 |
+
'complexity': True, # BertzCT complexity
|
| 52 |
+
'xlogp': True, # Wildman-Crippen LogP
|
| 53 |
+
'heavycnt': True, # heavy atom count
|
| 54 |
+
'hbonddonor': True, # H-bond donors
|
| 55 |
+
'hbondacc': True, # H-bond acceptors
|
| 56 |
+
'rotbonds': True, # rotatable bonds
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
# Pre-compile SMARTS patterns for efficiency
|
| 60 |
+
self._compile_patterns()
|
| 61 |
+
|
| 62 |
+
def set_property_flags(self, **kwargs):
|
| 63 |
+
"""
|
| 64 |
+
Enable/disable specific property calculations
|
| 65 |
+
|
| 66 |
+
Args:
|
| 67 |
+
**kwargs: property_name=True/False pairs
|
| 68 |
+
|
| 69 |
+
Example:
|
| 70 |
+
analyzer.set_property_flags(mf=False, xlogp=True, rotbonds=False)
|
| 71 |
+
"""
|
| 72 |
+
for key, value in kwargs.items():
|
| 73 |
+
if key in self.property_flags:
|
| 74 |
+
self.property_flags[key] = bool(value)
|
| 75 |
+
else:
|
| 76 |
+
raise ValueError(f"Unknown property: {key}. Valid properties: {list(self.property_flags.keys())}")
|
| 77 |
+
|
| 78 |
+
def enable_all_properties(self):
|
| 79 |
+
"""Enable calculation of all optional properties"""
|
| 80 |
+
for key in self.property_flags:
|
| 81 |
+
self.property_flags[key] = True
|
| 82 |
+
|
| 83 |
+
def disable_all_properties(self):
|
| 84 |
+
"""Disable calculation of all optional properties"""
|
| 85 |
+
for key in self.property_flags:
|
| 86 |
+
self.property_flags[key] = False
|
| 87 |
+
|
| 88 |
+
def _compile_patterns(self):
|
| 89 |
+
"""Pre-compile SMARTS patterns used frequently"""
|
| 90 |
+
self.patterns = {
|
| 91 |
+
# Long carbon chain (indicates potentially problematic linker)
|
| 92 |
+
# 16 consecutive CH2 groups
|
| 93 |
+
'long_chain': Chem.MolFromSmarts('[CH2][CH2][CH2][CH2][CH2][CH2][CH2][CH2][CH2][CH2][CH2][CH2][CH2][CH2][CH2][CH2]'),
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
def find_linker_rings(self, mol):
|
| 97 |
+
"""
|
| 98 |
+
Identify rings that are part of the linker (not peptide backbone or drug core)
|
| 99 |
+
|
| 100 |
+
Linker rings typically include:
|
| 101 |
+
- Succinimide (from maleimide after thiol addition)
|
| 102 |
+
- Small saturated rings used as spacers
|
| 103 |
+
- NOT aromatic rings (usually part of drug)
|
| 104 |
+
"""
|
| 105 |
+
ring_info = mol.GetRingInfo()
|
| 106 |
+
atom_rings = ring_info.AtomRings()
|
| 107 |
+
|
| 108 |
+
linker_rings = []
|
| 109 |
+
|
| 110 |
+
for ring in atom_rings:
|
| 111 |
+
ring_size = len(ring)
|
| 112 |
+
ring_atoms = [mol.GetAtomWithIdx(idx) for idx in ring]
|
| 113 |
+
ring_symbols = [atom.GetSymbol() for atom in ring_atoms]
|
| 114 |
+
|
| 115 |
+
# Skip aromatic rings (likely drug component)
|
| 116 |
+
if all(atom.GetIsAromatic() for atom in ring_atoms):
|
| 117 |
+
continue
|
| 118 |
+
|
| 119 |
+
# Check for succinimide (5-membered, N + 2 C=O)
|
| 120 |
+
if ring_size == 5 and 'N' in ring_symbols:
|
| 121 |
+
carbonyl_count = 0
|
| 122 |
+
for idx in ring:
|
| 123 |
+
atom = mol.GetAtomWithIdx(idx)
|
| 124 |
+
if atom.GetSymbol() == 'C':
|
| 125 |
+
for neighbor in atom.GetNeighbors():
|
| 126 |
+
if neighbor.GetSymbol() == 'O' and neighbor.GetIdx() not in ring:
|
| 127 |
+
bond = mol.GetBondBetweenAtoms(idx, neighbor.GetIdx())
|
| 128 |
+
if bond and bond.GetBondType() == Chem.BondType.DOUBLE:
|
| 129 |
+
carbonyl_count += 1
|
| 130 |
+
|
| 131 |
+
if carbonyl_count >= 2:
|
| 132 |
+
# This is a succinimide - valid linker ring
|
| 133 |
+
linker_rings.append(ring)
|
| 134 |
+
continue
|
| 135 |
+
|
| 136 |
+
# Check for small saturated rings (potential linker spacers)
|
| 137 |
+
if ring_size <= 6:
|
| 138 |
+
is_saturated = not any(atom.GetIsAromatic() for atom in ring_atoms)
|
| 139 |
+
if is_saturated:
|
| 140 |
+
linker_rings.append(ring)
|
| 141 |
+
|
| 142 |
+
# Flag very large non-aromatic rings (unusual in linkers)
|
| 143 |
+
if ring_size > 12:
|
| 144 |
+
linker_rings.append(ring) # Will be checked for validity
|
| 145 |
+
|
| 146 |
+
return linker_rings
|
| 147 |
+
|
| 148 |
+
def check_ring_closure_at_linker(self, mol):
|
| 149 |
+
"""
|
| 150 |
+
Check ring-closure constraints in the linker region
|
| 151 |
+
"""
|
| 152 |
+
linker_rings = self.find_linker_rings(mol)
|
| 153 |
+
|
| 154 |
+
if len(linker_rings) == 0:
|
| 155 |
+
return True # No linker rings is fine
|
| 156 |
+
|
| 157 |
+
for ring in linker_rings:
|
| 158 |
+
ring_size = len(ring)
|
| 159 |
+
|
| 160 |
+
# Impossible rings
|
| 161 |
+
if ring_size < 3:
|
| 162 |
+
return False
|
| 163 |
+
|
| 164 |
+
# 3-membered rings: check for severe strain
|
| 165 |
+
if ring_size == 3:
|
| 166 |
+
for atom_idx in ring:
|
| 167 |
+
atom = mol.GetAtomWithIdx(atom_idx)
|
| 168 |
+
# sp2 carbon in 3-ring is very strained (except epoxides/aziridines)
|
| 169 |
+
if atom.GetSymbol() == 'C':
|
| 170 |
+
if atom.GetHybridization() == Chem.HybridizationType.SP2:
|
| 171 |
+
# Check if it's part of a C=C (bad) vs just geometry
|
| 172 |
+
for neighbor in atom.GetNeighbors():
|
| 173 |
+
bond = mol.GetBondBetweenAtoms(atom_idx, neighbor.GetIdx())
|
| 174 |
+
if bond and bond.GetBondType() == Chem.BondType.DOUBLE:
|
| 175 |
+
if neighbor.GetSymbol() == 'C':
|
| 176 |
+
return False # C=C in 3-ring is invalid
|
| 177 |
+
|
| 178 |
+
# 4-membered rings: check for excessive sp2 character
|
| 179 |
+
if ring_size == 4:
|
| 180 |
+
sp2_count = sum(1 for idx in ring
|
| 181 |
+
if mol.GetAtomWithIdx(idx).GetHybridization() == Chem.HybridizationType.SP2)
|
| 182 |
+
if sp2_count > 2:
|
| 183 |
+
return False
|
| 184 |
+
|
| 185 |
+
# Very large rings in linker are suspicious
|
| 186 |
+
if ring_size > 12:
|
| 187 |
+
return False
|
| 188 |
+
|
| 189 |
+
return True
|
| 190 |
+
|
| 191 |
+
def check_linker_feasibility(self, mol):
|
| 192 |
+
"""
|
| 193 |
+
Check if linker properties are reasonable
|
| 194 |
+
|
| 195 |
+
Hard constraints only:
|
| 196 |
+
- Ring-closure constraints
|
| 197 |
+
- Long carbon chain check
|
| 198 |
+
"""
|
| 199 |
+
# Check ring-closure constraints
|
| 200 |
+
if not self.check_ring_closure_at_linker(mol):
|
| 201 |
+
return False
|
| 202 |
+
|
| 203 |
+
# Check for excessively long carbon chains
|
| 204 |
+
if self.patterns['long_chain'] and mol.HasSubstructMatch(self.patterns['long_chain']):
|
| 205 |
+
return False
|
| 206 |
+
|
| 207 |
+
return True
|
| 208 |
+
|
| 209 |
+
def check_representational_validity(self, mol):
|
| 210 |
+
"""
|
| 211 |
+
Check if molecule can be properly represented
|
| 212 |
+
"""
|
| 213 |
+
# Sanitization
|
| 214 |
+
try:
|
| 215 |
+
Chem.SanitizeMol(mol)
|
| 216 |
+
except:
|
| 217 |
+
return False
|
| 218 |
+
|
| 219 |
+
# Round-trip SMILES
|
| 220 |
+
try:
|
| 221 |
+
smiles = Chem.MolToSmiles(mol)
|
| 222 |
+
mol_reconstructed = Chem.MolFromSmiles(smiles)
|
| 223 |
+
if mol_reconstructed is None:
|
| 224 |
+
return False
|
| 225 |
+
except:
|
| 226 |
+
return False
|
| 227 |
+
|
| 228 |
+
# Check for chemistry problems
|
| 229 |
+
try:
|
| 230 |
+
problems = Chem.DetectChemistryProblems(mol)
|
| 231 |
+
for problem in problems:
|
| 232 |
+
problem_type = problem.GetType()
|
| 233 |
+
if problem_type in ['AtomValenceException', 'KekulizeException']:
|
| 234 |
+
return False
|
| 235 |
+
except:
|
| 236 |
+
return False
|
| 237 |
+
|
| 238 |
+
# 2D coordinates (basic structural validity)
|
| 239 |
+
try:
|
| 240 |
+
AllChem.Compute2DCoords(mol)
|
| 241 |
+
except:
|
| 242 |
+
return False
|
| 243 |
+
|
| 244 |
+
return True
|
| 245 |
+
|
| 246 |
+
def calculate_molecular_properties(self, mol):
|
| 247 |
+
"""
|
| 248 |
+
Calculate optional molecular properties based on property_flags
|
| 249 |
+
|
| 250 |
+
Returns dict of property_name: value for enabled properties
|
| 251 |
+
"""
|
| 252 |
+
properties = {}
|
| 253 |
+
|
| 254 |
+
if not self.calculate_properties:
|
| 255 |
+
return properties
|
| 256 |
+
|
| 257 |
+
if self.property_flags.get('mf'):
|
| 258 |
+
properties['mf'] = rdMolDescriptors.CalcMolFormula(mol)
|
| 259 |
+
|
| 260 |
+
if self.property_flags.get('polararea'):
|
| 261 |
+
properties['polararea'] = round(Descriptors.TPSA(mol), 2)
|
| 262 |
+
|
| 263 |
+
if self.property_flags.get('complexity'):
|
| 264 |
+
properties['complexity'] = round(Descriptors.BertzCT(mol), 2)
|
| 265 |
+
|
| 266 |
+
if self.property_flags.get('xlogp'):
|
| 267 |
+
properties['xlogp'] = round(Descriptors.MolLogP(mol), 2)
|
| 268 |
+
|
| 269 |
+
if self.property_flags.get('heavycnt'):
|
| 270 |
+
properties['heavycnt'] = mol.GetNumHeavyAtoms()
|
| 271 |
+
|
| 272 |
+
if self.property_flags.get('hbonddonor'):
|
| 273 |
+
properties['hbonddonor'] = Descriptors.NumHDonors(mol)
|
| 274 |
+
|
| 275 |
+
if self.property_flags.get('hbondacc'):
|
| 276 |
+
properties['hbondacc'] = Descriptors.NumHAcceptors(mol)
|
| 277 |
+
|
| 278 |
+
if self.property_flags.get('rotbonds'):
|
| 279 |
+
properties['rotbonds'] = Descriptors.NumRotatableBonds(mol)
|
| 280 |
+
|
| 281 |
+
return properties
|
| 282 |
+
|
| 283 |
+
def get_scores(self, input_seqs_list):
|
| 284 |
+
"""
|
| 285 |
+
Validate PDC structures based on hard constraints
|
| 286 |
+
|
| 287 |
+
Hard constraints:
|
| 288 |
+
1. Linker feasibility (ring-closure, long chains)
|
| 289 |
+
2. Representational validity (SMILES, sanitization, chemistry)
|
| 290 |
+
|
| 291 |
+
Returns list of 0/1 scores
|
| 292 |
+
"""
|
| 293 |
+
results = []
|
| 294 |
+
|
| 295 |
+
for candidates in input_seqs_list:
|
| 296 |
+
for smiles in candidates:
|
| 297 |
+
mol = Chem.MolFromSmiles(smiles)
|
| 298 |
+
if mol is None:
|
| 299 |
+
continue
|
| 300 |
+
|
| 301 |
+
# Run hard constraint checks only
|
| 302 |
+
linker_ok = self.check_linker_feasibility(mol)
|
| 303 |
+
representational_ok = self.check_representational_validity(mol)
|
| 304 |
+
|
| 305 |
+
is_valid = linker_ok and representational_ok
|
| 306 |
+
if is_valid:
|
| 307 |
+
results.append(1)
|
| 308 |
+
break
|
| 309 |
+
else:
|
| 310 |
+
results.append(0)
|
| 311 |
+
|
| 312 |
+
return results
|
| 313 |
+
|
| 314 |
+
def get_detailed_scores(self, input_seqs_list):
|
| 315 |
+
"""
|
| 316 |
+
Return detailed validation results with optional properties
|
| 317 |
+
"""
|
| 318 |
+
detailed_results = []
|
| 319 |
+
|
| 320 |
+
for candidates in input_seqs_list:
|
| 321 |
+
candidate_results = []
|
| 322 |
+
|
| 323 |
+
for smiles in candidates:
|
| 324 |
+
mol = Chem.MolFromSmiles(smiles)
|
| 325 |
+
if mol is None:
|
| 326 |
+
candidate_results.append({
|
| 327 |
+
'valid': 0,
|
| 328 |
+
'smiles': smiles,
|
| 329 |
+
'error': 'Cannot parse SMILES'
|
| 330 |
+
})
|
| 331 |
+
continue
|
| 332 |
+
|
| 333 |
+
# Run hard constraint checks
|
| 334 |
+
checks = {
|
| 335 |
+
'linker_feasible': self.check_linker_feasibility(mol),
|
| 336 |
+
'representational': self.check_representational_validity(mol)
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
is_valid = all(checks.values())
|
| 340 |
+
|
| 341 |
+
# Calculate optional properties if enabled
|
| 342 |
+
properties = self.calculate_molecular_properties(mol)
|
| 343 |
+
|
| 344 |
+
# Add linker ring info for diagnostics
|
| 345 |
+
linker_rings = self.find_linker_rings(mol)
|
| 346 |
+
checks['linker_ring_sizes'] = [len(ring) for ring in linker_rings]
|
| 347 |
+
|
| 348 |
+
candidate_results.append({
|
| 349 |
+
'valid': 1 if is_valid else 0,
|
| 350 |
+
'smiles': smiles,
|
| 351 |
+
'checks': checks,
|
| 352 |
+
'properties': properties
|
| 353 |
+
})
|
| 354 |
+
|
| 355 |
+
detailed_results.append(candidate_results)
|
| 356 |
+
|
| 357 |
+
return detailed_results
|
| 358 |
+
|
| 359 |
+
def __call__(self, input_seqs):
|
| 360 |
+
scores = self.get_scores(input_seqs)
|
| 361 |
+
return torch.tensor(scores)
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
# ============================================================================
|
| 365 |
+
# Testing
|
| 366 |
+
# ============================================================================
|
| 367 |
+
|
| 368 |
+
if __name__ == "__main__":
|
| 369 |
+
device = 'cpu'
|
| 370 |
+
pdc_analyzer = PDCAnalyzer(device)
|
| 371 |
+
|
| 372 |
+
print("=" * 70)
|
| 373 |
+
print("PDC Analyzer - Structural Validity Checker")
|
| 374 |
+
print("=" * 70)
|
| 375 |
+
print("\nHard constraints checked:")
|
| 376 |
+
print(" 1. Linker feasibility (ring-closure, long carbon chains)")
|
| 377 |
+
print(" 2. Representational validity (SMILES, sanitization)")
|
| 378 |
+
print("\nNote: Conjugation site checks are method-specific")
|
| 379 |
+
print(" and should be in the conjugation PDC builder.\n")
|
| 380 |
+
|
| 381 |
+
# Test property flag toggling
|
| 382 |
+
print("Testing property flags:")
|
| 383 |
+
print(f" Default flags: {pdc_analyzer.property_flags}")
|
| 384 |
+
|
| 385 |
+
pdc_analyzer.set_property_flags(mf=False, complexity=False)
|
| 386 |
+
print(f" After disabling mf, complexity: {pdc_analyzer.property_flags}")
|
| 387 |
+
|
| 388 |
+
pdc_analyzer.enable_all_properties()
|
| 389 |
+
print(f" After enable_all: {pdc_analyzer.property_flags}")
|
| 390 |
+
|
| 391 |
+
print("\n" + "=" * 70)
|
| 392 |
+
|
| 393 |
+
test_smiles = [
|
| 394 |
+
# Test 1: Valid structure
|
| 395 |
+
("NCC(=O)N[C@@H](CSC1CC(=O)N(CCOCCOCCOCCOCCN)C1=O)C(=O)NCC(=O)O",
|
| 396 |
+
"Valid structure with succinimide ring"),
|
| 397 |
+
|
| 398 |
+
# Test 2: Invalid - strained 3-membered ring with C=C
|
| 399 |
+
("NCC(=O)N[C@@H](CSC1=CC1CCCCN)C(=O)NCC(=O)O",
|
| 400 |
+
"Invalid: Strained 3-membered ring with sp2 carbon"),
|
| 401 |
+
|
| 402 |
+
# Test 3: Simple valid peptide
|
| 403 |
+
("NCC(=O)N[C@@H](C)C(=O)NCC(=O)O",
|
| 404 |
+
"Simple valid peptide structure"),
|
| 405 |
+
]
|
| 406 |
+
|
| 407 |
+
print("\nTest Results")
|
| 408 |
+
print("=" * 70 + "\n")
|
| 409 |
+
|
| 410 |
+
for i, (smiles, description) in enumerate(test_smiles):
|
| 411 |
+
print(f"Test {i+1}: {description}")
|
| 412 |
+
print(f"SMILES: {smiles[:60]}{'...' if len(smiles) > 60 else ''}")
|
| 413 |
+
|
| 414 |
+
detailed = pdc_analyzer.get_detailed_scores([smiles])[0]
|
| 415 |
+
|
| 416 |
+
valid_str = " VALID" if detailed['valid'] == 1 else " INVALID"
|
| 417 |
+
print(f"Result: {valid_str}")
|
| 418 |
+
|
| 419 |
+
if 'checks' in detailed:
|
| 420 |
+
print("\nHard constraint checks:")
|
| 421 |
+
for check_name in ['linker_feasible', 'representational']:
|
| 422 |
+
if check_name in detailed['checks']:
|
| 423 |
+
status = "" if detailed['checks'][check_name] else ""
|
| 424 |
+
print(f" {status} {check_name}")
|
| 425 |
+
|
| 426 |
+
if 'properties' in detailed and detailed['properties']:
|
| 427 |
+
print("\nMolecular properties:")
|
| 428 |
+
for prop, value in detailed['properties'].items():
|
| 429 |
+
print(f" {prop}: {value}")
|
| 430 |
+
|
| 431 |
+
print("\n" + "-" * 70 + "\n")
|
smiles/evaluation/peptide_analyzer.py
ADDED
|
@@ -0,0 +1,1259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import re
|
| 3 |
+
import pandas as pd
|
| 4 |
+
from io import StringIO
|
| 5 |
+
import rdkit
|
| 6 |
+
from rdkit import Chem
|
| 7 |
+
from rdkit.Chem import AllChem, Draw
|
| 8 |
+
import numpy as np
|
| 9 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 10 |
+
import matplotlib.pyplot as plt
|
| 11 |
+
import matplotlib.patches as patches
|
| 12 |
+
from io import BytesIO
|
| 13 |
+
import tempfile
|
| 14 |
+
from rdkit import Chem, RDLogger
|
| 15 |
+
RDLogger.DisableLog('rdApp.*')
|
| 16 |
+
|
| 17 |
+
import pdb
|
| 18 |
+
|
| 19 |
+
class PeptideAnalyzer:
|
| 20 |
+
def __init__(self):
|
| 21 |
+
self.bond_patterns = [
|
| 22 |
+
(r'OC\(=O\)', 'ester'), # Ester bond
|
| 23 |
+
(r'N\(C\)C\(=O\)', 'n_methyl'), # N-methylated peptide bond
|
| 24 |
+
(r'N[0-9]C\(=O\)', 'proline'), # Proline peptide bond
|
| 25 |
+
(r'NC\(=O\)', 'peptide'), # Standard peptide bond
|
| 26 |
+
(r'C\(=O\)N\(C\)', 'n_methyl_reverse'), # Reverse N-methylated
|
| 27 |
+
(r'C\(=O\)N[12]?', 'peptide_reverse') # Reverse peptide bond
|
| 28 |
+
]
|
| 29 |
+
# Three to one letter code mapping
|
| 30 |
+
self.three_to_one = {
|
| 31 |
+
'Ala': 'A', 'Cys': 'C', 'Asp': 'D', 'Glu': 'E',
|
| 32 |
+
'Phe': 'F', 'Gly': 'G', 'His': 'H', 'Ile': 'I',
|
| 33 |
+
'Lys': 'K', 'Leu': 'L', 'Met': 'M', 'Asn': 'N',
|
| 34 |
+
'Pro': 'P', 'Gln': 'Q', 'Arg': 'R', 'Ser': 'S',
|
| 35 |
+
'Thr': 'T', 'Val': 'V', 'Trp': 'W', 'Tyr': 'Y'
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
def is_peptide(self, smiles):
|
| 39 |
+
"""Check if the SMILES represents a peptide structure"""
|
| 40 |
+
# pdb.set_trace()
|
| 41 |
+
mol = Chem.MolFromSmiles(smiles)
|
| 42 |
+
if mol is None:
|
| 43 |
+
return False
|
| 44 |
+
|
| 45 |
+
# Look for peptide bonds: NC(=O) pattern
|
| 46 |
+
peptide_bond_pattern = Chem.MolFromSmarts('[NH][C](=O)')
|
| 47 |
+
if mol.HasSubstructMatch(peptide_bond_pattern):
|
| 48 |
+
return True
|
| 49 |
+
|
| 50 |
+
# Look for N-methylated peptide bonds: N(C)C(=O) pattern
|
| 51 |
+
n_methyl_pattern = Chem.MolFromSmarts('[N;H0;$(NC)](C)[C](=O)')
|
| 52 |
+
if mol.HasSubstructMatch(n_methyl_pattern):
|
| 53 |
+
return True
|
| 54 |
+
|
| 55 |
+
return False
|
| 56 |
+
|
| 57 |
+
def is_cyclic(self, smiles):
|
| 58 |
+
"""Improved cyclic peptide detection"""
|
| 59 |
+
# Check for C-terminal carboxyl
|
| 60 |
+
if smiles.endswith('C(=O)O'):
|
| 61 |
+
return False, [], []
|
| 62 |
+
|
| 63 |
+
# Find all numbers used in ring closures
|
| 64 |
+
ring_numbers = re.findall(r'(?:^|[^c])[0-9](?=[A-Z@\(\)])', smiles)
|
| 65 |
+
|
| 66 |
+
# Find aromatic ring numbers
|
| 67 |
+
aromatic_matches = re.findall(r'c[0-9](?:ccccc|c\[nH\]c)[0-9]', smiles)
|
| 68 |
+
aromatic_cycles = []
|
| 69 |
+
for match in aromatic_matches:
|
| 70 |
+
numbers = re.findall(r'[0-9]', match)
|
| 71 |
+
aromatic_cycles.extend(numbers)
|
| 72 |
+
|
| 73 |
+
# Numbers that aren't part of aromatic rings are peptide cycles
|
| 74 |
+
peptide_cycles = [n for n in ring_numbers if n not in aromatic_cycles]
|
| 75 |
+
|
| 76 |
+
is_cyclic = len(peptide_cycles) > 0 and not smiles.endswith('C(=O)O')
|
| 77 |
+
return is_cyclic, peptide_cycles, aromatic_cycles
|
| 78 |
+
|
| 79 |
+
def split_on_bonds(self, smiles):
|
| 80 |
+
"""Split SMILES into segments with simplified Pro handling"""
|
| 81 |
+
positions = []
|
| 82 |
+
used = set()
|
| 83 |
+
|
| 84 |
+
# Find Gly pattern first
|
| 85 |
+
gly_pattern = r'NCC\(=O\)'
|
| 86 |
+
for match in re.finditer(gly_pattern, smiles):
|
| 87 |
+
if not any(p in range(match.start(), match.end()) for p in used):
|
| 88 |
+
positions.append({
|
| 89 |
+
'start': match.start(),
|
| 90 |
+
'end': match.end(),
|
| 91 |
+
'type': 'gly',
|
| 92 |
+
'pattern': match.group()
|
| 93 |
+
})
|
| 94 |
+
used.update(range(match.start(), match.end()))
|
| 95 |
+
|
| 96 |
+
for pattern, bond_type in self.bond_patterns:
|
| 97 |
+
for match in re.finditer(pattern, smiles):
|
| 98 |
+
if not any(p in range(match.start(), match.end()) for p in used):
|
| 99 |
+
positions.append({
|
| 100 |
+
'start': match.start(),
|
| 101 |
+
'end': match.end(),
|
| 102 |
+
'type': bond_type,
|
| 103 |
+
'pattern': match.group()
|
| 104 |
+
})
|
| 105 |
+
used.update(range(match.start(), match.end()))
|
| 106 |
+
|
| 107 |
+
# Sort by position
|
| 108 |
+
positions.sort(key=lambda x: x['start'])
|
| 109 |
+
|
| 110 |
+
# Create segments
|
| 111 |
+
segments = []
|
| 112 |
+
|
| 113 |
+
if positions:
|
| 114 |
+
# First segment
|
| 115 |
+
if positions[0]['start'] > 0:
|
| 116 |
+
segments.append({
|
| 117 |
+
'content': smiles[0:positions[0]['start']],
|
| 118 |
+
'bond_after': positions[0]['pattern']
|
| 119 |
+
})
|
| 120 |
+
|
| 121 |
+
# Process segments
|
| 122 |
+
for i in range(len(positions)-1):
|
| 123 |
+
current = positions[i]
|
| 124 |
+
next_pos = positions[i+1]
|
| 125 |
+
|
| 126 |
+
if current['type'] == 'gly':
|
| 127 |
+
segments.append({
|
| 128 |
+
'content': 'NCC(=O)',
|
| 129 |
+
'bond_before': positions[i-1]['pattern'] if i > 0 else None,
|
| 130 |
+
'bond_after': next_pos['pattern']
|
| 131 |
+
})
|
| 132 |
+
else:
|
| 133 |
+
content = smiles[current['end']:next_pos['start']]
|
| 134 |
+
if content:
|
| 135 |
+
segments.append({
|
| 136 |
+
'content': content,
|
| 137 |
+
'bond_before': current['pattern'],
|
| 138 |
+
'bond_after': next_pos['pattern']
|
| 139 |
+
})
|
| 140 |
+
|
| 141 |
+
# Last segment
|
| 142 |
+
if positions[-1]['end'] < len(smiles):
|
| 143 |
+
segments.append({
|
| 144 |
+
'content': smiles[positions[-1]['end']:],
|
| 145 |
+
'bond_before': positions[-1]['pattern']
|
| 146 |
+
})
|
| 147 |
+
|
| 148 |
+
return segments
|
| 149 |
+
|
| 150 |
+
def clean_terminal_carboxyl(self, segment):
|
| 151 |
+
"""Remove C-terminal carboxyl only if it's the true terminus"""
|
| 152 |
+
content = segment['content']
|
| 153 |
+
|
| 154 |
+
# Only clean if:
|
| 155 |
+
# 1. Contains C(=O)O
|
| 156 |
+
# 2. No bond_after exists (meaning it's the last segment)
|
| 157 |
+
# 3. C(=O)O is at the end of the content
|
| 158 |
+
if 'C(=O)O' in content and not segment.get('bond_after'):
|
| 159 |
+
print('recognized?')
|
| 160 |
+
# Remove C(=O)O pattern regardless of position
|
| 161 |
+
cleaned = re.sub(r'\(C\(=O\)O\)', '', content)
|
| 162 |
+
# Remove any leftover empty parentheses
|
| 163 |
+
cleaned = re.sub(r'\(\)', '', cleaned)
|
| 164 |
+
print(cleaned)
|
| 165 |
+
return cleaned
|
| 166 |
+
return content
|
| 167 |
+
|
| 168 |
+
def identify_residue(self, segment):
|
| 169 |
+
"""Identify residue with Pro reconstruction"""
|
| 170 |
+
# Only clean terminal carboxyl if this is the last segment
|
| 171 |
+
content = self.clean_terminal_carboxyl(segment)
|
| 172 |
+
mods = self.get_modifications(segment)
|
| 173 |
+
|
| 174 |
+
# UAA pattern matching section - before regular residues
|
| 175 |
+
# Phenylglycine and derivatives
|
| 176 |
+
if 'c1ccccc1' in content:
|
| 177 |
+
if '[C@@H](c1ccccc1)' in content or '[C@H](c1ccccc1)' in content:
|
| 178 |
+
return '4', mods # Base phenylglycine
|
| 179 |
+
|
| 180 |
+
# 4-substituted phenylalanines
|
| 181 |
+
if 'Cc1ccc' in content:
|
| 182 |
+
if 'OMe' in content or 'OCc1ccc' in content:
|
| 183 |
+
return '0A1', mods # 4-methoxy-Phenylalanine
|
| 184 |
+
elif 'Clc1ccc' in content:
|
| 185 |
+
return '200', mods # 4-chloro-Phenylalanine
|
| 186 |
+
elif 'Brc1ccc' in content:
|
| 187 |
+
return '4BF', mods # 4-Bromo-phenylalanine
|
| 188 |
+
elif 'C#Nc1ccc' in content:
|
| 189 |
+
return '4CF', mods # 4-cyano-phenylalanine
|
| 190 |
+
elif 'Ic1ccc' in content:
|
| 191 |
+
return 'PHI', mods # 4-Iodo-phenylalanine
|
| 192 |
+
elif 'Fc1ccc' in content:
|
| 193 |
+
return 'PFF', mods # 4-Fluoro-phenylalanine
|
| 194 |
+
|
| 195 |
+
# Modified tryptophans
|
| 196 |
+
if 'c[nH]c2' in content:
|
| 197 |
+
if 'Oc2cccc2' in content:
|
| 198 |
+
return '0AF', mods # 7-hydroxy-tryptophan
|
| 199 |
+
elif 'Fc2cccc2' in content:
|
| 200 |
+
return '4FW', mods # 4-fluoro-tryptophan
|
| 201 |
+
elif 'Clc2cccc2' in content:
|
| 202 |
+
return '6CW', mods # 6-chloro-tryptophan
|
| 203 |
+
elif 'Brc2cccc2' in content:
|
| 204 |
+
return 'BTR', mods # 6-bromo-tryptophan
|
| 205 |
+
elif 'COc2cccc2' in content:
|
| 206 |
+
return 'MOT5', mods # 5-Methoxy-tryptophan
|
| 207 |
+
elif 'Cc2cccc2' in content:
|
| 208 |
+
return 'MTR5', mods # 5-Methyl-tryptophan
|
| 209 |
+
|
| 210 |
+
# Special amino acids
|
| 211 |
+
if 'CC(C)(C)[C@@H]' in content or 'CC(C)(C)[C@H]' in content:
|
| 212 |
+
return 'BUG', mods # Tertleucine
|
| 213 |
+
|
| 214 |
+
if 'CCCNC(=N)N' in content:
|
| 215 |
+
return 'CIR', mods # Citrulline
|
| 216 |
+
|
| 217 |
+
if '[SeH]' in content:
|
| 218 |
+
return 'CSE', mods # Selenocysteine
|
| 219 |
+
|
| 220 |
+
if '[NH3]CC[C@@H]' in content or '[NH3]CC[C@H]' in content:
|
| 221 |
+
return 'DAB', mods # Diaminobutyric acid
|
| 222 |
+
|
| 223 |
+
if 'C1CCCCC1' in content:
|
| 224 |
+
if 'C1CCCCC1[C@@H]' in content or 'C1CCCCC1[C@H]' in content:
|
| 225 |
+
return 'CHG', mods # Cyclohexylglycine
|
| 226 |
+
elif 'C1CCCCC1C[C@@H]' in content or 'C1CCCCC1C[C@H]' in content:
|
| 227 |
+
return 'ALC', mods # 3-cyclohexyl-alanine
|
| 228 |
+
|
| 229 |
+
# Naphthalene derivatives
|
| 230 |
+
if 'c1cccc2c1cccc2' in content:
|
| 231 |
+
if 'c1cccc2c1cccc2[C@@H]' in content or 'c1cccc2c1cccc2[C@H]' in content:
|
| 232 |
+
return 'NAL', mods # 2-Naphthyl-alanine
|
| 233 |
+
|
| 234 |
+
# Heteroaromatic derivatives
|
| 235 |
+
if 'c1cncc' in content:
|
| 236 |
+
return 'PYR4', mods # 3-(4-Pyridyl)-alanine
|
| 237 |
+
if 'c1cscc' in content:
|
| 238 |
+
return 'THA3', mods # 3-(3-thienyl)-alanine
|
| 239 |
+
if 'c1nnc' in content:
|
| 240 |
+
return 'TRZ4', mods # 3-(1,2,4-Triazol-1-yl)-alanine
|
| 241 |
+
|
| 242 |
+
# Modified serines and threonines
|
| 243 |
+
if 'OP(O)(O)O' in content:
|
| 244 |
+
if '[C@@H](COP' in content or '[C@H](COP' in content:
|
| 245 |
+
return 'SEP', mods # phosphoserine
|
| 246 |
+
elif '[C@@H](OP' in content or '[C@H](OP' in content:
|
| 247 |
+
return 'TPO', mods # phosphothreonine
|
| 248 |
+
|
| 249 |
+
# Specialized ring systems
|
| 250 |
+
if 'c1c2ccccc2cc2c1cccc2' in content:
|
| 251 |
+
return 'ANTH', mods # 3-(9-anthryl)-alanine
|
| 252 |
+
if 'c1csc2c1cccc2' in content:
|
| 253 |
+
return 'BTH3', mods # 3-(3-benzothienyl)-alanine
|
| 254 |
+
if '[C@]12C[C@H]3C[C@@H](C2)C[C@@H](C1)C3' in content:
|
| 255 |
+
return 'ADAM', mods # Adamanthane
|
| 256 |
+
|
| 257 |
+
# Fluorinated derivatives
|
| 258 |
+
if 'FC(F)(F)' in content:
|
| 259 |
+
if 'CC(F)(F)F' in content:
|
| 260 |
+
return 'FLA', mods # Trifluoro-alanine
|
| 261 |
+
if 'C(F)(F)F)c1' in content:
|
| 262 |
+
if 'c1ccccc1C(F)(F)F' in content:
|
| 263 |
+
return 'TFG2', mods # 2-(Trifluoromethyl)-phenylglycine
|
| 264 |
+
if 'c1cccc(c1)C(F)(F)F' in content:
|
| 265 |
+
return 'TFG3', mods # 3-(Trifluoromethyl)-phenylglycine
|
| 266 |
+
if 'c1ccc(cc1)C(F)(F)F' in content:
|
| 267 |
+
return 'TFG4', mods # 4-(Trifluoromethyl)-phenylglycine
|
| 268 |
+
|
| 269 |
+
# Multiple halogen patterns
|
| 270 |
+
if 'F' in content and 'c1' in content:
|
| 271 |
+
if 'c1ccc(c(c1)F)F' in content:
|
| 272 |
+
return 'F2F', mods # 3,4-Difluoro-phenylalanine
|
| 273 |
+
if 'cc(F)cc(c1)F' in content:
|
| 274 |
+
return 'WFP', mods # 3,5-Difluoro-phenylalanine
|
| 275 |
+
if 'Cl' in content and 'c1' in content:
|
| 276 |
+
if 'c1ccc(cc1Cl)Cl' in content:
|
| 277 |
+
return 'CP24', mods # 2,4-dichloro-phenylalanine
|
| 278 |
+
if 'c1ccc(c(c1)Cl)Cl' in content:
|
| 279 |
+
return 'CP34', mods # 3,4-dichloro-phenylalanine
|
| 280 |
+
|
| 281 |
+
# Hydroxy and amino derivatives
|
| 282 |
+
if 'O' in content and 'c1' in content:
|
| 283 |
+
if 'c1cc(O)cc(c1)O' in content:
|
| 284 |
+
return '3FG', mods # (2s)-amino(3,5-dihydroxyphenyl)-ethanoic acid
|
| 285 |
+
if 'c1ccc(c(c1)O)O' in content:
|
| 286 |
+
return 'DAH', mods # 3,4-Dihydroxy-phenylalanine
|
| 287 |
+
|
| 288 |
+
# Cyclic amino acids
|
| 289 |
+
if 'C1CCCC1' in content:
|
| 290 |
+
return 'CPA3', mods # 3-Cyclopentyl-alanine
|
| 291 |
+
if 'C1CCCCC1' in content:
|
| 292 |
+
if 'CC1CCCCC1' in content:
|
| 293 |
+
return 'ALC', mods # 3-cyclohexyl-alanine
|
| 294 |
+
else:
|
| 295 |
+
return 'CHG', mods # Cyclohexylglycine
|
| 296 |
+
|
| 297 |
+
# Chain-length variants
|
| 298 |
+
if 'CCC[C@@H]' in content or 'CCC[C@H]' in content:
|
| 299 |
+
return 'NLE', mods # Norleucine
|
| 300 |
+
if 'CC[C@@H]' in content or 'CC[C@H]' in content:
|
| 301 |
+
if not any(x in content for x in ['CC(C)', 'COC', 'CN(']):
|
| 302 |
+
return 'ABA', mods # 2-Aminobutyric acid
|
| 303 |
+
|
| 304 |
+
# Modified histidines
|
| 305 |
+
if 'c1cnc' in content:
|
| 306 |
+
if '[C@@H]1CN[C@@H](N1)F' in content:
|
| 307 |
+
return '2HF', mods # 2-fluoro-l-histidine
|
| 308 |
+
if 'c1cnc([nH]1)F' in content:
|
| 309 |
+
return '2HF1', mods # 2-fluoro-l-histidine variant
|
| 310 |
+
if 'c1c[nH]c(n1)F' in content:
|
| 311 |
+
return '2HF2', mods # 2-fluoro-l-histidine variant
|
| 312 |
+
|
| 313 |
+
# Sulfur and selenium containing
|
| 314 |
+
if '[SeH]' in content:
|
| 315 |
+
return 'CSE', mods # Selenocysteine
|
| 316 |
+
if 'S' in content:
|
| 317 |
+
if 'CSCc1ccccc1' in content:
|
| 318 |
+
return 'BCS', mods # benzylcysteine
|
| 319 |
+
if 'CCSC' in content:
|
| 320 |
+
return 'ESC', mods # Ethionine
|
| 321 |
+
if 'CCS' in content:
|
| 322 |
+
return 'HCS', mods # homocysteine
|
| 323 |
+
|
| 324 |
+
# Additional modifications
|
| 325 |
+
if 'CN=[N]=N' in content:
|
| 326 |
+
return 'AZDA', mods # azido-alanine
|
| 327 |
+
if '[NH]=[C](=[NH2])=[NH2]' in content:
|
| 328 |
+
if 'CCC[NH]=' in content:
|
| 329 |
+
return 'AGM', mods # 5-methyl-arginine
|
| 330 |
+
if 'CC[NH]=' in content:
|
| 331 |
+
return 'GDPR', mods # 2-Amino-3-guanidinopropionic acid
|
| 332 |
+
|
| 333 |
+
if 'CCON' in content:
|
| 334 |
+
return 'CAN', mods # canaline
|
| 335 |
+
if '[C@@H]1C=C[C@@H](C=C1)' in content:
|
| 336 |
+
return 'ACZ', mods # cis-amiclenomycin
|
| 337 |
+
if 'CCC(=O)[NH3]' in content:
|
| 338 |
+
return 'ONL', mods # 5-oxo-l-norleucine
|
| 339 |
+
if 'c1ccncc1' in content:
|
| 340 |
+
return 'PYR4', mods # 3-(4-Pyridyl)-alanine
|
| 341 |
+
if 'c1ccco1' in content:
|
| 342 |
+
return 'FUA2', mods # (2-furyl)-alanine
|
| 343 |
+
|
| 344 |
+
if 'c1ccc' in content:
|
| 345 |
+
if 'c1ccc(cc1)c1ccccc1' in content:
|
| 346 |
+
return 'BIF', mods # 4,4-biphenylalanine
|
| 347 |
+
if 'c1ccc(cc1)C(=O)c1ccccc1' in content:
|
| 348 |
+
return 'PBF', mods # 4-benzoyl-phenylalanine
|
| 349 |
+
if 'c1ccc(cc1)C(C)(C)C' in content:
|
| 350 |
+
return 'TBP4', mods # 4-tert-butyl-phenylalanine
|
| 351 |
+
if 'c1ccc(cc1)[C](=[NH2])=[NH2]' in content:
|
| 352 |
+
return '0BN', mods # 4-carbamimidoyl-l-phenylalanine
|
| 353 |
+
if 'c1cccc(c1)[C](=[NH2])=[NH2]' in content:
|
| 354 |
+
return 'APM', mods # m-amidinophenyl-3-alanine
|
| 355 |
+
|
| 356 |
+
# Multiple hydroxy patterns
|
| 357 |
+
if 'O' in content:
|
| 358 |
+
if '[C@H]([C@H](C)O)O' in content:
|
| 359 |
+
return 'ILX', mods # 4,5-dihydroxy-isoleucine
|
| 360 |
+
if '[C@H]([C@@H](C)O)O' in content:
|
| 361 |
+
return 'ALO', mods # Allo-threonine
|
| 362 |
+
if '[C@H](COP(O)(O)O)' in content:
|
| 363 |
+
return 'SEP', mods # phosphoserine
|
| 364 |
+
if '[C@H]([C@@H](C)OP(O)(O)O)' in content:
|
| 365 |
+
return 'TPO', mods # phosphothreonine
|
| 366 |
+
if '[C@H](c1ccc(O)cc1)O' in content:
|
| 367 |
+
return 'OMX', mods # (betar)-beta-hydroxy-l-tyrosine
|
| 368 |
+
if '[C@H](c1ccc(c(Cl)c1)O)O' in content:
|
| 369 |
+
return 'OMY', mods # (betar)-3-chloro-beta-hydroxy-l-tyrosine
|
| 370 |
+
|
| 371 |
+
# Heterocyclic patterns
|
| 372 |
+
if 'n1' in content:
|
| 373 |
+
if 'n1cccn1' in content:
|
| 374 |
+
return 'PYZ1', mods # 3-(1-Pyrazolyl)-alanine
|
| 375 |
+
if 'n1nncn1' in content:
|
| 376 |
+
return 'TEZA', mods # 3-(2-Tetrazolyl)-alanine
|
| 377 |
+
if 'c2c(n1)cccc2' in content:
|
| 378 |
+
return 'QU32', mods # 3-(2-Quinolyl)-alanine
|
| 379 |
+
if 'c1cnc2c(c1)cccc2' in content:
|
| 380 |
+
return 'QU33', mods # 3-(3-quinolyl)-alanine
|
| 381 |
+
if 'c1ccnc2c1cccc2' in content:
|
| 382 |
+
return 'QU34', mods # 3-(4-quinolyl)-alanine
|
| 383 |
+
if 'c1ccc2c(c1)nccc2' in content:
|
| 384 |
+
return 'QU35', mods # 3-(5-Quinolyl)-alanine
|
| 385 |
+
if 'c1ccc2c(c1)cncc2' in content:
|
| 386 |
+
return 'QU36', mods # 3-(6-Quinolyl)-alanine
|
| 387 |
+
if 'c1cnc2c(n1)cccc2' in content:
|
| 388 |
+
return 'QX32', mods # 3-(2-quinoxalyl)-alanine
|
| 389 |
+
|
| 390 |
+
# Multiple nitrogen patterns
|
| 391 |
+
if 'N' in content:
|
| 392 |
+
if '[NH3]CC[C@@H]' in content:
|
| 393 |
+
return 'DAB', mods # Diaminobutyric acid
|
| 394 |
+
if '[NH3]C[C@@H]' in content:
|
| 395 |
+
return 'DPP', mods # 2,3-Diaminopropanoic acid
|
| 396 |
+
if '[NH3]CCCCCC[C@@H]' in content:
|
| 397 |
+
return 'HHK', mods # (2s)-2,8-diaminooctanoic acid
|
| 398 |
+
if 'CCC[NH]=[C](=[NH2])=[NH2]' in content:
|
| 399 |
+
return 'GBUT', mods # 2-Amino-4-guanidinobutryric acid
|
| 400 |
+
if '[NH]=[C](=S)=[NH2]' in content:
|
| 401 |
+
return 'THIC', mods # Thio-citrulline
|
| 402 |
+
|
| 403 |
+
# Chain modified amino acids
|
| 404 |
+
if 'CC' in content:
|
| 405 |
+
if 'CCCC[C@@H]' in content:
|
| 406 |
+
return 'AHP', mods # 2-Aminoheptanoic acid
|
| 407 |
+
if 'CCC([C@@H])(C)C' in content:
|
| 408 |
+
return 'I2M', mods # 3-methyl-l-alloisoleucine
|
| 409 |
+
if 'CC[C@H]([C@@H])C' in content:
|
| 410 |
+
return 'IIL', mods # Allo-Isoleucine
|
| 411 |
+
if '[C@H](CCC(C)C)' in content:
|
| 412 |
+
return 'HLEU', mods # Homoleucine
|
| 413 |
+
if '[C@@H]([C@@H](C)O)C' in content:
|
| 414 |
+
return 'HLU', mods # beta-hydroxyleucine
|
| 415 |
+
|
| 416 |
+
# Modified glutamate/aspartate patterns
|
| 417 |
+
if '[C@@H]' in content:
|
| 418 |
+
if '[C@@H](C[C@@H](F))' in content:
|
| 419 |
+
return 'FGA4', mods # 4-Fluoro-glutamic acid
|
| 420 |
+
if '[C@@H](C[C@@H](O))' in content:
|
| 421 |
+
return '3GL', mods # 4-hydroxy-glutamic-acid
|
| 422 |
+
if '[C@@H](C[C@H](C))' in content:
|
| 423 |
+
return 'LME', mods # (3r)-3-methyl-l-glutamic acid
|
| 424 |
+
if '[C@@H](CC[C@H](C))' in content:
|
| 425 |
+
return 'MEG', mods # (3s)-3-methyl-l-glutamic acid
|
| 426 |
+
|
| 427 |
+
# Sulfur and selenium modifications
|
| 428 |
+
if 'S' in content:
|
| 429 |
+
if 'SCC[C@@H]' in content:
|
| 430 |
+
return 'HSER', mods # homoserine
|
| 431 |
+
if 'SCCN' in content:
|
| 432 |
+
return 'SLZ', mods # thialysine
|
| 433 |
+
if 'SC(=O)' in content:
|
| 434 |
+
return 'CSA', mods # s-acetonylcysteine
|
| 435 |
+
if '[S@@](=O)' in content:
|
| 436 |
+
return 'SME', mods # Methionine sulfoxide
|
| 437 |
+
if 'S(=O)(=O)' in content:
|
| 438 |
+
return 'OMT', mods # Methionine sulfone
|
| 439 |
+
|
| 440 |
+
# Double bond containing
|
| 441 |
+
if 'C=' in content:
|
| 442 |
+
if 'C=C[C@@H]' in content:
|
| 443 |
+
return '2AG', mods # 2-Allyl-glycine
|
| 444 |
+
if 'C=C[C@@H]' in content:
|
| 445 |
+
return 'LVG', mods # vinylglycine
|
| 446 |
+
if 'C=Cc1ccccc1' in content:
|
| 447 |
+
return 'STYA', mods # Styrylalanine
|
| 448 |
+
|
| 449 |
+
# Special cases
|
| 450 |
+
if '[C@@H]1Cc2c(C1)cccc2' in content:
|
| 451 |
+
return 'IGL', mods # alpha-amino-2-indanacetic acid
|
| 452 |
+
if '[C](=[C](=O)=O)=O' in content:
|
| 453 |
+
return '26P', mods # 2-amino-6-oxopimelic acid
|
| 454 |
+
if '[C](=[C](=O)=O)=C' in content:
|
| 455 |
+
return '2NP', mods # l-2-amino-6-methylene-pimelic acid
|
| 456 |
+
if 'c2cnc[nH]2' in content:
|
| 457 |
+
return 'HIS', mods # histidine core
|
| 458 |
+
if 'c1cccc2c1cc(O)cc2' in content:
|
| 459 |
+
return 'NAO1', mods # 5-hydroxy-1-naphthalene
|
| 460 |
+
if 'c1ccc2c(c1)cc(O)cc2' in content:
|
| 461 |
+
return 'NAO2', mods # 6-hydroxy-2-naphthalene
|
| 462 |
+
|
| 463 |
+
# Proline (P) - flexible ring numbers
|
| 464 |
+
if any([
|
| 465 |
+
# Check for any ring number in bond patterns
|
| 466 |
+
(segment.get('bond_after', '').startswith(f'N{n}C(=O)') and 'CCC' in content and
|
| 467 |
+
any(f'[C@@H]{n}' in content or f'[C@H]{n}' in content for n in '123456789'))
|
| 468 |
+
for n in '123456789'
|
| 469 |
+
]) or any([
|
| 470 |
+
# Check ending patterns with any ring number
|
| 471 |
+
(f'CCCN{n}' in content and content.endswith('=O') and
|
| 472 |
+
any(f'[C@@H]{n}' in content or f'[C@H]{n}' in content for n in '123456789'))
|
| 473 |
+
for n in '123456789'
|
| 474 |
+
]) or any([
|
| 475 |
+
# Handle CCC[C@H]n patterns
|
| 476 |
+
(content == f'CCC[C@H]{n}' and segment.get('bond_before', '').startswith(f'C(=O)N{n}')) or
|
| 477 |
+
(content == f'CCC[C@@H]{n}' and segment.get('bond_before', '').startswith(f'C(=O)N{n}')) or
|
| 478 |
+
# N-terminal Pro with any ring number
|
| 479 |
+
(f'N{n}CCC[C@H]{n}' in content) or
|
| 480 |
+
(f'N{n}CCC[C@@H]{n}' in content)
|
| 481 |
+
for n in '123456789'
|
| 482 |
+
]):
|
| 483 |
+
return 'Pro', mods
|
| 484 |
+
|
| 485 |
+
# Tryptophan (W) - more specific indole pattern
|
| 486 |
+
if re.search(r'c[0-9]c\[nH\]c[0-9]ccccc[0-9][0-9]', content) and \
|
| 487 |
+
'c[nH]c' in content.replace(' ', ''):
|
| 488 |
+
return 'Trp', mods
|
| 489 |
+
|
| 490 |
+
# Lysine (K) - both patterns
|
| 491 |
+
if '[C@@H](CCCCN)' in content or '[C@H](CCCCN)' in content:
|
| 492 |
+
return 'Lys', mods
|
| 493 |
+
|
| 494 |
+
# Arginine (R) - both patterns
|
| 495 |
+
if '[C@@H](CCCNC(=N)N)' in content or '[C@H](CCCNC(=N)N)' in content:
|
| 496 |
+
return 'Arg', mods
|
| 497 |
+
|
| 498 |
+
if ('C[C@H](CCCC)' in content or 'C[C@@H](CCCC)' in content) and 'CC(C)' not in content:
|
| 499 |
+
return 'Nle', mods
|
| 500 |
+
|
| 501 |
+
# Ornithine (Orn) - 3-carbon chain with NH2
|
| 502 |
+
if ('C[C@H](CCCN)' in content or 'C[C@@H](CCCN)' in content) and 'CC(C)' not in content:
|
| 503 |
+
return 'Orn', mods
|
| 504 |
+
|
| 505 |
+
# 2-Naphthylalanine (2Nal) - distinct from Phe pattern
|
| 506 |
+
if ('Cc3cc2ccccc2c3' in content) and ('C[C@H]' in content or 'C[C@@H]' in content):
|
| 507 |
+
return '2Nal', mods
|
| 508 |
+
|
| 509 |
+
# Cyclohexylalanine (Cha) - already in your code but moved here for clarity
|
| 510 |
+
if 'N2CCCCC2' in content or 'CCCCC2' in content:
|
| 511 |
+
return 'Cha', mods
|
| 512 |
+
|
| 513 |
+
# Aminobutyric acid (Abu) - 2-carbon chain
|
| 514 |
+
if ('C[C@H](CC)' in content or 'C[C@@H](CC)' in content) and not any(p in content for p in ['CC(C)', 'CCCC', 'CCC(C)']):
|
| 515 |
+
return 'Abu', mods
|
| 516 |
+
|
| 517 |
+
# Pipecolic acid (Pip) - 6-membered ring like Pro
|
| 518 |
+
if ('N3CCCCC3' in content or 'CCCCC3' in content) and ('C[C@H]' in content or 'C[C@@H]' in content):
|
| 519 |
+
return 'Pip', mods
|
| 520 |
+
|
| 521 |
+
# Cyclohexylglycine (Chg) - direct cyclohexyl without CH2
|
| 522 |
+
if ('C[C@H](C1CCCCC1)' in content or 'C[C@@H](C1CCCCC1)' in content):
|
| 523 |
+
return 'Chg', mods
|
| 524 |
+
|
| 525 |
+
# 4-Fluorophenylalanine (4F-Phe)
|
| 526 |
+
if ('Cc2ccc(F)cc2' in content) and ('C[C@H]' in content or 'C[C@@H]' in content):
|
| 527 |
+
return '4F-Phe', mods
|
| 528 |
+
|
| 529 |
+
# Regular residue identification
|
| 530 |
+
if ('NCC(=O)' in content) or (content == 'C'):
|
| 531 |
+
# Middle case - between bonds
|
| 532 |
+
if segment.get('bond_before') and segment.get('bond_after'):
|
| 533 |
+
if ('C(=O)N' in segment['bond_before'] or 'C(=O)N(C)' in segment['bond_before']):
|
| 534 |
+
return 'Gly', mods
|
| 535 |
+
# Terminal case - at the end
|
| 536 |
+
elif segment.get('bond_before') and segment.get('bond_before').startswith('C(=O)N'):
|
| 537 |
+
return 'Gly', mods
|
| 538 |
+
|
| 539 |
+
if 'CC(C)C[C@H]' in content or 'CC(C)C[C@@H]' in content:
|
| 540 |
+
return 'Leu', mods
|
| 541 |
+
if '[C@@H](CC(C)C)' in content or '[C@H](CC(C)C)' in content:
|
| 542 |
+
return 'Leu', mods
|
| 543 |
+
|
| 544 |
+
if '[C@@H]([C@@H](C)O)' in content or '[C@H]([C@H](C)O)' in content:
|
| 545 |
+
return 'Thr', mods
|
| 546 |
+
|
| 547 |
+
if '[C@H](Cc2ccccc2)' in content or '[C@@H](Cc2ccccc2)' in content:
|
| 548 |
+
return 'Phe', mods
|
| 549 |
+
|
| 550 |
+
if ('[C@H](C(C)C)' in content or # With outer parentheses
|
| 551 |
+
'[C@@H](C(C)C)' in content or # With outer parentheses
|
| 552 |
+
'[C@H]C(C)C' in content or # Without outer parentheses
|
| 553 |
+
'[C@@H]C(C)C' in content): # Without outer parentheses
|
| 554 |
+
if not any(p in content for p in ['CC(C)C[C@H]', 'CC(C)C[C@@H]']): # Still check not Leu
|
| 555 |
+
return 'Val', mods
|
| 556 |
+
|
| 557 |
+
if '[C@H](COC(C)(C)C)' in content or '[C@@H](COC(C)(C)C)' in content:
|
| 558 |
+
return 'O-tBu', mods
|
| 559 |
+
|
| 560 |
+
if any([
|
| 561 |
+
'CC[C@H](C)' in content,
|
| 562 |
+
'CC[C@@H](C)' in content,
|
| 563 |
+
'C(C)C[C@H]' in content and 'CC(C)C' not in content,
|
| 564 |
+
'C(C)C[C@@H]' in content and 'CC(C)C' not in content
|
| 565 |
+
]):
|
| 566 |
+
return 'Ile', mods
|
| 567 |
+
|
| 568 |
+
if ('[C@H](C)' in content or '[C@@H](C)' in content):
|
| 569 |
+
if not any(p in content for p in ['C(C)C', 'COC', 'CN(', 'C(C)O', 'CC[C@H]', 'CC[C@@H]']):
|
| 570 |
+
return 'Ala', mods
|
| 571 |
+
|
| 572 |
+
# Tyrosine (Tyr) - 4-hydroxybenzyl side chain
|
| 573 |
+
if re.search(r'Cc[0-9]ccc\(O\)cc[0-9]', content):
|
| 574 |
+
return 'Tyr', mods
|
| 575 |
+
|
| 576 |
+
|
| 577 |
+
# Serine (Ser) - Hydroxymethyl side chain
|
| 578 |
+
if '[C@H](CO)' in content or '[C@@H](CO)' in content:
|
| 579 |
+
if not ('C(C)O' in content or 'COC' in content):
|
| 580 |
+
return 'Ser', mods
|
| 581 |
+
|
| 582 |
+
# Threonine (Thr) - 1-hydroxyethyl side chain
|
| 583 |
+
if '[C@@H]([C@@H](C)O)' in content or '[C@H]([C@H](C)O)' in content or '[C@@H](C)O' in content or '[C@H](C)O' in content:
|
| 584 |
+
return 'Thr', mods
|
| 585 |
+
|
| 586 |
+
# Cysteine (Cys) - Thiol side chain
|
| 587 |
+
if '[C@H](CS)' in content or '[C@@H](CS)' in content:
|
| 588 |
+
return 'Cys', mods
|
| 589 |
+
|
| 590 |
+
# Methionine (Met) - Methylthioethyl side chain
|
| 591 |
+
if ('C[C@H](CCSC)' in content or 'C[C@@H](CCSC)' in content):
|
| 592 |
+
return 'Met', mods
|
| 593 |
+
|
| 594 |
+
# Asparagine (Asn) - Carbamoylmethyl side chain
|
| 595 |
+
if ('CC(=O)N' in content) and ('C[C@H]' in content or 'C[C@@H]' in content):
|
| 596 |
+
return 'Asn', mods
|
| 597 |
+
|
| 598 |
+
# Glutamine (Gln) - Carbamoylethyl side chain
|
| 599 |
+
if ('CCC(=O)N' in content) and ('C[C@H]' in content or 'C[C@@H]' in content):
|
| 600 |
+
return 'Gln', mods
|
| 601 |
+
|
| 602 |
+
# Aspartic acid (Asp) - Carboxymethyl side chain
|
| 603 |
+
if ('CC(=O)O' in content) and ('C[C@H]' in content or 'C[C@@H]' in content):
|
| 604 |
+
return 'Asp', mods
|
| 605 |
+
|
| 606 |
+
# Glutamic acid (Glu) - Carboxyethyl side chainCC(C(=O)O)N
|
| 607 |
+
if ('CCC(=O)O' in content) and ('C[C@H]' in content or 'C[C@@H]' in content):
|
| 608 |
+
return 'Glu', mods
|
| 609 |
+
|
| 610 |
+
# Arginine (Arg) - 3-guanidinopropyl side chain
|
| 611 |
+
if ('CCCNC(=N)N' in content) and ('C[C@H]' in content or 'C[C@@H]' in content):
|
| 612 |
+
return 'Arg', mods
|
| 613 |
+
|
| 614 |
+
# Histidine (His) - Imidazole side chain
|
| 615 |
+
if ('Cc2cnc[nH]2' in content) and ('C[C@H]' in content or 'C[C@@H]' in content):
|
| 616 |
+
return 'His', mods
|
| 617 |
+
|
| 618 |
+
return None, mods
|
| 619 |
+
|
| 620 |
+
def get_modifications(self, segment):
|
| 621 |
+
"""Get modifications based on bond types"""
|
| 622 |
+
mods = []
|
| 623 |
+
if segment.get('bond_after'):
|
| 624 |
+
if 'N(C)' in segment['bond_after'] or segment['bond_after'].startswith('C(=O)N(C)'):
|
| 625 |
+
mods.append('N-Me')
|
| 626 |
+
if 'OC(=O)' in segment['bond_after']:
|
| 627 |
+
mods.append('O-linked')
|
| 628 |
+
return mods
|
| 629 |
+
|
| 630 |
+
def analyze_structure(self, smiles):
|
| 631 |
+
"""Main analysis function with debug output"""
|
| 632 |
+
print("\nAnalyzing structure:", smiles)
|
| 633 |
+
|
| 634 |
+
# Split into segments
|
| 635 |
+
segments = self.split_on_bonds(smiles)
|
| 636 |
+
|
| 637 |
+
print("\nSegment Analysis:")
|
| 638 |
+
sequence = []
|
| 639 |
+
for i, segment in enumerate(segments):
|
| 640 |
+
print(f"\nSegment {i}:")
|
| 641 |
+
print(f"Content: {segment['content']}")
|
| 642 |
+
print(f"Bond before: {segment.get('bond_before', 'None')}")
|
| 643 |
+
print(f"Bond after: {segment.get('bond_after', 'None')}")
|
| 644 |
+
|
| 645 |
+
residue, mods = self.identify_residue(segment)
|
| 646 |
+
if residue:
|
| 647 |
+
if mods:
|
| 648 |
+
sequence.append(f"{residue}({','.join(mods)})")
|
| 649 |
+
else:
|
| 650 |
+
sequence.append(residue)
|
| 651 |
+
print(f"Identified as: {residue}")
|
| 652 |
+
print(f"Modifications: {mods}")
|
| 653 |
+
else:
|
| 654 |
+
print(f"Warning: Could not identify residue in segment: {segment['content']}")
|
| 655 |
+
|
| 656 |
+
# Check if cyclic
|
| 657 |
+
is_cyclic, peptide_cycles, aromatic_cycles = self.is_cyclic(smiles)
|
| 658 |
+
three_letter = '-'.join(sequence)
|
| 659 |
+
one_letter = ''.join(self.three_to_one.get(aa.split('(')[0], 'X') for aa in sequence)
|
| 660 |
+
|
| 661 |
+
if is_cyclic:
|
| 662 |
+
three_letter = f"cyclo({three_letter})"
|
| 663 |
+
one_letter = f"cyclo({one_letter})"
|
| 664 |
+
|
| 665 |
+
print(f"\nFinal sequence: {three_letter}")
|
| 666 |
+
print(f"One-letter code: {one_letter}")
|
| 667 |
+
print(f"Is cyclic: {is_cyclic}")
|
| 668 |
+
#print(f"Peptide cycles: {peptide_cycles}")
|
| 669 |
+
#print(f"Aromatic cycles: {aromatic_cycles}")
|
| 670 |
+
|
| 671 |
+
return three_letter, len(segments)
|
| 672 |
+
"""return {
|
| 673 |
+
'three_letter': three_letter,
|
| 674 |
+
#'one_letter': one_letter,
|
| 675 |
+
'is_cyclic': is_cyclic
|
| 676 |
+
}"""
|
| 677 |
+
|
| 678 |
+
def return_sequence(self, smiles):
|
| 679 |
+
"""Main analysis function with debug output"""
|
| 680 |
+
print("\nAnalyzing structure:", smiles)
|
| 681 |
+
|
| 682 |
+
# Split into segments
|
| 683 |
+
segments = self.split_on_bonds(smiles)
|
| 684 |
+
|
| 685 |
+
print("\nSegment Analysis:")
|
| 686 |
+
sequence = []
|
| 687 |
+
for i, segment in enumerate(segments):
|
| 688 |
+
print(f"\nSegment {i}:")
|
| 689 |
+
print(f"Content: {segment['content']}")
|
| 690 |
+
print(f"Bond before: {segment.get('bond_before', 'None')}")
|
| 691 |
+
print(f"Bond after: {segment.get('bond_after', 'None')}")
|
| 692 |
+
|
| 693 |
+
residue, mods = self.identify_residue(segment)
|
| 694 |
+
if residue:
|
| 695 |
+
if mods:
|
| 696 |
+
sequence.append(f"{residue}({','.join(mods)})")
|
| 697 |
+
else:
|
| 698 |
+
sequence.append(residue)
|
| 699 |
+
print(f"Identified as: {residue}")
|
| 700 |
+
print(f"Modifications: {mods}")
|
| 701 |
+
else:
|
| 702 |
+
print(f"Warning: Could not identify residue in segment: {segment['content']}")
|
| 703 |
+
|
| 704 |
+
return sequence
|
| 705 |
+
|
| 706 |
+
"""
|
| 707 |
+
def annotate_cyclic_structure(mol, sequence):
|
| 708 |
+
'''Create annotated 2D structure with clear, non-overlapping residue labels'''
|
| 709 |
+
# Generate 2D coordinates
|
| 710 |
+
# Generate 2D coordinates
|
| 711 |
+
AllChem.Compute2DCoords(mol)
|
| 712 |
+
|
| 713 |
+
# Create drawer with larger size for annotations
|
| 714 |
+
drawer = Draw.rdMolDraw2D.MolDraw2DCairo(2000, 2000) # Even larger size
|
| 715 |
+
|
| 716 |
+
# Get residue list and reverse it to match structural representation
|
| 717 |
+
if sequence.startswith('cyclo('):
|
| 718 |
+
residues = sequence[6:-1].split('-')
|
| 719 |
+
else:
|
| 720 |
+
residues = sequence.split('-')
|
| 721 |
+
residues = list(reversed(residues)) # Reverse the sequence
|
| 722 |
+
|
| 723 |
+
# Draw molecule first to get its bounds
|
| 724 |
+
drawer.drawOptions().addAtomIndices = False
|
| 725 |
+
drawer.DrawMolecule(mol)
|
| 726 |
+
drawer.FinishDrawing()
|
| 727 |
+
|
| 728 |
+
# Convert to PIL Image
|
| 729 |
+
img = Image.open(BytesIO(drawer.GetDrawingText()))
|
| 730 |
+
draw = ImageDraw.Draw(img)
|
| 731 |
+
|
| 732 |
+
try:
|
| 733 |
+
# Try to use DejaVuSans as it's commonly available on Linux systems
|
| 734 |
+
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 60)
|
| 735 |
+
small_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 60)
|
| 736 |
+
except OSError:
|
| 737 |
+
try:
|
| 738 |
+
# Fallback to Arial if available (common on Windows)
|
| 739 |
+
font = ImageFont.truetype("arial.ttf", 60)
|
| 740 |
+
small_font = ImageFont.truetype("arial.ttf", 60)
|
| 741 |
+
except OSError:
|
| 742 |
+
# If no TrueType fonts are available, fall back to default
|
| 743 |
+
print("Warning: TrueType fonts not available, using default font")
|
| 744 |
+
font = ImageFont.load_default()
|
| 745 |
+
small_font = ImageFont.load_default()
|
| 746 |
+
# Get molecule bounds
|
| 747 |
+
conf = mol.GetConformer()
|
| 748 |
+
positions = []
|
| 749 |
+
for i in range(mol.GetNumAtoms()):
|
| 750 |
+
pos = conf.GetAtomPosition(i)
|
| 751 |
+
positions.append((pos.x, pos.y))
|
| 752 |
+
|
| 753 |
+
x_coords = [p[0] for p in positions]
|
| 754 |
+
y_coords = [p[1] for p in positions]
|
| 755 |
+
min_x, max_x = min(x_coords), max(x_coords)
|
| 756 |
+
min_y, max_y = min(y_coords), max(y_coords)
|
| 757 |
+
|
| 758 |
+
# Calculate scaling factors
|
| 759 |
+
scale = 150 # Increased scale factor
|
| 760 |
+
center_x = 1000 # Image center
|
| 761 |
+
center_y = 1000
|
| 762 |
+
|
| 763 |
+
# Add residue labels in a circular arrangement around the structure
|
| 764 |
+
n_residues = len(residues)
|
| 765 |
+
radius = 700 # Distance of labels from center
|
| 766 |
+
|
| 767 |
+
# Start from the rightmost point (3 o'clock position) and go counterclockwise
|
| 768 |
+
# Offset by -3 positions to align with structure
|
| 769 |
+
offset = 0 # Adjust this value to match the structure alignment
|
| 770 |
+
for i, residue in enumerate(residues):
|
| 771 |
+
# Calculate position in a circle around the structure
|
| 772 |
+
# Start from 0 (3 o'clock) and go counterclockwise
|
| 773 |
+
angle = -(2 * np.pi * ((i + offset) % n_residues) / n_residues)
|
| 774 |
+
|
| 775 |
+
# Calculate label position
|
| 776 |
+
label_x = center_x + radius * np.cos(angle)
|
| 777 |
+
label_y = center_y + radius * np.sin(angle)
|
| 778 |
+
|
| 779 |
+
# Draw residue label
|
| 780 |
+
text = f"{i+1}. {residue}"
|
| 781 |
+
bbox = draw.textbbox((label_x, label_y), text, font=font)
|
| 782 |
+
padding = 10
|
| 783 |
+
draw.rectangle([bbox[0]-padding, bbox[1]-padding,
|
| 784 |
+
bbox[2]+padding, bbox[3]+padding],
|
| 785 |
+
fill='white', outline='white')
|
| 786 |
+
draw.text((label_x, label_y), text,
|
| 787 |
+
font=font, fill='black', anchor="mm")
|
| 788 |
+
|
| 789 |
+
# Add sequence at the top with white background
|
| 790 |
+
seq_text = f"Sequence: {sequence}"
|
| 791 |
+
bbox = draw.textbbox((center_x, 100), seq_text, font=small_font)
|
| 792 |
+
padding = 10
|
| 793 |
+
draw.rectangle([bbox[0]-padding, bbox[1]-padding,
|
| 794 |
+
bbox[2]+padding, bbox[3]+padding],
|
| 795 |
+
fill='white', outline='white')
|
| 796 |
+
draw.text((center_x, 100), seq_text,
|
| 797 |
+
font=small_font, fill='black', anchor="mm")
|
| 798 |
+
|
| 799 |
+
return img
|
| 800 |
+
"""
|
| 801 |
+
def annotate_cyclic_structure(mol, sequence):
|
| 802 |
+
"""Create structure visualization with just the sequence header"""
|
| 803 |
+
# Generate 2D coordinates
|
| 804 |
+
AllChem.Compute2DCoords(mol)
|
| 805 |
+
|
| 806 |
+
# Create drawer with larger size for annotations
|
| 807 |
+
drawer = Draw.rdMolDraw2D.MolDraw2DCairo(2000, 2000)
|
| 808 |
+
|
| 809 |
+
# Draw molecule first
|
| 810 |
+
drawer.drawOptions().addAtomIndices = False
|
| 811 |
+
drawer.DrawMolecule(mol)
|
| 812 |
+
drawer.FinishDrawing()
|
| 813 |
+
|
| 814 |
+
# Convert to PIL Image
|
| 815 |
+
img = Image.open(BytesIO(drawer.GetDrawingText()))
|
| 816 |
+
draw = ImageDraw.Draw(img)
|
| 817 |
+
try:
|
| 818 |
+
small_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 60)
|
| 819 |
+
except OSError:
|
| 820 |
+
try:
|
| 821 |
+
small_font = ImageFont.truetype("arial.ttf", 60)
|
| 822 |
+
except OSError:
|
| 823 |
+
print("Warning: TrueType fonts not available, using default font")
|
| 824 |
+
small_font = ImageFont.load_default()
|
| 825 |
+
|
| 826 |
+
# Add just the sequence header at the top
|
| 827 |
+
seq_text = f"Sequence: {sequence}"
|
| 828 |
+
bbox = draw.textbbox((1000, 100), seq_text, font=small_font)
|
| 829 |
+
padding = 10
|
| 830 |
+
draw.rectangle([bbox[0]-padding, bbox[1]-padding,
|
| 831 |
+
bbox[2]+padding, bbox[3]+padding],
|
| 832 |
+
fill='white', outline='white')
|
| 833 |
+
draw.text((1000, 100), seq_text,
|
| 834 |
+
font=small_font, fill='black', anchor="mm")
|
| 835 |
+
|
| 836 |
+
return img
|
| 837 |
+
|
| 838 |
+
def create_enhanced_linear_viz(sequence, smiles):
|
| 839 |
+
"""Create an enhanced linear representation using PeptideAnalyzer"""
|
| 840 |
+
analyzer = PeptideAnalyzer() # Create analyzer instance
|
| 841 |
+
|
| 842 |
+
# Create figure with two subplots
|
| 843 |
+
fig = plt.figure(figsize=(15, 10))
|
| 844 |
+
gs = fig.add_gridspec(2, 1, height_ratios=[1, 2])
|
| 845 |
+
ax_struct = fig.add_subplot(gs[0])
|
| 846 |
+
ax_detail = fig.add_subplot(gs[1])
|
| 847 |
+
|
| 848 |
+
# Parse sequence and get residues
|
| 849 |
+
if sequence.startswith('cyclo('):
|
| 850 |
+
residues = sequence[6:-1].split('-')
|
| 851 |
+
else:
|
| 852 |
+
residues = sequence.split('-')
|
| 853 |
+
|
| 854 |
+
# Get segments using analyzer
|
| 855 |
+
segments = analyzer.split_on_bonds(smiles)
|
| 856 |
+
|
| 857 |
+
# Debug print
|
| 858 |
+
print(f"Number of residues: {len(residues)}")
|
| 859 |
+
print(f"Number of segments: {len(segments)}")
|
| 860 |
+
|
| 861 |
+
# Top subplot - Basic structure
|
| 862 |
+
ax_struct.set_xlim(0, 10)
|
| 863 |
+
ax_struct.set_ylim(0, 2)
|
| 864 |
+
|
| 865 |
+
num_residues = len(residues)
|
| 866 |
+
spacing = 9.0 / (num_residues - 1) if num_residues > 1 else 9.0
|
| 867 |
+
|
| 868 |
+
# Draw basic structure
|
| 869 |
+
y_pos = 1.5
|
| 870 |
+
for i in range(num_residues):
|
| 871 |
+
x_pos = 0.5 + i * spacing
|
| 872 |
+
|
| 873 |
+
# Draw amino acid box
|
| 874 |
+
rect = patches.Rectangle((x_pos-0.3, y_pos-0.2), 0.6, 0.4,
|
| 875 |
+
facecolor='lightblue', edgecolor='black')
|
| 876 |
+
ax_struct.add_patch(rect)
|
| 877 |
+
|
| 878 |
+
# Draw connecting bonds if not the last residue
|
| 879 |
+
if i < num_residues - 1:
|
| 880 |
+
segment = segments[i] if i < len(segments) else None
|
| 881 |
+
if segment:
|
| 882 |
+
# Determine bond type from segment info
|
| 883 |
+
bond_type = 'ester' if 'O-linked' in segment.get('bond_after', '') else 'peptide'
|
| 884 |
+
is_n_methylated = 'N-Me' in segment.get('bond_after', '')
|
| 885 |
+
|
| 886 |
+
bond_color = 'red' if bond_type == 'ester' else 'black'
|
| 887 |
+
linestyle = '--' if bond_type == 'ester' else '-'
|
| 888 |
+
|
| 889 |
+
# Draw bond line
|
| 890 |
+
ax_struct.plot([x_pos+0.3, x_pos+spacing-0.3], [y_pos, y_pos],
|
| 891 |
+
color=bond_color, linestyle=linestyle, linewidth=2)
|
| 892 |
+
|
| 893 |
+
# Add bond type label
|
| 894 |
+
mid_x = x_pos + spacing/2
|
| 895 |
+
bond_label = f"{bond_type}"
|
| 896 |
+
if is_n_methylated:
|
| 897 |
+
bond_label += "\n(N-Me)"
|
| 898 |
+
ax_struct.text(mid_x, y_pos+0.1, bond_label,
|
| 899 |
+
ha='center', va='bottom', fontsize=10,
|
| 900 |
+
color=bond_color)
|
| 901 |
+
|
| 902 |
+
# Add residue label
|
| 903 |
+
ax_struct.text(x_pos, y_pos-0.5, residues[i],
|
| 904 |
+
ha='center', va='top', fontsize=14)
|
| 905 |
+
|
| 906 |
+
# Bottom subplot - Detailed breakdown
|
| 907 |
+
ax_detail.set_ylim(0, len(segments)+1)
|
| 908 |
+
ax_detail.set_xlim(0, 1)
|
| 909 |
+
|
| 910 |
+
# Create detailed breakdown
|
| 911 |
+
segment_y = len(segments) # Start from top
|
| 912 |
+
for i, segment in enumerate(segments):
|
| 913 |
+
y = segment_y - i
|
| 914 |
+
|
| 915 |
+
# Check if this is a bond or residue
|
| 916 |
+
residue, mods = analyzer.identify_residue(segment)
|
| 917 |
+
if residue:
|
| 918 |
+
text = f"Residue {i+1}: {residue}"
|
| 919 |
+
if mods:
|
| 920 |
+
text += f" ({', '.join(mods)})"
|
| 921 |
+
color = 'blue'
|
| 922 |
+
else:
|
| 923 |
+
# Must be a bond
|
| 924 |
+
text = f"Bond {i}: "
|
| 925 |
+
if 'O-linked' in segment.get('bond_after', ''):
|
| 926 |
+
text += "ester"
|
| 927 |
+
elif 'N-Me' in segment.get('bond_after', ''):
|
| 928 |
+
text += "peptide (N-methylated)"
|
| 929 |
+
else:
|
| 930 |
+
text += "peptide"
|
| 931 |
+
color = 'red'
|
| 932 |
+
|
| 933 |
+
# Add segment analysis
|
| 934 |
+
ax_detail.text(0.05, y, text, fontsize=12, color=color)
|
| 935 |
+
ax_detail.text(0.5, y, f"SMILES: {segment.get('content', '')}", fontsize=10, color='gray')
|
| 936 |
+
|
| 937 |
+
# If cyclic, add connection indicator
|
| 938 |
+
if sequence.startswith('cyclo('):
|
| 939 |
+
ax_struct.annotate('', xy=(9.5, y_pos), xytext=(0.5, y_pos),
|
| 940 |
+
arrowprops=dict(arrowstyle='<->', color='red', lw=2))
|
| 941 |
+
ax_struct.text(5, y_pos+0.3, 'Cyclic Connection',
|
| 942 |
+
ha='center', color='red', fontsize=14)
|
| 943 |
+
|
| 944 |
+
# Add titles and adjust layout
|
| 945 |
+
ax_struct.set_title("Peptide Structure Overview", pad=20)
|
| 946 |
+
ax_detail.set_title("Segment Analysis Breakdown", pad=20)
|
| 947 |
+
|
| 948 |
+
# Remove axes
|
| 949 |
+
for ax in [ax_struct, ax_detail]:
|
| 950 |
+
ax.set_xticks([])
|
| 951 |
+
ax.set_yticks([])
|
| 952 |
+
ax.axis('off')
|
| 953 |
+
|
| 954 |
+
plt.tight_layout()
|
| 955 |
+
return fig
|
| 956 |
+
|
| 957 |
+
class PeptideStructureGenerator:
|
| 958 |
+
"""A class to generate 3D structures of peptides using different embedding methods"""
|
| 959 |
+
|
| 960 |
+
@staticmethod
|
| 961 |
+
def prepare_molecule(smiles):
|
| 962 |
+
"""Prepare molecule with proper hydrogen handling"""
|
| 963 |
+
mol = Chem.MolFromSmiles(smiles, sanitize=False)
|
| 964 |
+
if mol is None:
|
| 965 |
+
raise ValueError("Failed to create molecule from SMILES")
|
| 966 |
+
|
| 967 |
+
# Calculate valence for each atom
|
| 968 |
+
for atom in mol.GetAtoms():
|
| 969 |
+
atom.UpdatePropertyCache(strict=False)
|
| 970 |
+
|
| 971 |
+
# Sanitize with reduced requirements
|
| 972 |
+
Chem.SanitizeMol(mol,
|
| 973 |
+
sanitizeOps=Chem.SANITIZE_FINDRADICALS|
|
| 974 |
+
Chem.SANITIZE_KEKULIZE|
|
| 975 |
+
Chem.SANITIZE_SETAROMATICITY|
|
| 976 |
+
Chem.SANITIZE_SETCONJUGATION|
|
| 977 |
+
Chem.SANITIZE_SETHYBRIDIZATION|
|
| 978 |
+
Chem.SANITIZE_CLEANUPCHIRALITY)
|
| 979 |
+
|
| 980 |
+
mol = Chem.AddHs(mol)
|
| 981 |
+
return mol
|
| 982 |
+
|
| 983 |
+
@staticmethod
|
| 984 |
+
def get_etkdg_params(attempt=0):
|
| 985 |
+
"""Get ETKDG parameters with optional modifications based on attempt number"""
|
| 986 |
+
params = AllChem.ETKDGv3()
|
| 987 |
+
params.randomSeed = -1
|
| 988 |
+
params.maxIterations = 200
|
| 989 |
+
params.numThreads = 4 # Reduced for web interface
|
| 990 |
+
params.useBasicKnowledge = True
|
| 991 |
+
params.enforceChirality = True
|
| 992 |
+
params.useExpTorsionAnglePrefs = True
|
| 993 |
+
params.useSmallRingTorsions = True
|
| 994 |
+
params.useMacrocycleTorsions = True
|
| 995 |
+
params.ETversion = 2
|
| 996 |
+
params.pruneRmsThresh = -1
|
| 997 |
+
params.embedRmsThresh = 0.5
|
| 998 |
+
|
| 999 |
+
if attempt > 10:
|
| 1000 |
+
params.bondLength = 1.5 + (attempt - 10) * 0.02
|
| 1001 |
+
params.useExpTorsionAnglePrefs = False
|
| 1002 |
+
|
| 1003 |
+
return params
|
| 1004 |
+
|
| 1005 |
+
def generate_structure_etkdg(self, smiles, max_attempts=20):
|
| 1006 |
+
"""Generate 3D structure using ETKDG without UFF optimization"""
|
| 1007 |
+
success = False
|
| 1008 |
+
mol = None
|
| 1009 |
+
|
| 1010 |
+
for attempt in range(max_attempts):
|
| 1011 |
+
try:
|
| 1012 |
+
mol = self.prepare_molecule(smiles)
|
| 1013 |
+
params = self.get_etkdg_params(attempt)
|
| 1014 |
+
|
| 1015 |
+
if AllChem.EmbedMolecule(mol, params) == 0:
|
| 1016 |
+
success = True
|
| 1017 |
+
break
|
| 1018 |
+
except Exception as e:
|
| 1019 |
+
continue
|
| 1020 |
+
|
| 1021 |
+
if not success:
|
| 1022 |
+
raise ValueError("Failed to generate structure with ETKDG")
|
| 1023 |
+
|
| 1024 |
+
return mol
|
| 1025 |
+
|
| 1026 |
+
def generate_structure_uff(self, smiles, max_attempts=20):
|
| 1027 |
+
"""Generate 3D structure using ETKDG followed by UFF optimization"""
|
| 1028 |
+
best_mol = None
|
| 1029 |
+
lowest_energy = float('inf')
|
| 1030 |
+
|
| 1031 |
+
for attempt in range(max_attempts):
|
| 1032 |
+
try:
|
| 1033 |
+
test_mol = self.prepare_molecule(smiles)
|
| 1034 |
+
params = self.get_etkdg_params(attempt)
|
| 1035 |
+
|
| 1036 |
+
if AllChem.EmbedMolecule(test_mol, params) == 0:
|
| 1037 |
+
res = AllChem.UFFOptimizeMolecule(test_mol, maxIters=2000,
|
| 1038 |
+
vdwThresh=10.0, confId=0,
|
| 1039 |
+
ignoreInterfragInteractions=True)
|
| 1040 |
+
|
| 1041 |
+
if res == 0:
|
| 1042 |
+
ff = AllChem.UFFGetMoleculeForceField(test_mol)
|
| 1043 |
+
if ff:
|
| 1044 |
+
current_energy = ff.CalcEnergy()
|
| 1045 |
+
if current_energy < lowest_energy:
|
| 1046 |
+
lowest_energy = current_energy
|
| 1047 |
+
best_mol = Chem.Mol(test_mol)
|
| 1048 |
+
except Exception:
|
| 1049 |
+
continue
|
| 1050 |
+
|
| 1051 |
+
if best_mol is None:
|
| 1052 |
+
raise ValueError("Failed to generate optimized structure")
|
| 1053 |
+
|
| 1054 |
+
return best_mol
|
| 1055 |
+
|
| 1056 |
+
@staticmethod
|
| 1057 |
+
def mol_to_sdf_bytes(mol):
|
| 1058 |
+
"""Convert RDKit molecule to SDF file bytes"""
|
| 1059 |
+
# First write to StringIO in text mode
|
| 1060 |
+
sio = StringIO()
|
| 1061 |
+
writer = Chem.SDWriter(sio)
|
| 1062 |
+
writer.write(mol)
|
| 1063 |
+
writer.close()
|
| 1064 |
+
|
| 1065 |
+
# Convert the string to bytes
|
| 1066 |
+
return sio.getvalue().encode('utf-8')
|
| 1067 |
+
|
| 1068 |
+
def process_input(smiles_input=None, file_obj=None, show_linear=False,
|
| 1069 |
+
show_segment_details=False, generate_3d=False, use_uff=False):
|
| 1070 |
+
"""Process input and create visualizations using PeptideAnalyzer"""
|
| 1071 |
+
analyzer = PeptideAnalyzer()
|
| 1072 |
+
temp_dir = tempfile.mkdtemp() if generate_3d else None
|
| 1073 |
+
structure_files = []
|
| 1074 |
+
|
| 1075 |
+
# Handle direct SMILES input
|
| 1076 |
+
if smiles_input:
|
| 1077 |
+
smiles = smiles_input.strip()
|
| 1078 |
+
|
| 1079 |
+
# First check if it's a peptide using analyzer's method
|
| 1080 |
+
if not analyzer.is_peptide(smiles):
|
| 1081 |
+
return "Error: Input SMILES does not appear to be a peptide structure.", None, None
|
| 1082 |
+
|
| 1083 |
+
try:
|
| 1084 |
+
# Create molecule
|
| 1085 |
+
mol = Chem.MolFromSmiles(smiles)
|
| 1086 |
+
if mol is None:
|
| 1087 |
+
return "Error: Invalid SMILES notation.", None, None
|
| 1088 |
+
|
| 1089 |
+
# Generate 3D structures if requested
|
| 1090 |
+
if generate_3d:
|
| 1091 |
+
generator = PeptideStructureGenerator()
|
| 1092 |
+
|
| 1093 |
+
try:
|
| 1094 |
+
# Generate ETKDG structure
|
| 1095 |
+
mol_etkdg = generator.generate_structure_etkdg(smiles)
|
| 1096 |
+
etkdg_path = os.path.join(temp_dir, "structure_etkdg.sdf")
|
| 1097 |
+
writer = Chem.SDWriter(etkdg_path)
|
| 1098 |
+
writer.write(mol_etkdg)
|
| 1099 |
+
writer.close()
|
| 1100 |
+
structure_files.append(etkdg_path)
|
| 1101 |
+
|
| 1102 |
+
# Generate UFF structure if requested
|
| 1103 |
+
if use_uff:
|
| 1104 |
+
mol_uff = generator.generate_structure_uff(smiles)
|
| 1105 |
+
uff_path = os.path.join(temp_dir, "structure_uff.sdf")
|
| 1106 |
+
writer = Chem.SDWriter(uff_path)
|
| 1107 |
+
writer.write(mol_uff)
|
| 1108 |
+
writer.close()
|
| 1109 |
+
structure_files.append(uff_path)
|
| 1110 |
+
|
| 1111 |
+
except Exception as e:
|
| 1112 |
+
return f"Error generating 3D structures: {str(e)}", None, None, None
|
| 1113 |
+
|
| 1114 |
+
# Use analyzer to get sequence
|
| 1115 |
+
segments = analyzer.split_on_bonds(smiles)
|
| 1116 |
+
|
| 1117 |
+
# Process segments and build sequence
|
| 1118 |
+
sequence_parts = []
|
| 1119 |
+
output_text = ""
|
| 1120 |
+
|
| 1121 |
+
# Only include segment analysis in output if requested
|
| 1122 |
+
if show_segment_details:
|
| 1123 |
+
output_text += "Segment Analysis:\n"
|
| 1124 |
+
for i, segment in enumerate(segments):
|
| 1125 |
+
output_text += f"\nSegment {i}:\n"
|
| 1126 |
+
output_text += f"Content: {segment['content']}\n"
|
| 1127 |
+
output_text += f"Bond before: {segment.get('bond_before', 'None')}\n"
|
| 1128 |
+
output_text += f"Bond after: {segment.get('bond_after', 'None')}\n"
|
| 1129 |
+
|
| 1130 |
+
residue, mods = analyzer.identify_residue(segment)
|
| 1131 |
+
if residue:
|
| 1132 |
+
if mods:
|
| 1133 |
+
sequence_parts.append(f"{residue}({','.join(mods)})")
|
| 1134 |
+
else:
|
| 1135 |
+
sequence_parts.append(residue)
|
| 1136 |
+
output_text += f"Identified as: {residue}\n"
|
| 1137 |
+
output_text += f"Modifications: {mods}\n"
|
| 1138 |
+
else:
|
| 1139 |
+
output_text += f"Warning: Could not identify residue in segment: {segment['content']}\n"
|
| 1140 |
+
output_text += "\n"
|
| 1141 |
+
else:
|
| 1142 |
+
# Just build sequence without detailed analysis in output
|
| 1143 |
+
for segment in segments:
|
| 1144 |
+
residue, mods = analyzer.identify_residue(segment)
|
| 1145 |
+
if residue:
|
| 1146 |
+
if mods:
|
| 1147 |
+
sequence_parts.append(f"{residue}({','.join(mods)})")
|
| 1148 |
+
else:
|
| 1149 |
+
sequence_parts.append(residue)
|
| 1150 |
+
|
| 1151 |
+
# Check if cyclic using analyzer's method
|
| 1152 |
+
is_cyclic, peptide_cycles, aromatic_cycles = analyzer.is_cyclic(smiles)
|
| 1153 |
+
three_letter = '-'.join(sequence_parts)
|
| 1154 |
+
one_letter = ''.join(analyzer.three_to_one.get(aa.split('(')[0], 'X') for aa in sequence_parts)
|
| 1155 |
+
|
| 1156 |
+
if is_cyclic:
|
| 1157 |
+
three_letter = f"cyclo({three_letter})"
|
| 1158 |
+
one_letter = f"cyclo({one_letter})"
|
| 1159 |
+
|
| 1160 |
+
# Create cyclic structure visualization
|
| 1161 |
+
img_cyclic = annotate_cyclic_structure(mol, three_letter)
|
| 1162 |
+
|
| 1163 |
+
# Create linear representation if requested
|
| 1164 |
+
img_linear = None
|
| 1165 |
+
if show_linear:
|
| 1166 |
+
fig_linear = create_enhanced_linear_viz(three_letter, smiles)
|
| 1167 |
+
buf = BytesIO()
|
| 1168 |
+
fig_linear.savefig(buf, format='png', bbox_inches='tight', dpi=300)
|
| 1169 |
+
buf.seek(0)
|
| 1170 |
+
img_linear = Image.open(buf)
|
| 1171 |
+
plt.close(fig_linear)
|
| 1172 |
+
|
| 1173 |
+
# Add summary to output
|
| 1174 |
+
summary = "Summary:\n"
|
| 1175 |
+
summary += f"Sequence: {three_letter}\n"
|
| 1176 |
+
summary += f"One-letter code: {one_letter}\n"
|
| 1177 |
+
summary += f"Is Cyclic: {'Yes' if is_cyclic else 'No'}\n"
|
| 1178 |
+
#if is_cyclic:
|
| 1179 |
+
#summary += f"Peptide Cycles: {', '.join(peptide_cycles)}\n"
|
| 1180 |
+
#summary += f"Aromatic Cycles: {', '.join(aromatic_cycles)}\n"
|
| 1181 |
+
|
| 1182 |
+
if structure_files:
|
| 1183 |
+
summary += "\n3D Structures Generated:\n"
|
| 1184 |
+
for filepath in structure_files:
|
| 1185 |
+
summary += f"- {os.path.basename(filepath)}\n"
|
| 1186 |
+
|
| 1187 |
+
return summary + output_text, img_cyclic, img_linear, structure_files if structure_files else None
|
| 1188 |
+
|
| 1189 |
+
except Exception as e:
|
| 1190 |
+
return f"Error processing SMILES: {str(e)}", None, None, None
|
| 1191 |
+
|
| 1192 |
+
# Handle file input
|
| 1193 |
+
if file_obj is not None:
|
| 1194 |
+
try:
|
| 1195 |
+
# Handle file content
|
| 1196 |
+
if hasattr(file_obj, 'name'):
|
| 1197 |
+
with open(file_obj.name, 'r') as f:
|
| 1198 |
+
content = f.read()
|
| 1199 |
+
else:
|
| 1200 |
+
content = file_obj.decode('utf-8') if isinstance(file_obj, bytes) else str(file_obj)
|
| 1201 |
+
|
| 1202 |
+
output_text = ""
|
| 1203 |
+
for line in content.splitlines():
|
| 1204 |
+
smiles = line.strip()
|
| 1205 |
+
if smiles:
|
| 1206 |
+
# Check if it's a peptide
|
| 1207 |
+
if not analyzer.is_peptide(smiles):
|
| 1208 |
+
output_text += f"Skipping non-peptide SMILES: {smiles}\n"
|
| 1209 |
+
continue
|
| 1210 |
+
|
| 1211 |
+
# Process this SMILES
|
| 1212 |
+
segments = analyzer.split_on_bonds(smiles)
|
| 1213 |
+
sequence_parts = []
|
| 1214 |
+
|
| 1215 |
+
# Add segment details if requested
|
| 1216 |
+
if show_segment_details:
|
| 1217 |
+
output_text += f"\nSegment Analysis for SMILES: {smiles}\n"
|
| 1218 |
+
for i, segment in enumerate(segments):
|
| 1219 |
+
output_text += f"\nSegment {i}:\n"
|
| 1220 |
+
output_text += f"Content: {segment['content']}\n"
|
| 1221 |
+
output_text += f"Bond before: {segment.get('bond_before', 'None')}\n"
|
| 1222 |
+
output_text += f"Bond after: {segment.get('bond_after', 'None')}\n"
|
| 1223 |
+
residue, mods = analyzer.identify_residue(segment)
|
| 1224 |
+
if residue:
|
| 1225 |
+
if mods:
|
| 1226 |
+
sequence_parts.append(f"{residue}({','.join(mods)})")
|
| 1227 |
+
else:
|
| 1228 |
+
sequence_parts.append(residue)
|
| 1229 |
+
output_text += f"Identified as: {residue}\n"
|
| 1230 |
+
output_text += f"Modifications: {mods}\n"
|
| 1231 |
+
else:
|
| 1232 |
+
for segment in segments:
|
| 1233 |
+
residue, mods = analyzer.identify_residue(segment)
|
| 1234 |
+
if residue:
|
| 1235 |
+
if mods:
|
| 1236 |
+
sequence_parts.append(f"{residue}({','.join(mods)})")
|
| 1237 |
+
else:
|
| 1238 |
+
sequence_parts.append(residue)
|
| 1239 |
+
|
| 1240 |
+
# Get cyclicity and create sequence
|
| 1241 |
+
is_cyclic, peptide_cycles, aromatic_cycles = analyzer.is_cyclic(smiles)
|
| 1242 |
+
sequence = f"cyclo({'-'.join(sequence_parts)})" if is_cyclic else '-'.join(sequence_parts)
|
| 1243 |
+
|
| 1244 |
+
output_text += f"\nSummary for SMILES: {smiles}\n"
|
| 1245 |
+
output_text += f"Sequence: {sequence}\n"
|
| 1246 |
+
output_text += f"Is Cyclic: {'Yes' if is_cyclic else 'No'}\n"
|
| 1247 |
+
if is_cyclic:
|
| 1248 |
+
output_text += f"Peptide Cycles: {', '.join(peptide_cycles)}\n"
|
| 1249 |
+
#output_text += f"Aromatic Cycles: {', '.join(aromatic_cycles)}\n"
|
| 1250 |
+
output_text += "-" * 50 + "\n"
|
| 1251 |
+
|
| 1252 |
+
return output_text, None, None
|
| 1253 |
+
|
| 1254 |
+
except Exception as e:
|
| 1255 |
+
return f"Error processing file: {str(e)}", None, None
|
| 1256 |
+
|
| 1257 |
+
return "No input provided.", None, None
|
| 1258 |
+
|
| 1259 |
+
|
smiles/evaluation/run_metrics.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Script to run peptide metrics analysis on SMILES text files
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
python run_metrics.py input.txt [--target TARGET] [--output OUTPUT_DIR] [--device DEVICE]
|
| 7 |
+
|
| 8 |
+
Arguments:
|
| 9 |
+
input.txt: Text file containing SMILES sequences (one per line)
|
| 10 |
+
--target: Target protein (GNRHR / ERBB2 / NRP1). If not specified, will try to detect from filename
|
| 11 |
+
--output: Output directory for results (default: same as input file directory)
|
| 12 |
+
--device: Device for computation (cuda or cpu, default: cuda if available)
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import argparse
|
| 16 |
+
import os
|
| 17 |
+
import sys
|
| 18 |
+
import pandas as pd
|
| 19 |
+
import tempfile
|
| 20 |
+
import torch
|
| 21 |
+
import shutil
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
# Add current directory to path for imports
|
| 25 |
+
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
| 26 |
+
|
| 27 |
+
from smiles.evaluation.metrics import Metrics
|
| 28 |
+
|
| 29 |
+
def read_smiles_file(input_path):
|
| 30 |
+
"""
|
| 31 |
+
Read SMILES sequences from a text file
|
| 32 |
+
|
| 33 |
+
Args:
|
| 34 |
+
input_path: Path to input text file
|
| 35 |
+
|
| 36 |
+
Returns:
|
| 37 |
+
List of SMILES strings
|
| 38 |
+
"""
|
| 39 |
+
smiles_list = []
|
| 40 |
+
|
| 41 |
+
with open(input_path, 'r') as f:
|
| 42 |
+
for line_num, line in enumerate(f, 1):
|
| 43 |
+
line = line.strip()
|
| 44 |
+
if line and not line.startswith('#'): # Skip empty lines and comments
|
| 45 |
+
smiles_list.append(line)
|
| 46 |
+
|
| 47 |
+
print(f"Read {len(smiles_list)} SMILES sequences from {input_path}")
|
| 48 |
+
return smiles_list
|
| 49 |
+
|
| 50 |
+
def create_temp_csv(smiles_list, target=None):
|
| 51 |
+
"""
|
| 52 |
+
Create a temporary CSV file with SMILES data
|
| 53 |
+
|
| 54 |
+
Args:
|
| 55 |
+
smiles_list: List of SMILES strings
|
| 56 |
+
target: Target protein name (for filename)
|
| 57 |
+
|
| 58 |
+
Returns:
|
| 59 |
+
Path to temporary CSV file
|
| 60 |
+
"""
|
| 61 |
+
# Create DataFrame
|
| 62 |
+
df = pd.DataFrame({'SMILES': smiles_list})
|
| 63 |
+
|
| 64 |
+
# Create temporary file
|
| 65 |
+
suffix = f"_{target}" if target else ""
|
| 66 |
+
temp_file = tempfile.NamedTemporaryFile(
|
| 67 |
+
mode='w',
|
| 68 |
+
suffix=f"{suffix}_smiles.csv",
|
| 69 |
+
delete=False
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
# Save to CSV
|
| 73 |
+
df.to_csv(temp_file.name, index=False)
|
| 74 |
+
temp_file.close()
|
| 75 |
+
|
| 76 |
+
print(f"Created temporary CSV: {temp_file.name}")
|
| 77 |
+
return temp_file.name
|
| 78 |
+
|
| 79 |
+
def determine_target(input_path, target_arg):
|
| 80 |
+
"""
|
| 81 |
+
Determine target protein from filename or argument
|
| 82 |
+
|
| 83 |
+
Args:
|
| 84 |
+
input_path: Path to input file
|
| 85 |
+
target_arg: Target specified via command line argument
|
| 86 |
+
|
| 87 |
+
Returns:
|
| 88 |
+
Target protein name or None
|
| 89 |
+
"""
|
| 90 |
+
if target_arg:
|
| 91 |
+
target = target_arg.upper()
|
| 92 |
+
if target not in ['GNRHR', 'ERBB2', 'NRP1']:
|
| 93 |
+
print(f"Warning: Unknown target '{target}'. Supported targets: GNRHR, ERBB2, NRP1")
|
| 94 |
+
return None
|
| 95 |
+
return target
|
| 96 |
+
|
| 97 |
+
# Try to detect from filename
|
| 98 |
+
filename = os.path.basename(input_path).upper()
|
| 99 |
+
if 'GNRHR' in filename:
|
| 100 |
+
return 'GNRHR'
|
| 101 |
+
elif 'ERBB2' in filename:
|
| 102 |
+
return 'ERBB2'
|
| 103 |
+
elif 'NRP1' in filename:
|
| 104 |
+
return 'NRP1'
|
| 105 |
+
else:
|
| 106 |
+
print("Warning: Could not determine target from filename. Specify with --target")
|
| 107 |
+
print("Supported targets: GNRHR, ERBB2, NRP1")
|
| 108 |
+
return None
|
| 109 |
+
|
| 110 |
+
def save_results(results, output_path):
|
| 111 |
+
"""
|
| 112 |
+
Save analysis results to a text file
|
| 113 |
+
|
| 114 |
+
Args:
|
| 115 |
+
results: Dictionary of analysis results
|
| 116 |
+
output_path: Path to output file
|
| 117 |
+
"""
|
| 118 |
+
with open(output_path, 'w') as f:
|
| 119 |
+
f.write("PEPTIDE METRICS ANALYSIS RESULTS\n")
|
| 120 |
+
f.write("=" * 50 + "\n\n")
|
| 121 |
+
|
| 122 |
+
# Basic statistics
|
| 123 |
+
f.write("BASIC STATISTICS:\n")
|
| 124 |
+
f.write(f" Total sequences: {results.get('total_sequences', 0)}\n")
|
| 125 |
+
f.write(f" Valid peptides: {results.get('valid_peptides', 0)}\n")
|
| 126 |
+
f.write(f" Validity rate: {results.get('validity_rate', 0):.1%}\n")
|
| 127 |
+
f.write(f" Valid conjugates: {results.get('valid_conjugates', 0)}\n")
|
| 128 |
+
f.write(f" Conjugation success rate: {results.get('conjugation_success_rate', 0):.1%}\n")
|
| 129 |
+
f.write(f" Target protein: {results.get('target', 'Unknown')}\n\n")
|
| 130 |
+
|
| 131 |
+
# Binding affinity
|
| 132 |
+
if 'binding_affinity_all_mean' in results:
|
| 133 |
+
f.write("BINDING AFFINITY (All valid peptides):\n")
|
| 134 |
+
f.write(f" Mean: {results['binding_affinity_all_mean']:.4f}\n")
|
| 135 |
+
f.write(f" Std: {results['binding_affinity_all_std']:.4f}\n")
|
| 136 |
+
f.write(f" Min: {results['binding_affinity_all_min']:.4f}\n")
|
| 137 |
+
f.write(f" Max: {results['binding_affinity_all_max']:.4f}\n\n")
|
| 138 |
+
|
| 139 |
+
if 'binding_affinity_conjugatable_mean' in results:
|
| 140 |
+
f.write("BINDING AFFINITY (Conjugatable peptides):\n")
|
| 141 |
+
f.write(f" Mean: {results['binding_affinity_conjugatable_mean']:.4f}\n")
|
| 142 |
+
f.write(f" Std: {results['binding_affinity_conjugatable_std']:.4f}\n")
|
| 143 |
+
f.write(f" Min: {results['binding_affinity_conjugatable_min']:.4f}\n")
|
| 144 |
+
f.write(f" Max: {results['binding_affinity_conjugatable_max']:.4f}\n\n")
|
| 145 |
+
|
| 146 |
+
# Raw results (for programmatic access)
|
| 147 |
+
f.write("RAW RESULTS (JSON format):\n")
|
| 148 |
+
f.write(str(results))
|
| 149 |
+
|
| 150 |
+
def main():
|
| 151 |
+
parser = argparse.ArgumentParser(
|
| 152 |
+
description="Run peptide metrics analysis on SMILES text files",
|
| 153 |
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
| 154 |
+
epilog="""
|
| 155 |
+
Examples:
|
| 156 |
+
python run_metrics.py peptides.txt
|
| 157 |
+
python run_metrics.py gnrhr_peptides.txt --target GNRHR
|
| 158 |
+
python run_metrics.py peptides.txt --target ERBB2 --output ./results
|
| 159 |
+
python run_metrics.py peptides.txt --target NRP1 --device cpu
|
| 160 |
+
"""
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
parser.add_argument('input_file', type=str,
|
| 164 |
+
help='Text file containing SMILES sequences (one per line)')
|
| 165 |
+
parser.add_argument('--target', type=str, choices=['GNRHR', 'ERBB2', 'NRP1'], default=None,
|
| 166 |
+
help='Target protein (GNRHR, ERBB2, NRP1). If not specified, will try to detect from filename')
|
| 167 |
+
parser.add_argument('--output', type=str, default=None,
|
| 168 |
+
help='Output directory for results (default: same as input file directory)')
|
| 169 |
+
parser.add_argument('--device', type=str, default='auto',
|
| 170 |
+
help='Device for computation (cuda, cpu, or auto)')
|
| 171 |
+
parser.add_argument('--no-csv', action='store_true',
|
| 172 |
+
help='Skip saving detailed CSV results')
|
| 173 |
+
|
| 174 |
+
args = parser.parse_args()
|
| 175 |
+
|
| 176 |
+
# Validate input file
|
| 177 |
+
if not os.path.exists(args.input_file):
|
| 178 |
+
print(f"Error: Input file '{args.input_file}' does not exist")
|
| 179 |
+
return 1
|
| 180 |
+
|
| 181 |
+
# Determine device
|
| 182 |
+
if args.device == 'auto':
|
| 183 |
+
device = 'cuda' if torch.cuda.is_available() else 'cpu'
|
| 184 |
+
else:
|
| 185 |
+
device = args.device
|
| 186 |
+
|
| 187 |
+
print(f"Using device: {device}")
|
| 188 |
+
|
| 189 |
+
# Set up output directory
|
| 190 |
+
if args.output:
|
| 191 |
+
output_dir = args.output
|
| 192 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 193 |
+
else:
|
| 194 |
+
output_dir = os.path.dirname(os.path.abspath(args.input_file))
|
| 195 |
+
|
| 196 |
+
# Generate output filenames
|
| 197 |
+
base_name = Path(args.input_file).stem
|
| 198 |
+
results_file = os.path.join(output_dir, f"{base_name}_metrics.txt")
|
| 199 |
+
|
| 200 |
+
try:
|
| 201 |
+
# Read SMILES sequences
|
| 202 |
+
print("Reading SMILES sequences...")
|
| 203 |
+
smiles_list = read_smiles_file(args.input_file)
|
| 204 |
+
|
| 205 |
+
if not smiles_list:
|
| 206 |
+
print("Error: No SMILES sequences found in input file")
|
| 207 |
+
return 1
|
| 208 |
+
|
| 209 |
+
# Determine target
|
| 210 |
+
target = determine_target(args.input_file, args.target)
|
| 211 |
+
if not target:
|
| 212 |
+
print("Error: Could not determine target protein. Use --target to specify.")
|
| 213 |
+
return 1
|
| 214 |
+
|
| 215 |
+
# Create temporary CSV file
|
| 216 |
+
temp_csv_path = create_temp_csv(smiles_list, target)
|
| 217 |
+
|
| 218 |
+
try:
|
| 219 |
+
# Initialize metrics calculator
|
| 220 |
+
print("Initializing metrics calculator...")
|
| 221 |
+
metrics_calculator = Metrics(
|
| 222 |
+
input_type='peptide',
|
| 223 |
+
device=device
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
# Run analysis
|
| 227 |
+
print("Running peptide analysis...")
|
| 228 |
+
results = metrics_calculator.get_metrics_for_peptides(
|
| 229 |
+
temp_csv_path,
|
| 230 |
+
save_csv=(not args.no_csv)
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
# Save results
|
| 234 |
+
save_results(results, results_file)
|
| 235 |
+
print(f"\nResults saved to: {results_file}")
|
| 236 |
+
|
| 237 |
+
# Handle detailed CSV if it was created
|
| 238 |
+
if not args.no_csv and results.get('csv_path'):
|
| 239 |
+
source_csv = results['csv_path']
|
| 240 |
+
|
| 241 |
+
if os.path.exists(source_csv):
|
| 242 |
+
final_csv = os.path.join(output_dir, f"{base_name}_conjugate_analysis.csv")
|
| 243 |
+
|
| 244 |
+
# If output_dir is different from source directory, move the file
|
| 245 |
+
if os.path.abspath(os.path.dirname(source_csv)) != os.path.abspath(output_dir):
|
| 246 |
+
try:
|
| 247 |
+
shutil.move(source_csv, final_csv)
|
| 248 |
+
print(f"Detailed CSV moved to: {final_csv}")
|
| 249 |
+
except Exception as e:
|
| 250 |
+
# Fallback: copy then delete
|
| 251 |
+
try:
|
| 252 |
+
shutil.copy2(source_csv, final_csv)
|
| 253 |
+
os.remove(source_csv)
|
| 254 |
+
print(f"Detailed CSV copied to: {final_csv}")
|
| 255 |
+
except Exception as e2:
|
| 256 |
+
print(f"Warning: Failed to move CSV file: {e2}")
|
| 257 |
+
print(f"CSV remains at: {source_csv}")
|
| 258 |
+
else:
|
| 259 |
+
print(f"Detailed CSV already saved at: {source_csv}")
|
| 260 |
+
else:
|
| 261 |
+
print(f"Warning: Expected detailed CSV not found at: {source_csv}")
|
| 262 |
+
elif not args.no_csv:
|
| 263 |
+
print("Warning: No detailed CSV was created (possibly no valid conjugates found)")
|
| 264 |
+
|
| 265 |
+
finally:
|
| 266 |
+
# Clean up temporary CSV
|
| 267 |
+
if os.path.exists(temp_csv_path):
|
| 268 |
+
os.unlink(temp_csv_path)
|
| 269 |
+
print(f"Cleaned up temporary file: {temp_csv_path}")
|
| 270 |
+
|
| 271 |
+
print("\nAnalysis complete!")
|
| 272 |
+
return 0
|
| 273 |
+
|
| 274 |
+
except Exception as e:
|
| 275 |
+
print(f"Error during analysis: {e}")
|
| 276 |
+
import traceback
|
| 277 |
+
traceback.print_exc()
|
| 278 |
+
return 1
|
| 279 |
+
|
| 280 |
+
if __name__ == "__main__":
|
| 281 |
+
exit_code = main()
|
| 282 |
+
sys.exit(exit_code)
|
smiles/evaluation/run_metrics_uaa.py
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
run_metrics_uaa.py
|
| 4 |
+
==================
|
| 5 |
+
|
| 6 |
+
Re-evaluates already-generated UAA peptide-drug conjugates after stripping
|
| 7 |
+
Fmoc-strategy side-chain protecting groups, and reports protected-vs-deprotected
|
| 8 |
+
correlation for the binding-affinity oracle.
|
| 9 |
+
|
| 10 |
+
This script does NOT re-run guidance / generation. It works entirely on the
|
| 11 |
+
existing `*_conjugate_analysis.csv` files produced by run_metrics.py.
|
| 12 |
+
|
| 13 |
+
Pipeline per input CSV
|
| 14 |
+
----------------------
|
| 15 |
+
1. Load CSV (must contain `peptide_smiles`, `conjugate_smiles`, `binding_affinity`).
|
| 16 |
+
2. Strip protecting groups from `peptide_smiles` and `conjugate_smiles`
|
| 17 |
+
using a small library of RDKit reactions (Mmt, Trt, Boc, OtBu ester,
|
| 18 |
+
OtBu/OBn ether, OBn ester, OBn-phosphate, Pbf).
|
| 19 |
+
3. Recompute molecular properties (MW, LogP, TPSA, HBD/HBA, rotbonds,
|
| 20 |
+
heavy atoms) on the deprotected conjugate.
|
| 21 |
+
4. Re-tokenize the deprotected peptide SMILES and re-score with the same
|
| 22 |
+
`BindingAffinity` model used originally. (Skip with --no-rescore.)
|
| 23 |
+
5. Optionally pair each row with the matching NCAA sequence string from the
|
| 24 |
+
original `.txt` file (--ncaa-txt or auto-detect).
|
| 25 |
+
6. Save `<input>_deprot.csv` with both protected and deprotected columns,
|
| 26 |
+
plus a `uaa_deprotection_summary.csv` aggregating across all inputs:
|
| 27 |
+
- n
|
| 28 |
+
- mean / std of binding (×10 pKd) protected and deprotected
|
| 29 |
+
- Pearson and Spearman correlation across the two
|
| 30 |
+
- per-rule strip counts on conjugates
|
| 31 |
+
|
| 32 |
+
Usage
|
| 33 |
+
-----
|
| 34 |
+
python run_metrics_uaa.py INPUT_CSV [INPUT_CSV ...] \
|
| 35 |
+
[--output OUTPUT_DIR] [--target {ERBB2,GNRHR,NRP1}] \
|
| 36 |
+
[--device {cuda,cpu,auto}] [--no-rescore] \
|
| 37 |
+
[--vocab PATH] [--splits PATH] [--ncaa-txt-dir PATH]
|
| 38 |
+
|
| 39 |
+
Examples
|
| 40 |
+
--------
|
| 41 |
+
# Re-evaluate one CSV
|
| 42 |
+
python -m smiles.evaluation.run_metrics_uaa \
|
| 43 |
+
smiles/results/uaa_results/cupofmoca_guided_uaa_ERBB2_SSCpt_k8_len10_smiles_conjugate_analysis.csv \
|
| 44 |
+
--output smiles/results/uaa_results_deprot
|
| 45 |
+
|
| 46 |
+
# Re-evaluate everything (see scripts/analyze_smiles_uaa_deprotect.sh)
|
| 47 |
+
"""
|
| 48 |
+
|
| 49 |
+
import argparse
|
| 50 |
+
import os
|
| 51 |
+
import sys
|
| 52 |
+
from pathlib import Path
|
| 53 |
+
|
| 54 |
+
import numpy as np
|
| 55 |
+
import pandas as pd
|
| 56 |
+
|
| 57 |
+
from rdkit import Chem, RDLogger
|
| 58 |
+
from rdkit.Chem import AllChem, Descriptors
|
| 59 |
+
|
| 60 |
+
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
| 61 |
+
|
| 62 |
+
RDLogger.DisableLog("rdApp.*")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ---------------------------------------------------------------------------
|
| 66 |
+
# Target protein sequences (must match metrics.py - copied here so this script
|
| 67 |
+
# is self-contained and doesn't trigger the full Metrics(...) construction
|
| 68 |
+
# which loads a 30K reference SMILES set we don't need.)
|
| 69 |
+
# ---------------------------------------------------------------------------
|
| 70 |
+
TARGET_SEQUENCES = {
|
| 71 |
+
'GNRHR': (
|
| 72 |
+
'MANSASPEQNQNHCSAINNSIPLMQGNLPTLTLSGKIRVTVTFFLFLLSATFNASFLLKLQKWTQKKEKGKK'
|
| 73 |
+
'LSRMKLLLKHLTLANLLETLIVMPLDGMWNITVQWYAGELLCKVLSYLKLFSMYAPAFMMVVISLDRSLAIT'
|
| 74 |
+
'RPLALKSNSKVGQSMVGLAWILSSVFAGPQLYIFRMIHLADSSGQTKVFSQCVTHCSFSQWWHQAFYNFFTF'
|
| 75 |
+
'SCLFIIPLFIMLICNAKIIFTLTRVLHQDPHELQLNQSKNNIPRARLKTLKMTVAFATSFTVCWTPYYVLGI'
|
| 76 |
+
'WYWFDPEMLNRLSDPVNHFFFLFAFLNPCFDPLIYGYFSL'
|
| 77 |
+
),
|
| 78 |
+
'ERBB2': (
|
| 79 |
+
'MELAALCRWGLLLALLPPGAASTQVCTGTDMKLRLPASPETHLDMLRHLYQGCQVVQGNLELTYLPTNASLS'
|
| 80 |
+
'FLQDIQEVQGYVLIAHNQVRQVPLQRLRIVRGTQLFEDNYALAVLDNGDPLNNTTPVTGASPGGLRELQLRS'
|
| 81 |
+
'LTEILKGGVLIQRNPQLCYQDTILWKDIFHKNNQLALTLIDTNRSRACHPCSPMCKGSRCWGESSEDCQSLT'
|
| 82 |
+
'RTVCAGGCARCKGPLPTDCCHEQCAAGCTGPKHSDCLACLHFNHSGICELHCPALVTYNTDTFESMPNPEGR'
|
| 83 |
+
'YTFGASCVTACPYNYLSTDVGSCTLVCPLHNQEVTAEDGTQRCEKCSKPCARVCYGLGMEHLREVRAVTSAN'
|
| 84 |
+
'IQEFAGCKKIFGSLAFLPESFDGDPASNTAPLQPEQLQVFETLEEITGYLYISAWPDSLPDLSVFQNLQVIR'
|
| 85 |
+
'GRILHNGAYSLTLQGLGISWLGLRSLRELGSGLALIHHNTHLCFVHTVPWDQLFRNPHQALLHTANRPEDEC'
|
| 86 |
+
'VGEGLACHQLCARGHCWGPGPTQCVNCSQFLRGQECVEECRVLQGLPREYVNARHCLPCHPECQPQNGSVTC'
|
| 87 |
+
'FGPEADQCVACAHYKDPPFCVARCPSGVKPDLSYMPIWKFPDEEGACQPCPINCTHSCVDLDDKGCPAEQRA'
|
| 88 |
+
'SPLTSIISAVVGILLVVVLGVVFGILIKRRQQKIRKYTMRRLLQETELVEPLTPSGAMPNQAQMRILKETEL'
|
| 89 |
+
'RKVKVLGSGAFGTVYKGIWIPDGENVKIPVAIKVLRENTSPKANKEILDEAYVMAGVGSPYVSRLLGICLTS'
|
| 90 |
+
'TVQLVTQLMPYGCLLDHVRENRGRLGSQDLLNWCMQIAKGMSYLEDVRLVHRDLAARNVLVKSPNHVKITDF'
|
| 91 |
+
'GLARLLDIDETEYHADGGKVPIKWMALESILRRRFTHQSDVWSYGVTVWELMTFGAKPYDGIPAREIPDLLE'
|
| 92 |
+
'KGERLPQPPICTIDVYMIMVKCWMIDSECRPRFRELVSEFSRMARDPQRFVVIQNEDLGPASPLDSTFYRSL'
|
| 93 |
+
'LEDDDMGDLVDAEEYLVPQQGFFCPDPAPGAGGMVHHRHRSSSTRSGGGDLTLGLEPSEEEAPRSPLAPSEG'
|
| 94 |
+
'AGSDVFDGDLGMGAAKGLQSLPTHDPSPLQRYSEDPTVPLPSETDGYVAPLTCSPQPEYVNQPDVRPQPPSP'
|
| 95 |
+
'REGPLPAARPAGATLERPKTLSPGKNGVVKDVFAFGGAVENPEYLTPQGGAAPQPHPPPAFSPAFDNLYYWD'
|
| 96 |
+
'QDPPERGAPPSTFKGTPTAENPEYLGLDVPV'
|
| 97 |
+
),
|
| 98 |
+
'NRP1': (
|
| 99 |
+
'MERGLPLLCAVLALVLAPAGAFRNDKCGDTIKIESPGYLTSPGYPHSYHPSEKCEWLIQAPDPYQRIMINFN'
|
| 100 |
+
'PHFDLEDRDCKYDYVEVFDGENENGHFRGKFCGKIAPPPVVSSGPFLFIKFVSDYETHGAGFSIRYEIFKRG'
|
| 101 |
+
'PECSQNYTTPSGVIKSPGFPEKYPNSLECTYIVFVPKMSEIILEFESFDLEPDSNPPGGMFCRYDRLEIWDG'
|
| 102 |
+
'FPDVGPHIGRYCGQKTPGRIRSSSGILSMVFYTDSAIAKEGFSANYSVLQSSVSEDFKCMEALGMESGEIHS'
|
| 103 |
+
'DQITASSQYSTNWSAERSRLNYPENGWTPGEDSYREWIQVDLGLLRFVTAVGTQGAISKETKKKYYVKTYKI'
|
| 104 |
+
'DVSSNGEDWITIKEGNKPVLFQGNTNPTDVVVAVFPKPLITRFVRIKPATWETGISMRFEVYGCKITDYPCS'
|
| 105 |
+
'GMLGMVSGLISDSQITSSNQGDRNWMPENIRLVTSRSGWALPPAPHSYINEWLQIDLGEEKIVRGIIIQGGK'
|
| 106 |
+
'HRENKVFMRKFKIGYSNNGSDWKMIMDDSKRKAKSFEGNNNYDTPELRTFPALSTRFIRIYPERATHGGLGL'
|
| 107 |
+
'RMELLGCEVEAPTAGPTTPNGNLVDECDDDQANCHSGTGDDFQLTGGTTVLATEKPTVIDSTIQSEFPTYGF'
|
| 108 |
+
'NCEFGWGSHKTFCHWEHDNHVQLKWSVLTSKTGPIQDHTGDGNFIYSQADENQKGKVARLVSPVVYSQNSAH'
|
| 109 |
+
'CMTFWYHMSGSHVGTLRVKLRYQKPEEYDQLVWMAIGHQGDHWKEGRVLLHKSLKLYQVIFEGEIGKGNLGG'
|
| 110 |
+
'IAVDDISINNHISQEDCAKPADLDKKNPEIKIDETGSTPGYEGEGEGDKNISRKPGNVLKTLDPILITIIAM'
|
| 111 |
+
'SALGVLLGAVCGVVLYCACWHNGMSERNLSALENYNFELVDGVKLKKDKLNTQSTYSEA'
|
| 112 |
+
),
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
# ---------------------------------------------------------------------------
|
| 117 |
+
# Deprotection rules (Fmoc SPPS side-chain protecting groups).
|
| 118 |
+
# Each rule is (label, reaction-SMARTS). Reactions are applied iteratively
|
| 119 |
+
# until no rule matches; on each match we re-sanitize the molecule.
|
| 120 |
+
# Patterns are intentionally specific so they don't accidentally rewrite
|
| 121 |
+
# atoms in the linker/payload (Aoa, SS, DSPA, Cpt, Dau, SN-38 do not contain
|
| 122 |
+
# tBu, trityl, or benzyl-phosphate groups).
|
| 123 |
+
# ---------------------------------------------------------------------------
|
| 124 |
+
DEPROTECTION_RULES = [
|
| 125 |
+
# 4-methoxytrityl on Cys: -S-C(Ph)(Ph)(p-anisyl) -> -SH
|
| 126 |
+
("Mmt-S",
|
| 127 |
+
"[#16:1][C](-c1ccccc1)(-c2ccccc2)-c3ccc(O[CH3])cc3>>[#16:1]"),
|
| 128 |
+
|
| 129 |
+
# Trityl on Cys / His-N: -S-C(Ph)3 -> -SH ; -N-C(Ph)3 -> -NH
|
| 130 |
+
("Trt-S/N",
|
| 131 |
+
"[#16,#7:1][C](-c1ccccc1)(-c2ccccc2)-c3ccccc3>>[*:1]"),
|
| 132 |
+
|
| 133 |
+
# Boc on amine: -N-C(=O)-O-C(CH3)3 -> -NH
|
| 134 |
+
("Boc-N",
|
| 135 |
+
"[N:1][C](=O)O[C]([CH3])([CH3])[CH3]>>[N:1]"),
|
| 136 |
+
|
| 137 |
+
# OtBu ester on COOH: -C(=O)-O-C(CH3)3 -> -C(=O)OH
|
| 138 |
+
("OtBu-ester",
|
| 139 |
+
"[C:1](=O)O[C]([CH3])([CH3])[CH3]>>[C:1](=O)O"),
|
| 140 |
+
|
| 141 |
+
# OtBu ether on Ser/Thr/Tyr: -O-C(CH3)3 -> -OH
|
| 142 |
+
("OtBu-ether",
|
| 143 |
+
"[O:1][C]([CH3])([CH3])[CH3]>>[O:1]"),
|
| 144 |
+
|
| 145 |
+
# Bn on phosphate / phosphonate: -O-P(=O)(O)-O-CH2-Ph -> -O-P(=O)(O)-OH
|
| 146 |
+
("Bn-phosphate",
|
| 147 |
+
"[#15:1](=O)([O])O[CH2]c1ccccc1>>[#15:1](=O)([O])O"),
|
| 148 |
+
|
| 149 |
+
# Bn ester: -C(=O)-O-CH2-Ph -> -C(=O)OH
|
| 150 |
+
("Bn-ester",
|
| 151 |
+
"[C:1](=O)O[CH2]c1ccccc1>>[C:1](=O)O"),
|
| 152 |
+
|
| 153 |
+
# Bn ether (catch-all, after phosphate/ester): -O-CH2-Ph -> -OH
|
| 154 |
+
("Bn-ether",
|
| 155 |
+
"[O:1][CH2]c1ccccc1>>[O:1]"),
|
| 156 |
+
|
| 157 |
+
# Pbf on Arg: -N-S(=O)(=O)-pentamethyldihydrobenzofuran -> -N
|
| 158 |
+
("Pbf-N",
|
| 159 |
+
"[N:1][S](=O)(=O)c1c([CH3])c2c(c1)O[C]([CH3])([CH3])[C]2([CH3])[CH3]>>[N:1]"),
|
| 160 |
+
|
| 161 |
+
# 2,4-dimethoxybenzyl (Dmb) amide / amine cap: -N-CH2-Ar(2,4-OMe) -> -NH
|
| 162 |
+
("Dmb-N",
|
| 163 |
+
"[N:1][CH2]c1cc(O[CH3])cc(O[CH3])c1>>[N:1]"),
|
| 164 |
+
("Dmb-N-alt",
|
| 165 |
+
"[N:1][CH2]c1c(O[CH3])cc(O[CH3])cc1>>[N:1]"),
|
| 166 |
+
]
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def deprotect_mol(mol, rules, max_iters=30):
|
| 170 |
+
"""Apply deprotection reactions until convergence.
|
| 171 |
+
|
| 172 |
+
Returns
|
| 173 |
+
-------
|
| 174 |
+
final_mol : Chem.Mol or None
|
| 175 |
+
counts : dict {label: int} -- how many times each rule fired.
|
| 176 |
+
"""
|
| 177 |
+
counts = {label: 0 for label, _ in rules}
|
| 178 |
+
if mol is None:
|
| 179 |
+
return None, counts
|
| 180 |
+
|
| 181 |
+
# Pre-compile reactions
|
| 182 |
+
compiled = []
|
| 183 |
+
for label, rxn_sm in rules:
|
| 184 |
+
try:
|
| 185 |
+
rxn = AllChem.ReactionFromSmarts(rxn_sm)
|
| 186 |
+
if rxn.GetNumReactantTemplates() == 1:
|
| 187 |
+
compiled.append((label, rxn))
|
| 188 |
+
except Exception:
|
| 189 |
+
pass
|
| 190 |
+
|
| 191 |
+
for _ in range(max_iters):
|
| 192 |
+
changed = False
|
| 193 |
+
for label, rxn in compiled:
|
| 194 |
+
try:
|
| 195 |
+
products = rxn.RunReactants((mol,))
|
| 196 |
+
except Exception:
|
| 197 |
+
continue
|
| 198 |
+
if not products:
|
| 199 |
+
continue
|
| 200 |
+
new_mol = products[0][0]
|
| 201 |
+
try:
|
| 202 |
+
Chem.SanitizeMol(new_mol)
|
| 203 |
+
except Exception:
|
| 204 |
+
continue
|
| 205 |
+
try:
|
| 206 |
+
if Chem.MolToSmiles(new_mol) == Chem.MolToSmiles(mol):
|
| 207 |
+
continue
|
| 208 |
+
except Exception:
|
| 209 |
+
continue
|
| 210 |
+
mol = new_mol
|
| 211 |
+
counts[label] += 1
|
| 212 |
+
changed = True
|
| 213 |
+
break # restart rule list from the top
|
| 214 |
+
if not changed:
|
| 215 |
+
break
|
| 216 |
+
return mol, counts
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def deprotect_smiles(smi, rules):
|
| 220 |
+
"""Convenience wrapper: SMILES -> (deprot_smi, counts)."""
|
| 221 |
+
empty = {label: 0 for label, _ in rules}
|
| 222 |
+
if not isinstance(smi, str) or not smi:
|
| 223 |
+
return None, empty
|
| 224 |
+
mol = Chem.MolFromSmiles(smi)
|
| 225 |
+
if mol is None:
|
| 226 |
+
return None, empty
|
| 227 |
+
final_mol, counts = deprotect_mol(mol, rules)
|
| 228 |
+
if final_mol is None:
|
| 229 |
+
return None, counts
|
| 230 |
+
try:
|
| 231 |
+
return Chem.MolToSmiles(final_mol), counts
|
| 232 |
+
except Exception:
|
| 233 |
+
return None, counts
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def compute_props(smi):
|
| 237 |
+
if not isinstance(smi, str) or not smi:
|
| 238 |
+
return None
|
| 239 |
+
mol = Chem.MolFromSmiles(smi)
|
| 240 |
+
if mol is None:
|
| 241 |
+
return None
|
| 242 |
+
return {
|
| 243 |
+
'mw_dep': float(Descriptors.MolWt(mol)),
|
| 244 |
+
'logp_dep': float(Descriptors.MolLogP(mol)),
|
| 245 |
+
'tpsa_dep': float(Descriptors.TPSA(mol)),
|
| 246 |
+
'hbd_dep': int(Descriptors.NumHDonors(mol)),
|
| 247 |
+
'hba_dep': int(Descriptors.NumHAcceptors(mol)),
|
| 248 |
+
'rotbonds_dep': int(Descriptors.NumRotatableBonds(mol)),
|
| 249 |
+
'heavy_dep': int(mol.GetNumHeavyAtoms()),
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def determine_target(input_path, target_arg):
|
| 254 |
+
if target_arg:
|
| 255 |
+
t = target_arg.upper()
|
| 256 |
+
if t in TARGET_SEQUENCES:
|
| 257 |
+
return t
|
| 258 |
+
fn = os.path.basename(input_path).upper()
|
| 259 |
+
for t in TARGET_SEQUENCES:
|
| 260 |
+
if t in fn:
|
| 261 |
+
return t
|
| 262 |
+
return None
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def score_binding_smiles(model, tokenizer, smi_list, device, batch_size=32):
|
| 266 |
+
"""Score a list of peptide SMILES with the BindingAffinity model.
|
| 267 |
+
|
| 268 |
+
Returns a numpy array of length len(smi_list); failed entries are NaN.
|
| 269 |
+
"""
|
| 270 |
+
import torch
|
| 271 |
+
out = np.full(len(smi_list), np.nan, dtype=float)
|
| 272 |
+
if not smi_list:
|
| 273 |
+
return out
|
| 274 |
+
|
| 275 |
+
valid_idx = []
|
| 276 |
+
valid_tok = []
|
| 277 |
+
for i, s in enumerate(smi_list):
|
| 278 |
+
if not isinstance(s, str) or not s:
|
| 279 |
+
continue
|
| 280 |
+
try:
|
| 281 |
+
t = tokenizer(s)
|
| 282 |
+
except Exception:
|
| 283 |
+
continue
|
| 284 |
+
if hasattr(t, 'input_ids'):
|
| 285 |
+
t = t.input_ids
|
| 286 |
+
if isinstance(t, list):
|
| 287 |
+
t = torch.tensor(t)
|
| 288 |
+
if not torch.is_tensor(t):
|
| 289 |
+
continue
|
| 290 |
+
if t.dim() == 2:
|
| 291 |
+
t = t.squeeze(0)
|
| 292 |
+
if t.dim() != 1 or t.numel() == 0:
|
| 293 |
+
continue
|
| 294 |
+
valid_idx.append(i)
|
| 295 |
+
valid_tok.append(t.long())
|
| 296 |
+
|
| 297 |
+
if not valid_tok:
|
| 298 |
+
return out
|
| 299 |
+
|
| 300 |
+
for j in range(0, len(valid_tok), batch_size):
|
| 301 |
+
chunk = valid_tok[j:j + batch_size]
|
| 302 |
+
max_len = max(int(t.numel()) for t in chunk)
|
| 303 |
+
pad = torch.zeros((len(chunk), max_len), dtype=torch.long)
|
| 304 |
+
for k, t in enumerate(chunk):
|
| 305 |
+
pad[k, :t.numel()] = t
|
| 306 |
+
try:
|
| 307 |
+
with torch.no_grad():
|
| 308 |
+
scores = model(pad.to(device)).cpu().numpy().reshape(-1)
|
| 309 |
+
except Exception as e:
|
| 310 |
+
print(f" [score_binding] batch {j}: {e}")
|
| 311 |
+
continue
|
| 312 |
+
for k, idx in enumerate(valid_idx[j:j + batch_size]):
|
| 313 |
+
out[idx] = float(scores[k])
|
| 314 |
+
return out
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def maybe_load_ncaa_sequences(csv_path, ncaa_dir):
|
| 318 |
+
"""Pair rows of the CSV with NCAA sequence strings from the matching
|
| 319 |
+
`<base>.txt` two-column file (NCAA<TAB>SMILES). Returns a list of
|
| 320 |
+
NCAA strings (None for unmatched rows) aligned to the CSV rows.
|
| 321 |
+
|
| 322 |
+
Lookup strategy:
|
| 323 |
+
1. Read all (NCAA, SMILES) pairs from the .txt.
|
| 324 |
+
2. For each row in the CSV, match by `peptide_smiles`.
|
| 325 |
+
"""
|
| 326 |
+
base = Path(csv_path).stem
|
| 327 |
+
if base.endswith('_conjugate_analysis'):
|
| 328 |
+
base = base[: -len('_conjugate_analysis')]
|
| 329 |
+
candidates = []
|
| 330 |
+
if ncaa_dir:
|
| 331 |
+
candidates.append(os.path.join(ncaa_dir, base + '.txt'))
|
| 332 |
+
csv_dir = os.path.dirname(os.path.abspath(csv_path))
|
| 333 |
+
candidates.extend([
|
| 334 |
+
os.path.join(csv_dir, base + '.txt'),
|
| 335 |
+
os.path.join(csv_dir, '..', base + '.txt'),
|
| 336 |
+
os.path.join(os.path.dirname(csv_dir), base + '.txt'),
|
| 337 |
+
])
|
| 338 |
+
# also try stripping _smiles suffix used by some files
|
| 339 |
+
if base.endswith('_smiles'):
|
| 340 |
+
b2 = base[: -len('_smiles')]
|
| 341 |
+
for d in [ncaa_dir, csv_dir, os.path.join(csv_dir, '..'), os.path.dirname(csv_dir)]:
|
| 342 |
+
if d:
|
| 343 |
+
candidates.append(os.path.join(d, b2 + '.txt'))
|
| 344 |
+
|
| 345 |
+
for path in candidates:
|
| 346 |
+
if path and os.path.exists(path):
|
| 347 |
+
try:
|
| 348 |
+
pairs = {}
|
| 349 |
+
with open(path, 'r', encoding='utf-8', errors='replace') as f:
|
| 350 |
+
for line in f:
|
| 351 |
+
line = line.rstrip('\n').rstrip('\r')
|
| 352 |
+
if not line:
|
| 353 |
+
continue
|
| 354 |
+
if '\t' in line:
|
| 355 |
+
ncaa, smi = line.split('\t', 1)
|
| 356 |
+
else:
|
| 357 |
+
# one-column SMILES file, no NCAA available
|
| 358 |
+
return None
|
| 359 |
+
pairs[smi] = ncaa
|
| 360 |
+
return path, pairs
|
| 361 |
+
except Exception:
|
| 362 |
+
continue
|
| 363 |
+
return None
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def parse_args():
|
| 367 |
+
p = argparse.ArgumentParser(
|
| 368 |
+
description="UAA peptide-drug conjugate deprotection + re-scoring",
|
| 369 |
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
| 370 |
+
)
|
| 371 |
+
p.add_argument('inputs', nargs='+',
|
| 372 |
+
help='UAA *_conjugate_analysis.csv file(s) produced by run_metrics.py')
|
| 373 |
+
p.add_argument('--output', '-o', default=None,
|
| 374 |
+
help='Output directory for *_deprot.csv files and summary '
|
| 375 |
+
'(default: same directory as each input)')
|
| 376 |
+
p.add_argument('--target', default=None, choices=list(TARGET_SEQUENCES),
|
| 377 |
+
help='Target protein (default: auto-detect from filename)')
|
| 378 |
+
p.add_argument('--device', default='auto',
|
| 379 |
+
help='cuda / cpu / auto (default: auto)')
|
| 380 |
+
p.add_argument('--no-rescore', action='store_true',
|
| 381 |
+
help='Skip binding rescoring (deprotect + recompute properties only)')
|
| 382 |
+
p.add_argument('--vocab', default='./smiles/smiles_tokenizer/new_vocab.txt')
|
| 383 |
+
p.add_argument('--splits', default='./smiles/smiles_tokenizer/new_splits.txt')
|
| 384 |
+
p.add_argument('--ncaa-txt-dir', default=None,
|
| 385 |
+
help='Directory holding the original NCAA .txt files '
|
| 386 |
+
'(NCAA<TAB>SMILES). Default: same dir as the CSV.')
|
| 387 |
+
return p.parse_args()
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
def main():
|
| 391 |
+
args = parse_args()
|
| 392 |
+
|
| 393 |
+
# Lazy heavy imports (only if needed)
|
| 394 |
+
if not args.no_rescore:
|
| 395 |
+
import torch
|
| 396 |
+
if args.device == 'auto':
|
| 397 |
+
device = 'cuda' if torch.cuda.is_available() else 'cpu'
|
| 398 |
+
else:
|
| 399 |
+
device = args.device
|
| 400 |
+
print(f"Device: {device}")
|
| 401 |
+
|
| 402 |
+
# Load tokenizer + binding model lazily
|
| 403 |
+
try:
|
| 404 |
+
from smiles.scoring.classifiers import BindingAffinity
|
| 405 |
+
from smiles.smiles_tokenizer.my_tokenizers import SMILES_SPE_Tokenizer
|
| 406 |
+
except Exception as e:
|
| 407 |
+
print(f"Cannot import binding-affinity oracle modules: {e}")
|
| 408 |
+
print("Falling back to --no-rescore.")
|
| 409 |
+
args.no_rescore = True
|
| 410 |
+
|
| 411 |
+
if not args.no_rescore:
|
| 412 |
+
if not (os.path.exists(args.vocab) and os.path.exists(args.splits)):
|
| 413 |
+
print(f"Tokenizer vocab/splits not found "
|
| 414 |
+
f"(--vocab={args.vocab}, --splits={args.splits}).")
|
| 415 |
+
print("Run from MoCA root, or pass --vocab/--splits. Skipping rescoring.")
|
| 416 |
+
args.no_rescore = True
|
| 417 |
+
else:
|
| 418 |
+
tokenizer = SMILES_SPE_Tokenizer(args.vocab, args.splits)
|
| 419 |
+
else:
|
| 420 |
+
device = 'cpu'
|
| 421 |
+
|
| 422 |
+
# Print rule list
|
| 423 |
+
print("Deprotection rules (in order, applied iteratively):")
|
| 424 |
+
for label, _ in DEPROTECTION_RULES:
|
| 425 |
+
print(f" - {label}")
|
| 426 |
+
|
| 427 |
+
binding_models = {}
|
| 428 |
+
summary_rows = []
|
| 429 |
+
overall_strip_counts = {label: 0 for label, _ in DEPROTECTION_RULES}
|
| 430 |
+
|
| 431 |
+
for in_csv in args.inputs:
|
| 432 |
+
print(f"\n>>> {in_csv}")
|
| 433 |
+
if not os.path.exists(in_csv):
|
| 434 |
+
print(f" missing, skipping")
|
| 435 |
+
continue
|
| 436 |
+
df = pd.read_csv(in_csv)
|
| 437 |
+
if df.empty:
|
| 438 |
+
print(f" empty CSV, skipping")
|
| 439 |
+
continue
|
| 440 |
+
target = determine_target(in_csv, args.target)
|
| 441 |
+
if target is None:
|
| 442 |
+
print(f" cannot determine target, skipping")
|
| 443 |
+
continue
|
| 444 |
+
print(f" target = {target}, n = {len(df)}")
|
| 445 |
+
|
| 446 |
+
# Try to attach NCAA sequence strings from the matching .txt
|
| 447 |
+
ncaa_seq_col = [None] * len(df)
|
| 448 |
+
ncaa_lookup = maybe_load_ncaa_sequences(in_csv, args.ncaa_txt_dir)
|
| 449 |
+
if ncaa_lookup is not None:
|
| 450 |
+
ncaa_path, pairs = ncaa_lookup
|
| 451 |
+
n_matched = 0
|
| 452 |
+
for i, row in df.iterrows():
|
| 453 |
+
smi = row.get('peptide_smiles')
|
| 454 |
+
if isinstance(smi, str) and smi in pairs:
|
| 455 |
+
ncaa_seq_col[i] = pairs[smi]
|
| 456 |
+
n_matched += 1
|
| 457 |
+
print(f" paired {n_matched}/{len(df)} rows with NCAA sequences from {ncaa_path}")
|
| 458 |
+
df['ncaa_seq'] = ncaa_seq_col
|
| 459 |
+
|
| 460 |
+
# ---- Deprotection ----
|
| 461 |
+
prot_pept = df['peptide_smiles'].fillna('').astype(str).tolist()
|
| 462 |
+
prot_conj = df['conjugate_smiles'].fillna('').astype(str).tolist() \
|
| 463 |
+
if 'conjugate_smiles' in df.columns else [''] * len(df)
|
| 464 |
+
|
| 465 |
+
deprot_pept = []
|
| 466 |
+
deprot_conj = []
|
| 467 |
+
rule_counts_pept = {label: 0 for label, _ in DEPROTECTION_RULES}
|
| 468 |
+
rule_counts_conj = {label: 0 for label, _ in DEPROTECTION_RULES}
|
| 469 |
+
n_strips_pept = []
|
| 470 |
+
n_strips_conj = []
|
| 471 |
+
for sp, sc in zip(prot_pept, prot_conj):
|
| 472 |
+
dp, cp = deprotect_smiles(sp, DEPROTECTION_RULES)
|
| 473 |
+
dc, cc = deprotect_smiles(sc, DEPROTECTION_RULES)
|
| 474 |
+
deprot_pept.append(dp)
|
| 475 |
+
deprot_conj.append(dc)
|
| 476 |
+
for k, v in cp.items():
|
| 477 |
+
rule_counts_pept[k] += v
|
| 478 |
+
for k, v in cc.items():
|
| 479 |
+
rule_counts_conj[k] += v
|
| 480 |
+
overall_strip_counts[k] += v
|
| 481 |
+
n_strips_pept.append(sum(cp.values()))
|
| 482 |
+
n_strips_conj.append(sum(cc.values()))
|
| 483 |
+
|
| 484 |
+
df['peptide_smiles_deprot'] = deprot_pept
|
| 485 |
+
df['conjugate_smiles_deprot'] = deprot_conj
|
| 486 |
+
df['n_strip_pept'] = n_strips_pept
|
| 487 |
+
df['n_strip_conj'] = n_strips_conj
|
| 488 |
+
|
| 489 |
+
# ---- Properties on deprotected conjugate ----
|
| 490 |
+
props_list = [compute_props(s) for s in deprot_conj]
|
| 491 |
+
for k in ('mw_dep', 'logp_dep', 'tpsa_dep', 'hbd_dep',
|
| 492 |
+
'hba_dep', 'rotbonds_dep', 'heavy_dep'):
|
| 493 |
+
df[k] = [p[k] if p else np.nan for p in props_list]
|
| 494 |
+
|
| 495 |
+
# ---- Binding rescoring ----
|
| 496 |
+
df['binding_protected'] = df.get('binding_affinity', np.nan)
|
| 497 |
+
if args.no_rescore:
|
| 498 |
+
df['binding_deprotected'] = np.nan
|
| 499 |
+
else:
|
| 500 |
+
if target not in binding_models:
|
| 501 |
+
print(f" loading binding-affinity model for {target}...")
|
| 502 |
+
seq = TARGET_SEQUENCES[target]
|
| 503 |
+
binding_models[target] = BindingAffinity(seq, device=device)
|
| 504 |
+
model = binding_models[target]
|
| 505 |
+
print(f" scoring {len(deprot_pept)} deprotected peptide SMILES...")
|
| 506 |
+
df['binding_deprotected'] = score_binding_smiles(
|
| 507 |
+
model, tokenizer, deprot_pept, device
|
| 508 |
+
)
|
| 509 |
+
|
| 510 |
+
# Convenience pKd columns (×10)
|
| 511 |
+
df['binding_protected_pKd'] = df['binding_protected'] * 10
|
| 512 |
+
df['binding_deprotected_pKd'] = df['binding_deprotected'] * 10
|
| 513 |
+
|
| 514 |
+
# ---- Save per-CSV output ----
|
| 515 |
+
out_dir = args.output or os.path.dirname(os.path.abspath(in_csv))
|
| 516 |
+
os.makedirs(out_dir, exist_ok=True)
|
| 517 |
+
out_csv = os.path.join(out_dir, Path(in_csv).stem + '_deprot.csv')
|
| 518 |
+
df.to_csv(out_csv, index=False)
|
| 519 |
+
print(f" -> {out_csv}")
|
| 520 |
+
|
| 521 |
+
# ---- Per-CSV summary ----
|
| 522 |
+
bp = pd.to_numeric(df['binding_protected'], errors='coerce').values
|
| 523 |
+
bd = pd.to_numeric(df['binding_deprotected'], errors='coerce').values
|
| 524 |
+
mask = np.isfinite(bp) & np.isfinite(bd)
|
| 525 |
+
n_pair = int(mask.sum())
|
| 526 |
+
if n_pair > 1:
|
| 527 |
+
try:
|
| 528 |
+
from scipy.stats import pearsonr, spearmanr
|
| 529 |
+
pr = float(pearsonr(bp[mask], bd[mask])[0])
|
| 530 |
+
sr = float(spearmanr(bp[mask], bd[mask]).correlation)
|
| 531 |
+
except Exception:
|
| 532 |
+
bp_z = bp[mask] - bp[mask].mean()
|
| 533 |
+
bd_z = bd[mask] - bd[mask].mean()
|
| 534 |
+
denom = (np.linalg.norm(bp_z) * np.linalg.norm(bd_z))
|
| 535 |
+
pr = float((bp_z * bd_z).sum() / denom) if denom else float('nan')
|
| 536 |
+
sr = float('nan')
|
| 537 |
+
else:
|
| 538 |
+
pr = float('nan')
|
| 539 |
+
sr = float('nan')
|
| 540 |
+
|
| 541 |
+
bp_v = bp[np.isfinite(bp)]
|
| 542 |
+
bd_v = bd[np.isfinite(bd)]
|
| 543 |
+
row = {
|
| 544 |
+
'file': os.path.basename(in_csv),
|
| 545 |
+
'target': target,
|
| 546 |
+
'n': len(df),
|
| 547 |
+
'n_with_both': n_pair,
|
| 548 |
+
'protect_avg_strips_per_conj': float(np.mean(n_strips_conj)) if n_strips_conj else 0.0,
|
| 549 |
+
'binding_prot_pKd_mean': float(bp_v.mean() * 10) if bp_v.size else float('nan'),
|
| 550 |
+
'binding_prot_pKd_std': float(bp_v.std() * 10) if bp_v.size else float('nan'),
|
| 551 |
+
'binding_dep_pKd_mean': float(bd_v.mean() * 10) if bd_v.size else float('nan'),
|
| 552 |
+
'binding_dep_pKd_std': float(bd_v.std() * 10) if bd_v.size else float('nan'),
|
| 553 |
+
'pearson_r': pr,
|
| 554 |
+
'spearman_r': sr,
|
| 555 |
+
}
|
| 556 |
+
for label, _ in DEPROTECTION_RULES:
|
| 557 |
+
row[f'strips_conj__{label}'] = rule_counts_conj[label]
|
| 558 |
+
summary_rows.append(row)
|
| 559 |
+
|
| 560 |
+
# Print
|
| 561 |
+
print(f"\n --- {os.path.basename(in_csv)} ---")
|
| 562 |
+
print(f" binding (pKd) protected : {row['binding_prot_pKd_mean']:.3f} ± {row['binding_prot_pKd_std']:.3f} (n={int(np.isfinite(bp).sum())})")
|
| 563 |
+
print(f" binding (pKd) deprotected : {row['binding_dep_pKd_mean']:.3f} ± {row['binding_dep_pKd_std']:.3f} (n={int(np.isfinite(bd).sum())})")
|
| 564 |
+
print(f" Pearson r (prot vs deprot, paired n={n_pair}): {pr:.3f}")
|
| 565 |
+
print(f" Spearman r (prot vs deprot, paired n={n_pair}): {sr:.3f}")
|
| 566 |
+
avg_per = float(np.mean(n_strips_conj)) if n_strips_conj else 0.0
|
| 567 |
+
print(f" Total strip events on conjugates: {sum(n_strips_conj)} ({avg_per:.2f} per molecule)")
|
| 568 |
+
for label, _ in DEPROTECTION_RULES:
|
| 569 |
+
v = rule_counts_conj[label]
|
| 570 |
+
if v:
|
| 571 |
+
print(f" {label:<14s} {v}")
|
| 572 |
+
|
| 573 |
+
# ---- Aggregate summary ----
|
| 574 |
+
if summary_rows:
|
| 575 |
+
out_dir = args.output or os.path.dirname(os.path.abspath(args.inputs[0]))
|
| 576 |
+
os.makedirs(out_dir, exist_ok=True)
|
| 577 |
+
summary_csv = os.path.join(out_dir, 'uaa_deprotection_summary.csv')
|
| 578 |
+
pd.DataFrame(summary_rows).to_csv(summary_csv, index=False)
|
| 579 |
+
print(f"\nAggregate summary -> {summary_csv}")
|
| 580 |
+
print("\nOverall strip events on conjugates across all inputs:")
|
| 581 |
+
for label, _ in DEPROTECTION_RULES:
|
| 582 |
+
v = overall_strip_counts[label]
|
| 583 |
+
if v:
|
| 584 |
+
print(f" {label:<14s} {v}")
|
| 585 |
+
|
| 586 |
+
return 0
|
| 587 |
+
|
| 588 |
+
|
| 589 |
+
if __name__ == '__main__':
|
| 590 |
+
sys.exit(main() or 0)
|
smiles/generation/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""PDC generation entry points."""
|
smiles/generation/cupofmoca_guided.py
ADDED
|
@@ -0,0 +1,996 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CupOfMoCA-style Guided Generation with Alternative Guidance Methods
|
| 3 |
+
|
| 4 |
+
This implements Algorithm 2 (Sampling with CupOfMoCA) with different guidance strategies:
|
| 5 |
+
1. Exponential Tilting (ExpTilt) - Direct implementation of Algorithm 2
|
| 6 |
+
2. Classifier Guidance (CG) - Using external scores to adjust logits
|
| 7 |
+
3. Best-of-K Selection (BoK) - Sample K candidates and select best
|
| 8 |
+
|
| 9 |
+
Key differences from the Barker-based method in guided.py:
|
| 10 |
+
- ExpTilt: Uses p'(z') ∝ p(z') · exp(β · S(z')) directly without Barker balancing
|
| 11 |
+
- CG: Adjusts base logits using external classifier/score signals: logits = base + w * score_logits
|
| 12 |
+
- BoK: Samples multiple candidates per step and selects the one with highest score
|
| 13 |
+
|
| 14 |
+
Note on Classifier Guidance vs Classifier-Free Guidance:
|
| 15 |
+
- Classifier Guidance (what we implement here): External scorers modify the generation distribution
|
| 16 |
+
- Classifier-Free Guidance (NOT implemented): Would require model trained with conditioning dropout,
|
| 17 |
+
and extrapolates between conditional and unconditional model outputs without external classifiers
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
import argparse
|
| 21 |
+
import re
|
| 22 |
+
import random
|
| 23 |
+
from collections import Counter
|
| 24 |
+
import csv
|
| 25 |
+
import json
|
| 26 |
+
import os
|
| 27 |
+
from datetime import datetime
|
| 28 |
+
from enum import Enum
|
| 29 |
+
from typing import List, Optional, Tuple, Dict, Any
|
| 30 |
+
|
| 31 |
+
import torch
|
| 32 |
+
import torch.nn as nn
|
| 33 |
+
import torch.nn.functional as F
|
| 34 |
+
from tqdm import tqdm
|
| 35 |
+
import numpy as np
|
| 36 |
+
|
| 37 |
+
# Import from the original codebase
|
| 38 |
+
from smiles.models.train import MDLMLightningModule
|
| 39 |
+
from smiles.smiles_tokenizer.my_tokenizers import SMILES_SPE_Tokenizer
|
| 40 |
+
from smiles.evaluation.peptide_analyzer import PeptideAnalyzer
|
| 41 |
+
from smiles.evaluation.pdc_analyzer import PDCAnalyzer
|
| 42 |
+
from smiles.scoring.classifiers import Conjugation, BindingAffinity
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class GuidanceMethod(Enum):
|
| 46 |
+
"""Available guidance methods"""
|
| 47 |
+
EXPONENTIAL_TILT = "exp_tilt" # Direct exponential tilting (Algorithm 2)
|
| 48 |
+
CLASSIFIER_GUIDANCE = "cg" # Classifier/score-based logit adjustment
|
| 49 |
+
BEST_OF_K = "bok" # Best-of-K selection
|
| 50 |
+
HYBRID = "hybrid" # Combination of methods
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class GenerationTracker:
|
| 54 |
+
"""
|
| 55 |
+
Tracks detailed validity information during generation
|
| 56 |
+
"""
|
| 57 |
+
def __init__(self, output_dir='./generation_logs'):
|
| 58 |
+
self.output_dir = output_dir
|
| 59 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 60 |
+
|
| 61 |
+
self.run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 62 |
+
self.step_logs = []
|
| 63 |
+
self.final_results = []
|
| 64 |
+
|
| 65 |
+
def log_step(self, step: int, scores, detailed_results=None, improved_indices=None,
|
| 66 |
+
guidance_info: Optional[Dict] = None):
|
| 67 |
+
"""Log information for a generation step"""
|
| 68 |
+
step_info = {
|
| 69 |
+
'step': step,
|
| 70 |
+
'scores': scores.cpu().numpy().tolist() if isinstance(scores, torch.Tensor) else scores,
|
| 71 |
+
'num_valid': int(sum(1 for s in scores if s > 0)) if hasattr(scores, '__iter__') else 0,
|
| 72 |
+
'improved_indices': improved_indices or [],
|
| 73 |
+
'guidance_info': guidance_info or {}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
if detailed_results:
|
| 77 |
+
step_info['detailed'] = []
|
| 78 |
+
for i, result in enumerate(detailed_results):
|
| 79 |
+
step_info['detailed'].append({
|
| 80 |
+
'sample_idx': i,
|
| 81 |
+
'is_valid_peptide': result.get('is_valid_peptide', False),
|
| 82 |
+
'has_conjugation_site': result.get('has_conjugation_site', False),
|
| 83 |
+
'conjugation_success': result.get('conjugation_success', False),
|
| 84 |
+
'pdc_valid': result.get('pdc_valid', False),
|
| 85 |
+
'num_conjugates': len(result.get('conjugates', [])),
|
| 86 |
+
'error': result.get('error')
|
| 87 |
+
})
|
| 88 |
+
|
| 89 |
+
self.step_logs.append(step_info)
|
| 90 |
+
|
| 91 |
+
def log_final(self, sequences, detailed_results_with_props):
|
| 92 |
+
"""Log final results with properties"""
|
| 93 |
+
for i, (seq, result) in enumerate(zip(sequences, detailed_results_with_props)):
|
| 94 |
+
final_info = {
|
| 95 |
+
'sample_idx': i,
|
| 96 |
+
'peptide_smiles': seq,
|
| 97 |
+
'is_valid_peptide': result.get('is_valid_peptide', False),
|
| 98 |
+
'has_conjugation_site': result.get('has_conjugation_site', False),
|
| 99 |
+
'num_conjugates': len(result.get('conjugates', [])),
|
| 100 |
+
'num_valid_pdcs': len(result.get('valid_pdcs', [])),
|
| 101 |
+
'best_pdc': None,
|
| 102 |
+
'linker_used': result.get('linker_used', ''),
|
| 103 |
+
'all_conjugates': result.get('conjugates', []),
|
| 104 |
+
'properties': result.get('properties', {})
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
if result.get('best_pdc'):
|
| 108 |
+
final_info['best_pdc'] = {
|
| 109 |
+
'smiles': result['best_pdc'].get('smiles', ''),
|
| 110 |
+
'checks': result['best_pdc'].get('checks', {})
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
self.final_results.append(final_info)
|
| 114 |
+
|
| 115 |
+
def save(self, filename_prefix='generation'):
|
| 116 |
+
"""Save all logs to files"""
|
| 117 |
+
step_log_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_steps.json")
|
| 118 |
+
with open(step_log_path, 'w') as f:
|
| 119 |
+
json.dump(self.step_logs, f, indent=2, default=str)
|
| 120 |
+
|
| 121 |
+
final_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_final.json")
|
| 122 |
+
with open(final_path, 'w') as f:
|
| 123 |
+
json.dump(self.final_results, f, indent=2, default=str)
|
| 124 |
+
|
| 125 |
+
csv_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_summary.csv")
|
| 126 |
+
with open(csv_path, 'w', newline='') as f:
|
| 127 |
+
writer = csv.writer(f)
|
| 128 |
+
writer.writerow([
|
| 129 |
+
'sample_idx', 'peptide_smiles', 'is_valid_peptide', 'has_conjugation_site',
|
| 130 |
+
'linker_used', 'num_conjugates', 'num_valid_pdcs', 'best_pdc_smiles',
|
| 131 |
+
'mf', 'polararea', 'xlogp', 'heavycnt', 'rotbonds'
|
| 132 |
+
])
|
| 133 |
+
for result in self.final_results:
|
| 134 |
+
props = result.get('properties', {})
|
| 135 |
+
writer.writerow([
|
| 136 |
+
result['sample_idx'],
|
| 137 |
+
result['peptide_smiles'],
|
| 138 |
+
result['is_valid_peptide'],
|
| 139 |
+
result['has_conjugation_site'],
|
| 140 |
+
result.get('linker_used', ''),
|
| 141 |
+
result['num_conjugates'],
|
| 142 |
+
result['num_valid_pdcs'],
|
| 143 |
+
result['best_pdc']['smiles'] if result.get('best_pdc') else '',
|
| 144 |
+
props.get('mf', ''),
|
| 145 |
+
props.get('polararea', ''),
|
| 146 |
+
props.get('xlogp', ''),
|
| 147 |
+
props.get('heavycnt', ''),
|
| 148 |
+
props.get('rotbonds', '')
|
| 149 |
+
])
|
| 150 |
+
|
| 151 |
+
# Save dedicated PDC output: peptide → linker → all conjugate SMILES
|
| 152 |
+
pdc_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_pdcs.csv")
|
| 153 |
+
with open(pdc_path, 'w', newline='') as f:
|
| 154 |
+
writer = csv.writer(f)
|
| 155 |
+
writer.writerow(['sample_idx', 'peptide_smiles', 'linker_used', 'conjugate_idx', 'pdc_smiles'])
|
| 156 |
+
for result in self.final_results:
|
| 157 |
+
for j, conj in enumerate(result.get('all_conjugates', [])):
|
| 158 |
+
writer.writerow([
|
| 159 |
+
result['sample_idx'],
|
| 160 |
+
result['peptide_smiles'],
|
| 161 |
+
result.get('linker_used', ''),
|
| 162 |
+
j,
|
| 163 |
+
conj
|
| 164 |
+
])
|
| 165 |
+
|
| 166 |
+
print(f"\n=== Generation logs saved ===")
|
| 167 |
+
print(f" Step logs: {step_log_path}")
|
| 168 |
+
print(f" Final results: {final_path}")
|
| 169 |
+
print(f" Summary CSV: {csv_path}")
|
| 170 |
+
print(f" PDC conjugates: {pdc_path}")
|
| 171 |
+
|
| 172 |
+
return step_log_path, final_path, csv_path, pdc_path
|
| 173 |
+
|
| 174 |
+
def print_step_summary(self, step, scores, detailed_results=None, guidance_method=None):
|
| 175 |
+
"""Print a summary for the current step"""
|
| 176 |
+
if isinstance(scores, torch.Tensor):
|
| 177 |
+
scores_list = scores.cpu().numpy().tolist()
|
| 178 |
+
else:
|
| 179 |
+
scores_list = list(scores)
|
| 180 |
+
|
| 181 |
+
num_valid = sum(1 for s in scores_list if s > 0)
|
| 182 |
+
|
| 183 |
+
print(f"\n--- Step {step} Summary ({guidance_method or 'unknown'}) ---")
|
| 184 |
+
print(f" Valid: {num_valid}/{len(scores_list)}")
|
| 185 |
+
|
| 186 |
+
if detailed_results:
|
| 187 |
+
valid_peptide = sum(1 for r in detailed_results if r.get('is_valid_peptide'))
|
| 188 |
+
has_site = sum(1 for r in detailed_results if r.get('has_conjugation_site'))
|
| 189 |
+
conj_success = sum(1 for r in detailed_results if r.get('conjugation_success'))
|
| 190 |
+
pdc_valid = sum(1 for r in detailed_results if r.get('pdc_valid'))
|
| 191 |
+
|
| 192 |
+
print(f" Breakdown:")
|
| 193 |
+
print(f" Valid peptides: {valid_peptide}")
|
| 194 |
+
print(f" Has conjugation site: {has_site}")
|
| 195 |
+
print(f" Conjugation success: {conj_success}")
|
| 196 |
+
print(f" PDC valid: {pdc_valid}")
|
| 197 |
+
|
| 198 |
+
def print_final_summary(self):
|
| 199 |
+
"""Print final summary"""
|
| 200 |
+
print("\n" + "=" * 60)
|
| 201 |
+
print("FINAL GENERATION SUMMARY")
|
| 202 |
+
print("=" * 60)
|
| 203 |
+
|
| 204 |
+
total = len(self.final_results)
|
| 205 |
+
valid_pdcs = sum(1 for r in self.final_results if r.get('num_valid_pdcs', 0) > 0)
|
| 206 |
+
|
| 207 |
+
print(f"\nTotal sequences: {total}")
|
| 208 |
+
print(f"With valid PDCs: {valid_pdcs}")
|
| 209 |
+
|
| 210 |
+
print("\nDetailed results:")
|
| 211 |
+
for result in self.final_results:
|
| 212 |
+
status = "" if result.get('num_valid_pdcs', 0) > 0 else ""
|
| 213 |
+
print(f"\n [{status}] Sample {result['sample_idx']}:")
|
| 214 |
+
print(f" Peptide: {result['peptide_smiles'][:60]}...")
|
| 215 |
+
print(f" Valid peptide: {result['is_valid_peptide']}")
|
| 216 |
+
print(f" Has conjugation site: {result['has_conjugation_site']}")
|
| 217 |
+
print(f" Conjugates: {result['num_conjugates']}")
|
| 218 |
+
print(f" Valid PDCs: {result['num_valid_pdcs']}")
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
class CupOfMoCAGenerator:
|
| 222 |
+
"""
|
| 223 |
+
CupOfMoCA-style generator implementing Algorithm 2 with multiple guidance strategies.
|
| 224 |
+
|
| 225 |
+
Key differences from Barker-based approach:
|
| 226 |
+
1. ExpTilt: Direct exponential tilting without balancing function
|
| 227 |
+
2. CFG: Classifier-free guidance by mixing logits
|
| 228 |
+
3. BoK: Best-of-K selection strategy
|
| 229 |
+
"""
|
| 230 |
+
|
| 231 |
+
def __init__(self, model, device, objectives, args, tracker=None):
|
| 232 |
+
self.model = model
|
| 233 |
+
self.device = device
|
| 234 |
+
self.objectives = objectives
|
| 235 |
+
self.args = args
|
| 236 |
+
self.num_objectives = len(objectives)
|
| 237 |
+
self.peptide_analyzer = PeptideAnalyzer()
|
| 238 |
+
self.pdc_analyzer = PDCAnalyzer(device)
|
| 239 |
+
self.invalid_tokens = [0, 1, 2, 3, 4, 585, 586] # Special tokens to avoid
|
| 240 |
+
self.tracker = tracker
|
| 241 |
+
|
| 242 |
+
# Parse guidance method
|
| 243 |
+
self.guidance_method = GuidanceMethod(args.guidance_method)
|
| 244 |
+
|
| 245 |
+
# Find the Conjugation objective for detailed tracking
|
| 246 |
+
self.conjugation_obj = None
|
| 247 |
+
for obj in objectives:
|
| 248 |
+
if isinstance(obj, Conjugation):
|
| 249 |
+
self.conjugation_obj = obj
|
| 250 |
+
break
|
| 251 |
+
|
| 252 |
+
# =========================================================================
|
| 253 |
+
# Initial Generation (x0 from prior μ₀)
|
| 254 |
+
# =========================================================================
|
| 255 |
+
|
| 256 |
+
def _generate_single_x0(self, n_steps: int = 16, temperature: float = 1.0) -> torch.Tensor:
|
| 257 |
+
"""
|
| 258 |
+
Generate initial sequences using flow matching (Algorithm 2, Line 6: Sample Z_0 ~ μ_0)
|
| 259 |
+
This follows the flow from noise to data without guidance.
|
| 260 |
+
"""
|
| 261 |
+
self.model.eval()
|
| 262 |
+
|
| 263 |
+
# Start with random tokens (noise at t=0)
|
| 264 |
+
x = torch.randint(
|
| 265 |
+
0, self.model.tokenizer.vocab_size,
|
| 266 |
+
(self.args.num_samples, self.args.gen_len),
|
| 267 |
+
device=self.device
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
# Time schedule: t_0 = 0 to t_K = T (Line 5 of Algorithm 2)
|
| 271 |
+
time_steps = torch.linspace(0.0, 1.0, n_steps + 1, device=self.device)
|
| 272 |
+
|
| 273 |
+
with torch.no_grad():
|
| 274 |
+
for i in tqdm(range(n_steps), desc="Initial Flow Matching", leave=False):
|
| 275 |
+
t_curr = time_steps[i]
|
| 276 |
+
t_next = time_steps[i + 1]
|
| 277 |
+
|
| 278 |
+
t_tensor = torch.full((self.args.num_samples,), t_curr, device=self.device)
|
| 279 |
+
|
| 280 |
+
# Get predicted distribution (Line 8: p_{k+1} ← Φ_{θ*}^{t_k→t_{k+1}}(Z_k))
|
| 281 |
+
logits = self.model(x, t_tensor)
|
| 282 |
+
logits = logits / temperature
|
| 283 |
+
logits[:, :, 586] = -1000 # Mask invalid token
|
| 284 |
+
|
| 285 |
+
pred_x1 = torch.argmax(logits, dim=-1)
|
| 286 |
+
|
| 287 |
+
if i == n_steps - 1:
|
| 288 |
+
x = pred_x1
|
| 289 |
+
break
|
| 290 |
+
|
| 291 |
+
# Interpolate between noise and prediction
|
| 292 |
+
noise_prob = 1.0 - t_next
|
| 293 |
+
mask = torch.rand(x.shape, device=self.device) < noise_prob
|
| 294 |
+
noise = torch.randint(0, self.model.tokenizer.vocab_size, x.shape, device=self.device)
|
| 295 |
+
x = torch.where(mask, noise, pred_x1)
|
| 296 |
+
|
| 297 |
+
return x
|
| 298 |
+
|
| 299 |
+
def _check_conjugation_validity(self, x: torch.Tensor) -> torch.Tensor:
|
| 300 |
+
"""Check if sequences can be conjugated (validity indicator 1_valid in Algorithm 2)"""
|
| 301 |
+
if self.conjugation_obj is None:
|
| 302 |
+
sequences = self.model.tokenizer.batch_decode(x)
|
| 303 |
+
return torch.tensor([self.peptide_analyzer.is_peptide(seq) for seq in sequences],
|
| 304 |
+
device=self.device)
|
| 305 |
+
scores = self.conjugation_obj(x)
|
| 306 |
+
return scores > 0
|
| 307 |
+
|
| 308 |
+
def generate_x0(self, n_steps: int = 16, temperature: float = 1.0,
|
| 309 |
+
require_valid: bool = False, max_retries: int = 10) -> torch.Tensor:
|
| 310 |
+
"""
|
| 311 |
+
Generate initial x0 sequences with optional validity enforcement.
|
| 312 |
+
Implements the constraint that x0 should satisfy validity before guidance.
|
| 313 |
+
"""
|
| 314 |
+
print("Generating initial sequences (x0 from prior)...")
|
| 315 |
+
|
| 316 |
+
x = self._generate_single_x0(n_steps, temperature)
|
| 317 |
+
|
| 318 |
+
if require_valid:
|
| 319 |
+
print("Enforcing initial validity constraint...")
|
| 320 |
+
|
| 321 |
+
for retry in range(max_retries):
|
| 322 |
+
validity = self._check_conjugation_validity(x)
|
| 323 |
+
num_valid = validity.sum().item()
|
| 324 |
+
num_invalid = self.args.num_samples - num_valid
|
| 325 |
+
|
| 326 |
+
if num_invalid == 0:
|
| 327 |
+
print(f" All {self.args.num_samples} samples are valid!")
|
| 328 |
+
break
|
| 329 |
+
|
| 330 |
+
print(f" Retry {retry + 1}/{max_retries}: {num_valid}/{self.args.num_samples} valid")
|
| 331 |
+
|
| 332 |
+
invalid_indices = torch.where(~validity)[0]
|
| 333 |
+
original_num_samples = self.args.num_samples
|
| 334 |
+
self.args.num_samples = num_invalid
|
| 335 |
+
new_x = self._generate_single_x0(n_steps, temperature)
|
| 336 |
+
self.args.num_samples = original_num_samples
|
| 337 |
+
x[invalid_indices] = new_x
|
| 338 |
+
|
| 339 |
+
final_validity = self._check_conjugation_validity(x)
|
| 340 |
+
if final_validity.sum().item() < self.args.num_samples:
|
| 341 |
+
print(f"⚠ After {max_retries} retries, {final_validity.sum().item()}/{self.args.num_samples} valid")
|
| 342 |
+
|
| 343 |
+
generated_sequences = self.model.tokenizer.batch_decode(x)
|
| 344 |
+
for seq in generated_sequences:
|
| 345 |
+
print(f" {seq[:80]}...")
|
| 346 |
+
|
| 347 |
+
return x
|
| 348 |
+
|
| 349 |
+
# =========================================================================
|
| 350 |
+
# Score Functions
|
| 351 |
+
# =========================================================================
|
| 352 |
+
|
| 353 |
+
def _get_scores(self, x_batch: torch.Tensor) -> torch.Tensor:
|
| 354 |
+
"""Calculate scores for all objectives: S(z) in Algorithm 2"""
|
| 355 |
+
scores = []
|
| 356 |
+
for obj_func in self.objectives:
|
| 357 |
+
scores.append(obj_func(x_batch.to(self.device)))
|
| 358 |
+
return torch.stack(scores, dim=0).to(self.device)
|
| 359 |
+
|
| 360 |
+
def _compute_aggregated_score(self, scores: torch.Tensor, weights: torch.Tensor) -> torch.Tensor:
|
| 361 |
+
"""Compute weighted aggregated score S(z) for guidance"""
|
| 362 |
+
# Weighted sum or min depending on aggregation strategy
|
| 363 |
+
if self.args.score_aggregation == "weighted_sum":
|
| 364 |
+
return (weights * scores).sum(dim=0)
|
| 365 |
+
elif self.args.score_aggregation == "weighted_min":
|
| 366 |
+
return torch.min(weights * scores, dim=0).values
|
| 367 |
+
else: # default: weighted sum
|
| 368 |
+
return (weights * scores).sum(dim=0)
|
| 369 |
+
|
| 370 |
+
# =========================================================================
|
| 371 |
+
# Guidance Methods
|
| 372 |
+
# =========================================================================
|
| 373 |
+
|
| 374 |
+
def _exponential_tilt_guidance(
|
| 375 |
+
self,
|
| 376 |
+
x: torch.Tensor,
|
| 377 |
+
pos_probs: torch.Tensor,
|
| 378 |
+
candidates: torch.Tensor,
|
| 379 |
+
mut_idx: int,
|
| 380 |
+
sample_idx: int,
|
| 381 |
+
beta: float,
|
| 382 |
+
weights: torch.Tensor,
|
| 383 |
+
current_score: float = None
|
| 384 |
+
) -> Tuple[int, Dict]:
|
| 385 |
+
"""
|
| 386 |
+
Exponential Tilting Guidance (Algorithm 2, Lines 10-12) with acceptance criterion
|
| 387 |
+
|
| 388 |
+
p'(z') ∝ p_{k+1}(z') · exp(β · S(z')) · 1_valid(z')
|
| 389 |
+
|
| 390 |
+
Added: Only accept if new_score >= current_score (prevents regression)
|
| 391 |
+
"""
|
| 392 |
+
num_candidates = len(candidates)
|
| 393 |
+
current_token = x[sample_idx, mut_idx].item()
|
| 394 |
+
|
| 395 |
+
if num_candidates == 0:
|
| 396 |
+
return current_token, {'method': 'exp_tilt', 'fallback': True, 'accepted': False}
|
| 397 |
+
|
| 398 |
+
# Limit candidates for efficiency
|
| 399 |
+
if num_candidates > self.args.top_k:
|
| 400 |
+
candidates = candidates[:self.args.top_k]
|
| 401 |
+
num_candidates = self.args.top_k
|
| 402 |
+
|
| 403 |
+
# Create batch of proposals
|
| 404 |
+
x_proposals = x[sample_idx].unsqueeze(0).repeat(num_candidates, 1)
|
| 405 |
+
x_proposals[:, mut_idx] = candidates
|
| 406 |
+
|
| 407 |
+
# Compute scores S(z') for all candidates
|
| 408 |
+
proposal_scores = self._get_scores(x_proposals) # [num_objectives, num_candidates]
|
| 409 |
+
aggregated_scores = self._compute_aggregated_score(proposal_scores, weights) # [num_candidates]
|
| 410 |
+
|
| 411 |
+
# Validity mask: 1_valid(z')
|
| 412 |
+
validity_mask = (proposal_scores[0] > 0).float() # Using first objective (conjugation) as validity
|
| 413 |
+
|
| 414 |
+
# Get base probabilities from the model
|
| 415 |
+
base_probs = pos_probs[sample_idx, candidates]
|
| 416 |
+
|
| 417 |
+
# Apply exponential tilting with numerical stability:
|
| 418 |
+
# p'(z') ∝ p(z') · exp(β · S(z')) · 1_valid(z')
|
| 419 |
+
#
|
| 420 |
+
# To avoid overflow, we use log-space computation:
|
| 421 |
+
# log(p') = log(p) + β·S + log(1_valid)
|
| 422 |
+
# Then normalize in log-space before exp
|
| 423 |
+
|
| 424 |
+
log_base_probs = torch.log(base_probs + 1e-10)
|
| 425 |
+
log_exp_scores = beta * aggregated_scores
|
| 426 |
+
log_validity = torch.log(validity_mask + 1e-10) # -inf for invalid
|
| 427 |
+
|
| 428 |
+
log_tilted = log_base_probs + log_exp_scores + log_validity
|
| 429 |
+
|
| 430 |
+
# Subtract max for numerical stability (log-sum-exp trick)
|
| 431 |
+
log_tilted_max = log_tilted.max()
|
| 432 |
+
if torch.isinf(log_tilted_max) or torch.isnan(log_tilted_max):
|
| 433 |
+
# All candidates are invalid or have zero probability
|
| 434 |
+
# Fallback to base distribution
|
| 435 |
+
fallback = True
|
| 436 |
+
final_probs = base_probs / (base_probs.sum() + 1e-9)
|
| 437 |
+
else:
|
| 438 |
+
log_tilted_stable = log_tilted - log_tilted_max
|
| 439 |
+
tilted_probs = torch.exp(log_tilted_stable)
|
| 440 |
+
|
| 441 |
+
# Normalize
|
| 442 |
+
prob_sum = tilted_probs.sum()
|
| 443 |
+
if prob_sum < 1e-10 or torch.isnan(prob_sum) or torch.isinf(prob_sum):
|
| 444 |
+
# Fallback to base distribution
|
| 445 |
+
fallback = True
|
| 446 |
+
final_probs = base_probs / (base_probs.sum() + 1e-9)
|
| 447 |
+
else:
|
| 448 |
+
fallback = False
|
| 449 |
+
final_probs = tilted_probs / prob_sum
|
| 450 |
+
|
| 451 |
+
# Final safety check
|
| 452 |
+
if torch.any(torch.isnan(final_probs)) or torch.any(torch.isinf(final_probs)):
|
| 453 |
+
fallback = True
|
| 454 |
+
final_probs = base_probs / (base_probs.sum() + 1e-9)
|
| 455 |
+
|
| 456 |
+
# Ensure no negative or zero probabilities for multinomial
|
| 457 |
+
final_probs = torch.clamp(final_probs, min=1e-10)
|
| 458 |
+
final_probs = final_probs / final_probs.sum()
|
| 459 |
+
|
| 460 |
+
selected_idx = torch.multinomial(final_probs, 1).item()
|
| 461 |
+
selected_token = candidates[selected_idx].item()
|
| 462 |
+
selected_score = aggregated_scores[selected_idx].item()
|
| 463 |
+
|
| 464 |
+
# ACCEPTANCE CRITERION: Only accept if score improves or stays same
|
| 465 |
+
accepted = True
|
| 466 |
+
if self.args.use_acceptance and current_score is not None:
|
| 467 |
+
if selected_score < current_score:
|
| 468 |
+
# Reject: keep current token
|
| 469 |
+
selected_token = current_token
|
| 470 |
+
accepted = False
|
| 471 |
+
|
| 472 |
+
info = {
|
| 473 |
+
'method': 'exp_tilt',
|
| 474 |
+
'beta': beta,
|
| 475 |
+
'num_candidates': num_candidates,
|
| 476 |
+
'proposal_score': aggregated_scores[selected_idx].item(),
|
| 477 |
+
'selected_score': selected_score if accepted else current_score,
|
| 478 |
+
'fallback': fallback,
|
| 479 |
+
'validity_rate': validity_mask.mean().item(),
|
| 480 |
+
'accepted': accepted
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
return selected_token, info
|
| 484 |
+
|
| 485 |
+
def _classifier_guidance(
|
| 486 |
+
self,
|
| 487 |
+
x: torch.Tensor,
|
| 488 |
+
logits: torch.Tensor,
|
| 489 |
+
mut_idx: int,
|
| 490 |
+
sample_idx: int,
|
| 491 |
+
guidance_weight: float,
|
| 492 |
+
weights: torch.Tensor
|
| 493 |
+
) -> Tuple[int, Dict]:
|
| 494 |
+
"""
|
| 495 |
+
Classifier Guidance (Score-based Logit Adjustment)
|
| 496 |
+
|
| 497 |
+
Uses external score functions to adjust the base model's logits:
|
| 498 |
+
logits_guided = logits_base + guidance_weight * score_logits
|
| 499 |
+
|
| 500 |
+
This differs from Classifier-Free Guidance (CFG) which requires:
|
| 501 |
+
- A model trained with conditioning dropout
|
| 502 |
+
- Extrapolation between conditional and unconditional model outputs
|
| 503 |
+
|
| 504 |
+
Here we use external classifiers/scorers to provide guidance signal.
|
| 505 |
+
"""
|
| 506 |
+
base_logits = logits[sample_idx, mut_idx, :]
|
| 507 |
+
|
| 508 |
+
# Get top-k candidates from base distribution
|
| 509 |
+
sorted_logits, sorted_indices = torch.sort(base_logits, descending=True)
|
| 510 |
+
candidates = sorted_indices[:self.args.top_k]
|
| 511 |
+
|
| 512 |
+
# Filter invalid tokens
|
| 513 |
+
valid_mask = ~torch.isin(candidates, torch.tensor(self.invalid_tokens, device=self.device))
|
| 514 |
+
candidates = candidates[valid_mask]
|
| 515 |
+
|
| 516 |
+
if len(candidates) == 0:
|
| 517 |
+
return x[sample_idx, mut_idx].item(), {'method': 'cg', 'fallback': True}
|
| 518 |
+
|
| 519 |
+
# Create proposals and score them
|
| 520 |
+
x_proposals = x[sample_idx].unsqueeze(0).repeat(len(candidates), 1)
|
| 521 |
+
x_proposals[:, mut_idx] = candidates
|
| 522 |
+
|
| 523 |
+
proposal_scores = self._get_scores(x_proposals)
|
| 524 |
+
aggregated_scores = self._compute_aggregated_score(proposal_scores, weights)
|
| 525 |
+
|
| 526 |
+
# Create score-based logits adjustment
|
| 527 |
+
# Higher score -> higher probability
|
| 528 |
+
score_logits = aggregated_scores * self.args.score_temperature
|
| 529 |
+
|
| 530 |
+
# Base logits for candidates
|
| 531 |
+
base_candidate_logits = base_logits[candidates]
|
| 532 |
+
|
| 533 |
+
# Classifier guidance: guided = base + w * score_adjustment
|
| 534 |
+
guided_logits = base_candidate_logits + guidance_weight * score_logits
|
| 535 |
+
|
| 536 |
+
# Convert to probabilities
|
| 537 |
+
guided_probs = F.softmax(guided_logits, dim=-1)
|
| 538 |
+
|
| 539 |
+
# Sample
|
| 540 |
+
selected_idx = torch.multinomial(guided_probs, 1).item()
|
| 541 |
+
selected_token = candidates[selected_idx].item()
|
| 542 |
+
|
| 543 |
+
info = {
|
| 544 |
+
'method': 'cg',
|
| 545 |
+
'guidance_weight': guidance_weight,
|
| 546 |
+
'num_candidates': len(candidates),
|
| 547 |
+
'selected_score': aggregated_scores[selected_idx].item(),
|
| 548 |
+
'fallback': False
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
return selected_token, info
|
| 552 |
+
|
| 553 |
+
def _best_of_k_guidance(
|
| 554 |
+
self,
|
| 555 |
+
x: torch.Tensor,
|
| 556 |
+
pos_probs: torch.Tensor,
|
| 557 |
+
candidates: torch.Tensor,
|
| 558 |
+
mut_idx: int,
|
| 559 |
+
sample_idx: int,
|
| 560 |
+
k: int,
|
| 561 |
+
weights: torch.Tensor
|
| 562 |
+
) -> Tuple[int, Dict]:
|
| 563 |
+
"""
|
| 564 |
+
Best-of-K Selection
|
| 565 |
+
|
| 566 |
+
1. Sample K candidates from the base distribution
|
| 567 |
+
2. Score all K candidates
|
| 568 |
+
3. Select the one with the highest score
|
| 569 |
+
|
| 570 |
+
This is a simple but effective strategy that doesn't require
|
| 571 |
+
probability manipulation.
|
| 572 |
+
"""
|
| 573 |
+
num_candidates = len(candidates)
|
| 574 |
+
if num_candidates == 0:
|
| 575 |
+
return x[sample_idx, mut_idx].item(), {'method': 'bok', 'fallback': True}
|
| 576 |
+
|
| 577 |
+
# Limit k to available candidates
|
| 578 |
+
k = min(k, num_candidates)
|
| 579 |
+
|
| 580 |
+
# Get probabilities for candidates
|
| 581 |
+
candidate_probs = pos_probs[sample_idx, candidates]
|
| 582 |
+
candidate_probs = candidate_probs / (candidate_probs.sum() + 1e-9)
|
| 583 |
+
|
| 584 |
+
# Sample K candidates
|
| 585 |
+
if k >= num_candidates:
|
| 586 |
+
sampled_indices = torch.arange(num_candidates, device=self.device)
|
| 587 |
+
else:
|
| 588 |
+
sampled_indices = torch.multinomial(candidate_probs, k, replacement=False)
|
| 589 |
+
|
| 590 |
+
sampled_tokens = candidates[sampled_indices]
|
| 591 |
+
|
| 592 |
+
# Create proposals for all K samples
|
| 593 |
+
x_proposals = x[sample_idx].unsqueeze(0).repeat(len(sampled_tokens), 1)
|
| 594 |
+
x_proposals[:, mut_idx] = sampled_tokens
|
| 595 |
+
|
| 596 |
+
# Score all proposals
|
| 597 |
+
proposal_scores = self._get_scores(x_proposals)
|
| 598 |
+
aggregated_scores = self._compute_aggregated_score(proposal_scores, weights)
|
| 599 |
+
|
| 600 |
+
# Select best
|
| 601 |
+
best_idx = torch.argmax(aggregated_scores).item()
|
| 602 |
+
selected_token = sampled_tokens[best_idx].item()
|
| 603 |
+
|
| 604 |
+
info = {
|
| 605 |
+
'method': 'bok',
|
| 606 |
+
'k': k,
|
| 607 |
+
'num_candidates': num_candidates,
|
| 608 |
+
'selected_score': aggregated_scores[best_idx].item(),
|
| 609 |
+
'mean_score': aggregated_scores.mean().item(),
|
| 610 |
+
'max_score': aggregated_scores.max().item(),
|
| 611 |
+
'fallback': False
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
return selected_token, info
|
| 615 |
+
|
| 616 |
+
def _hybrid_guidance(
|
| 617 |
+
self,
|
| 618 |
+
x: torch.Tensor,
|
| 619 |
+
pos_probs: torch.Tensor,
|
| 620 |
+
logits: torch.Tensor,
|
| 621 |
+
candidates: torch.Tensor,
|
| 622 |
+
mut_idx: int,
|
| 623 |
+
sample_idx: int,
|
| 624 |
+
step: int,
|
| 625 |
+
beta: float,
|
| 626 |
+
weights: torch.Tensor
|
| 627 |
+
) -> Tuple[int, Dict]:
|
| 628 |
+
"""
|
| 629 |
+
Hybrid Guidance: Combines multiple methods adaptively
|
| 630 |
+
|
| 631 |
+
Strategy:
|
| 632 |
+
- Early steps: Use Best-of-K for exploration
|
| 633 |
+
- Middle steps: Use Exponential Tilting for balanced guidance
|
| 634 |
+
- Late steps: Use CFG for refinement
|
| 635 |
+
"""
|
| 636 |
+
total_steps = self.args.optimization_steps
|
| 637 |
+
progress = step / max(total_steps - 1, 1)
|
| 638 |
+
|
| 639 |
+
if progress < 0.3:
|
| 640 |
+
# Early: Best-of-K for exploration
|
| 641 |
+
return self._best_of_k_guidance(
|
| 642 |
+
x, pos_probs, candidates, mut_idx, sample_idx,
|
| 643 |
+
k=self.args.bok_k, weights=weights
|
| 644 |
+
)
|
| 645 |
+
elif progress < 0.7:
|
| 646 |
+
# Middle: Exponential tilting
|
| 647 |
+
return self._exponential_tilt_guidance(
|
| 648 |
+
x, pos_probs, candidates, mut_idx, sample_idx,
|
| 649 |
+
beta=beta, weights=weights
|
| 650 |
+
)
|
| 651 |
+
else:
|
| 652 |
+
# Late: Classifier guidance for refinement
|
| 653 |
+
return self._classifier_guidance(
|
| 654 |
+
x, logits, mut_idx, sample_idx,
|
| 655 |
+
guidance_weight=self.args.cg_weight, weights=weights
|
| 656 |
+
)
|
| 657 |
+
|
| 658 |
+
# =========================================================================
|
| 659 |
+
# Main Generation Loop (Algorithm 2)
|
| 660 |
+
# =========================================================================
|
| 661 |
+
|
| 662 |
+
def generate(self) -> torch.Tensor:
|
| 663 |
+
"""
|
| 664 |
+
Main generation loop implementing Algorithm 2 with the selected guidance method.
|
| 665 |
+
|
| 666 |
+
Lines 7-17 of Algorithm 2:
|
| 667 |
+
for k = 0 to K-1 do
|
| 668 |
+
p_{k+1} ← Φ_{θ*}^{t_k→t_{k+1}}(Z_k)
|
| 669 |
+
if guidance is active (β > 0) then
|
| 670 |
+
for each candidate z' ∈ TopK(p_{k+1}) do
|
| 671 |
+
p'(z') ∝ p_{k+1}(z') · exp(β · S(z')) · 1_valid(z')
|
| 672 |
+
Sample Z_{k+1} ~ p'
|
| 673 |
+
else
|
| 674 |
+
Sample Z_{k+1} ~ p_{k+1}
|
| 675 |
+
return Z_K
|
| 676 |
+
"""
|
| 677 |
+
# Generate initial x0 with validity constraint
|
| 678 |
+
x = self.generate_x0(
|
| 679 |
+
require_valid=self.args.require_valid_x0,
|
| 680 |
+
max_retries=self.args.max_x0_retries
|
| 681 |
+
)
|
| 682 |
+
|
| 683 |
+
# Setup weights
|
| 684 |
+
if self.args.weights is None:
|
| 685 |
+
weights = torch.tensor(
|
| 686 |
+
[1.0] + [1.0 / (self.num_objectives - 1)] * (self.num_objectives - 1),
|
| 687 |
+
device=self.device
|
| 688 |
+
).view(-1, 1)
|
| 689 |
+
else:
|
| 690 |
+
weights = torch.tensor(self.args.weights, device=self.device).view(-1, 1)
|
| 691 |
+
if len(weights) != self.num_objectives:
|
| 692 |
+
raise ValueError("Number of weights must match number of objectives.")
|
| 693 |
+
|
| 694 |
+
print(f"\nGuidance Method: {self.guidance_method.value}")
|
| 695 |
+
print(f"Weights: {weights.squeeze().tolist()}")
|
| 696 |
+
print(f"β range: [{self.args.beta_min}, {self.args.beta_max}]")
|
| 697 |
+
print(f"Acceptance criterion: {'ENABLED' if self.args.use_acceptance else 'DISABLED'}")
|
| 698 |
+
|
| 699 |
+
# Main optimization loop (Line 7: for k = 0 to K-1)
|
| 700 |
+
with torch.no_grad():
|
| 701 |
+
for step in tqdm(range(self.args.optimization_steps), desc="Guided Generation"):
|
| 702 |
+
improved_indices = []
|
| 703 |
+
|
| 704 |
+
# Anneal guidance strength β (from beta_min to beta_max)
|
| 705 |
+
progress = step / max(self.args.optimization_steps - 1, 1)
|
| 706 |
+
beta = self.args.beta_min + (self.args.beta_max - self.args.beta_min) * progress
|
| 707 |
+
|
| 708 |
+
# Choose position to modify (can be random or sequential)
|
| 709 |
+
if self.args.position_strategy == "random":
|
| 710 |
+
mut_idx = random.randint(1, self.args.gen_len - 2)
|
| 711 |
+
else: # sequential
|
| 712 |
+
mut_idx = (step % (self.args.gen_len - 2)) + 1
|
| 713 |
+
|
| 714 |
+
# Get model prediction (Line 8: p_{k+1} ← Φ_{θ*}^{t_k→t_{k+1}}(Z_k))
|
| 715 |
+
time_t = torch.full(
|
| 716 |
+
(self.args.num_samples,),
|
| 717 |
+
step / self.args.optimization_steps,
|
| 718 |
+
device=self.device
|
| 719 |
+
)
|
| 720 |
+
|
| 721 |
+
logits = self.model(x, time_t)
|
| 722 |
+
probs = F.softmax(logits, dim=-1)
|
| 723 |
+
pos_probs = probs[:, mut_idx, :]
|
| 724 |
+
|
| 725 |
+
# Zero out current tokens and invalid tokens
|
| 726 |
+
pos_probs[:, x[:, mut_idx]] = 0
|
| 727 |
+
pos_probs[:, self.invalid_tokens] = 0
|
| 728 |
+
|
| 729 |
+
# Get TopK candidates (Line 10: for each candidate z' ∈ TopK(p_{k+1}))
|
| 730 |
+
sorted_probs, sorted_indices = torch.sort(pos_probs, descending=True)
|
| 731 |
+
|
| 732 |
+
# Get current scores for comparison
|
| 733 |
+
current_scores = self._get_scores(x)
|
| 734 |
+
current_aggregated = self._compute_aggregated_score(current_scores, weights)
|
| 735 |
+
|
| 736 |
+
if step == 0:
|
| 737 |
+
print(f"\nInitial Scores: {current_scores.cpu().numpy()}")
|
| 738 |
+
if self.tracker and self.conjugation_obj:
|
| 739 |
+
detailed = self.conjugation_obj.get_last_detailed_results()
|
| 740 |
+
self.tracker.log_step(step, current_scores[0], detailed)
|
| 741 |
+
self.tracker.print_step_summary(step, current_scores[0], detailed,
|
| 742 |
+
self.guidance_method.value)
|
| 743 |
+
|
| 744 |
+
# Process each sample
|
| 745 |
+
final_tokens = []
|
| 746 |
+
guidance_infos = []
|
| 747 |
+
|
| 748 |
+
for i in range(self.args.num_samples):
|
| 749 |
+
# Get candidates for this sample
|
| 750 |
+
sample_probs = sorted_probs[i]
|
| 751 |
+
sample_indices = sorted_indices[i]
|
| 752 |
+
|
| 753 |
+
# Top-p filtering
|
| 754 |
+
cumsum = torch.cumsum(sample_probs, dim=0)
|
| 755 |
+
mask = cumsum <= self.args.top_p
|
| 756 |
+
mask[0] = True # Always include at least one
|
| 757 |
+
candidates = sample_indices[mask]
|
| 758 |
+
|
| 759 |
+
# Current score for this sample (for acceptance criterion)
|
| 760 |
+
current_sample_score = current_aggregated[i].item()
|
| 761 |
+
|
| 762 |
+
# Apply selected guidance method (Lines 9-15)
|
| 763 |
+
if beta > 0: # Guidance active (Line 9)
|
| 764 |
+
if self.guidance_method == GuidanceMethod.EXPONENTIAL_TILT:
|
| 765 |
+
token, info = self._exponential_tilt_guidance(
|
| 766 |
+
x, pos_probs, candidates, mut_idx, i, beta, weights,
|
| 767 |
+
current_score=current_sample_score
|
| 768 |
+
)
|
| 769 |
+
elif self.guidance_method == GuidanceMethod.CLASSIFIER_GUIDANCE:
|
| 770 |
+
token, info = self._classifier_guidance(
|
| 771 |
+
x, logits, mut_idx, i, self.args.cg_weight, weights
|
| 772 |
+
)
|
| 773 |
+
elif self.guidance_method == GuidanceMethod.BEST_OF_K:
|
| 774 |
+
token, info = self._best_of_k_guidance(
|
| 775 |
+
x, pos_probs, candidates, mut_idx, i, self.args.bok_k, weights
|
| 776 |
+
)
|
| 777 |
+
elif self.guidance_method == GuidanceMethod.HYBRID:
|
| 778 |
+
token, info = self._hybrid_guidance(
|
| 779 |
+
x, pos_probs, logits, candidates, mut_idx, i, step, beta, weights
|
| 780 |
+
)
|
| 781 |
+
else:
|
| 782 |
+
raise ValueError(f"Unknown guidance method: {self.guidance_method}")
|
| 783 |
+
else:
|
| 784 |
+
# No guidance: sample from p_{k+1} directly (Line 15)
|
| 785 |
+
candidate_probs = pos_probs[i, candidates]
|
| 786 |
+
candidate_probs = candidate_probs / (candidate_probs.sum() + 1e-9)
|
| 787 |
+
idx = torch.multinomial(candidate_probs, 1).item()
|
| 788 |
+
token = candidates[idx].item()
|
| 789 |
+
info = {'method': 'unguided'}
|
| 790 |
+
|
| 791 |
+
final_tokens.append(token)
|
| 792 |
+
guidance_infos.append(info)
|
| 793 |
+
|
| 794 |
+
# Update sequences with new tokens
|
| 795 |
+
x[:, mut_idx] = torch.tensor(final_tokens, device=self.device)
|
| 796 |
+
|
| 797 |
+
# Check for improvements
|
| 798 |
+
new_scores = self._get_scores(x)
|
| 799 |
+
new_aggregated = self._compute_aggregated_score(new_scores, weights)
|
| 800 |
+
|
| 801 |
+
for i in range(self.args.num_samples):
|
| 802 |
+
if new_aggregated[i] > current_aggregated[i]:
|
| 803 |
+
improved_indices.append(i)
|
| 804 |
+
|
| 805 |
+
if improved_indices:
|
| 806 |
+
print(f"\nStep {step}: Improved samples: {improved_indices}")
|
| 807 |
+
for i in improved_indices:
|
| 808 |
+
print(f" Sample {i}:")
|
| 809 |
+
# print(f" Previous score: {current_aggregated[i].item():.4f} | Per-objective: {current_scores[:, i].cpu().numpy()}")
|
| 810 |
+
print(f" New score: {new_aggregated[i].item():.4f} | Per-objective: {new_scores[:, i].cpu().numpy()}")
|
| 811 |
+
print(f" Sequences: {self.model.tokenizer.batch_decode(x[improved_indices])}")
|
| 812 |
+
|
| 813 |
+
# Logging
|
| 814 |
+
if self.tracker and self.conjugation_obj and (step % 4 == 0 or step == self.args.optimization_steps - 1):
|
| 815 |
+
step_scores = self._get_scores(x)
|
| 816 |
+
detailed = self.conjugation_obj.get_last_detailed_results()
|
| 817 |
+
self.tracker.log_step(
|
| 818 |
+
step, step_scores[0], detailed, improved_indices,
|
| 819 |
+
guidance_info={'method': self.guidance_method.value, 'beta': beta}
|
| 820 |
+
)
|
| 821 |
+
|
| 822 |
+
if step == self.args.optimization_steps - 1:
|
| 823 |
+
self.tracker.print_step_summary(step, step_scores[0], detailed,
|
| 824 |
+
self.guidance_method.value)
|
| 825 |
+
|
| 826 |
+
return x
|
| 827 |
+
|
| 828 |
+
|
| 829 |
+
# =============================================================================
|
| 830 |
+
# Main Execution
|
| 831 |
+
# =============================================================================
|
| 832 |
+
|
| 833 |
+
def main(args):
|
| 834 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 835 |
+
print(f"Using device: {device}")
|
| 836 |
+
print(f"Guidance method: {args.guidance_method}")
|
| 837 |
+
|
| 838 |
+
tokenizer = SMILES_SPE_Tokenizer(
|
| 839 |
+
'./smiles/smiles_tokenizer/new_vocab.txt',
|
| 840 |
+
'./smiles/smiles_tokenizer/new_splits.txt'
|
| 841 |
+
)
|
| 842 |
+
|
| 843 |
+
# Initialize objectives
|
| 844 |
+
conjugation_model = Conjugation(tokenizer, device=device, builder_type=args.builder_type)
|
| 845 |
+
affinity_model = BindingAffinity(args.target, device)
|
| 846 |
+
OBJECTIVE_FUNCTIONS = [conjugation_model, affinity_model]
|
| 847 |
+
|
| 848 |
+
# Initialize tracker
|
| 849 |
+
tracker = GenerationTracker(output_dir=args.output_dir)
|
| 850 |
+
|
| 851 |
+
# Set random seeds
|
| 852 |
+
if args.seed is not None:
|
| 853 |
+
random.seed(args.seed)
|
| 854 |
+
np.random.seed(args.seed)
|
| 855 |
+
torch.manual_seed(args.seed)
|
| 856 |
+
if torch.cuda.is_available():
|
| 857 |
+
torch.cuda.manual_seed_all(args.seed)
|
| 858 |
+
print(f"Random seed: {args.seed}")
|
| 859 |
+
|
| 860 |
+
# Load model
|
| 861 |
+
print(f"Loading model from: {args.checkpoint}")
|
| 862 |
+
checkpoint = torch.load(args.checkpoint, map_location=device, weights_only=False)
|
| 863 |
+
model_hparams = checkpoint["hyper_parameters"]["args"]
|
| 864 |
+
|
| 865 |
+
model = MDLMLightningModule.load_from_checkpoint(
|
| 866 |
+
args.checkpoint,
|
| 867 |
+
args=model_hparams,
|
| 868 |
+
tokenizer=tokenizer,
|
| 869 |
+
map_location=device,
|
| 870 |
+
strict=False
|
| 871 |
+
)
|
| 872 |
+
model.to(device)
|
| 873 |
+
print("Model loaded successfully.")
|
| 874 |
+
|
| 875 |
+
# Create generator
|
| 876 |
+
generator = CupOfMoCAGenerator(model, device, OBJECTIVE_FUNCTIONS, args, tracker=tracker)
|
| 877 |
+
|
| 878 |
+
all_sequences = []
|
| 879 |
+
all_final_results = []
|
| 880 |
+
|
| 881 |
+
for batch_idx in range(args.num_batches):
|
| 882 |
+
print(f"\n{'=' * 60}")
|
| 883 |
+
print(f"BATCH {batch_idx + 1}/{args.num_batches}")
|
| 884 |
+
print(f"{'=' * 60}")
|
| 885 |
+
|
| 886 |
+
generated_tokens = generator.generate()
|
| 887 |
+
final_scores = generator._get_scores(generated_tokens.detach()).detach().cpu().numpy()
|
| 888 |
+
sequence_str = tokenizer.batch_decode(generated_tokens)
|
| 889 |
+
|
| 890 |
+
print("\n--- Batch Results ---")
|
| 891 |
+
print(f"Sequences: {sequence_str}")
|
| 892 |
+
print(f"Scores: {final_scores}")
|
| 893 |
+
|
| 894 |
+
all_sequences.extend(sequence_str)
|
| 895 |
+
|
| 896 |
+
# Get detailed results
|
| 897 |
+
detailed_with_props = conjugation_model.get_detailed_scores_with_properties(generated_tokens)
|
| 898 |
+
tracker.log_final(sequence_str, detailed_with_props)
|
| 899 |
+
all_final_results.extend(detailed_with_props)
|
| 900 |
+
|
| 901 |
+
# Save results
|
| 902 |
+
tracker.print_final_summary()
|
| 903 |
+
tracker.save(filename_prefix=f'cupofmoca_{args.guidance_method}')
|
| 904 |
+
|
| 905 |
+
with open(args.output_file, 'w') as f:
|
| 906 |
+
for seq in all_sequences:
|
| 907 |
+
f.write(seq + '\n')
|
| 908 |
+
print(f"\nSequences saved to: {args.output_file}")
|
| 909 |
+
print("\nGeneration complete.")
|
| 910 |
+
|
| 911 |
+
|
| 912 |
+
if __name__ == "__main__":
|
| 913 |
+
parser = argparse.ArgumentParser(
|
| 914 |
+
description="CupOfMoCA-style Guided Generation with Alternative Guidance Methods"
|
| 915 |
+
)
|
| 916 |
+
|
| 917 |
+
# Model and data
|
| 918 |
+
parser.add_argument("--checkpoint", type=str, required=True,
|
| 919 |
+
help="Path to trained model checkpoint")
|
| 920 |
+
parser.add_argument("--target", type=str, required=True,
|
| 921 |
+
help="Target protein sequence for binding affinity")
|
| 922 |
+
|
| 923 |
+
# Generation settings
|
| 924 |
+
parser.add_argument("--num_samples", type=int, default=10,
|
| 925 |
+
help="Number of samples per batch")
|
| 926 |
+
parser.add_argument("--num_batches", type=int, default=10,
|
| 927 |
+
help="Number of batches to generate")
|
| 928 |
+
parser.add_argument("--gen_len", type=int, default=50,
|
| 929 |
+
help="Length of sequences to generate")
|
| 930 |
+
parser.add_argument("--optimization_steps", type=int, default=16,
|
| 931 |
+
help="Number of guided optimization steps (K in Algorithm 2)")
|
| 932 |
+
|
| 933 |
+
# Guidance method selection
|
| 934 |
+
parser.add_argument("--guidance_method", type=str, default="exp_tilt",
|
| 935 |
+
choices=["exp_tilt", "cg", "bok", "hybrid"],
|
| 936 |
+
help="Guidance method: exp_tilt (exponential tilting), "
|
| 937 |
+
"cg (classifier/score guidance), bok (best-of-k), hybrid")
|
| 938 |
+
|
| 939 |
+
# Guidance parameters
|
| 940 |
+
parser.add_argument("--beta_min", type=float, default=1.0,
|
| 941 |
+
help="Minimum guidance strength β")
|
| 942 |
+
parser.add_argument("--beta_max", type=float, default=20.0,
|
| 943 |
+
help="Maximum guidance strength β")
|
| 944 |
+
parser.add_argument("--cg_weight", type=float, default=2.0,
|
| 945 |
+
help="Classifier guidance weight (for cg method)")
|
| 946 |
+
parser.add_argument("--use_acceptance", action="store_true",
|
| 947 |
+
help="Use acceptance criterion: only accept if new_score >= current_score (prevents regression)")
|
| 948 |
+
parser.add_argument("--bok_k", type=int, default=8,
|
| 949 |
+
help="Number of candidates for best-of-k selection")
|
| 950 |
+
parser.add_argument("--top_k", type=int, default=200,
|
| 951 |
+
help="Top-K candidates to consider for guidance")
|
| 952 |
+
parser.add_argument("--top_p", type=float, default=0.9,
|
| 953 |
+
help="Top-p threshold for candidate filtering")
|
| 954 |
+
parser.add_argument("--score_temperature", type=float, default=1.0,
|
| 955 |
+
help="Temperature for score-based logit adjustment")
|
| 956 |
+
parser.add_argument("--score_aggregation", type=str, default="weighted_sum",
|
| 957 |
+
choices=["weighted_sum", "weighted_min"],
|
| 958 |
+
help="How to aggregate multi-objective scores")
|
| 959 |
+
|
| 960 |
+
# Weights
|
| 961 |
+
parser.add_argument("--weights", type=float, nargs='+', default=None,
|
| 962 |
+
help="Weights for objectives (e.g., 0.5 0.5)")
|
| 963 |
+
|
| 964 |
+
# Position selection
|
| 965 |
+
parser.add_argument("--position_strategy", type=str, default="random",
|
| 966 |
+
choices=["random", "sequential"],
|
| 967 |
+
help="Strategy for selecting mutation positions")
|
| 968 |
+
|
| 969 |
+
# Initial generation
|
| 970 |
+
parser.add_argument("--require_valid_x0", action="store_true",
|
| 971 |
+
help="Regenerate x0 until valid conjugatable peptides")
|
| 972 |
+
parser.add_argument("--max_x0_retries", type=int, default=10,
|
| 973 |
+
help="Max retries for valid x0 generation")
|
| 974 |
+
|
| 975 |
+
# PDC builder
|
| 976 |
+
parser.add_argument("--builder_type", type=str, default="AoaDau",
|
| 977 |
+
choices=["AoaDau", "ValAlaDau", "ValCitDau", "GlutarylDau", "Dau",
|
| 978 |
+
"SSCpt", "VcMMAE", "DSPACpt", "MalHexCpt", "DSECCpt", "Cpt",
|
| 979 |
+
"DSPASN38", "DSEBSN38", "DSEPSN38", "SN38"],
|
| 980 |
+
help="PDC builder type for conjugation. "
|
| 981 |
+
"'Dau' samples linker by GNRHR+Dau frequency. "
|
| 982 |
+
"'Cpt' samples linker by ERBB2+Cpt frequency. "
|
| 983 |
+
"'SN38' samples linker by NRP1+SN38 frequency.")
|
| 984 |
+
|
| 985 |
+
# Output
|
| 986 |
+
parser.add_argument("--output_file", type=str, default="./smiles.txt",
|
| 987 |
+
help="File to save generated sequences")
|
| 988 |
+
parser.add_argument("--output_dir", type=str, default="./generation_logs",
|
| 989 |
+
help="Directory for detailed logs")
|
| 990 |
+
|
| 991 |
+
# Reproducibility
|
| 992 |
+
parser.add_argument("--seed", type=int, default=None,
|
| 993 |
+
help="Random seed")
|
| 994 |
+
|
| 995 |
+
args = parser.parse_args()
|
| 996 |
+
main(args)
|
smiles/generation/cupofmoca_guided_aa.py
ADDED
|
@@ -0,0 +1,1126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CupOfMoCA-style guided generation with the natural-AA foundation model."""
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import csv
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
import random
|
| 8 |
+
import sys
|
| 9 |
+
import time
|
| 10 |
+
from collections import defaultdict
|
| 11 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 12 |
+
from contextlib import contextmanager
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
from enum import Enum
|
| 15 |
+
from typing import Dict, Optional, Tuple
|
| 16 |
+
|
| 17 |
+
import numpy as np
|
| 18 |
+
import torch
|
| 19 |
+
import torch.nn as nn
|
| 20 |
+
import torch.nn.functional as F
|
| 21 |
+
from easydict import EasyDict as edict
|
| 22 |
+
from tqdm import tqdm
|
| 23 |
+
|
| 24 |
+
from smiles.models.train import MDLMLightningModule
|
| 25 |
+
from smiles.smiles_tokenizer.my_tokenizers import SMILES_SPE_Tokenizer
|
| 26 |
+
from smiles.evaluation.peptide_analyzer import PeptideAnalyzer
|
| 27 |
+
from smiles.evaluation.pdc_analyzer import PDCAnalyzer
|
| 28 |
+
from smiles.scoring.classifiers import Conjugation, BindingAffinity
|
| 29 |
+
|
| 30 |
+
# AA-mode shared utilities
|
| 31 |
+
from smiles.models.aa_helpers import (
|
| 32 |
+
ESM2AminoTokenizer as _AAEsmTokenizer,
|
| 33 |
+
MDLM_PepReDi as _AAMDLM,
|
| 34 |
+
make_affinity,
|
| 35 |
+
DEFAULT_AA_AFFINITY_CKPT,
|
| 36 |
+
INVALID_AA_TOKEN_IDS as _AA_INVALID_IDS,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
from third_party.SMILESToPeptide.peptide_to_smiles import aa_to_smiles
|
| 40 |
+
from third_party.SMILESToPeptide.aminoacid_selective import one_letter_to_three_letter
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# =============================================================================
|
| 44 |
+
# Amino Acid Foundation Model (DiT-based MDLM)
|
| 45 |
+
# =============================================================================
|
| 46 |
+
|
| 47 |
+
# --- AA-mode Model + Tokenizer (imported from aa_helpers) ---
|
| 48 |
+
MDLM = _AAMDLM
|
| 49 |
+
AminoTokenizer = _AAEsmTokenizer
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# =============================================================================
|
| 53 |
+
# Profiler
|
| 54 |
+
# =============================================================================
|
| 55 |
+
|
| 56 |
+
class Profiler:
|
| 57 |
+
"""Aggregates wall-clock time per named section. Optionally syncs CUDA.
|
| 58 |
+
|
| 59 |
+
`reset()` clears all state (use between batches for self-contained summaries).
|
| 60 |
+
`print_summary()` shows cumulative totals since the last reset, plus a
|
| 61 |
+
delta column showing time since the previous `print_summary()` call.
|
| 62 |
+
"""
|
| 63 |
+
|
| 64 |
+
def __init__(self, enabled=False, cuda_sync=True):
|
| 65 |
+
self.enabled = enabled
|
| 66 |
+
self.cuda_sync = cuda_sync and torch.cuda.is_available()
|
| 67 |
+
self.totals = defaultdict(float)
|
| 68 |
+
self.counts = defaultdict(int)
|
| 69 |
+
self._last_totals = defaultdict(float)
|
| 70 |
+
self._last_counts = defaultdict(int)
|
| 71 |
+
|
| 72 |
+
@contextmanager
|
| 73 |
+
def section(self, name):
|
| 74 |
+
if not self.enabled:
|
| 75 |
+
yield
|
| 76 |
+
return
|
| 77 |
+
if self.cuda_sync:
|
| 78 |
+
torch.cuda.synchronize()
|
| 79 |
+
t0 = time.perf_counter()
|
| 80 |
+
try:
|
| 81 |
+
yield
|
| 82 |
+
finally:
|
| 83 |
+
if self.cuda_sync:
|
| 84 |
+
torch.cuda.synchronize()
|
| 85 |
+
self.totals[name] += time.perf_counter() - t0
|
| 86 |
+
self.counts[name] += 1
|
| 87 |
+
|
| 88 |
+
def reset(self):
|
| 89 |
+
self.totals.clear()
|
| 90 |
+
self.counts.clear()
|
| 91 |
+
self._last_totals.clear()
|
| 92 |
+
self._last_counts.clear()
|
| 93 |
+
|
| 94 |
+
def print_summary(self, header=""):
|
| 95 |
+
if not self.enabled or not self.totals:
|
| 96 |
+
return
|
| 97 |
+
cumulative_total = sum(self.totals.values())
|
| 98 |
+
delta_totals = {k: self.totals[k] - self._last_totals.get(k, 0.0)
|
| 99 |
+
for k in self.totals}
|
| 100 |
+
delta_counts = {k: self.counts[k] - self._last_counts.get(k, 0)
|
| 101 |
+
for k in self.counts}
|
| 102 |
+
interval_total = sum(delta_totals.values())
|
| 103 |
+
|
| 104 |
+
print(f"\n=== Profiler summary {header} ===")
|
| 105 |
+
print(f" (cumulative since reset | interval since previous summary)")
|
| 106 |
+
print(f" {'section':<34} "
|
| 107 |
+
f"{'calls':>6} {'cum(s)':>8} {'cum_avg':>8} "
|
| 108 |
+
f"{'Δcalls':>6} {'Δ(s)':>7} {'Δavg':>7} {'%Δ':>5}")
|
| 109 |
+
for name, tot in sorted(self.totals.items(), key=lambda kv: -kv[1]):
|
| 110 |
+
n = self.counts[name]
|
| 111 |
+
cum_avg = (tot / n) * 1000.0 if n else 0.0
|
| 112 |
+
dt = delta_totals[name]
|
| 113 |
+
dn = delta_counts[name]
|
| 114 |
+
d_avg = (dt / dn) * 1000.0 if dn else 0.0
|
| 115 |
+
d_pct = (dt / interval_total) * 100.0 if interval_total > 0 else 0.0
|
| 116 |
+
print(f" {name:<34} "
|
| 117 |
+
f"{n:>6d} {tot:>8.2f} {cum_avg:>7.1f}ms "
|
| 118 |
+
f"{dn:>6d} {dt:>7.2f} {d_avg:>6.1f}ms {d_pct:>4.1f}%")
|
| 119 |
+
print(f" {'-' * 34} {'-' * 6} {'-' * 8} {'-' * 8} "
|
| 120 |
+
f"{'-' * 6} {'-' * 7} {'-' * 7} {'-' * 5}")
|
| 121 |
+
print(f" {'TOTAL':<34} {'':>6} {cumulative_total:>8.2f} {'':>8} "
|
| 122 |
+
f"{'':>6} {interval_total:>7.2f}")
|
| 123 |
+
|
| 124 |
+
self._last_totals = dict(self.totals)
|
| 125 |
+
self._last_counts = dict(self.counts)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
# =============================================================================
|
| 129 |
+
# Guidance Method Enum
|
| 130 |
+
# =============================================================================
|
| 131 |
+
|
| 132 |
+
class GuidanceMethod(Enum):
|
| 133 |
+
EXPONENTIAL_TILT = "exp_tilt"
|
| 134 |
+
CLASSIFIER_GUIDANCE = "cg"
|
| 135 |
+
BEST_OF_K = "bok"
|
| 136 |
+
HYBRID = "hybrid"
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# =============================================================================
|
| 140 |
+
# Generation Tracker
|
| 141 |
+
# =============================================================================
|
| 142 |
+
|
| 143 |
+
class GenerationTracker:
|
| 144 |
+
def __init__(self, output_dir='./generation_logs'):
|
| 145 |
+
self.output_dir = output_dir
|
| 146 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 147 |
+
self.run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 148 |
+
self.step_logs = []
|
| 149 |
+
self.final_results = []
|
| 150 |
+
|
| 151 |
+
def log_step(self, step, scores, detailed_results=None, improved_indices=None,
|
| 152 |
+
guidance_info: Optional[Dict] = None):
|
| 153 |
+
step_info = {
|
| 154 |
+
'step': step,
|
| 155 |
+
'scores': scores.cpu().numpy().tolist() if isinstance(scores, torch.Tensor) else scores,
|
| 156 |
+
'num_valid': int(sum(1 for s in scores if s > 0)) if hasattr(scores, '__iter__') else 0,
|
| 157 |
+
'improved_indices': improved_indices or [],
|
| 158 |
+
'guidance_info': guidance_info or {}
|
| 159 |
+
}
|
| 160 |
+
if detailed_results:
|
| 161 |
+
step_info['detailed'] = []
|
| 162 |
+
for i, result in enumerate(detailed_results):
|
| 163 |
+
step_info['detailed'].append({
|
| 164 |
+
'sample_idx': i,
|
| 165 |
+
'is_valid_peptide': result.get('is_valid_peptide', False),
|
| 166 |
+
'has_conjugation_site': result.get('has_conjugation_site', False),
|
| 167 |
+
'conjugation_success': result.get('conjugation_success', False),
|
| 168 |
+
'pdc_valid': result.get('pdc_valid', False),
|
| 169 |
+
'num_conjugates': len(result.get('conjugates', [])),
|
| 170 |
+
'error': result.get('error')
|
| 171 |
+
})
|
| 172 |
+
self.step_logs.append(step_info)
|
| 173 |
+
|
| 174 |
+
def log_final(self, sequences, detailed_results_with_props, smiles_list=None):
|
| 175 |
+
for i, (seq, result) in enumerate(zip(sequences, detailed_results_with_props)):
|
| 176 |
+
final_info = {
|
| 177 |
+
'sample_idx': i,
|
| 178 |
+
'aa_sequence': seq,
|
| 179 |
+
'peptide_smiles': smiles_list[i] if smiles_list else seq,
|
| 180 |
+
'is_valid_peptide': result.get('is_valid_peptide', False),
|
| 181 |
+
'has_conjugation_site': result.get('has_conjugation_site', False),
|
| 182 |
+
'num_conjugates': len(result.get('conjugates', [])),
|
| 183 |
+
'num_valid_pdcs': len(result.get('valid_pdcs', [])),
|
| 184 |
+
'best_pdc': None,
|
| 185 |
+
'linker_used': result.get('linker_used', ''),
|
| 186 |
+
'all_conjugates': result.get('conjugates', []),
|
| 187 |
+
'properties': result.get('properties', {})
|
| 188 |
+
}
|
| 189 |
+
if result.get('best_pdc'):
|
| 190 |
+
final_info['best_pdc'] = {
|
| 191 |
+
'smiles': result['best_pdc'].get('smiles', ''),
|
| 192 |
+
'checks': result['best_pdc'].get('checks', {})
|
| 193 |
+
}
|
| 194 |
+
self.final_results.append(final_info)
|
| 195 |
+
|
| 196 |
+
def save(self, filename_prefix='generation'):
|
| 197 |
+
step_log_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_steps.json")
|
| 198 |
+
with open(step_log_path, 'w') as f:
|
| 199 |
+
json.dump(self.step_logs, f, indent=2, default=str)
|
| 200 |
+
|
| 201 |
+
final_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_final.json")
|
| 202 |
+
with open(final_path, 'w') as f:
|
| 203 |
+
json.dump(self.final_results, f, indent=2, default=str)
|
| 204 |
+
|
| 205 |
+
csv_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_summary.csv")
|
| 206 |
+
with open(csv_path, 'w', newline='') as f:
|
| 207 |
+
writer = csv.writer(f)
|
| 208 |
+
writer.writerow([
|
| 209 |
+
'sample_idx', 'aa_sequence', 'peptide_smiles', 'is_valid_peptide',
|
| 210 |
+
'has_conjugation_site', 'linker_used', 'num_conjugates', 'num_valid_pdcs',
|
| 211 |
+
'best_pdc_smiles', 'mf', 'polararea', 'xlogp', 'heavycnt', 'rotbonds'
|
| 212 |
+
])
|
| 213 |
+
for result in self.final_results:
|
| 214 |
+
props = result.get('properties', {})
|
| 215 |
+
writer.writerow([
|
| 216 |
+
result['sample_idx'],
|
| 217 |
+
result.get('aa_sequence', ''),
|
| 218 |
+
result['peptide_smiles'],
|
| 219 |
+
result['is_valid_peptide'],
|
| 220 |
+
result['has_conjugation_site'],
|
| 221 |
+
result.get('linker_used', ''),
|
| 222 |
+
result['num_conjugates'],
|
| 223 |
+
result['num_valid_pdcs'],
|
| 224 |
+
result['best_pdc']['smiles'] if result.get('best_pdc') else '',
|
| 225 |
+
props.get('mf', ''),
|
| 226 |
+
props.get('polararea', ''),
|
| 227 |
+
props.get('xlogp', ''),
|
| 228 |
+
props.get('heavycnt', ''),
|
| 229 |
+
props.get('rotbonds', '')
|
| 230 |
+
])
|
| 231 |
+
|
| 232 |
+
pdc_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_pdcs.csv")
|
| 233 |
+
with open(pdc_path, 'w', newline='') as f:
|
| 234 |
+
writer = csv.writer(f)
|
| 235 |
+
writer.writerow(['sample_idx', 'aa_sequence', 'peptide_smiles', 'linker_used',
|
| 236 |
+
'conjugate_idx', 'pdc_smiles'])
|
| 237 |
+
for result in self.final_results:
|
| 238 |
+
for j, conj in enumerate(result.get('all_conjugates', [])):
|
| 239 |
+
writer.writerow([
|
| 240 |
+
result['sample_idx'],
|
| 241 |
+
result.get('aa_sequence', ''),
|
| 242 |
+
result['peptide_smiles'],
|
| 243 |
+
result.get('linker_used', ''),
|
| 244 |
+
j,
|
| 245 |
+
conj
|
| 246 |
+
])
|
| 247 |
+
|
| 248 |
+
print(f"\n=== Generation logs saved ===")
|
| 249 |
+
print(f" Step logs: {step_log_path}")
|
| 250 |
+
print(f" Final results: {final_path}")
|
| 251 |
+
print(f" Summary CSV: {csv_path}")
|
| 252 |
+
print(f" PDC conjugates: {pdc_path}")
|
| 253 |
+
return step_log_path, final_path, csv_path, pdc_path
|
| 254 |
+
|
| 255 |
+
def print_step_summary(self, step, scores, detailed_results=None, guidance_method=None):
|
| 256 |
+
scores_list = scores.cpu().numpy().tolist() if isinstance(scores, torch.Tensor) else list(scores)
|
| 257 |
+
num_valid = sum(1 for s in scores_list if s > 0)
|
| 258 |
+
print(f"\n--- Step {step} Summary ({guidance_method or 'unknown'}) ---")
|
| 259 |
+
print(f" Valid: {num_valid}/{len(scores_list)}")
|
| 260 |
+
if detailed_results:
|
| 261 |
+
print(f" Breakdown:")
|
| 262 |
+
print(f" Valid peptides: {sum(1 for r in detailed_results if r.get('is_valid_peptide'))}")
|
| 263 |
+
print(f" Has conjugation site: {sum(1 for r in detailed_results if r.get('has_conjugation_site'))}")
|
| 264 |
+
print(f" Conjugation success: {sum(1 for r in detailed_results if r.get('conjugation_success'))}")
|
| 265 |
+
print(f" PDC valid: {sum(1 for r in detailed_results if r.get('pdc_valid'))}")
|
| 266 |
+
|
| 267 |
+
def print_final_summary(self):
|
| 268 |
+
print("\n" + "=" * 60)
|
| 269 |
+
print("FINAL GENERATION SUMMARY")
|
| 270 |
+
print("=" * 60)
|
| 271 |
+
total = len(self.final_results)
|
| 272 |
+
valid_pdcs = sum(1 for r in self.final_results if r.get('num_valid_pdcs', 0) > 0)
|
| 273 |
+
print(f"\nTotal sequences: {total}")
|
| 274 |
+
print(f"With valid PDCs: {valid_pdcs}")
|
| 275 |
+
for result in self.final_results:
|
| 276 |
+
status = "" if result.get('num_valid_pdcs', 0) > 0 else ""
|
| 277 |
+
print(f"\n [{status}] Sample {result['sample_idx']}:")
|
| 278 |
+
print(f" AA seq: {result.get('aa_sequence', '')}")
|
| 279 |
+
print(f" Peptide SMILES: {result['peptide_smiles'][:60]}...")
|
| 280 |
+
print(f" Valid peptide: {result['is_valid_peptide']}")
|
| 281 |
+
print(f" Has conjugation site: {result['has_conjugation_site']}")
|
| 282 |
+
print(f" Conjugates: {result['num_conjugates']}")
|
| 283 |
+
print(f" Valid PDCs: {result['num_valid_pdcs']}")
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
# =============================================================================
|
| 287 |
+
# CupOfMoCA Generator (UAA-aware)
|
| 288 |
+
# =============================================================================
|
| 289 |
+
|
| 290 |
+
class CupOfMoCAGeneratorUAA:
|
| 291 |
+
"""CupOfMoCA Algorithm 2 generator that operates in AA token space.
|
| 292 |
+
|
| 293 |
+
AA tokens are converted to SMILES on the fly for scoring with the
|
| 294 |
+
Conjugation and BindingAffinity objectives.
|
| 295 |
+
"""
|
| 296 |
+
|
| 297 |
+
def __init__(self, model, device, objectives, args, tokenizer, tokenizer_type='amino',
|
| 298 |
+
tracker=None):
|
| 299 |
+
self.model = model
|
| 300 |
+
self.device = device
|
| 301 |
+
self.objectives = objectives
|
| 302 |
+
self.args = args
|
| 303 |
+
self.num_objectives = len(objectives)
|
| 304 |
+
self.peptide_analyzer = PeptideAnalyzer()
|
| 305 |
+
self.pdc_analyzer = PDCAnalyzer(device)
|
| 306 |
+
self.tracker = tracker
|
| 307 |
+
self.tokenizer = tokenizer
|
| 308 |
+
self.tokenizer_type = tokenizer_type
|
| 309 |
+
self.guidance_method = GuidanceMethod(args.guidance_method)
|
| 310 |
+
self.profiler = Profiler(enabled=getattr(args, 'profile', False))
|
| 311 |
+
|
| 312 |
+
cascade_workers = max(1, getattr(args, 'cascade_workers', 1))
|
| 313 |
+
self._cascade_pool = (
|
| 314 |
+
ThreadPoolExecutor(max_workers=cascade_workers)
|
| 315 |
+
if cascade_workers > 1 else None
|
| 316 |
+
)
|
| 317 |
+
|
| 318 |
+
if tokenizer_type == 'amino':
|
| 319 |
+
# PepReDi natural-AA vocab is 24 tokens; 0-3 = cls/pad/eos/unk,
|
| 320 |
+
# 4-23 = canonical AAs. Block specials only. (UAA used id 38
|
| 321 |
+
# for D-AA 'g'; that's out of PepReDi vocab range.)
|
| 322 |
+
self.invalid_tokens = [0, 1, 2, 3]
|
| 323 |
+
self.smiles_tokenizer = SMILES_SPE_Tokenizer(
|
| 324 |
+
args.smiles_vocab_path, args.smiles_splits_path
|
| 325 |
+
)
|
| 326 |
+
else:
|
| 327 |
+
self.invalid_tokens = [0, 1, 2, 3, 4, 585, 586]
|
| 328 |
+
self.smiles_tokenizer = None
|
| 329 |
+
|
| 330 |
+
self.conjugation_obj = None
|
| 331 |
+
for obj in objectives:
|
| 332 |
+
if isinstance(obj, Conjugation):
|
| 333 |
+
self.conjugation_obj = obj
|
| 334 |
+
break
|
| 335 |
+
|
| 336 |
+
# ------------------------------------------------------------------
|
| 337 |
+
# x0 generation (Algorithm 2, Line 6)
|
| 338 |
+
# ------------------------------------------------------------------
|
| 339 |
+
def _generate_single_x0(self, n_steps=16, temperature=1.0):
|
| 340 |
+
self.model.eval()
|
| 341 |
+
|
| 342 |
+
if self.tokenizer_type == 'amino':
|
| 343 |
+
vocab_size = self.model.vocab_size
|
| 344 |
+
valid_tokens = [i for i in range(vocab_size) if i not in self.invalid_tokens]
|
| 345 |
+
valid_tokens = torch.tensor(valid_tokens, device=self.device)
|
| 346 |
+
rand_indices = torch.randint(
|
| 347 |
+
0, len(valid_tokens),
|
| 348 |
+
(self.args.num_samples, self.args.gen_len),
|
| 349 |
+
device=self.device
|
| 350 |
+
)
|
| 351 |
+
body = valid_tokens[rand_indices]
|
| 352 |
+
cls_col = torch.full((self.args.num_samples, 1), self.tokenizer.cls_id, device=self.device)
|
| 353 |
+
eos_col = torch.full((self.args.num_samples, 1), self.tokenizer.eos_id, device=self.device)
|
| 354 |
+
x = torch.cat([cls_col, body, eos_col], dim=1)
|
| 355 |
+
else:
|
| 356 |
+
vocab_size = self.model.tokenizer.vocab_size
|
| 357 |
+
x = torch.randint(
|
| 358 |
+
0, vocab_size,
|
| 359 |
+
(self.args.num_samples, self.args.gen_len),
|
| 360 |
+
device=self.device
|
| 361 |
+
)
|
| 362 |
+
|
| 363 |
+
time_steps = torch.linspace(0.0, 1.0, n_steps + 1, device=self.device)
|
| 364 |
+
|
| 365 |
+
with torch.no_grad():
|
| 366 |
+
for i in tqdm(range(n_steps), desc="Initial Flow Matching", leave=False):
|
| 367 |
+
t_curr = time_steps[i]
|
| 368 |
+
t_next = time_steps[i + 1]
|
| 369 |
+
t_tensor = torch.full((self.args.num_samples,), t_curr, device=self.device)
|
| 370 |
+
|
| 371 |
+
logits = self.model(x, t_tensor) / temperature
|
| 372 |
+
for inv_tok in self.invalid_tokens:
|
| 373 |
+
if inv_tok < logits.shape[-1]:
|
| 374 |
+
logits[:, :, inv_tok] = -1000
|
| 375 |
+
|
| 376 |
+
pred_x1 = torch.argmax(logits, dim=-1)
|
| 377 |
+
if i == n_steps - 1:
|
| 378 |
+
x = pred_x1
|
| 379 |
+
break
|
| 380 |
+
|
| 381 |
+
noise_prob = 1.0 - t_next
|
| 382 |
+
mask = torch.rand(x.shape, device=self.device) < noise_prob
|
| 383 |
+
noise = torch.randint(0, vocab_size, x.shape, device=self.device)
|
| 384 |
+
x = torch.where(mask, noise, pred_x1)
|
| 385 |
+
|
| 386 |
+
return x
|
| 387 |
+
|
| 388 |
+
def _aa_to_smiles_batch(self, x_batch):
|
| 389 |
+
"""Decode AA tokens, convert to SMILES via three-letter codes."""
|
| 390 |
+
with self.profiler.section("aa2smiles.decode"):
|
| 391 |
+
seqs = self.tokenizer.batch_decode(x_batch)
|
| 392 |
+
with self.profiler.section("aa2smiles.one_to_three"):
|
| 393 |
+
three_letter_seqs = [one_letter_to_three_letter(s) for s in seqs]
|
| 394 |
+
with self.profiler.section("aa2smiles.aa_to_smiles"):
|
| 395 |
+
with ThreadPoolExecutor() as pool:
|
| 396 |
+
smiles_seqs = list(pool.map(
|
| 397 |
+
lambda s: aa_to_smiles(s, cyclic=self.args.cyclic),
|
| 398 |
+
three_letter_seqs
|
| 399 |
+
))
|
| 400 |
+
return seqs, smiles_seqs
|
| 401 |
+
|
| 402 |
+
def _check_conjugation_validity(self, x):
|
| 403 |
+
if self.tokenizer_type == 'amino':
|
| 404 |
+
_, smiles_seqs = self._aa_to_smiles_batch(x)
|
| 405 |
+
validities = [
|
| 406 |
+
self.peptide_analyzer.is_peptide(s) if isinstance(s, str) else False
|
| 407 |
+
for s in smiles_seqs
|
| 408 |
+
]
|
| 409 |
+
return torch.tensor(validities, device=self.device)
|
| 410 |
+
|
| 411 |
+
if self.conjugation_obj is None:
|
| 412 |
+
sequences = self.tokenizer.batch_decode(x)
|
| 413 |
+
return torch.tensor(
|
| 414 |
+
[self.peptide_analyzer.is_peptide(seq) for seq in sequences],
|
| 415 |
+
device=self.device
|
| 416 |
+
)
|
| 417 |
+
return self.conjugation_obj(x) > 0
|
| 418 |
+
|
| 419 |
+
def generate_x0(self, n_steps=16, temperature=1.0,
|
| 420 |
+
require_valid=False, max_retries=10):
|
| 421 |
+
print("Generating initial sequences (x0 from prior)...")
|
| 422 |
+
x = self._generate_single_x0(n_steps, temperature)
|
| 423 |
+
|
| 424 |
+
if require_valid:
|
| 425 |
+
print("Enforcing initial validity constraint...")
|
| 426 |
+
for retry in range(max_retries):
|
| 427 |
+
validity = self._check_conjugation_validity(x)
|
| 428 |
+
num_valid = validity.sum().item()
|
| 429 |
+
num_invalid = self.args.num_samples - num_valid
|
| 430 |
+
if num_invalid == 0:
|
| 431 |
+
print(f" All {self.args.num_samples} samples are valid!")
|
| 432 |
+
break
|
| 433 |
+
print(f" Retry {retry + 1}/{max_retries}: {num_valid}/{self.args.num_samples} valid")
|
| 434 |
+
invalid_indices = torch.where(~validity)[0]
|
| 435 |
+
original_num = self.args.num_samples
|
| 436 |
+
self.args.num_samples = num_invalid
|
| 437 |
+
new_x = self._generate_single_x0(n_steps, temperature)
|
| 438 |
+
self.args.num_samples = original_num
|
| 439 |
+
x[invalid_indices] = new_x
|
| 440 |
+
|
| 441 |
+
final_validity = self._check_conjugation_validity(x)
|
| 442 |
+
if final_validity.sum().item() < self.args.num_samples:
|
| 443 |
+
print(f"⚠ After {max_retries} retries, "
|
| 444 |
+
f"{final_validity.sum().item()}/{self.args.num_samples} valid")
|
| 445 |
+
|
| 446 |
+
generated_sequences = self.tokenizer.batch_decode(x)
|
| 447 |
+
for seq in generated_sequences:
|
| 448 |
+
print(f" {seq}")
|
| 449 |
+
return x
|
| 450 |
+
|
| 451 |
+
# ------------------------------------------------------------------
|
| 452 |
+
# Scoring
|
| 453 |
+
# ------------------------------------------------------------------
|
| 454 |
+
def _get_scores(self, x_batch):
|
| 455 |
+
with self.profiler.section("get_scores.total"):
|
| 456 |
+
scores = []
|
| 457 |
+
|
| 458 |
+
if self.tokenizer_type == 'amino':
|
| 459 |
+
_, smiles_seqs = self._aa_to_smiles_batch(x_batch)
|
| 460 |
+
with self.profiler.section("get_scores.valid_mask"):
|
| 461 |
+
valid_mask = torch.tensor(
|
| 462 |
+
[1 if isinstance(s, str) else 0 for s in smiles_seqs],
|
| 463 |
+
device=self.device
|
| 464 |
+
)
|
| 465 |
+
for i in torch.where((1 - valid_mask) == 1)[0].tolist():
|
| 466 |
+
smiles_seqs[i] = 'C'
|
| 467 |
+
|
| 468 |
+
with self.profiler.section("get_scores.smiles_tokenize"):
|
| 469 |
+
smiles_batch = self.smiles_tokenizer(
|
| 470 |
+
smiles_seqs, return_tensors='pt', padding=True
|
| 471 |
+
).to(self.device)
|
| 472 |
+
attention_mask = smiles_batch.get("attention_mask", None)
|
| 473 |
+
|
| 474 |
+
for obj_func in self.objectives:
|
| 475 |
+
obj_name = type(obj_func).__name__
|
| 476 |
+
with self.profiler.section(f"get_scores.obj.{obj_name}"):
|
| 477 |
+
if getattr(obj_func, "input_type", None) == "aa":
|
| 478 |
+
# AA-input objective: score raw AA tokens (no
|
| 479 |
+
# SMILES conversion, no valid_mask multiply).
|
| 480 |
+
score = obj_func(x_batch.to(self.device)).to(self.device)
|
| 481 |
+
scores.append(score)
|
| 482 |
+
else:
|
| 483 |
+
try:
|
| 484 |
+
score = obj_func(
|
| 485 |
+
smiles_batch["input_ids"], attention_mask=attention_mask
|
| 486 |
+
).to(self.device)
|
| 487 |
+
except TypeError:
|
| 488 |
+
score = obj_func(smiles_batch["input_ids"]).to(self.device)
|
| 489 |
+
scores.append(score * valid_mask)
|
| 490 |
+
else:
|
| 491 |
+
for obj_func in self.objectives:
|
| 492 |
+
obj_name = type(obj_func).__name__
|
| 493 |
+
with self.profiler.section(f"get_scores.obj.{obj_name}"):
|
| 494 |
+
scores.append(obj_func(x_batch.to(self.device)))
|
| 495 |
+
|
| 496 |
+
return torch.stack(scores, dim=0).to(self.device)
|
| 497 |
+
|
| 498 |
+
def _compute_aggregated_score(self, scores, weights):
|
| 499 |
+
if self.args.score_aggregation == "weighted_min":
|
| 500 |
+
return torch.min(weights * scores, dim=0).values
|
| 501 |
+
return (weights * scores).sum(dim=0)
|
| 502 |
+
|
| 503 |
+
# ------------------------------------------------------------------
|
| 504 |
+
# Cascade scoring (cheap filter + affinity on all → full PDC on top-K)
|
| 505 |
+
# ------------------------------------------------------------------
|
| 506 |
+
def _get_scores_cascaded(self, x_batch):
|
| 507 |
+
"""Run cheap conjugation pre-filter + BindingAffinity on all candidates,
|
| 508 |
+
then run the expensive PDC validation only on the top `cascade_k` by
|
| 509 |
+
affinity (among those that pass the cheap filter).
|
| 510 |
+
|
| 511 |
+
Returns [num_objectives, batch] in the same layout as `_get_scores`.
|
| 512 |
+
Falls back to full scoring if Conjugation isn't an objective or
|
| 513 |
+
tokenizer_type != 'amino'.
|
| 514 |
+
"""
|
| 515 |
+
if self.tokenizer_type != 'amino' or self.conjugation_obj is None:
|
| 516 |
+
return self._get_scores(x_batch)
|
| 517 |
+
|
| 518 |
+
with self.profiler.section("cascade.total"):
|
| 519 |
+
n = x_batch.shape[0]
|
| 520 |
+
cascade_k = self.args.cascade_k
|
| 521 |
+
conj_obj = self.conjugation_obj
|
| 522 |
+
|
| 523 |
+
_, smiles_seqs = self._aa_to_smiles_batch(x_batch)
|
| 524 |
+
valid_mask = torch.tensor(
|
| 525 |
+
[1.0 if isinstance(s, str) else 0.0 for s in smiles_seqs],
|
| 526 |
+
device=self.device
|
| 527 |
+
)
|
| 528 |
+
safe_smiles = [s if isinstance(s, str) else 'C' for s in smiles_seqs]
|
| 529 |
+
|
| 530 |
+
with self.profiler.section("cascade.cheap_filter"):
|
| 531 |
+
cheap_pass = []
|
| 532 |
+
for s, vm in zip(safe_smiles, valid_mask.tolist()):
|
| 533 |
+
if vm == 0.0:
|
| 534 |
+
cheap_pass.append(False)
|
| 535 |
+
continue
|
| 536 |
+
cheap_pass.append(
|
| 537 |
+
conj_obj.is_valid_peptide(s)
|
| 538 |
+
and conj_obj.has_conjugation_site(s)
|
| 539 |
+
)
|
| 540 |
+
cheap_pass_t = torch.tensor(
|
| 541 |
+
cheap_pass, device=self.device, dtype=torch.float
|
| 542 |
+
)
|
| 543 |
+
|
| 544 |
+
with self.profiler.section("cascade.smiles_tokenize"):
|
| 545 |
+
smiles_batch = self.smiles_tokenizer(
|
| 546 |
+
safe_smiles, return_tensors='pt', padding=True
|
| 547 |
+
).to(self.device)
|
| 548 |
+
attention_mask = smiles_batch.get("attention_mask", None)
|
| 549 |
+
|
| 550 |
+
aff_obj = next(
|
| 551 |
+
(o for o in self.objectives if not isinstance(o, Conjugation)),
|
| 552 |
+
None
|
| 553 |
+
)
|
| 554 |
+
if aff_obj is None:
|
| 555 |
+
aff_scores = torch.zeros(n, device=self.device)
|
| 556 |
+
else:
|
| 557 |
+
with self.profiler.section(f"cascade.obj.{type(aff_obj).__name__}"):
|
| 558 |
+
if getattr(aff_obj, "input_type", None) == "aa":
|
| 559 |
+
# AA-input affinity: score raw AA tokens directly.
|
| 560 |
+
# Don't multiply by valid_mask (the AA->SMILES outcome
|
| 561 |
+
# is irrelevant for the AA-input scorer).
|
| 562 |
+
aff_scores = aff_obj(x_batch.to(self.device)).to(self.device)
|
| 563 |
+
else:
|
| 564 |
+
try:
|
| 565 |
+
aff_scores = aff_obj(
|
| 566 |
+
smiles_batch["input_ids"], attention_mask=attention_mask
|
| 567 |
+
).to(self.device)
|
| 568 |
+
except TypeError:
|
| 569 |
+
aff_scores = aff_obj(smiles_batch["input_ids"]).to(self.device)
|
| 570 |
+
aff_scores = aff_scores * valid_mask
|
| 571 |
+
|
| 572 |
+
eligible_idx = torch.where(cheap_pass_t > 0)[0]
|
| 573 |
+
if cascade_k > 0 and len(eligible_idx) > cascade_k:
|
| 574 |
+
topk_local = torch.topk(aff_scores[eligible_idx], k=cascade_k).indices
|
| 575 |
+
validate_idx = eligible_idx[topk_local]
|
| 576 |
+
else:
|
| 577 |
+
validate_idx = eligible_idx
|
| 578 |
+
|
| 579 |
+
conj_scores = torch.zeros(n, device=self.device)
|
| 580 |
+
with self.profiler.section("cascade.full_validate"):
|
| 581 |
+
for j in validate_idx.tolist():
|
| 582 |
+
ok, conjs, _, _ = conj_obj.conjugate_peptide(
|
| 583 |
+
safe_smiles[j], exclude_n_terminus=True
|
| 584 |
+
)
|
| 585 |
+
if not ok or not conjs:
|
| 586 |
+
continue
|
| 587 |
+
has_valid, _ = conj_obj.validate_conjugates(conjs)
|
| 588 |
+
if has_valid:
|
| 589 |
+
conj_scores[j] = 1.0
|
| 590 |
+
|
| 591 |
+
out = []
|
| 592 |
+
for obj in self.objectives:
|
| 593 |
+
if isinstance(obj, Conjugation):
|
| 594 |
+
out.append(conj_scores)
|
| 595 |
+
else:
|
| 596 |
+
out.append(aff_scores)
|
| 597 |
+
return torch.stack(out, dim=0).to(self.device)
|
| 598 |
+
|
| 599 |
+
# ------------------------------------------------------------------
|
| 600 |
+
# Guidance methods
|
| 601 |
+
# ------------------------------------------------------------------
|
| 602 |
+
def _exponential_tilt_guidance(self, x, pos_probs, candidates, mut_idx, sample_idx,
|
| 603 |
+
beta, weights, current_score=None):
|
| 604 |
+
num_candidates = len(candidates)
|
| 605 |
+
current_token = x[sample_idx, mut_idx].item()
|
| 606 |
+
|
| 607 |
+
if num_candidates == 0:
|
| 608 |
+
return current_token, {'method': 'exp_tilt', 'fallback': True, 'accepted': False}
|
| 609 |
+
|
| 610 |
+
if num_candidates > self.args.top_k:
|
| 611 |
+
candidates = candidates[:self.args.top_k]
|
| 612 |
+
num_candidates = self.args.top_k
|
| 613 |
+
|
| 614 |
+
x_proposals = x[sample_idx].unsqueeze(0).repeat(num_candidates, 1)
|
| 615 |
+
x_proposals[:, mut_idx] = candidates
|
| 616 |
+
|
| 617 |
+
with self.profiler.section("exp_tilt.proposal_scores"):
|
| 618 |
+
if self.args.cascade_k > 0:
|
| 619 |
+
proposal_scores = self._get_scores_cascaded(x_proposals)
|
| 620 |
+
else:
|
| 621 |
+
proposal_scores = self._get_scores(x_proposals)
|
| 622 |
+
aggregated_scores = self._compute_aggregated_score(proposal_scores, weights)
|
| 623 |
+
validity_mask = (proposal_scores[0] > 0).float()
|
| 624 |
+
base_probs = pos_probs[sample_idx, candidates]
|
| 625 |
+
|
| 626 |
+
log_base_probs = torch.log(base_probs + 1e-10)
|
| 627 |
+
log_exp_scores = beta * aggregated_scores
|
| 628 |
+
log_validity = torch.log(validity_mask + 1e-10)
|
| 629 |
+
log_tilted = log_base_probs + log_exp_scores + log_validity
|
| 630 |
+
|
| 631 |
+
log_tilted_max = log_tilted.max()
|
| 632 |
+
fallback = False
|
| 633 |
+
if torch.isinf(log_tilted_max) or torch.isnan(log_tilted_max):
|
| 634 |
+
fallback = True
|
| 635 |
+
final_probs = base_probs / (base_probs.sum() + 1e-9)
|
| 636 |
+
else:
|
| 637 |
+
log_tilted_stable = log_tilted - log_tilted_max
|
| 638 |
+
tilted_probs = torch.exp(log_tilted_stable)
|
| 639 |
+
prob_sum = tilted_probs.sum()
|
| 640 |
+
if prob_sum < 1e-10 or torch.isnan(prob_sum) or torch.isinf(prob_sum):
|
| 641 |
+
fallback = True
|
| 642 |
+
final_probs = base_probs / (base_probs.sum() + 1e-9)
|
| 643 |
+
else:
|
| 644 |
+
final_probs = tilted_probs / prob_sum
|
| 645 |
+
|
| 646 |
+
if torch.any(torch.isnan(final_probs)) or torch.any(torch.isinf(final_probs)):
|
| 647 |
+
fallback = True
|
| 648 |
+
final_probs = base_probs / (base_probs.sum() + 1e-9)
|
| 649 |
+
|
| 650 |
+
final_probs = torch.clamp(final_probs, min=1e-10)
|
| 651 |
+
final_probs = final_probs / final_probs.sum()
|
| 652 |
+
|
| 653 |
+
selected_idx = torch.multinomial(final_probs, 1).item()
|
| 654 |
+
selected_token = candidates[selected_idx].item()
|
| 655 |
+
selected_score = aggregated_scores[selected_idx].item()
|
| 656 |
+
|
| 657 |
+
accepted = True
|
| 658 |
+
if self.args.use_acceptance and current_score is not None:
|
| 659 |
+
if selected_score < current_score:
|
| 660 |
+
selected_token = current_token
|
| 661 |
+
accepted = False
|
| 662 |
+
|
| 663 |
+
return selected_token, {
|
| 664 |
+
'method': 'exp_tilt', 'beta': beta, 'num_candidates': num_candidates,
|
| 665 |
+
'proposal_score': aggregated_scores[selected_idx].item(),
|
| 666 |
+
'selected_score': selected_score if accepted else current_score,
|
| 667 |
+
'fallback': fallback, 'validity_rate': validity_mask.mean().item(),
|
| 668 |
+
'accepted': accepted
|
| 669 |
+
}
|
| 670 |
+
|
| 671 |
+
def _classifier_guidance(self, x, logits, mut_idx, sample_idx,
|
| 672 |
+
guidance_weight, weights):
|
| 673 |
+
base_logits = logits[sample_idx, mut_idx, :]
|
| 674 |
+
sorted_logits, sorted_indices = torch.sort(base_logits, descending=True)
|
| 675 |
+
candidates = sorted_indices[:self.args.top_k]
|
| 676 |
+
|
| 677 |
+
valid_mask = ~torch.isin(
|
| 678 |
+
candidates, torch.tensor(self.invalid_tokens, device=self.device)
|
| 679 |
+
)
|
| 680 |
+
candidates = candidates[valid_mask]
|
| 681 |
+
|
| 682 |
+
if len(candidates) == 0:
|
| 683 |
+
return x[sample_idx, mut_idx].item(), {'method': 'cg', 'fallback': True}
|
| 684 |
+
|
| 685 |
+
x_proposals = x[sample_idx].unsqueeze(0).repeat(len(candidates), 1)
|
| 686 |
+
x_proposals[:, mut_idx] = candidates
|
| 687 |
+
|
| 688 |
+
with self.profiler.section("cg.proposal_scores"):
|
| 689 |
+
if self.args.cascade_k > 0:
|
| 690 |
+
proposal_scores = self._get_scores_cascaded(x_proposals)
|
| 691 |
+
else:
|
| 692 |
+
proposal_scores = self._get_scores(x_proposals)
|
| 693 |
+
aggregated_scores = self._compute_aggregated_score(proposal_scores, weights)
|
| 694 |
+
|
| 695 |
+
score_logits = aggregated_scores * self.args.score_temperature
|
| 696 |
+
base_candidate_logits = base_logits[candidates]
|
| 697 |
+
guided_logits = base_candidate_logits + guidance_weight * score_logits
|
| 698 |
+
guided_probs = F.softmax(guided_logits, dim=-1)
|
| 699 |
+
|
| 700 |
+
selected_idx = torch.multinomial(guided_probs, 1).item()
|
| 701 |
+
return candidates[selected_idx].item(), {
|
| 702 |
+
'method': 'cg', 'guidance_weight': guidance_weight,
|
| 703 |
+
'num_candidates': len(candidates),
|
| 704 |
+
'selected_score': aggregated_scores[selected_idx].item(), 'fallback': False
|
| 705 |
+
}
|
| 706 |
+
|
| 707 |
+
def _best_of_k_guidance(self, x, pos_probs, candidates, mut_idx, sample_idx,
|
| 708 |
+
k, weights):
|
| 709 |
+
num_candidates = len(candidates)
|
| 710 |
+
if num_candidates == 0:
|
| 711 |
+
return x[sample_idx, mut_idx].item(), {'method': 'bok', 'fallback': True}
|
| 712 |
+
|
| 713 |
+
k = min(k, num_candidates)
|
| 714 |
+
candidate_probs = pos_probs[sample_idx, candidates]
|
| 715 |
+
candidate_probs = candidate_probs / (candidate_probs.sum() + 1e-9)
|
| 716 |
+
|
| 717 |
+
if k >= num_candidates:
|
| 718 |
+
sampled_indices = torch.arange(num_candidates, device=self.device)
|
| 719 |
+
else:
|
| 720 |
+
sampled_indices = torch.multinomial(candidate_probs, k, replacement=False)
|
| 721 |
+
|
| 722 |
+
sampled_tokens = candidates[sampled_indices]
|
| 723 |
+
x_proposals = x[sample_idx].unsqueeze(0).repeat(len(sampled_tokens), 1)
|
| 724 |
+
x_proposals[:, mut_idx] = sampled_tokens
|
| 725 |
+
|
| 726 |
+
with self.profiler.section("bok.proposal_scores"):
|
| 727 |
+
if self.args.cascade_k > 0:
|
| 728 |
+
proposal_scores = self._get_scores_cascaded(x_proposals)
|
| 729 |
+
else:
|
| 730 |
+
proposal_scores = self._get_scores(x_proposals)
|
| 731 |
+
aggregated_scores = self._compute_aggregated_score(proposal_scores, weights)
|
| 732 |
+
|
| 733 |
+
best_idx = torch.argmax(aggregated_scores).item()
|
| 734 |
+
return sampled_tokens[best_idx].item(), {
|
| 735 |
+
'method': 'bok', 'k': k, 'num_candidates': num_candidates,
|
| 736 |
+
'selected_score': aggregated_scores[best_idx].item(),
|
| 737 |
+
'mean_score': aggregated_scores.mean().item(),
|
| 738 |
+
'max_score': aggregated_scores.max().item(), 'fallback': False
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
def _hybrid_guidance(self, x, pos_probs, logits, candidates, mut_idx, sample_idx,
|
| 742 |
+
step, beta, weights):
|
| 743 |
+
progress = step / max(self.args.optimization_steps - 1, 1)
|
| 744 |
+
if progress < 0.3:
|
| 745 |
+
return self._best_of_k_guidance(
|
| 746 |
+
x, pos_probs, candidates, mut_idx, sample_idx,
|
| 747 |
+
k=self.args.bok_k, weights=weights
|
| 748 |
+
)
|
| 749 |
+
elif progress < 0.7:
|
| 750 |
+
return self._exponential_tilt_guidance(
|
| 751 |
+
x, pos_probs, candidates, mut_idx, sample_idx,
|
| 752 |
+
beta=beta, weights=weights
|
| 753 |
+
)
|
| 754 |
+
else:
|
| 755 |
+
return self._classifier_guidance(
|
| 756 |
+
x, logits, mut_idx, sample_idx,
|
| 757 |
+
guidance_weight=self.args.cg_weight, weights=weights
|
| 758 |
+
)
|
| 759 |
+
|
| 760 |
+
# ------------------------------------------------------------------
|
| 761 |
+
# Main loop
|
| 762 |
+
# ------------------------------------------------------------------
|
| 763 |
+
def generate(self):
|
| 764 |
+
self.profiler.reset()
|
| 765 |
+
x = self.generate_x0(
|
| 766 |
+
require_valid=self.args.require_valid_x0,
|
| 767 |
+
max_retries=self.args.max_x0_retries
|
| 768 |
+
)
|
| 769 |
+
|
| 770 |
+
if self.args.weights is None:
|
| 771 |
+
weights = torch.tensor(
|
| 772 |
+
[1.0] + [1.0 / (self.num_objectives - 1)] * (self.num_objectives - 1),
|
| 773 |
+
device=self.device
|
| 774 |
+
).view(-1, 1)
|
| 775 |
+
else:
|
| 776 |
+
weights = torch.tensor(self.args.weights, device=self.device).view(-1, 1)
|
| 777 |
+
if len(weights) != self.num_objectives:
|
| 778 |
+
raise ValueError("Number of weights must match number of objectives.")
|
| 779 |
+
|
| 780 |
+
print(f"\nGuidance Method: {self.guidance_method.value}")
|
| 781 |
+
print(f"Weights: {weights.squeeze().tolist()}")
|
| 782 |
+
print(f"β range: [{self.args.beta_min}, {self.args.beta_max}]")
|
| 783 |
+
print(f"Acceptance criterion: {'ENABLED' if self.args.use_acceptance else 'DISABLED'}")
|
| 784 |
+
|
| 785 |
+
with torch.no_grad():
|
| 786 |
+
for step in tqdm(range(self.args.optimization_steps), desc="Guided Generation"):
|
| 787 |
+
improved_indices = []
|
| 788 |
+
|
| 789 |
+
progress = step / max(self.args.optimization_steps - 1, 1)
|
| 790 |
+
beta = self.args.beta_min + (self.args.beta_max - self.args.beta_min) * progress
|
| 791 |
+
|
| 792 |
+
if self.args.position_strategy == "random":
|
| 793 |
+
mut_idx = random.randint(1, self.args.gen_len - 2)
|
| 794 |
+
else:
|
| 795 |
+
mut_idx = (step % (self.args.gen_len - 2)) + 1
|
| 796 |
+
|
| 797 |
+
time_t = torch.full(
|
| 798 |
+
(self.args.num_samples,),
|
| 799 |
+
step / self.args.optimization_steps,
|
| 800 |
+
device=self.device
|
| 801 |
+
)
|
| 802 |
+
|
| 803 |
+
with self.profiler.section("loop.model_forward"):
|
| 804 |
+
logits = self.model(x, time_t)
|
| 805 |
+
probs = F.softmax(logits, dim=-1)
|
| 806 |
+
pos_probs = probs[:, mut_idx, :]
|
| 807 |
+
|
| 808 |
+
pos_probs[torch.arange(self.args.num_samples), x[:, mut_idx]] = 0
|
| 809 |
+
for inv_tok in self.invalid_tokens:
|
| 810 |
+
pos_probs[:, inv_tok] = 0
|
| 811 |
+
|
| 812 |
+
with self.profiler.section("loop.top_p_sort"):
|
| 813 |
+
sorted_probs, sorted_indices = torch.sort(pos_probs, descending=True)
|
| 814 |
+
|
| 815 |
+
with self.profiler.section("loop.current_scores"):
|
| 816 |
+
current_scores = self._get_scores(x)
|
| 817 |
+
current_aggregated = self._compute_aggregated_score(current_scores, weights)
|
| 818 |
+
|
| 819 |
+
if step == 0:
|
| 820 |
+
print(f"\nInitial Scores: {current_scores.cpu().numpy()}")
|
| 821 |
+
if self.tracker and self.conjugation_obj:
|
| 822 |
+
detailed = self.conjugation_obj.get_last_detailed_results()
|
| 823 |
+
self.tracker.log_step(step, current_scores[0], detailed)
|
| 824 |
+
self.tracker.print_step_summary(
|
| 825 |
+
step, current_scores[0], detailed, self.guidance_method.value
|
| 826 |
+
)
|
| 827 |
+
|
| 828 |
+
final_tokens = []
|
| 829 |
+
for i in range(self.args.num_samples):
|
| 830 |
+
sample_probs = sorted_probs[i]
|
| 831 |
+
sample_indices = sorted_indices[i]
|
| 832 |
+
|
| 833 |
+
cumsum = torch.cumsum(sample_probs, dim=0)
|
| 834 |
+
mask = cumsum <= self.args.top_p
|
| 835 |
+
mask[0] = True
|
| 836 |
+
candidates = sample_indices[mask]
|
| 837 |
+
|
| 838 |
+
valid_candidate_mask = ~torch.isin(
|
| 839 |
+
candidates, torch.tensor(self.invalid_tokens, device=self.device)
|
| 840 |
+
)
|
| 841 |
+
candidates = candidates[valid_candidate_mask]
|
| 842 |
+
|
| 843 |
+
current_sample_score = current_aggregated[i].item()
|
| 844 |
+
|
| 845 |
+
if beta > 0 or self.guidance_method != GuidanceMethod.EXPONENTIAL_TILT:
|
| 846 |
+
with self.profiler.section(f"loop.guidance.{self.guidance_method.value}"):
|
| 847 |
+
if self.guidance_method == GuidanceMethod.EXPONENTIAL_TILT:
|
| 848 |
+
token, _ = self._exponential_tilt_guidance(
|
| 849 |
+
x, pos_probs, candidates, mut_idx, i, beta, weights,
|
| 850 |
+
current_score=current_sample_score
|
| 851 |
+
)
|
| 852 |
+
elif self.guidance_method == GuidanceMethod.CLASSIFIER_GUIDANCE:
|
| 853 |
+
token, _ = self._classifier_guidance(
|
| 854 |
+
x, logits, mut_idx, i, self.args.cg_weight, weights
|
| 855 |
+
)
|
| 856 |
+
elif self.guidance_method == GuidanceMethod.BEST_OF_K:
|
| 857 |
+
token, _ = self._best_of_k_guidance(
|
| 858 |
+
x, pos_probs, candidates, mut_idx, i, self.args.bok_k, weights
|
| 859 |
+
)
|
| 860 |
+
elif self.guidance_method == GuidanceMethod.HYBRID:
|
| 861 |
+
token, _ = self._hybrid_guidance(
|
| 862 |
+
x, pos_probs, logits, candidates, mut_idx, i, step, beta, weights
|
| 863 |
+
)
|
| 864 |
+
else:
|
| 865 |
+
raise ValueError(f"Unknown guidance: {self.guidance_method}")
|
| 866 |
+
else:
|
| 867 |
+
if len(candidates) == 0:
|
| 868 |
+
token = x[i, mut_idx].item()
|
| 869 |
+
else:
|
| 870 |
+
cprobs = pos_probs[i, candidates]
|
| 871 |
+
cprobs = cprobs / (cprobs.sum() + 1e-9)
|
| 872 |
+
idx = torch.multinomial(cprobs, 1).item()
|
| 873 |
+
token = candidates[idx].item()
|
| 874 |
+
|
| 875 |
+
final_tokens.append(token)
|
| 876 |
+
|
| 877 |
+
x[:, mut_idx] = torch.tensor(final_tokens, device=self.device)
|
| 878 |
+
|
| 879 |
+
with self.profiler.section("loop.new_scores"):
|
| 880 |
+
new_scores = self._get_scores(x)
|
| 881 |
+
new_aggregated = self._compute_aggregated_score(new_scores, weights)
|
| 882 |
+
for i in range(self.args.num_samples):
|
| 883 |
+
if new_aggregated[i] > current_aggregated[i]:
|
| 884 |
+
improved_indices.append(i)
|
| 885 |
+
|
| 886 |
+
if improved_indices:
|
| 887 |
+
for i in improved_indices:
|
| 888 |
+
prev_weighted = (weights.squeeze(1) * current_scores[:, i]).sum()
|
| 889 |
+
new_weighted = (weights.squeeze(1) * new_scores[:, i]).sum()
|
| 890 |
+
print(f"Previous Weighted Sum: {prev_weighted}")
|
| 891 |
+
print(f"Previous Scores: {current_scores[:, i]}")
|
| 892 |
+
print(f"New Weighted Sum: {new_weighted}")
|
| 893 |
+
print(f"New Scores: {new_scores[:, i]}")
|
| 894 |
+
print(self.tokenizer.batch_decode(x[improved_indices]))
|
| 895 |
+
|
| 896 |
+
if self.tracker and self.conjugation_obj and \
|
| 897 |
+
(step % 4 == 0 or step == self.args.optimization_steps - 1):
|
| 898 |
+
detailed = self.conjugation_obj.get_last_detailed_results()
|
| 899 |
+
self.tracker.log_step(
|
| 900 |
+
step, new_scores[0], detailed, improved_indices,
|
| 901 |
+
guidance_info={'method': self.guidance_method.value, 'beta': beta}
|
| 902 |
+
)
|
| 903 |
+
if step == self.args.optimization_steps - 1:
|
| 904 |
+
self.tracker.print_step_summary(
|
| 905 |
+
step, new_scores[0], detailed, self.guidance_method.value
|
| 906 |
+
)
|
| 907 |
+
|
| 908 |
+
if (self.profiler.enabled
|
| 909 |
+
and self.args.profile_every > 0
|
| 910 |
+
and (step + 1) % self.args.profile_every == 0):
|
| 911 |
+
self.profiler.print_summary(header=f"(through step {step + 1})")
|
| 912 |
+
|
| 913 |
+
self.profiler.print_summary(header="(end of generate)")
|
| 914 |
+
return x
|
| 915 |
+
|
| 916 |
+
|
| 917 |
+
# =============================================================================
|
| 918 |
+
# Main
|
| 919 |
+
# =============================================================================
|
| 920 |
+
|
| 921 |
+
def main(args):
|
| 922 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 923 |
+
print(f"Using device: {device}")
|
| 924 |
+
print(f"Tokenizer type: {args.tokenizer_type}")
|
| 925 |
+
print(f"Guidance method: {args.guidance_method}")
|
| 926 |
+
|
| 927 |
+
if args.tokenizer_type == 'amino':
|
| 928 |
+
print("Using Amino Acid tokenizer")
|
| 929 |
+
tokenizer = AminoTokenizer()
|
| 930 |
+
smiles_tokenizer = SMILES_SPE_Tokenizer(args.smiles_vocab_path, args.smiles_splits_path)
|
| 931 |
+
else:
|
| 932 |
+
print("Using SMILES tokenizer")
|
| 933 |
+
tokenizer = SMILES_SPE_Tokenizer(args.smiles_vocab_path, args.smiles_splits_path)
|
| 934 |
+
smiles_tokenizer = tokenizer
|
| 935 |
+
|
| 936 |
+
conjugation_model = Conjugation(smiles_tokenizer, device=device, builder_type=args.builder_type)
|
| 937 |
+
# Binding affinity: AA-input or SMILES-input depending on --affinity_type.
|
| 938 |
+
affinity_model = make_affinity(
|
| 939 |
+
args.affinity_type,
|
| 940 |
+
target_protein_seq=args.target,
|
| 941 |
+
device=device,
|
| 942 |
+
aa_ckpt_path=args.aa_affinity_ckpt,
|
| 943 |
+
)
|
| 944 |
+
print(f"Affinity scorer: {type(affinity_model).__name__} (input_type={getattr(affinity_model, 'input_type', '?')})")
|
| 945 |
+
OBJECTIVE_FUNCTIONS = [conjugation_model, affinity_model]
|
| 946 |
+
|
| 947 |
+
tracker = GenerationTracker(output_dir=args.output_dir)
|
| 948 |
+
|
| 949 |
+
if args.seed is not None:
|
| 950 |
+
random.seed(args.seed)
|
| 951 |
+
np.random.seed(args.seed)
|
| 952 |
+
torch.manual_seed(args.seed)
|
| 953 |
+
if torch.cuda.is_available():
|
| 954 |
+
torch.cuda.manual_seed_all(args.seed)
|
| 955 |
+
print(f"Random seed: {args.seed}")
|
| 956 |
+
|
| 957 |
+
print(f"Loading model from: {args.checkpoint}")
|
| 958 |
+
checkpoint = torch.load(args.checkpoint, map_location=device, weights_only=False)
|
| 959 |
+
|
| 960 |
+
if args.tokenizer_type == 'amino':
|
| 961 |
+
raw_model_args = checkpoint['args']
|
| 962 |
+
if isinstance(raw_model_args, argparse.Namespace):
|
| 963 |
+
raw_model_args = vars(raw_model_args)
|
| 964 |
+
model_args = edict(raw_model_args)
|
| 965 |
+
model = MDLM(
|
| 966 |
+
vocab_size=model_args.vocab_size,
|
| 967 |
+
seq_len=model_args.seq_len,
|
| 968 |
+
model_dim=model_args.model_dim,
|
| 969 |
+
n_heads=model_args.n_heads,
|
| 970 |
+
n_layers=model_args.n_layers,
|
| 971 |
+
).to(device)
|
| 972 |
+
model.load_state_dict(checkpoint['model_state_dict'])
|
| 973 |
+
print(f"Amino model loaded. Vocab size: {model_args.vocab_size}")
|
| 974 |
+
else:
|
| 975 |
+
model_hparams = checkpoint["hyper_parameters"]["args"]
|
| 976 |
+
model = MDLMLightningModule.load_from_checkpoint(
|
| 977 |
+
args.checkpoint, args=model_hparams, tokenizer=tokenizer,
|
| 978 |
+
map_location=device, strict=False
|
| 979 |
+
)
|
| 980 |
+
model.to(device)
|
| 981 |
+
model.eval()
|
| 982 |
+
print("Model loaded successfully.")
|
| 983 |
+
|
| 984 |
+
generator = CupOfMoCAGeneratorUAA(
|
| 985 |
+
model, device, OBJECTIVE_FUNCTIONS, args,
|
| 986 |
+
tokenizer=tokenizer, tokenizer_type=args.tokenizer_type, tracker=tracker
|
| 987 |
+
)
|
| 988 |
+
|
| 989 |
+
all_sequences = []
|
| 990 |
+
all_smiles = []
|
| 991 |
+
all_final_results = []
|
| 992 |
+
|
| 993 |
+
for batch_idx in range(args.num_batches):
|
| 994 |
+
print(f"\n{'=' * 60}")
|
| 995 |
+
print(f"BATCH {batch_idx + 1}/{args.num_batches}")
|
| 996 |
+
print(f"{'=' * 60}")
|
| 997 |
+
|
| 998 |
+
generated_tokens = generator.generate()
|
| 999 |
+
final_scores = generator._get_scores(generated_tokens.detach()).detach().cpu().numpy()
|
| 1000 |
+
sequence_str = tokenizer.batch_decode(generated_tokens)
|
| 1001 |
+
|
| 1002 |
+
print("\n--- Batch Results ---")
|
| 1003 |
+
print(f"Sequences: {sequence_str}")
|
| 1004 |
+
print(f"Scores: {final_scores}")
|
| 1005 |
+
|
| 1006 |
+
all_sequences.extend(sequence_str)
|
| 1007 |
+
|
| 1008 |
+
if args.tokenizer_type == 'amino':
|
| 1009 |
+
smiles_list = []
|
| 1010 |
+
for seq in sequence_str:
|
| 1011 |
+
three_letter = one_letter_to_three_letter(seq)
|
| 1012 |
+
smiles = aa_to_smiles(three_letter, cyclic=args.cyclic)
|
| 1013 |
+
smiles_list.append(smiles if isinstance(smiles, str) else 'C')
|
| 1014 |
+
smiles_tokens = smiles_tokenizer(
|
| 1015 |
+
smiles_list, return_tensors='pt', padding=True
|
| 1016 |
+
).to(device)
|
| 1017 |
+
detailed_with_props = conjugation_model.get_detailed_scores_with_properties(
|
| 1018 |
+
smiles_tokens["input_ids"]
|
| 1019 |
+
)
|
| 1020 |
+
all_smiles.extend(smiles_list)
|
| 1021 |
+
tracker.log_final(sequence_str, detailed_with_props, smiles_list=smiles_list)
|
| 1022 |
+
else:
|
| 1023 |
+
detailed_with_props = conjugation_model.get_detailed_scores_with_properties(generated_tokens)
|
| 1024 |
+
tracker.log_final(sequence_str, detailed_with_props)
|
| 1025 |
+
all_final_results.extend(detailed_with_props)
|
| 1026 |
+
|
| 1027 |
+
tracker.print_final_summary()
|
| 1028 |
+
tracker.save(filename_prefix=f'cupofmoca_uaa_{args.guidance_method}')
|
| 1029 |
+
|
| 1030 |
+
with open(args.output_file, 'w') as f:
|
| 1031 |
+
for i, seq in enumerate(all_sequences):
|
| 1032 |
+
if args.tokenizer_type == 'amino':
|
| 1033 |
+
smiles = all_smiles[i] if i < len(all_smiles) else ''
|
| 1034 |
+
f.write(f"{seq}\t{smiles}\n")
|
| 1035 |
+
else:
|
| 1036 |
+
f.write(seq + '\n')
|
| 1037 |
+
print(f"\nSequences saved to: {args.output_file}")
|
| 1038 |
+
print("\nGeneration complete.")
|
| 1039 |
+
|
| 1040 |
+
|
| 1041 |
+
if __name__ == "__main__":
|
| 1042 |
+
parser = argparse.ArgumentParser(
|
| 1043 |
+
description="CupOfMoCA-style Guided Generation with UAA Foundation Model"
|
| 1044 |
+
)
|
| 1045 |
+
|
| 1046 |
+
parser.add_argument("--checkpoint", type=str, required=True)
|
| 1047 |
+
parser.add_argument("--target", type=str, required=True)
|
| 1048 |
+
|
| 1049 |
+
parser.add_argument("--num_samples", type=int, default=10)
|
| 1050 |
+
parser.add_argument("--num_batches", type=int, default=10)
|
| 1051 |
+
parser.add_argument("--gen_len", type=int, default=50)
|
| 1052 |
+
parser.add_argument("--optimization_steps", type=int, default=128)
|
| 1053 |
+
|
| 1054 |
+
parser.add_argument("--guidance_method", type=str, default="exp_tilt",
|
| 1055 |
+
choices=["exp_tilt", "cg", "bok", "hybrid"])
|
| 1056 |
+
|
| 1057 |
+
parser.add_argument("--beta_min", type=float, default=1.0)
|
| 1058 |
+
parser.add_argument("--beta_max", type=float, default=20.0)
|
| 1059 |
+
parser.add_argument("--cg_weight", type=float, default=2.0)
|
| 1060 |
+
parser.add_argument("--use_acceptance", action="store_true")
|
| 1061 |
+
parser.add_argument("--bok_k", type=int, default=8)
|
| 1062 |
+
parser.add_argument("--top_k", type=int, default=200)
|
| 1063 |
+
parser.add_argument("--top_p", type=float, default=0.9)
|
| 1064 |
+
parser.add_argument("--score_temperature", type=float, default=1.0)
|
| 1065 |
+
parser.add_argument("--score_aggregation", type=str, default="weighted_sum",
|
| 1066 |
+
choices=["weighted_sum", "weighted_min"])
|
| 1067 |
+
|
| 1068 |
+
parser.add_argument("--weights", type=float, nargs='+', default=None)
|
| 1069 |
+
|
| 1070 |
+
parser.add_argument("--position_strategy", type=str, default="random",
|
| 1071 |
+
choices=["random", "sequential"])
|
| 1072 |
+
|
| 1073 |
+
parser.add_argument("--require_valid_x0", action="store_true")
|
| 1074 |
+
parser.add_argument("--max_x0_retries", type=int, default=10)
|
| 1075 |
+
|
| 1076 |
+
parser.add_argument("--builder_type", type=str, default="AoaDau",
|
| 1077 |
+
choices=["AoaDau", "ValAlaDau", "ValCitDau", "GlutarylDau", "Dau",
|
| 1078 |
+
"SSCpt", "VcMMAE", "DSPACpt", "MalHexCpt", "DSECCpt", "Cpt",
|
| 1079 |
+
"DSPASN38", "DSEBSN38", "DSEPSN38", "SN38"])
|
| 1080 |
+
|
| 1081 |
+
parser.add_argument("--tokenizer_type", type=str, default="amino",
|
| 1082 |
+
choices=["smiles", "amino"])
|
| 1083 |
+
parser.add_argument("--cyclic", action="store_true",
|
| 1084 |
+
help="Generate cyclic peptides (only for amino tokenizer).")
|
| 1085 |
+
parser.add_argument("--smiles_vocab_path", type=str,
|
| 1086 |
+
default="./smiles/smiles_tokenizer/new_vocab.txt")
|
| 1087 |
+
parser.add_argument("--smiles_splits_path", type=str,
|
| 1088 |
+
default="./smiles/smiles_tokenizer/new_splits.txt")
|
| 1089 |
+
|
| 1090 |
+
# AA-mode binding-affinity selection (this is the WT-AA / PepReDi script).
|
| 1091 |
+
parser.add_argument("--affinity_type", type=str, default="smiles",
|
| 1092 |
+
choices=["aa", "smiles"],
|
| 1093 |
+
help="Which binding-affinity oracle to guide on: "
|
| 1094 |
+
"'aa' = AA-input UnpooledBindingPredictor "
|
| 1095 |
+
"(matches moPPIt's setup); 'smiles' = "
|
| 1096 |
+
"SMILES-input BindingAffinity (matches the "
|
| 1097 |
+
"SMILES CupOFMoCA tables).")
|
| 1098 |
+
parser.add_argument("--aa_affinity_ckpt", type=str,
|
| 1099 |
+
default=DEFAULT_AA_AFFINITY_CKPT,
|
| 1100 |
+
help="Path to AA-input affinity predictor checkpoint.")
|
| 1101 |
+
|
| 1102 |
+
parser.add_argument("--output_file", type=str, default="./smiles.txt")
|
| 1103 |
+
parser.add_argument("--output_dir", type=str, default="./generation_logs")
|
| 1104 |
+
|
| 1105 |
+
parser.add_argument("--seed", type=int, default=None)
|
| 1106 |
+
|
| 1107 |
+
parser.add_argument("--profile", action="store_true",
|
| 1108 |
+
help="Enable per-section wall-clock profiling.")
|
| 1109 |
+
parser.add_argument("--profile_every", type=int, default=0,
|
| 1110 |
+
help="Print profiler summary every N optimization steps "
|
| 1111 |
+
"(0 = only at end of each generate()).")
|
| 1112 |
+
|
| 1113 |
+
parser.add_argument("--cascade_k", type=int, default=0,
|
| 1114 |
+
help="Cascade scoring: score all proposals with cheap "
|
| 1115 |
+
"Conjugation pre-filter + BindingAffinity, then run "
|
| 1116 |
+
"the expensive PDC validation only on the top-K by "
|
| 1117 |
+
"affinity (among cheap-filter survivors). 0 disables "
|
| 1118 |
+
"(full scoring on all proposals, original behavior).")
|
| 1119 |
+
parser.add_argument("--cascade_workers", type=int, default=1,
|
| 1120 |
+
help="Number of worker threads for parallelizing "
|
| 1121 |
+
"cascade.full_validate (CPU-bound RDKit work). "
|
| 1122 |
+
"1 = sequential (original behavior). Try 4-8 when "
|
| 1123 |
+
"--cascade_k is high. Ignored if --cascade_k=0.")
|
| 1124 |
+
|
| 1125 |
+
args = parser.parse_args()
|
| 1126 |
+
main(args)
|
smiles/generation/cupofmoca_guided_uaa.py
ADDED
|
@@ -0,0 +1,1196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CupOfMoCA-style Guided Generation with UAA Foundation Model
|
| 3 |
+
|
| 4 |
+
Combines CupOfMoCA Algorithm 2 guidance strategies (exponential tilting,
|
| 5 |
+
classifier guidance, best-of-K, hybrid) with the amino-acid (UAA) foundation
|
| 6 |
+
model. Sequences are generated in AA token space, converted to SMILES for
|
| 7 |
+
scoring against Conjugation (peptide+PDC validity) and BindingAffinity.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import argparse
|
| 11 |
+
import csv
|
| 12 |
+
import json
|
| 13 |
+
import os
|
| 14 |
+
import random
|
| 15 |
+
import sys
|
| 16 |
+
import time
|
| 17 |
+
from collections import defaultdict
|
| 18 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 19 |
+
from contextlib import contextmanager
|
| 20 |
+
from datetime import datetime
|
| 21 |
+
from enum import Enum
|
| 22 |
+
from typing import Dict, Optional, Tuple
|
| 23 |
+
|
| 24 |
+
import numpy as np
|
| 25 |
+
import torch
|
| 26 |
+
import torch.nn as nn
|
| 27 |
+
import torch.nn.functional as F
|
| 28 |
+
from easydict import EasyDict as edict
|
| 29 |
+
from tqdm import tqdm
|
| 30 |
+
|
| 31 |
+
from smiles.models.train import MDLMLightningModule
|
| 32 |
+
from smiles.smiles_tokenizer.my_tokenizers import SMILES_SPE_Tokenizer
|
| 33 |
+
from smiles.evaluation.peptide_analyzer import PeptideAnalyzer
|
| 34 |
+
from smiles.evaluation.pdc_analyzer import PDCAnalyzer
|
| 35 |
+
from smiles.scoring.classifiers import Conjugation, BindingAffinity
|
| 36 |
+
|
| 37 |
+
from third_party.SMILESToPeptide.peptide_to_smiles import aa_to_smiles
|
| 38 |
+
from third_party.SMILESToPeptide.aminoacid_selective import one_letter_to_three_letter
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# =============================================================================
|
| 42 |
+
# Amino Acid Foundation Model (DiT-based MDLM)
|
| 43 |
+
# =============================================================================
|
| 44 |
+
|
| 45 |
+
def modulate(x, shift, scale):
|
| 46 |
+
return x * (1 + scale.unsqueeze(1)) + shift.unsqueeze(1)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class TimestepEmbedder(nn.Module):
|
| 50 |
+
def __init__(self, hidden_size):
|
| 51 |
+
super().__init__()
|
| 52 |
+
self.mlp = nn.Sequential(
|
| 53 |
+
nn.Linear(1, hidden_size, bias=True),
|
| 54 |
+
nn.SiLU(),
|
| 55 |
+
nn.Linear(hidden_size, hidden_size, bias=True),
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
def forward(self, t):
|
| 59 |
+
return self.mlp(t.unsqueeze(-1))
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class DiTBlock(nn.Module):
|
| 63 |
+
def __init__(self, hidden_size, n_heads):
|
| 64 |
+
super().__init__()
|
| 65 |
+
self.norm1 = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
|
| 66 |
+
self.attn = nn.MultiheadAttention(hidden_size, n_heads, batch_first=True)
|
| 67 |
+
self.norm2 = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
|
| 68 |
+
self.mlp = nn.Sequential(
|
| 69 |
+
nn.Linear(hidden_size, 4 * hidden_size),
|
| 70 |
+
nn.GELU(),
|
| 71 |
+
nn.Linear(4 * hidden_size, hidden_size),
|
| 72 |
+
)
|
| 73 |
+
self.adaLN_modulation = nn.Sequential(
|
| 74 |
+
nn.SiLU(),
|
| 75 |
+
nn.Linear(hidden_size, 6 * hidden_size, bias=True),
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
def forward(self, x, c):
|
| 79 |
+
shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = \
|
| 80 |
+
self.adaLN_modulation(c).chunk(6, dim=1)
|
| 81 |
+
x_norm1 = modulate(self.norm1(x), shift_msa, scale_msa)
|
| 82 |
+
attn_output, _ = self.attn(x_norm1, x_norm1, x_norm1)
|
| 83 |
+
x = x + gate_msa.unsqueeze(1) * attn_output
|
| 84 |
+
x_norm2 = modulate(self.norm2(x), shift_mlp, scale_mlp)
|
| 85 |
+
mlp_output = self.mlp(x_norm2)
|
| 86 |
+
x = x + gate_mlp.unsqueeze(1) * mlp_output
|
| 87 |
+
return x
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class MDLM(nn.Module):
|
| 91 |
+
def __init__(self, vocab_size, seq_len, model_dim, n_heads, n_layers):
|
| 92 |
+
super().__init__()
|
| 93 |
+
self.vocab_size = vocab_size
|
| 94 |
+
self.seq_len = seq_len
|
| 95 |
+
self.model_dim = model_dim
|
| 96 |
+
|
| 97 |
+
self.token_embedder = nn.Embedding(vocab_size, model_dim)
|
| 98 |
+
self.pos_embedder = nn.Parameter(torch.randn(1, seq_len, model_dim))
|
| 99 |
+
self.time_embedder = TimestepEmbedder(model_dim)
|
| 100 |
+
|
| 101 |
+
self.transformer_blocks = nn.ModuleList([
|
| 102 |
+
DiTBlock(model_dim, n_heads) for _ in range(n_layers)
|
| 103 |
+
])
|
| 104 |
+
self.final_norm = nn.LayerNorm(model_dim)
|
| 105 |
+
self.lm_head = nn.Linear(model_dim, vocab_size)
|
| 106 |
+
|
| 107 |
+
def forward(self, x, t):
|
| 108 |
+
seq_len = x.shape[1]
|
| 109 |
+
x_embed = self.token_embedder(x) + self.pos_embedder[:, :seq_len, :]
|
| 110 |
+
t_embed = self.time_embedder(t)
|
| 111 |
+
for block in self.transformer_blocks:
|
| 112 |
+
x_embed = block(x_embed, t_embed)
|
| 113 |
+
x_embed = self.final_norm(x_embed)
|
| 114 |
+
return self.lm_head(x_embed)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
class AminoTokenizer:
|
| 118 |
+
def __init__(self):
|
| 119 |
+
self.vocab = {
|
| 120 |
+
'<cls>': 0, '<pad>': 1, '<eos>': 2, '<unk>': 3,
|
| 121 |
+
'L': 4, 'A': 5, 'G': 6, 'V': 7, 'S': 8, 'E': 9,
|
| 122 |
+
'R': 10, 'T': 11, 'I': 12, 'D': 13, 'P': 14, 'K': 15,
|
| 123 |
+
'Q': 16, 'N': 17, 'F': 18, 'Y': 19, 'M': 20, 'H': 21,
|
| 124 |
+
'W': 22, 'C': 23,
|
| 125 |
+
'Ŷ': 24, 'Ĉ': 25, 'Ĝ': 26, 'Ė': 27, 'Ƙ': 28,
|
| 126 |
+
'Ṃ': 29, 'Ł': 30, 'Ċ': 31, 'Ṯ': 32,
|
| 127 |
+
'a': 33, 'c': 34, 'd': 35, 'e': 36, 'f': 37,
|
| 128 |
+
'g': 38, 'h': 39, 'i': 40, 'k': 41, 'l': 42,
|
| 129 |
+
'm': 43, 'n': 44, 'p': 45, 'q': 46, 'r': 47,
|
| 130 |
+
's': 48, 't': 49, 'v': 50, 'w': 51, 'y': 52,
|
| 131 |
+
}
|
| 132 |
+
self.id_to_token = {i: t for t, i in self.vocab.items()}
|
| 133 |
+
self.cls_token, self.pad_token = '<cls>', '<pad>'
|
| 134 |
+
self.eos_token, self.unk_token = '<eos>', '<unk>'
|
| 135 |
+
self.cls_id = self.vocab[self.cls_token]
|
| 136 |
+
self.pad_id = self.vocab[self.pad_token]
|
| 137 |
+
self.eos_id = self.vocab[self.eos_token]
|
| 138 |
+
self.unk_id = self.vocab[self.unk_token]
|
| 139 |
+
self.vocab_size = len(self.vocab)
|
| 140 |
+
|
| 141 |
+
def decode(self, ids, skip_special_tokens=True):
|
| 142 |
+
if isinstance(ids, torch.Tensor):
|
| 143 |
+
ids = ids.tolist()
|
| 144 |
+
tokens = []
|
| 145 |
+
for i in ids:
|
| 146 |
+
tok = self.id_to_token.get(int(i), self.unk_token)
|
| 147 |
+
if skip_special_tokens and tok in {self.cls_token, self.eos_token, self.pad_token}:
|
| 148 |
+
continue
|
| 149 |
+
tokens.append(tok)
|
| 150 |
+
return ''.join(tokens)
|
| 151 |
+
|
| 152 |
+
def batch_decode(self, batch_ids, skip_special_tokens=True):
|
| 153 |
+
return [self.decode(ids, skip_special_tokens=skip_special_tokens) for ids in batch_ids]
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
# =============================================================================
|
| 157 |
+
# Profiler
|
| 158 |
+
# =============================================================================
|
| 159 |
+
|
| 160 |
+
class Profiler:
|
| 161 |
+
"""Aggregates wall-clock time per named section. Optionally syncs CUDA.
|
| 162 |
+
|
| 163 |
+
`reset()` clears all state (use between batches for self-contained summaries).
|
| 164 |
+
`print_summary()` shows cumulative totals since the last reset, plus a
|
| 165 |
+
delta column showing time since the previous `print_summary()` call.
|
| 166 |
+
"""
|
| 167 |
+
|
| 168 |
+
def __init__(self, enabled=False, cuda_sync=True):
|
| 169 |
+
self.enabled = enabled
|
| 170 |
+
self.cuda_sync = cuda_sync and torch.cuda.is_available()
|
| 171 |
+
self.totals = defaultdict(float)
|
| 172 |
+
self.counts = defaultdict(int)
|
| 173 |
+
self._last_totals = defaultdict(float)
|
| 174 |
+
self._last_counts = defaultdict(int)
|
| 175 |
+
|
| 176 |
+
@contextmanager
|
| 177 |
+
def section(self, name):
|
| 178 |
+
if not self.enabled:
|
| 179 |
+
yield
|
| 180 |
+
return
|
| 181 |
+
if self.cuda_sync:
|
| 182 |
+
torch.cuda.synchronize()
|
| 183 |
+
t0 = time.perf_counter()
|
| 184 |
+
try:
|
| 185 |
+
yield
|
| 186 |
+
finally:
|
| 187 |
+
if self.cuda_sync:
|
| 188 |
+
torch.cuda.synchronize()
|
| 189 |
+
self.totals[name] += time.perf_counter() - t0
|
| 190 |
+
self.counts[name] += 1
|
| 191 |
+
|
| 192 |
+
def reset(self):
|
| 193 |
+
self.totals.clear()
|
| 194 |
+
self.counts.clear()
|
| 195 |
+
self._last_totals.clear()
|
| 196 |
+
self._last_counts.clear()
|
| 197 |
+
|
| 198 |
+
def print_summary(self, header=""):
|
| 199 |
+
if not self.enabled or not self.totals:
|
| 200 |
+
return
|
| 201 |
+
cumulative_total = sum(self.totals.values())
|
| 202 |
+
delta_totals = {k: self.totals[k] - self._last_totals.get(k, 0.0)
|
| 203 |
+
for k in self.totals}
|
| 204 |
+
delta_counts = {k: self.counts[k] - self._last_counts.get(k, 0)
|
| 205 |
+
for k in self.counts}
|
| 206 |
+
interval_total = sum(delta_totals.values())
|
| 207 |
+
|
| 208 |
+
print(f"\n=== Profiler summary {header} ===")
|
| 209 |
+
print(f" (cumulative since reset | interval since previous summary)")
|
| 210 |
+
print(f" {'section':<34} "
|
| 211 |
+
f"{'calls':>6} {'cum(s)':>8} {'cum_avg':>8} "
|
| 212 |
+
f"{'Δcalls':>6} {'Δ(s)':>7} {'Δavg':>7} {'%Δ':>5}")
|
| 213 |
+
for name, tot in sorted(self.totals.items(), key=lambda kv: -kv[1]):
|
| 214 |
+
n = self.counts[name]
|
| 215 |
+
cum_avg = (tot / n) * 1000.0 if n else 0.0
|
| 216 |
+
dt = delta_totals[name]
|
| 217 |
+
dn = delta_counts[name]
|
| 218 |
+
d_avg = (dt / dn) * 1000.0 if dn else 0.0
|
| 219 |
+
d_pct = (dt / interval_total) * 100.0 if interval_total > 0 else 0.0
|
| 220 |
+
print(f" {name:<34} "
|
| 221 |
+
f"{n:>6d} {tot:>8.2f} {cum_avg:>7.1f}ms "
|
| 222 |
+
f"{dn:>6d} {dt:>7.2f} {d_avg:>6.1f}ms {d_pct:>4.1f}%")
|
| 223 |
+
print(f" {'-' * 34} {'-' * 6} {'-' * 8} {'-' * 8} "
|
| 224 |
+
f"{'-' * 6} {'-' * 7} {'-' * 7} {'-' * 5}")
|
| 225 |
+
print(f" {'TOTAL':<34} {'':>6} {cumulative_total:>8.2f} {'':>8} "
|
| 226 |
+
f"{'':>6} {interval_total:>7.2f}")
|
| 227 |
+
|
| 228 |
+
self._last_totals = dict(self.totals)
|
| 229 |
+
self._last_counts = dict(self.counts)
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
# =============================================================================
|
| 233 |
+
# Guidance Method Enum
|
| 234 |
+
# =============================================================================
|
| 235 |
+
|
| 236 |
+
class GuidanceMethod(Enum):
|
| 237 |
+
EXPONENTIAL_TILT = "exp_tilt"
|
| 238 |
+
CLASSIFIER_GUIDANCE = "cg"
|
| 239 |
+
BEST_OF_K = "bok"
|
| 240 |
+
HYBRID = "hybrid"
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
# =============================================================================
|
| 244 |
+
# Generation Tracker
|
| 245 |
+
# =============================================================================
|
| 246 |
+
|
| 247 |
+
class GenerationTracker:
|
| 248 |
+
def __init__(self, output_dir='./generation_logs'):
|
| 249 |
+
self.output_dir = output_dir
|
| 250 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 251 |
+
self.run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 252 |
+
self.step_logs = []
|
| 253 |
+
self.final_results = []
|
| 254 |
+
|
| 255 |
+
def log_step(self, step, scores, detailed_results=None, improved_indices=None,
|
| 256 |
+
guidance_info: Optional[Dict] = None):
|
| 257 |
+
step_info = {
|
| 258 |
+
'step': step,
|
| 259 |
+
'scores': scores.cpu().numpy().tolist() if isinstance(scores, torch.Tensor) else scores,
|
| 260 |
+
'num_valid': int(sum(1 for s in scores if s > 0)) if hasattr(scores, '__iter__') else 0,
|
| 261 |
+
'improved_indices': improved_indices or [],
|
| 262 |
+
'guidance_info': guidance_info or {}
|
| 263 |
+
}
|
| 264 |
+
if detailed_results:
|
| 265 |
+
step_info['detailed'] = []
|
| 266 |
+
for i, result in enumerate(detailed_results):
|
| 267 |
+
step_info['detailed'].append({
|
| 268 |
+
'sample_idx': i,
|
| 269 |
+
'is_valid_peptide': result.get('is_valid_peptide', False),
|
| 270 |
+
'has_conjugation_site': result.get('has_conjugation_site', False),
|
| 271 |
+
'conjugation_success': result.get('conjugation_success', False),
|
| 272 |
+
'pdc_valid': result.get('pdc_valid', False),
|
| 273 |
+
'num_conjugates': len(result.get('conjugates', [])),
|
| 274 |
+
'error': result.get('error')
|
| 275 |
+
})
|
| 276 |
+
self.step_logs.append(step_info)
|
| 277 |
+
|
| 278 |
+
def log_final(self, sequences, detailed_results_with_props, smiles_list=None):
|
| 279 |
+
for i, (seq, result) in enumerate(zip(sequences, detailed_results_with_props)):
|
| 280 |
+
final_info = {
|
| 281 |
+
'sample_idx': i,
|
| 282 |
+
'aa_sequence': seq,
|
| 283 |
+
'peptide_smiles': smiles_list[i] if smiles_list else seq,
|
| 284 |
+
'is_valid_peptide': result.get('is_valid_peptide', False),
|
| 285 |
+
'has_conjugation_site': result.get('has_conjugation_site', False),
|
| 286 |
+
'num_conjugates': len(result.get('conjugates', [])),
|
| 287 |
+
'num_valid_pdcs': len(result.get('valid_pdcs', [])),
|
| 288 |
+
'best_pdc': None,
|
| 289 |
+
'linker_used': result.get('linker_used', ''),
|
| 290 |
+
'all_conjugates': result.get('conjugates', []),
|
| 291 |
+
'properties': result.get('properties', {})
|
| 292 |
+
}
|
| 293 |
+
if result.get('best_pdc'):
|
| 294 |
+
final_info['best_pdc'] = {
|
| 295 |
+
'smiles': result['best_pdc'].get('smiles', ''),
|
| 296 |
+
'checks': result['best_pdc'].get('checks', {})
|
| 297 |
+
}
|
| 298 |
+
self.final_results.append(final_info)
|
| 299 |
+
|
| 300 |
+
def save(self, filename_prefix='generation'):
|
| 301 |
+
step_log_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_steps.json")
|
| 302 |
+
with open(step_log_path, 'w') as f:
|
| 303 |
+
json.dump(self.step_logs, f, indent=2, default=str)
|
| 304 |
+
|
| 305 |
+
final_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_final.json")
|
| 306 |
+
with open(final_path, 'w') as f:
|
| 307 |
+
json.dump(self.final_results, f, indent=2, default=str)
|
| 308 |
+
|
| 309 |
+
csv_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_summary.csv")
|
| 310 |
+
with open(csv_path, 'w', newline='') as f:
|
| 311 |
+
writer = csv.writer(f)
|
| 312 |
+
writer.writerow([
|
| 313 |
+
'sample_idx', 'aa_sequence', 'peptide_smiles', 'is_valid_peptide',
|
| 314 |
+
'has_conjugation_site', 'linker_used', 'num_conjugates', 'num_valid_pdcs',
|
| 315 |
+
'best_pdc_smiles', 'mf', 'polararea', 'xlogp', 'heavycnt', 'rotbonds'
|
| 316 |
+
])
|
| 317 |
+
for result in self.final_results:
|
| 318 |
+
props = result.get('properties', {})
|
| 319 |
+
writer.writerow([
|
| 320 |
+
result['sample_idx'],
|
| 321 |
+
result.get('aa_sequence', ''),
|
| 322 |
+
result['peptide_smiles'],
|
| 323 |
+
result['is_valid_peptide'],
|
| 324 |
+
result['has_conjugation_site'],
|
| 325 |
+
result.get('linker_used', ''),
|
| 326 |
+
result['num_conjugates'],
|
| 327 |
+
result['num_valid_pdcs'],
|
| 328 |
+
result['best_pdc']['smiles'] if result.get('best_pdc') else '',
|
| 329 |
+
props.get('mf', ''),
|
| 330 |
+
props.get('polararea', ''),
|
| 331 |
+
props.get('xlogp', ''),
|
| 332 |
+
props.get('heavycnt', ''),
|
| 333 |
+
props.get('rotbonds', '')
|
| 334 |
+
])
|
| 335 |
+
|
| 336 |
+
pdc_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_pdcs.csv")
|
| 337 |
+
with open(pdc_path, 'w', newline='') as f:
|
| 338 |
+
writer = csv.writer(f)
|
| 339 |
+
writer.writerow(['sample_idx', 'aa_sequence', 'peptide_smiles', 'linker_used',
|
| 340 |
+
'conjugate_idx', 'pdc_smiles'])
|
| 341 |
+
for result in self.final_results:
|
| 342 |
+
for j, conj in enumerate(result.get('all_conjugates', [])):
|
| 343 |
+
writer.writerow([
|
| 344 |
+
result['sample_idx'],
|
| 345 |
+
result.get('aa_sequence', ''),
|
| 346 |
+
result['peptide_smiles'],
|
| 347 |
+
result.get('linker_used', ''),
|
| 348 |
+
j,
|
| 349 |
+
conj
|
| 350 |
+
])
|
| 351 |
+
|
| 352 |
+
print(f"\n=== Generation logs saved ===")
|
| 353 |
+
print(f" Step logs: {step_log_path}")
|
| 354 |
+
print(f" Final results: {final_path}")
|
| 355 |
+
print(f" Summary CSV: {csv_path}")
|
| 356 |
+
print(f" PDC conjugates: {pdc_path}")
|
| 357 |
+
return step_log_path, final_path, csv_path, pdc_path
|
| 358 |
+
|
| 359 |
+
def print_step_summary(self, step, scores, detailed_results=None, guidance_method=None):
|
| 360 |
+
scores_list = scores.cpu().numpy().tolist() if isinstance(scores, torch.Tensor) else list(scores)
|
| 361 |
+
num_valid = sum(1 for s in scores_list if s > 0)
|
| 362 |
+
print(f"\n--- Step {step} Summary ({guidance_method or 'unknown'}) ---")
|
| 363 |
+
print(f" Valid: {num_valid}/{len(scores_list)}")
|
| 364 |
+
if detailed_results:
|
| 365 |
+
print(f" Breakdown:")
|
| 366 |
+
print(f" Valid peptides: {sum(1 for r in detailed_results if r.get('is_valid_peptide'))}")
|
| 367 |
+
print(f" Has conjugation site: {sum(1 for r in detailed_results if r.get('has_conjugation_site'))}")
|
| 368 |
+
print(f" Conjugation success: {sum(1 for r in detailed_results if r.get('conjugation_success'))}")
|
| 369 |
+
print(f" PDC valid: {sum(1 for r in detailed_results if r.get('pdc_valid'))}")
|
| 370 |
+
|
| 371 |
+
def print_final_summary(self):
|
| 372 |
+
print("\n" + "=" * 60)
|
| 373 |
+
print("FINAL GENERATION SUMMARY")
|
| 374 |
+
print("=" * 60)
|
| 375 |
+
total = len(self.final_results)
|
| 376 |
+
valid_pdcs = sum(1 for r in self.final_results if r.get('num_valid_pdcs', 0) > 0)
|
| 377 |
+
print(f"\nTotal sequences: {total}")
|
| 378 |
+
print(f"With valid PDCs: {valid_pdcs}")
|
| 379 |
+
for result in self.final_results:
|
| 380 |
+
status = "" if result.get('num_valid_pdcs', 0) > 0 else ""
|
| 381 |
+
print(f"\n [{status}] Sample {result['sample_idx']}:")
|
| 382 |
+
print(f" AA seq: {result.get('aa_sequence', '')}")
|
| 383 |
+
print(f" Peptide SMILES: {result['peptide_smiles'][:60]}...")
|
| 384 |
+
print(f" Valid peptide: {result['is_valid_peptide']}")
|
| 385 |
+
print(f" Has conjugation site: {result['has_conjugation_site']}")
|
| 386 |
+
print(f" Conjugates: {result['num_conjugates']}")
|
| 387 |
+
print(f" Valid PDCs: {result['num_valid_pdcs']}")
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
# =============================================================================
|
| 391 |
+
# CupOfMoCA Generator (UAA-aware)
|
| 392 |
+
# =============================================================================
|
| 393 |
+
|
| 394 |
+
class CupOfMoCAGeneratorUAA:
|
| 395 |
+
"""CupOfMoCA Algorithm 2 generator that operates in AA token space.
|
| 396 |
+
|
| 397 |
+
AA tokens are converted to SMILES on the fly for scoring with the
|
| 398 |
+
Conjugation and BindingAffinity objectives.
|
| 399 |
+
"""
|
| 400 |
+
|
| 401 |
+
def __init__(self, model, device, objectives, args, tokenizer, tokenizer_type='amino',
|
| 402 |
+
tracker=None):
|
| 403 |
+
self.model = model
|
| 404 |
+
self.device = device
|
| 405 |
+
self.objectives = objectives
|
| 406 |
+
self.args = args
|
| 407 |
+
self.num_objectives = len(objectives)
|
| 408 |
+
self.peptide_analyzer = PeptideAnalyzer()
|
| 409 |
+
self.pdc_analyzer = PDCAnalyzer(device)
|
| 410 |
+
self.tracker = tracker
|
| 411 |
+
self.tokenizer = tokenizer
|
| 412 |
+
self.tokenizer_type = tokenizer_type
|
| 413 |
+
self.guidance_method = GuidanceMethod(args.guidance_method)
|
| 414 |
+
self.profiler = Profiler(enabled=getattr(args, 'profile', False))
|
| 415 |
+
|
| 416 |
+
cascade_workers = max(1, getattr(args, 'cascade_workers', 1))
|
| 417 |
+
self._cascade_pool = (
|
| 418 |
+
ThreadPoolExecutor(max_workers=cascade_workers)
|
| 419 |
+
if cascade_workers > 1 else None
|
| 420 |
+
)
|
| 421 |
+
|
| 422 |
+
if tokenizer_type == 'amino':
|
| 423 |
+
self.invalid_tokens = [0, 1, 2, 3, 38] # specials + 'g'
|
| 424 |
+
self.smiles_tokenizer = SMILES_SPE_Tokenizer(
|
| 425 |
+
args.smiles_vocab_path, args.smiles_splits_path
|
| 426 |
+
)
|
| 427 |
+
else:
|
| 428 |
+
self.invalid_tokens = [0, 1, 2, 3, 4, 585, 586]
|
| 429 |
+
self.smiles_tokenizer = None
|
| 430 |
+
|
| 431 |
+
self.conjugation_obj = None
|
| 432 |
+
for obj in objectives:
|
| 433 |
+
if isinstance(obj, Conjugation):
|
| 434 |
+
self.conjugation_obj = obj
|
| 435 |
+
break
|
| 436 |
+
|
| 437 |
+
# ------------------------------------------------------------------
|
| 438 |
+
# x0 generation (Algorithm 2, Line 6)
|
| 439 |
+
# ------------------------------------------------------------------
|
| 440 |
+
def _generate_single_x0(self, n_steps=16, temperature=1.0):
|
| 441 |
+
self.model.eval()
|
| 442 |
+
|
| 443 |
+
if self.tokenizer_type == 'amino':
|
| 444 |
+
vocab_size = self.model.vocab_size
|
| 445 |
+
valid_tokens = [i for i in range(vocab_size) if i not in self.invalid_tokens]
|
| 446 |
+
valid_tokens = torch.tensor(valid_tokens, device=self.device)
|
| 447 |
+
rand_indices = torch.randint(
|
| 448 |
+
0, len(valid_tokens),
|
| 449 |
+
(self.args.num_samples, self.args.gen_len),
|
| 450 |
+
device=self.device
|
| 451 |
+
)
|
| 452 |
+
body = valid_tokens[rand_indices]
|
| 453 |
+
cls_col = torch.full((self.args.num_samples, 1), self.tokenizer.cls_id, device=self.device)
|
| 454 |
+
eos_col = torch.full((self.args.num_samples, 1), self.tokenizer.eos_id, device=self.device)
|
| 455 |
+
x = torch.cat([cls_col, body, eos_col], dim=1)
|
| 456 |
+
else:
|
| 457 |
+
vocab_size = self.model.tokenizer.vocab_size
|
| 458 |
+
x = torch.randint(
|
| 459 |
+
0, vocab_size,
|
| 460 |
+
(self.args.num_samples, self.args.gen_len),
|
| 461 |
+
device=self.device
|
| 462 |
+
)
|
| 463 |
+
|
| 464 |
+
time_steps = torch.linspace(0.0, 1.0, n_steps + 1, device=self.device)
|
| 465 |
+
|
| 466 |
+
with torch.no_grad():
|
| 467 |
+
for i in tqdm(range(n_steps), desc="Initial Flow Matching", leave=False):
|
| 468 |
+
t_curr = time_steps[i]
|
| 469 |
+
t_next = time_steps[i + 1]
|
| 470 |
+
t_tensor = torch.full((self.args.num_samples,), t_curr, device=self.device)
|
| 471 |
+
|
| 472 |
+
logits = self.model(x, t_tensor) / temperature
|
| 473 |
+
for inv_tok in self.invalid_tokens:
|
| 474 |
+
if inv_tok < logits.shape[-1]:
|
| 475 |
+
logits[:, :, inv_tok] = -1000
|
| 476 |
+
|
| 477 |
+
pred_x1 = torch.argmax(logits, dim=-1)
|
| 478 |
+
if i == n_steps - 1:
|
| 479 |
+
x = pred_x1
|
| 480 |
+
break
|
| 481 |
+
|
| 482 |
+
noise_prob = 1.0 - t_next
|
| 483 |
+
mask = torch.rand(x.shape, device=self.device) < noise_prob
|
| 484 |
+
noise = torch.randint(0, vocab_size, x.shape, device=self.device)
|
| 485 |
+
x = torch.where(mask, noise, pred_x1)
|
| 486 |
+
|
| 487 |
+
return x
|
| 488 |
+
|
| 489 |
+
def _aa_to_smiles_batch(self, x_batch):
|
| 490 |
+
"""Decode AA tokens, convert to SMILES via three-letter codes."""
|
| 491 |
+
with self.profiler.section("aa2smiles.decode"):
|
| 492 |
+
seqs = self.tokenizer.batch_decode(x_batch)
|
| 493 |
+
with self.profiler.section("aa2smiles.one_to_three"):
|
| 494 |
+
three_letter_seqs = [one_letter_to_three_letter(s) for s in seqs]
|
| 495 |
+
with self.profiler.section("aa2smiles.aa_to_smiles"):
|
| 496 |
+
with ThreadPoolExecutor() as pool:
|
| 497 |
+
smiles_seqs = list(pool.map(
|
| 498 |
+
lambda s: aa_to_smiles(s, cyclic=self.args.cyclic),
|
| 499 |
+
three_letter_seqs
|
| 500 |
+
))
|
| 501 |
+
return seqs, smiles_seqs
|
| 502 |
+
|
| 503 |
+
def _check_conjugation_validity(self, x):
|
| 504 |
+
if self.tokenizer_type == 'amino':
|
| 505 |
+
_, smiles_seqs = self._aa_to_smiles_batch(x)
|
| 506 |
+
validities = [
|
| 507 |
+
self.peptide_analyzer.is_peptide(s) if isinstance(s, str) else False
|
| 508 |
+
for s in smiles_seqs
|
| 509 |
+
]
|
| 510 |
+
return torch.tensor(validities, device=self.device)
|
| 511 |
+
|
| 512 |
+
if self.conjugation_obj is None:
|
| 513 |
+
sequences = self.tokenizer.batch_decode(x)
|
| 514 |
+
return torch.tensor(
|
| 515 |
+
[self.peptide_analyzer.is_peptide(seq) for seq in sequences],
|
| 516 |
+
device=self.device
|
| 517 |
+
)
|
| 518 |
+
return self.conjugation_obj(x) > 0
|
| 519 |
+
|
| 520 |
+
def generate_x0(self, n_steps=16, temperature=1.0,
|
| 521 |
+
require_valid=False, max_retries=10):
|
| 522 |
+
print("Generating initial sequences (x0 from prior)...")
|
| 523 |
+
x = self._generate_single_x0(n_steps, temperature)
|
| 524 |
+
|
| 525 |
+
if require_valid:
|
| 526 |
+
print("Enforcing initial validity constraint...")
|
| 527 |
+
for retry in range(max_retries):
|
| 528 |
+
validity = self._check_conjugation_validity(x)
|
| 529 |
+
num_valid = validity.sum().item()
|
| 530 |
+
num_invalid = self.args.num_samples - num_valid
|
| 531 |
+
if num_invalid == 0:
|
| 532 |
+
print(f" All {self.args.num_samples} samples are valid!")
|
| 533 |
+
break
|
| 534 |
+
print(f" Retry {retry + 1}/{max_retries}: {num_valid}/{self.args.num_samples} valid")
|
| 535 |
+
invalid_indices = torch.where(~validity)[0]
|
| 536 |
+
original_num = self.args.num_samples
|
| 537 |
+
self.args.num_samples = num_invalid
|
| 538 |
+
new_x = self._generate_single_x0(n_steps, temperature)
|
| 539 |
+
self.args.num_samples = original_num
|
| 540 |
+
x[invalid_indices] = new_x
|
| 541 |
+
|
| 542 |
+
final_validity = self._check_conjugation_validity(x)
|
| 543 |
+
if final_validity.sum().item() < self.args.num_samples:
|
| 544 |
+
print(f"⚠ After {max_retries} retries, "
|
| 545 |
+
f"{final_validity.sum().item()}/{self.args.num_samples} valid")
|
| 546 |
+
|
| 547 |
+
generated_sequences = self.tokenizer.batch_decode(x)
|
| 548 |
+
for seq in generated_sequences:
|
| 549 |
+
print(f" {seq}")
|
| 550 |
+
return x
|
| 551 |
+
|
| 552 |
+
# ------------------------------------------------------------------
|
| 553 |
+
# Scoring
|
| 554 |
+
# ------------------------------------------------------------------
|
| 555 |
+
def _get_scores(self, x_batch):
|
| 556 |
+
with self.profiler.section("get_scores.total"):
|
| 557 |
+
scores = []
|
| 558 |
+
|
| 559 |
+
if self.tokenizer_type == 'amino':
|
| 560 |
+
_, smiles_seqs = self._aa_to_smiles_batch(x_batch)
|
| 561 |
+
with self.profiler.section("get_scores.valid_mask"):
|
| 562 |
+
valid_mask = torch.tensor(
|
| 563 |
+
[1 if isinstance(s, str) else 0 for s in smiles_seqs],
|
| 564 |
+
device=self.device
|
| 565 |
+
)
|
| 566 |
+
for i in torch.where((1 - valid_mask) == 1)[0].tolist():
|
| 567 |
+
smiles_seqs[i] = 'C'
|
| 568 |
+
|
| 569 |
+
with self.profiler.section("get_scores.smiles_tokenize"):
|
| 570 |
+
smiles_batch = self.smiles_tokenizer(
|
| 571 |
+
smiles_seqs, return_tensors='pt', padding=True
|
| 572 |
+
).to(self.device)
|
| 573 |
+
attention_mask = smiles_batch.get("attention_mask", None)
|
| 574 |
+
|
| 575 |
+
for obj_func in self.objectives:
|
| 576 |
+
obj_name = type(obj_func).__name__
|
| 577 |
+
with self.profiler.section(f"get_scores.obj.{obj_name}"):
|
| 578 |
+
try:
|
| 579 |
+
score = obj_func(
|
| 580 |
+
smiles_batch["input_ids"], attention_mask=attention_mask
|
| 581 |
+
).to(self.device)
|
| 582 |
+
except TypeError:
|
| 583 |
+
score = obj_func(smiles_batch["input_ids"]).to(self.device)
|
| 584 |
+
scores.append(score * valid_mask)
|
| 585 |
+
else:
|
| 586 |
+
for obj_func in self.objectives:
|
| 587 |
+
obj_name = type(obj_func).__name__
|
| 588 |
+
with self.profiler.section(f"get_scores.obj.{obj_name}"):
|
| 589 |
+
scores.append(obj_func(x_batch.to(self.device)))
|
| 590 |
+
|
| 591 |
+
return torch.stack(scores, dim=0).to(self.device)
|
| 592 |
+
|
| 593 |
+
def _compute_aggregated_score(self, scores, weights):
|
| 594 |
+
if self.args.score_aggregation == "weighted_min":
|
| 595 |
+
return torch.min(weights * scores, dim=0).values
|
| 596 |
+
return (weights * scores).sum(dim=0)
|
| 597 |
+
|
| 598 |
+
# ------------------------------------------------------------------
|
| 599 |
+
# Cascade scoring (cheap filter + affinity on all → full PDC on top-K)
|
| 600 |
+
# ------------------------------------------------------------------
|
| 601 |
+
def _get_scores_cascaded(self, x_batch):
|
| 602 |
+
"""Run cheap conjugation pre-filter + BindingAffinity on all candidates,
|
| 603 |
+
then run the expensive PDC validation only on the top `cascade_k` by
|
| 604 |
+
affinity (among those that pass the cheap filter).
|
| 605 |
+
|
| 606 |
+
Returns [num_objectives, batch] in the same layout as `_get_scores`.
|
| 607 |
+
Falls back to full scoring if Conjugation isn't an objective or
|
| 608 |
+
tokenizer_type != 'amino'.
|
| 609 |
+
"""
|
| 610 |
+
if self.tokenizer_type != 'amino' or self.conjugation_obj is None:
|
| 611 |
+
return self._get_scores(x_batch)
|
| 612 |
+
|
| 613 |
+
with self.profiler.section("cascade.total"):
|
| 614 |
+
n = x_batch.shape[0]
|
| 615 |
+
cascade_k = self.args.cascade_k
|
| 616 |
+
conj_obj = self.conjugation_obj
|
| 617 |
+
|
| 618 |
+
_, smiles_seqs = self._aa_to_smiles_batch(x_batch)
|
| 619 |
+
valid_mask = torch.tensor(
|
| 620 |
+
[1.0 if isinstance(s, str) else 0.0 for s in smiles_seqs],
|
| 621 |
+
device=self.device
|
| 622 |
+
)
|
| 623 |
+
safe_smiles = [s if isinstance(s, str) else 'C' for s in smiles_seqs]
|
| 624 |
+
|
| 625 |
+
with self.profiler.section("cascade.cheap_filter"):
|
| 626 |
+
cheap_pass = []
|
| 627 |
+
for s, vm in zip(safe_smiles, valid_mask.tolist()):
|
| 628 |
+
if vm == 0.0:
|
| 629 |
+
cheap_pass.append(False)
|
| 630 |
+
continue
|
| 631 |
+
cheap_pass.append(
|
| 632 |
+
conj_obj.is_valid_peptide(s)
|
| 633 |
+
and conj_obj.has_conjugation_site(s)
|
| 634 |
+
)
|
| 635 |
+
cheap_pass_t = torch.tensor(
|
| 636 |
+
cheap_pass, device=self.device, dtype=torch.float
|
| 637 |
+
)
|
| 638 |
+
|
| 639 |
+
with self.profiler.section("cascade.smiles_tokenize"):
|
| 640 |
+
smiles_batch = self.smiles_tokenizer(
|
| 641 |
+
safe_smiles, return_tensors='pt', padding=True
|
| 642 |
+
).to(self.device)
|
| 643 |
+
attention_mask = smiles_batch.get("attention_mask", None)
|
| 644 |
+
|
| 645 |
+
aff_obj = next(
|
| 646 |
+
(o for o in self.objectives if not isinstance(o, Conjugation)),
|
| 647 |
+
None
|
| 648 |
+
)
|
| 649 |
+
if aff_obj is None:
|
| 650 |
+
aff_scores = torch.zeros(n, device=self.device)
|
| 651 |
+
else:
|
| 652 |
+
with self.profiler.section(f"cascade.obj.{type(aff_obj).__name__}"):
|
| 653 |
+
try:
|
| 654 |
+
aff_scores = aff_obj(
|
| 655 |
+
smiles_batch["input_ids"], attention_mask=attention_mask
|
| 656 |
+
).to(self.device)
|
| 657 |
+
except TypeError:
|
| 658 |
+
aff_scores = aff_obj(smiles_batch["input_ids"]).to(self.device)
|
| 659 |
+
aff_scores = aff_scores * valid_mask
|
| 660 |
+
|
| 661 |
+
eligible_idx = torch.where(cheap_pass_t > 0)[0]
|
| 662 |
+
if cascade_k > 0 and len(eligible_idx) > cascade_k:
|
| 663 |
+
topk_local = torch.topk(aff_scores[eligible_idx], k=cascade_k).indices
|
| 664 |
+
validate_idx = eligible_idx[topk_local]
|
| 665 |
+
else:
|
| 666 |
+
validate_idx = eligible_idx
|
| 667 |
+
|
| 668 |
+
conj_scores = torch.zeros(n, device=self.device)
|
| 669 |
+
with self.profiler.section("cascade.full_validate"):
|
| 670 |
+
for j in validate_idx.tolist():
|
| 671 |
+
ok, conjs, _, _ = conj_obj.conjugate_peptide(
|
| 672 |
+
safe_smiles[j], exclude_n_terminus=True
|
| 673 |
+
)
|
| 674 |
+
if not ok or not conjs:
|
| 675 |
+
continue
|
| 676 |
+
has_valid, _ = conj_obj.validate_conjugates(conjs)
|
| 677 |
+
if has_valid:
|
| 678 |
+
conj_scores[j] = 1.0
|
| 679 |
+
|
| 680 |
+
out = []
|
| 681 |
+
for obj in self.objectives:
|
| 682 |
+
if isinstance(obj, Conjugation):
|
| 683 |
+
out.append(conj_scores)
|
| 684 |
+
else:
|
| 685 |
+
out.append(aff_scores)
|
| 686 |
+
return torch.stack(out, dim=0).to(self.device)
|
| 687 |
+
|
| 688 |
+
# ------------------------------------------------------------------
|
| 689 |
+
# Guidance methods
|
| 690 |
+
# ------------------------------------------------------------------
|
| 691 |
+
def _exponential_tilt_guidance(self, x, pos_probs, candidates, mut_idx, sample_idx,
|
| 692 |
+
beta, weights, current_score=None):
|
| 693 |
+
num_candidates = len(candidates)
|
| 694 |
+
current_token = x[sample_idx, mut_idx].item()
|
| 695 |
+
|
| 696 |
+
if num_candidates == 0:
|
| 697 |
+
return current_token, {'method': 'exp_tilt', 'fallback': True, 'accepted': False}
|
| 698 |
+
|
| 699 |
+
if num_candidates > self.args.top_k:
|
| 700 |
+
candidates = candidates[:self.args.top_k]
|
| 701 |
+
num_candidates = self.args.top_k
|
| 702 |
+
|
| 703 |
+
x_proposals = x[sample_idx].unsqueeze(0).repeat(num_candidates, 1)
|
| 704 |
+
x_proposals[:, mut_idx] = candidates
|
| 705 |
+
|
| 706 |
+
with self.profiler.section("exp_tilt.proposal_scores"):
|
| 707 |
+
if self.args.cascade_k > 0:
|
| 708 |
+
proposal_scores = self._get_scores_cascaded(x_proposals)
|
| 709 |
+
else:
|
| 710 |
+
proposal_scores = self._get_scores(x_proposals)
|
| 711 |
+
aggregated_scores = self._compute_aggregated_score(proposal_scores, weights)
|
| 712 |
+
validity_mask = (proposal_scores[0] > 0).float()
|
| 713 |
+
base_probs = pos_probs[sample_idx, candidates]
|
| 714 |
+
|
| 715 |
+
log_base_probs = torch.log(base_probs + 1e-10)
|
| 716 |
+
log_exp_scores = beta * aggregated_scores
|
| 717 |
+
log_validity = torch.log(validity_mask + 1e-10)
|
| 718 |
+
log_tilted = log_base_probs + log_exp_scores + log_validity
|
| 719 |
+
|
| 720 |
+
log_tilted_max = log_tilted.max()
|
| 721 |
+
fallback = False
|
| 722 |
+
if torch.isinf(log_tilted_max) or torch.isnan(log_tilted_max):
|
| 723 |
+
fallback = True
|
| 724 |
+
final_probs = base_probs / (base_probs.sum() + 1e-9)
|
| 725 |
+
else:
|
| 726 |
+
log_tilted_stable = log_tilted - log_tilted_max
|
| 727 |
+
tilted_probs = torch.exp(log_tilted_stable)
|
| 728 |
+
prob_sum = tilted_probs.sum()
|
| 729 |
+
if prob_sum < 1e-10 or torch.isnan(prob_sum) or torch.isinf(prob_sum):
|
| 730 |
+
fallback = True
|
| 731 |
+
final_probs = base_probs / (base_probs.sum() + 1e-9)
|
| 732 |
+
else:
|
| 733 |
+
final_probs = tilted_probs / prob_sum
|
| 734 |
+
|
| 735 |
+
if torch.any(torch.isnan(final_probs)) or torch.any(torch.isinf(final_probs)):
|
| 736 |
+
fallback = True
|
| 737 |
+
final_probs = base_probs / (base_probs.sum() + 1e-9)
|
| 738 |
+
|
| 739 |
+
final_probs = torch.clamp(final_probs, min=1e-10)
|
| 740 |
+
final_probs = final_probs / final_probs.sum()
|
| 741 |
+
|
| 742 |
+
selected_idx = torch.multinomial(final_probs, 1).item()
|
| 743 |
+
selected_token = candidates[selected_idx].item()
|
| 744 |
+
selected_score = aggregated_scores[selected_idx].item()
|
| 745 |
+
|
| 746 |
+
accepted = True
|
| 747 |
+
if self.args.use_acceptance and current_score is not None:
|
| 748 |
+
if selected_score < current_score:
|
| 749 |
+
selected_token = current_token
|
| 750 |
+
accepted = False
|
| 751 |
+
|
| 752 |
+
return selected_token, {
|
| 753 |
+
'method': 'exp_tilt', 'beta': beta, 'num_candidates': num_candidates,
|
| 754 |
+
'proposal_score': aggregated_scores[selected_idx].item(),
|
| 755 |
+
'selected_score': selected_score if accepted else current_score,
|
| 756 |
+
'fallback': fallback, 'validity_rate': validity_mask.mean().item(),
|
| 757 |
+
'accepted': accepted
|
| 758 |
+
}
|
| 759 |
+
|
| 760 |
+
def _classifier_guidance(self, x, logits, mut_idx, sample_idx,
|
| 761 |
+
guidance_weight, weights):
|
| 762 |
+
base_logits = logits[sample_idx, mut_idx, :]
|
| 763 |
+
sorted_logits, sorted_indices = torch.sort(base_logits, descending=True)
|
| 764 |
+
candidates = sorted_indices[:self.args.top_k]
|
| 765 |
+
|
| 766 |
+
valid_mask = ~torch.isin(
|
| 767 |
+
candidates, torch.tensor(self.invalid_tokens, device=self.device)
|
| 768 |
+
)
|
| 769 |
+
candidates = candidates[valid_mask]
|
| 770 |
+
|
| 771 |
+
if len(candidates) == 0:
|
| 772 |
+
return x[sample_idx, mut_idx].item(), {'method': 'cg', 'fallback': True}
|
| 773 |
+
|
| 774 |
+
x_proposals = x[sample_idx].unsqueeze(0).repeat(len(candidates), 1)
|
| 775 |
+
x_proposals[:, mut_idx] = candidates
|
| 776 |
+
|
| 777 |
+
with self.profiler.section("cg.proposal_scores"):
|
| 778 |
+
if self.args.cascade_k > 0:
|
| 779 |
+
proposal_scores = self._get_scores_cascaded(x_proposals)
|
| 780 |
+
else:
|
| 781 |
+
proposal_scores = self._get_scores(x_proposals)
|
| 782 |
+
aggregated_scores = self._compute_aggregated_score(proposal_scores, weights)
|
| 783 |
+
|
| 784 |
+
score_logits = aggregated_scores * self.args.score_temperature
|
| 785 |
+
base_candidate_logits = base_logits[candidates]
|
| 786 |
+
guided_logits = base_candidate_logits + guidance_weight * score_logits
|
| 787 |
+
guided_probs = F.softmax(guided_logits, dim=-1)
|
| 788 |
+
|
| 789 |
+
selected_idx = torch.multinomial(guided_probs, 1).item()
|
| 790 |
+
return candidates[selected_idx].item(), {
|
| 791 |
+
'method': 'cg', 'guidance_weight': guidance_weight,
|
| 792 |
+
'num_candidates': len(candidates),
|
| 793 |
+
'selected_score': aggregated_scores[selected_idx].item(), 'fallback': False
|
| 794 |
+
}
|
| 795 |
+
|
| 796 |
+
def _best_of_k_guidance(self, x, pos_probs, candidates, mut_idx, sample_idx,
|
| 797 |
+
k, weights):
|
| 798 |
+
num_candidates = len(candidates)
|
| 799 |
+
if num_candidates == 0:
|
| 800 |
+
return x[sample_idx, mut_idx].item(), {'method': 'bok', 'fallback': True}
|
| 801 |
+
|
| 802 |
+
k = min(k, num_candidates)
|
| 803 |
+
candidate_probs = pos_probs[sample_idx, candidates]
|
| 804 |
+
candidate_probs = candidate_probs / (candidate_probs.sum() + 1e-9)
|
| 805 |
+
|
| 806 |
+
if k >= num_candidates:
|
| 807 |
+
sampled_indices = torch.arange(num_candidates, device=self.device)
|
| 808 |
+
else:
|
| 809 |
+
sampled_indices = torch.multinomial(candidate_probs, k, replacement=False)
|
| 810 |
+
|
| 811 |
+
sampled_tokens = candidates[sampled_indices]
|
| 812 |
+
x_proposals = x[sample_idx].unsqueeze(0).repeat(len(sampled_tokens), 1)
|
| 813 |
+
x_proposals[:, mut_idx] = sampled_tokens
|
| 814 |
+
|
| 815 |
+
with self.profiler.section("bok.proposal_scores"):
|
| 816 |
+
if self.args.cascade_k > 0:
|
| 817 |
+
proposal_scores = self._get_scores_cascaded(x_proposals)
|
| 818 |
+
else:
|
| 819 |
+
proposal_scores = self._get_scores(x_proposals)
|
| 820 |
+
aggregated_scores = self._compute_aggregated_score(proposal_scores, weights)
|
| 821 |
+
|
| 822 |
+
best_idx = torch.argmax(aggregated_scores).item()
|
| 823 |
+
return sampled_tokens[best_idx].item(), {
|
| 824 |
+
'method': 'bok', 'k': k, 'num_candidates': num_candidates,
|
| 825 |
+
'selected_score': aggregated_scores[best_idx].item(),
|
| 826 |
+
'mean_score': aggregated_scores.mean().item(),
|
| 827 |
+
'max_score': aggregated_scores.max().item(), 'fallback': False
|
| 828 |
+
}
|
| 829 |
+
|
| 830 |
+
def _hybrid_guidance(self, x, pos_probs, logits, candidates, mut_idx, sample_idx,
|
| 831 |
+
step, beta, weights):
|
| 832 |
+
progress = step / max(self.args.optimization_steps - 1, 1)
|
| 833 |
+
if progress < 0.3:
|
| 834 |
+
return self._best_of_k_guidance(
|
| 835 |
+
x, pos_probs, candidates, mut_idx, sample_idx,
|
| 836 |
+
k=self.args.bok_k, weights=weights
|
| 837 |
+
)
|
| 838 |
+
elif progress < 0.7:
|
| 839 |
+
return self._exponential_tilt_guidance(
|
| 840 |
+
x, pos_probs, candidates, mut_idx, sample_idx,
|
| 841 |
+
beta=beta, weights=weights
|
| 842 |
+
)
|
| 843 |
+
else:
|
| 844 |
+
return self._classifier_guidance(
|
| 845 |
+
x, logits, mut_idx, sample_idx,
|
| 846 |
+
guidance_weight=self.args.cg_weight, weights=weights
|
| 847 |
+
)
|
| 848 |
+
|
| 849 |
+
# ------------------------------------------------------------------
|
| 850 |
+
# Main loop
|
| 851 |
+
# ------------------------------------------------------------------
|
| 852 |
+
def generate(self):
|
| 853 |
+
self.profiler.reset()
|
| 854 |
+
x = self.generate_x0(
|
| 855 |
+
require_valid=self.args.require_valid_x0,
|
| 856 |
+
max_retries=self.args.max_x0_retries
|
| 857 |
+
)
|
| 858 |
+
|
| 859 |
+
if self.args.weights is None:
|
| 860 |
+
weights = torch.tensor(
|
| 861 |
+
[1.0] + [1.0 / (self.num_objectives - 1)] * (self.num_objectives - 1),
|
| 862 |
+
device=self.device
|
| 863 |
+
).view(-1, 1)
|
| 864 |
+
else:
|
| 865 |
+
weights = torch.tensor(self.args.weights, device=self.device).view(-1, 1)
|
| 866 |
+
if len(weights) != self.num_objectives:
|
| 867 |
+
raise ValueError("Number of weights must match number of objectives.")
|
| 868 |
+
|
| 869 |
+
print(f"\nGuidance Method: {self.guidance_method.value}")
|
| 870 |
+
print(f"Weights: {weights.squeeze().tolist()}")
|
| 871 |
+
print(f"β range: [{self.args.beta_min}, {self.args.beta_max}]")
|
| 872 |
+
print(f"Acceptance criterion: {'ENABLED' if self.args.use_acceptance else 'DISABLED'}")
|
| 873 |
+
|
| 874 |
+
with torch.no_grad():
|
| 875 |
+
for step in tqdm(range(self.args.optimization_steps), desc="Guided Generation"):
|
| 876 |
+
improved_indices = []
|
| 877 |
+
|
| 878 |
+
progress = step / max(self.args.optimization_steps - 1, 1)
|
| 879 |
+
beta = self.args.beta_min + (self.args.beta_max - self.args.beta_min) * progress
|
| 880 |
+
|
| 881 |
+
if self.args.position_strategy == "random":
|
| 882 |
+
mut_idx = random.randint(1, self.args.gen_len - 2)
|
| 883 |
+
else:
|
| 884 |
+
mut_idx = (step % (self.args.gen_len - 2)) + 1
|
| 885 |
+
|
| 886 |
+
time_t = torch.full(
|
| 887 |
+
(self.args.num_samples,),
|
| 888 |
+
step / self.args.optimization_steps,
|
| 889 |
+
device=self.device
|
| 890 |
+
)
|
| 891 |
+
|
| 892 |
+
with self.profiler.section("loop.model_forward"):
|
| 893 |
+
logits = self.model(x, time_t)
|
| 894 |
+
probs = F.softmax(logits, dim=-1)
|
| 895 |
+
pos_probs = probs[:, mut_idx, :]
|
| 896 |
+
|
| 897 |
+
pos_probs[torch.arange(self.args.num_samples), x[:, mut_idx]] = 0
|
| 898 |
+
for inv_tok in self.invalid_tokens:
|
| 899 |
+
pos_probs[:, inv_tok] = 0
|
| 900 |
+
|
| 901 |
+
with self.profiler.section("loop.top_p_sort"):
|
| 902 |
+
sorted_probs, sorted_indices = torch.sort(pos_probs, descending=True)
|
| 903 |
+
|
| 904 |
+
with self.profiler.section("loop.current_scores"):
|
| 905 |
+
current_scores = self._get_scores(x)
|
| 906 |
+
current_aggregated = self._compute_aggregated_score(current_scores, weights)
|
| 907 |
+
|
| 908 |
+
if step == 0:
|
| 909 |
+
print(f"\nInitial Scores: {current_scores.cpu().numpy()}")
|
| 910 |
+
if self.tracker and self.conjugation_obj:
|
| 911 |
+
detailed = self.conjugation_obj.get_last_detailed_results()
|
| 912 |
+
self.tracker.log_step(step, current_scores[0], detailed)
|
| 913 |
+
self.tracker.print_step_summary(
|
| 914 |
+
step, current_scores[0], detailed, self.guidance_method.value
|
| 915 |
+
)
|
| 916 |
+
|
| 917 |
+
final_tokens = []
|
| 918 |
+
for i in range(self.args.num_samples):
|
| 919 |
+
sample_probs = sorted_probs[i]
|
| 920 |
+
sample_indices = sorted_indices[i]
|
| 921 |
+
|
| 922 |
+
cumsum = torch.cumsum(sample_probs, dim=0)
|
| 923 |
+
mask = cumsum <= self.args.top_p
|
| 924 |
+
mask[0] = True
|
| 925 |
+
candidates = sample_indices[mask]
|
| 926 |
+
|
| 927 |
+
valid_candidate_mask = ~torch.isin(
|
| 928 |
+
candidates, torch.tensor(self.invalid_tokens, device=self.device)
|
| 929 |
+
)
|
| 930 |
+
candidates = candidates[valid_candidate_mask]
|
| 931 |
+
|
| 932 |
+
current_sample_score = current_aggregated[i].item()
|
| 933 |
+
|
| 934 |
+
if beta > 0 or self.guidance_method != GuidanceMethod.EXPONENTIAL_TILT:
|
| 935 |
+
with self.profiler.section(f"loop.guidance.{self.guidance_method.value}"):
|
| 936 |
+
if self.guidance_method == GuidanceMethod.EXPONENTIAL_TILT:
|
| 937 |
+
token, _ = self._exponential_tilt_guidance(
|
| 938 |
+
x, pos_probs, candidates, mut_idx, i, beta, weights,
|
| 939 |
+
current_score=current_sample_score
|
| 940 |
+
)
|
| 941 |
+
elif self.guidance_method == GuidanceMethod.CLASSIFIER_GUIDANCE:
|
| 942 |
+
token, _ = self._classifier_guidance(
|
| 943 |
+
x, logits, mut_idx, i, self.args.cg_weight, weights
|
| 944 |
+
)
|
| 945 |
+
elif self.guidance_method == GuidanceMethod.BEST_OF_K:
|
| 946 |
+
token, _ = self._best_of_k_guidance(
|
| 947 |
+
x, pos_probs, candidates, mut_idx, i, self.args.bok_k, weights
|
| 948 |
+
)
|
| 949 |
+
elif self.guidance_method == GuidanceMethod.HYBRID:
|
| 950 |
+
token, _ = self._hybrid_guidance(
|
| 951 |
+
x, pos_probs, logits, candidates, mut_idx, i, step, beta, weights
|
| 952 |
+
)
|
| 953 |
+
else:
|
| 954 |
+
raise ValueError(f"Unknown guidance: {self.guidance_method}")
|
| 955 |
+
else:
|
| 956 |
+
if len(candidates) == 0:
|
| 957 |
+
token = x[i, mut_idx].item()
|
| 958 |
+
else:
|
| 959 |
+
cprobs = pos_probs[i, candidates]
|
| 960 |
+
cprobs = cprobs / (cprobs.sum() + 1e-9)
|
| 961 |
+
idx = torch.multinomial(cprobs, 1).item()
|
| 962 |
+
token = candidates[idx].item()
|
| 963 |
+
|
| 964 |
+
final_tokens.append(token)
|
| 965 |
+
|
| 966 |
+
x[:, mut_idx] = torch.tensor(final_tokens, device=self.device)
|
| 967 |
+
|
| 968 |
+
with self.profiler.section("loop.new_scores"):
|
| 969 |
+
new_scores = self._get_scores(x)
|
| 970 |
+
new_aggregated = self._compute_aggregated_score(new_scores, weights)
|
| 971 |
+
for i in range(self.args.num_samples):
|
| 972 |
+
if new_aggregated[i] > current_aggregated[i]:
|
| 973 |
+
improved_indices.append(i)
|
| 974 |
+
|
| 975 |
+
if improved_indices:
|
| 976 |
+
for i in improved_indices:
|
| 977 |
+
prev_weighted = (weights.squeeze(1) * current_scores[:, i]).sum()
|
| 978 |
+
new_weighted = (weights.squeeze(1) * new_scores[:, i]).sum()
|
| 979 |
+
print(f"Previous Weighted Sum: {prev_weighted}")
|
| 980 |
+
print(f"Previous Scores: {current_scores[:, i]}")
|
| 981 |
+
print(f"New Weighted Sum: {new_weighted}")
|
| 982 |
+
print(f"New Scores: {new_scores[:, i]}")
|
| 983 |
+
print(self.tokenizer.batch_decode(x[improved_indices]))
|
| 984 |
+
|
| 985 |
+
if self.tracker and self.conjugation_obj and \
|
| 986 |
+
(step % 4 == 0 or step == self.args.optimization_steps - 1):
|
| 987 |
+
detailed = self.conjugation_obj.get_last_detailed_results()
|
| 988 |
+
self.tracker.log_step(
|
| 989 |
+
step, new_scores[0], detailed, improved_indices,
|
| 990 |
+
guidance_info={'method': self.guidance_method.value, 'beta': beta}
|
| 991 |
+
)
|
| 992 |
+
if step == self.args.optimization_steps - 1:
|
| 993 |
+
self.tracker.print_step_summary(
|
| 994 |
+
step, new_scores[0], detailed, self.guidance_method.value
|
| 995 |
+
)
|
| 996 |
+
|
| 997 |
+
if (self.profiler.enabled
|
| 998 |
+
and self.args.profile_every > 0
|
| 999 |
+
and (step + 1) % self.args.profile_every == 0):
|
| 1000 |
+
self.profiler.print_summary(header=f"(through step {step + 1})")
|
| 1001 |
+
|
| 1002 |
+
self.profiler.print_summary(header="(end of generate)")
|
| 1003 |
+
return x
|
| 1004 |
+
|
| 1005 |
+
|
| 1006 |
+
# =============================================================================
|
| 1007 |
+
# Main
|
| 1008 |
+
# =============================================================================
|
| 1009 |
+
|
| 1010 |
+
def main(args):
|
| 1011 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 1012 |
+
print(f"Using device: {device}")
|
| 1013 |
+
print(f"Tokenizer type: {args.tokenizer_type}")
|
| 1014 |
+
print(f"Guidance method: {args.guidance_method}")
|
| 1015 |
+
|
| 1016 |
+
if args.tokenizer_type == 'amino':
|
| 1017 |
+
print("Using Amino Acid tokenizer")
|
| 1018 |
+
tokenizer = AminoTokenizer()
|
| 1019 |
+
smiles_tokenizer = SMILES_SPE_Tokenizer(args.smiles_vocab_path, args.smiles_splits_path)
|
| 1020 |
+
else:
|
| 1021 |
+
print("Using SMILES tokenizer")
|
| 1022 |
+
tokenizer = SMILES_SPE_Tokenizer(args.smiles_vocab_path, args.smiles_splits_path)
|
| 1023 |
+
smiles_tokenizer = tokenizer
|
| 1024 |
+
|
| 1025 |
+
conjugation_model = Conjugation(smiles_tokenizer, device=device, builder_type=args.builder_type)
|
| 1026 |
+
affinity_model = BindingAffinity(args.target, device)
|
| 1027 |
+
OBJECTIVE_FUNCTIONS = [conjugation_model, affinity_model]
|
| 1028 |
+
|
| 1029 |
+
tracker = GenerationTracker(output_dir=args.output_dir)
|
| 1030 |
+
|
| 1031 |
+
if args.seed is not None:
|
| 1032 |
+
random.seed(args.seed)
|
| 1033 |
+
np.random.seed(args.seed)
|
| 1034 |
+
torch.manual_seed(args.seed)
|
| 1035 |
+
if torch.cuda.is_available():
|
| 1036 |
+
torch.cuda.manual_seed_all(args.seed)
|
| 1037 |
+
print(f"Random seed: {args.seed}")
|
| 1038 |
+
|
| 1039 |
+
print(f"Loading model from: {args.checkpoint}")
|
| 1040 |
+
checkpoint = torch.load(args.checkpoint, map_location=device, weights_only=False)
|
| 1041 |
+
|
| 1042 |
+
if args.tokenizer_type == 'amino':
|
| 1043 |
+
raw_model_args = checkpoint['args']
|
| 1044 |
+
if isinstance(raw_model_args, argparse.Namespace):
|
| 1045 |
+
raw_model_args = vars(raw_model_args)
|
| 1046 |
+
model_args = edict(raw_model_args)
|
| 1047 |
+
model = MDLM(
|
| 1048 |
+
vocab_size=model_args.vocab_size,
|
| 1049 |
+
seq_len=model_args.seq_len,
|
| 1050 |
+
model_dim=model_args.model_dim,
|
| 1051 |
+
n_heads=model_args.n_heads,
|
| 1052 |
+
n_layers=model_args.n_layers,
|
| 1053 |
+
).to(device)
|
| 1054 |
+
model.load_state_dict(checkpoint['model_state_dict'])
|
| 1055 |
+
print(f"Amino model loaded. Vocab size: {model_args.vocab_size}")
|
| 1056 |
+
else:
|
| 1057 |
+
model_hparams = checkpoint["hyper_parameters"]["args"]
|
| 1058 |
+
model = MDLMLightningModule.load_from_checkpoint(
|
| 1059 |
+
args.checkpoint, args=model_hparams, tokenizer=tokenizer,
|
| 1060 |
+
map_location=device, strict=False
|
| 1061 |
+
)
|
| 1062 |
+
model.to(device)
|
| 1063 |
+
model.eval()
|
| 1064 |
+
print("Model loaded successfully.")
|
| 1065 |
+
|
| 1066 |
+
generator = CupOfMoCAGeneratorUAA(
|
| 1067 |
+
model, device, OBJECTIVE_FUNCTIONS, args,
|
| 1068 |
+
tokenizer=tokenizer, tokenizer_type=args.tokenizer_type, tracker=tracker
|
| 1069 |
+
)
|
| 1070 |
+
|
| 1071 |
+
all_sequences = []
|
| 1072 |
+
all_smiles = []
|
| 1073 |
+
all_final_results = []
|
| 1074 |
+
|
| 1075 |
+
for batch_idx in range(args.num_batches):
|
| 1076 |
+
print(f"\n{'=' * 60}")
|
| 1077 |
+
print(f"BATCH {batch_idx + 1}/{args.num_batches}")
|
| 1078 |
+
print(f"{'=' * 60}")
|
| 1079 |
+
|
| 1080 |
+
generated_tokens = generator.generate()
|
| 1081 |
+
final_scores = generator._get_scores(generated_tokens.detach()).detach().cpu().numpy()
|
| 1082 |
+
sequence_str = tokenizer.batch_decode(generated_tokens)
|
| 1083 |
+
|
| 1084 |
+
print("\n--- Batch Results ---")
|
| 1085 |
+
print(f"Sequences: {sequence_str}")
|
| 1086 |
+
print(f"Scores: {final_scores}")
|
| 1087 |
+
|
| 1088 |
+
all_sequences.extend(sequence_str)
|
| 1089 |
+
|
| 1090 |
+
if args.tokenizer_type == 'amino':
|
| 1091 |
+
smiles_list = []
|
| 1092 |
+
for seq in sequence_str:
|
| 1093 |
+
three_letter = one_letter_to_three_letter(seq)
|
| 1094 |
+
smiles = aa_to_smiles(three_letter, cyclic=args.cyclic)
|
| 1095 |
+
smiles_list.append(smiles if isinstance(smiles, str) else 'C')
|
| 1096 |
+
smiles_tokens = smiles_tokenizer(
|
| 1097 |
+
smiles_list, return_tensors='pt', padding=True
|
| 1098 |
+
).to(device)
|
| 1099 |
+
detailed_with_props = conjugation_model.get_detailed_scores_with_properties(
|
| 1100 |
+
smiles_tokens["input_ids"]
|
| 1101 |
+
)
|
| 1102 |
+
all_smiles.extend(smiles_list)
|
| 1103 |
+
tracker.log_final(sequence_str, detailed_with_props, smiles_list=smiles_list)
|
| 1104 |
+
else:
|
| 1105 |
+
detailed_with_props = conjugation_model.get_detailed_scores_with_properties(generated_tokens)
|
| 1106 |
+
tracker.log_final(sequence_str, detailed_with_props)
|
| 1107 |
+
all_final_results.extend(detailed_with_props)
|
| 1108 |
+
|
| 1109 |
+
tracker.print_final_summary()
|
| 1110 |
+
tracker.save(filename_prefix=f'cupofmoca_uaa_{args.guidance_method}')
|
| 1111 |
+
|
| 1112 |
+
with open(args.output_file, 'w') as f:
|
| 1113 |
+
for i, seq in enumerate(all_sequences):
|
| 1114 |
+
if args.tokenizer_type == 'amino':
|
| 1115 |
+
smiles = all_smiles[i] if i < len(all_smiles) else ''
|
| 1116 |
+
f.write(f"{seq}\t{smiles}\n")
|
| 1117 |
+
else:
|
| 1118 |
+
f.write(seq + '\n')
|
| 1119 |
+
print(f"\nSequences saved to: {args.output_file}")
|
| 1120 |
+
print("\nGeneration complete.")
|
| 1121 |
+
|
| 1122 |
+
|
| 1123 |
+
if __name__ == "__main__":
|
| 1124 |
+
parser = argparse.ArgumentParser(
|
| 1125 |
+
description="CupOfMoCA-style Guided Generation with UAA Foundation Model"
|
| 1126 |
+
)
|
| 1127 |
+
|
| 1128 |
+
parser.add_argument("--checkpoint", type=str, required=True)
|
| 1129 |
+
parser.add_argument("--target", type=str, required=True)
|
| 1130 |
+
|
| 1131 |
+
parser.add_argument("--num_samples", type=int, default=10)
|
| 1132 |
+
parser.add_argument("--num_batches", type=int, default=10)
|
| 1133 |
+
parser.add_argument("--gen_len", type=int, default=50)
|
| 1134 |
+
parser.add_argument("--optimization_steps", type=int, default=128)
|
| 1135 |
+
|
| 1136 |
+
parser.add_argument("--guidance_method", type=str, default="exp_tilt",
|
| 1137 |
+
choices=["exp_tilt", "cg", "bok", "hybrid"])
|
| 1138 |
+
|
| 1139 |
+
parser.add_argument("--beta_min", type=float, default=1.0)
|
| 1140 |
+
parser.add_argument("--beta_max", type=float, default=20.0)
|
| 1141 |
+
parser.add_argument("--cg_weight", type=float, default=2.0)
|
| 1142 |
+
parser.add_argument("--use_acceptance", action="store_true")
|
| 1143 |
+
parser.add_argument("--bok_k", type=int, default=8)
|
| 1144 |
+
parser.add_argument("--top_k", type=int, default=200)
|
| 1145 |
+
parser.add_argument("--top_p", type=float, default=0.9)
|
| 1146 |
+
parser.add_argument("--score_temperature", type=float, default=1.0)
|
| 1147 |
+
parser.add_argument("--score_aggregation", type=str, default="weighted_sum",
|
| 1148 |
+
choices=["weighted_sum", "weighted_min"])
|
| 1149 |
+
|
| 1150 |
+
parser.add_argument("--weights", type=float, nargs='+', default=None)
|
| 1151 |
+
|
| 1152 |
+
parser.add_argument("--position_strategy", type=str, default="random",
|
| 1153 |
+
choices=["random", "sequential"])
|
| 1154 |
+
|
| 1155 |
+
parser.add_argument("--require_valid_x0", action="store_true")
|
| 1156 |
+
parser.add_argument("--max_x0_retries", type=int, default=10)
|
| 1157 |
+
|
| 1158 |
+
parser.add_argument("--builder_type", type=str, default="AoaDau",
|
| 1159 |
+
choices=["AoaDau", "ValAlaDau", "ValCitDau", "GlutarylDau", "Dau",
|
| 1160 |
+
"SSCpt", "VcMMAE", "DSPACpt", "MalHexCpt", "DSECCpt", "Cpt",
|
| 1161 |
+
"DSPASN38", "DSEBSN38", "DSEPSN38", "SN38"])
|
| 1162 |
+
|
| 1163 |
+
parser.add_argument("--tokenizer_type", type=str, default="amino",
|
| 1164 |
+
choices=["smiles", "amino"])
|
| 1165 |
+
parser.add_argument("--cyclic", action="store_true",
|
| 1166 |
+
help="Generate cyclic peptides (only for amino tokenizer).")
|
| 1167 |
+
parser.add_argument("--smiles_vocab_path", type=str,
|
| 1168 |
+
default="./smiles/smiles_tokenizer/new_vocab.txt")
|
| 1169 |
+
parser.add_argument("--smiles_splits_path", type=str,
|
| 1170 |
+
default="./smiles/smiles_tokenizer/new_splits.txt")
|
| 1171 |
+
|
| 1172 |
+
parser.add_argument("--output_file", type=str, default="./smiles.txt")
|
| 1173 |
+
parser.add_argument("--output_dir", type=str, default="./generation_logs")
|
| 1174 |
+
|
| 1175 |
+
parser.add_argument("--seed", type=int, default=None)
|
| 1176 |
+
|
| 1177 |
+
parser.add_argument("--profile", action="store_true",
|
| 1178 |
+
help="Enable per-section wall-clock profiling.")
|
| 1179 |
+
parser.add_argument("--profile_every", type=int, default=0,
|
| 1180 |
+
help="Print profiler summary every N optimization steps "
|
| 1181 |
+
"(0 = only at end of each generate()).")
|
| 1182 |
+
|
| 1183 |
+
parser.add_argument("--cascade_k", type=int, default=0,
|
| 1184 |
+
help="Cascade scoring: score all proposals with cheap "
|
| 1185 |
+
"Conjugation pre-filter + BindingAffinity, then run "
|
| 1186 |
+
"the expensive PDC validation only on the top-K by "
|
| 1187 |
+
"affinity (among cheap-filter survivors). 0 disables "
|
| 1188 |
+
"(full scoring on all proposals, original behavior).")
|
| 1189 |
+
parser.add_argument("--cascade_workers", type=int, default=1,
|
| 1190 |
+
help="Number of worker threads for parallelizing "
|
| 1191 |
+
"cascade.full_validate (CPU-bound RDKit work). "
|
| 1192 |
+
"1 = sequential (original behavior). Try 4-8 when "
|
| 1193 |
+
"--cascade_k is high. Ignored if --cascade_k=0.")
|
| 1194 |
+
|
| 1195 |
+
args = parser.parse_args()
|
| 1196 |
+
main(args)
|
smiles/generation/cupofmoca_staged.py
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CupOfMoCA-style Staged Generation with Exponential Tilting
|
| 3 |
+
|
| 4 |
+
This implements Algorithm 2 (Sampling with CupOfMoCA) with exponential tilting for staged generation.
|
| 5 |
+
Unlike the guided version, this uses:
|
| 6 |
+
- Peptide analyzer (not conjugation) as validity checker
|
| 7 |
+
- No PDC building during generation
|
| 8 |
+
- Simpler objectives focused on peptide validity and binding affinity
|
| 9 |
+
|
| 10 |
+
Exponential Tilting: p'(z') ∝ p(z') · exp(β · S(z')) · 1_valid(z')
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import argparse
|
| 14 |
+
import os
|
| 15 |
+
import json
|
| 16 |
+
import csv
|
| 17 |
+
import random
|
| 18 |
+
from collections import Counter
|
| 19 |
+
from datetime import datetime
|
| 20 |
+
from typing import List, Optional, Tuple, Dict, Any
|
| 21 |
+
|
| 22 |
+
import numpy as np
|
| 23 |
+
import torch
|
| 24 |
+
import torch.nn as nn
|
| 25 |
+
import torch.nn.functional as F
|
| 26 |
+
from tqdm import tqdm
|
| 27 |
+
|
| 28 |
+
# Import from the original codebase
|
| 29 |
+
from smiles.models.train import MDLMLightningModule, PeptideAnalyzer
|
| 30 |
+
from smiles.smiles_tokenizer.my_tokenizers import SMILES_SPE_Tokenizer
|
| 31 |
+
from smiles.scoring.classifiers import Analyzer, BindingAffinity
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class GenerationTracker:
|
| 35 |
+
"""Tracks detailed validity information during generation"""
|
| 36 |
+
def __init__(self, output_dir='./generation_logs'):
|
| 37 |
+
self.output_dir = output_dir
|
| 38 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 39 |
+
|
| 40 |
+
self.run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 41 |
+
self.step_logs = []
|
| 42 |
+
self.final_results = []
|
| 43 |
+
|
| 44 |
+
def log_step(self, step: int, scores, improved_indices=None, guidance_info: Optional[Dict] = None):
|
| 45 |
+
"""Log information for a generation step"""
|
| 46 |
+
step_info = {
|
| 47 |
+
'step': step,
|
| 48 |
+
'scores': scores.cpu().numpy().tolist() if isinstance(scores, torch.Tensor) else scores,
|
| 49 |
+
'num_valid': int(sum(1 for s in scores if s > 0)) if hasattr(scores, '__iter__') else 0,
|
| 50 |
+
'improved_indices': improved_indices or [],
|
| 51 |
+
'guidance_info': guidance_info or {}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
self.step_logs.append(step_info)
|
| 55 |
+
|
| 56 |
+
def log_final(self, sequences, scores):
|
| 57 |
+
"""Log final results"""
|
| 58 |
+
for i, (seq, score_vec) in enumerate(zip(sequences, scores.T)):
|
| 59 |
+
final_info = {
|
| 60 |
+
'sample_idx': i,
|
| 61 |
+
'peptide_smiles': seq,
|
| 62 |
+
'is_valid_peptide': bool(score_vec[0] > 0),
|
| 63 |
+
'binding_score': float(score_vec[1]) if len(score_vec) > 1 else None,
|
| 64 |
+
}
|
| 65 |
+
self.final_results.append(final_info)
|
| 66 |
+
|
| 67 |
+
def save(self, filename_prefix='generation'):
|
| 68 |
+
"""Save all logs to files"""
|
| 69 |
+
step_log_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_steps.json")
|
| 70 |
+
with open(step_log_path, 'w') as f:
|
| 71 |
+
json.dump(self.step_logs, f, indent=2, default=str)
|
| 72 |
+
|
| 73 |
+
final_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_final.json")
|
| 74 |
+
with open(final_path, 'w') as f:
|
| 75 |
+
json.dump(self.final_results, f, indent=2, default=str)
|
| 76 |
+
|
| 77 |
+
csv_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_summary.csv")
|
| 78 |
+
with open(csv_path, 'w', newline='') as f:
|
| 79 |
+
writer = csv.writer(f)
|
| 80 |
+
writer.writerow(['sample_idx', 'peptide_smiles', 'is_valid_peptide', 'binding_score'])
|
| 81 |
+
for result in self.final_results:
|
| 82 |
+
writer.writerow([
|
| 83 |
+
result['sample_idx'],
|
| 84 |
+
result['peptide_smiles'],
|
| 85 |
+
result['is_valid_peptide'],
|
| 86 |
+
result.get('binding_score', '')
|
| 87 |
+
])
|
| 88 |
+
|
| 89 |
+
print(f"\n=== Generation logs saved ===")
|
| 90 |
+
print(f" Step logs: {step_log_path}")
|
| 91 |
+
print(f" Final results: {final_path}")
|
| 92 |
+
print(f" Summary CSV: {csv_path}")
|
| 93 |
+
|
| 94 |
+
return step_log_path, final_path, csv_path
|
| 95 |
+
|
| 96 |
+
def print_step_summary(self, step, scores):
|
| 97 |
+
"""Print a summary for the current step"""
|
| 98 |
+
if isinstance(scores, torch.Tensor):
|
| 99 |
+
scores_list = scores.cpu().numpy().tolist()
|
| 100 |
+
else:
|
| 101 |
+
scores_list = list(scores)
|
| 102 |
+
|
| 103 |
+
num_valid = sum(1 for s in scores_list if s > 0)
|
| 104 |
+
|
| 105 |
+
print(f"\n--- Step {step} Summary ---")
|
| 106 |
+
print(f" Valid peptides: {num_valid}/{len(scores_list)}")
|
| 107 |
+
|
| 108 |
+
def print_final_summary(self):
|
| 109 |
+
"""Print final summary"""
|
| 110 |
+
print("\n" + "=" * 60)
|
| 111 |
+
print("FINAL GENERATION SUMMARY")
|
| 112 |
+
print("=" * 60)
|
| 113 |
+
|
| 114 |
+
total = len(self.final_results)
|
| 115 |
+
valid_peptides = sum(1 for r in self.final_results if r.get('is_valid_peptide', False))
|
| 116 |
+
|
| 117 |
+
print(f"\nTotal sequences: {total}")
|
| 118 |
+
print(f"Valid peptides: {valid_peptides}")
|
| 119 |
+
|
| 120 |
+
if any(r.get('binding_score') is not None for r in self.final_results):
|
| 121 |
+
avg_binding = np.mean([r['binding_score'] for r in self.final_results if r.get('binding_score') is not None])
|
| 122 |
+
print(f"Average binding score: {avg_binding:.3f}")
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
class CupOfMoCAStaged:
|
| 126 |
+
"""
|
| 127 |
+
CupOfMoCA-style staged generator with exponential tilting.
|
| 128 |
+
|
| 129 |
+
Uses peptide analyzer instead of conjugation model.
|
| 130 |
+
Focuses on generating valid peptides with good binding affinity.
|
| 131 |
+
"""
|
| 132 |
+
|
| 133 |
+
def __init__(self, model, device, objectives, args, tracker=None):
|
| 134 |
+
self.model = model
|
| 135 |
+
self.device = device
|
| 136 |
+
self.objectives = objectives
|
| 137 |
+
self.args = args
|
| 138 |
+
self.num_objectives = len(objectives)
|
| 139 |
+
self.peptide_analyzer = PeptideAnalyzer()
|
| 140 |
+
self.invalid_tokens = [0, 1, 2, 3, 4, 585, 586] # Special tokens to avoid
|
| 141 |
+
self.tracker = tracker
|
| 142 |
+
|
| 143 |
+
# Find the Analyzer objective for validity checking
|
| 144 |
+
self.analyzer_obj = None
|
| 145 |
+
for obj in objectives:
|
| 146 |
+
if isinstance(obj, Analyzer):
|
| 147 |
+
self.analyzer_obj = obj
|
| 148 |
+
break
|
| 149 |
+
|
| 150 |
+
# =========================================================================
|
| 151 |
+
# Initial Generation (x0 from prior)
|
| 152 |
+
# =========================================================================
|
| 153 |
+
|
| 154 |
+
def generate_x0(self, n_steps: int = 16, temperature: float = 1.0) -> torch.Tensor:
|
| 155 |
+
"""
|
| 156 |
+
Generate initial sequences using flow matching.
|
| 157 |
+
This follows the flow from noise to data without guidance.
|
| 158 |
+
"""
|
| 159 |
+
print("Generating initial sequences (x0 from prior)...")
|
| 160 |
+
self.model.eval()
|
| 161 |
+
|
| 162 |
+
# Start with random tokens (noise at t=0)
|
| 163 |
+
x = torch.randint(
|
| 164 |
+
0, self.model.tokenizer.vocab_size,
|
| 165 |
+
(self.args.num_samples, self.args.gen_len),
|
| 166 |
+
device=self.device
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
# Time schedule
|
| 170 |
+
time_steps = torch.linspace(0.0, 1.0, n_steps + 1, device=self.device)
|
| 171 |
+
|
| 172 |
+
with torch.no_grad():
|
| 173 |
+
for i in tqdm(range(n_steps), desc="Initial Flow Matching", leave=False):
|
| 174 |
+
t_curr = time_steps[i]
|
| 175 |
+
t_next = time_steps[i+1]
|
| 176 |
+
|
| 177 |
+
t_tensor = torch.full((self.args.num_samples,), t_curr, device=self.device)
|
| 178 |
+
|
| 179 |
+
# Get model's prediction
|
| 180 |
+
logits = self.model(x, t_tensor)
|
| 181 |
+
logits = logits / temperature
|
| 182 |
+
logits[:, :, 586] = -1000 # Avoid padding token
|
| 183 |
+
|
| 184 |
+
pred_x1 = torch.argmax(logits, dim=-1)
|
| 185 |
+
|
| 186 |
+
if i == n_steps - 1:
|
| 187 |
+
x = pred_x1
|
| 188 |
+
break
|
| 189 |
+
|
| 190 |
+
# Construct next state with noise
|
| 191 |
+
noise_prob = 1.0 - t_next
|
| 192 |
+
mask = torch.rand(x.shape, device=self.device) < noise_prob
|
| 193 |
+
noise = torch.randint(0, self.model.tokenizer.vocab_size, x.shape, device=self.device)
|
| 194 |
+
x = torch.where(mask, noise, pred_x1)
|
| 195 |
+
|
| 196 |
+
generated_sequences = self.model.tokenizer.batch_decode(x)
|
| 197 |
+
|
| 198 |
+
# Analyze the validity of the generated sequences
|
| 199 |
+
validities = []
|
| 200 |
+
for seq in generated_sequences:
|
| 201 |
+
validities.append(self.peptide_analyzer.is_peptide(seq))
|
| 202 |
+
print(seq)
|
| 203 |
+
print(f"Initial Sequence Validity: {validities}")
|
| 204 |
+
|
| 205 |
+
return x
|
| 206 |
+
|
| 207 |
+
# =========================================================================
|
| 208 |
+
# Score Functions
|
| 209 |
+
# =========================================================================
|
| 210 |
+
|
| 211 |
+
def _get_scores(self, x_batch: torch.Tensor) -> torch.Tensor:
|
| 212 |
+
"""Calculate scores for all objectives"""
|
| 213 |
+
scores = []
|
| 214 |
+
for obj_func in self.objectives:
|
| 215 |
+
scores.append(obj_func(x_batch.to(self.device)))
|
| 216 |
+
return torch.stack(scores, dim=0).to(self.device)
|
| 217 |
+
|
| 218 |
+
def _compute_aggregated_score(self, scores: torch.Tensor, weights: torch.Tensor) -> torch.Tensor:
|
| 219 |
+
"""Compute weighted aggregated score for guidance"""
|
| 220 |
+
if self.args.score_aggregation == "weighted_sum":
|
| 221 |
+
return (weights * scores).sum(dim=0)
|
| 222 |
+
elif self.args.score_aggregation == "weighted_min":
|
| 223 |
+
return torch.min(weights * scores, dim=0).values
|
| 224 |
+
else:
|
| 225 |
+
return (weights * scores).sum(dim=0)
|
| 226 |
+
|
| 227 |
+
# =========================================================================
|
| 228 |
+
# Exponential Tilting Guidance
|
| 229 |
+
# =========================================================================
|
| 230 |
+
|
| 231 |
+
def _exponential_tilt_guidance(
|
| 232 |
+
self,
|
| 233 |
+
x: torch.Tensor,
|
| 234 |
+
pos_probs: torch.Tensor,
|
| 235 |
+
candidates: torch.Tensor,
|
| 236 |
+
mut_idx: int,
|
| 237 |
+
sample_idx: int,
|
| 238 |
+
beta: float,
|
| 239 |
+
weights: torch.Tensor,
|
| 240 |
+
current_score: float = None
|
| 241 |
+
) -> Tuple[int, Dict]:
|
| 242 |
+
"""
|
| 243 |
+
Exponential Tilting Guidance with acceptance criterion
|
| 244 |
+
|
| 245 |
+
p'(z') ∝ p(z') · exp(β · S(z')) · 1_valid(z')
|
| 246 |
+
|
| 247 |
+
Only accept if new_score >= current_score (prevents regression)
|
| 248 |
+
"""
|
| 249 |
+
num_candidates = len(candidates)
|
| 250 |
+
current_token = x[sample_idx, mut_idx].item()
|
| 251 |
+
|
| 252 |
+
if num_candidates == 0:
|
| 253 |
+
return current_token, {'method': 'exp_tilt', 'fallback': True, 'accepted': False}
|
| 254 |
+
|
| 255 |
+
# Limit candidates for efficiency
|
| 256 |
+
if num_candidates > self.args.top_k:
|
| 257 |
+
candidates = candidates[:self.args.top_k]
|
| 258 |
+
num_candidates = self.args.top_k
|
| 259 |
+
|
| 260 |
+
# Create batch of proposals
|
| 261 |
+
x_proposals = x[sample_idx].unsqueeze(0).repeat(num_candidates, 1)
|
| 262 |
+
x_proposals[:, mut_idx] = candidates
|
| 263 |
+
|
| 264 |
+
# Compute scores for all candidates
|
| 265 |
+
proposal_scores = self._get_scores(x_proposals)
|
| 266 |
+
aggregated_scores = self._compute_aggregated_score(proposal_scores, weights)
|
| 267 |
+
|
| 268 |
+
# Validity mask (using first objective - peptide analyzer)
|
| 269 |
+
validity_mask = (proposal_scores[0] > 0).float()
|
| 270 |
+
|
| 271 |
+
# Get base probabilities
|
| 272 |
+
base_probs = pos_probs[sample_idx, candidates]
|
| 273 |
+
|
| 274 |
+
# Apply exponential tilting in log-space for numerical stability
|
| 275 |
+
log_base_probs = torch.log(base_probs + 1e-10)
|
| 276 |
+
log_exp_scores = beta * aggregated_scores
|
| 277 |
+
log_validity = torch.log(validity_mask + 1e-10)
|
| 278 |
+
|
| 279 |
+
log_tilted = log_base_probs + log_exp_scores + log_validity
|
| 280 |
+
|
| 281 |
+
# Numerical stability
|
| 282 |
+
log_tilted_max = log_tilted.max()
|
| 283 |
+
fallback = False
|
| 284 |
+
|
| 285 |
+
if torch.isinf(log_tilted_max) or torch.isnan(log_tilted_max):
|
| 286 |
+
# Fallback to base distribution
|
| 287 |
+
fallback = True
|
| 288 |
+
final_probs = base_probs / (base_probs.sum() + 1e-9)
|
| 289 |
+
else:
|
| 290 |
+
log_tilted_stable = log_tilted - log_tilted_max
|
| 291 |
+
tilted_probs = torch.exp(log_tilted_stable)
|
| 292 |
+
|
| 293 |
+
prob_sum = tilted_probs.sum()
|
| 294 |
+
if prob_sum < 1e-10 or torch.isnan(prob_sum) or torch.isinf(prob_sum):
|
| 295 |
+
fallback = True
|
| 296 |
+
final_probs = base_probs / (base_probs.sum() + 1e-9)
|
| 297 |
+
else:
|
| 298 |
+
final_probs = tilted_probs / prob_sum
|
| 299 |
+
|
| 300 |
+
# Safety check
|
| 301 |
+
if torch.any(torch.isnan(final_probs)) or torch.any(torch.isinf(final_probs)):
|
| 302 |
+
fallback = True
|
| 303 |
+
final_probs = base_probs / (base_probs.sum() + 1e-9)
|
| 304 |
+
|
| 305 |
+
# Ensure valid probabilities
|
| 306 |
+
final_probs = torch.clamp(final_probs, min=1e-10)
|
| 307 |
+
final_probs = final_probs / final_probs.sum()
|
| 308 |
+
|
| 309 |
+
# Sample from guided distribution
|
| 310 |
+
selected_idx = torch.multinomial(final_probs, 1).item()
|
| 311 |
+
selected_token = candidates[selected_idx].item()
|
| 312 |
+
selected_score = aggregated_scores[selected_idx].item()
|
| 313 |
+
|
| 314 |
+
# ACCEPTANCE CRITERION: Only accept if score improves or stays same
|
| 315 |
+
accepted = True
|
| 316 |
+
if self.args.use_acceptance and current_score is not None:
|
| 317 |
+
if selected_score < current_score:
|
| 318 |
+
selected_token = current_token
|
| 319 |
+
selected_score = current_score
|
| 320 |
+
accepted = False
|
| 321 |
+
|
| 322 |
+
info = {
|
| 323 |
+
'method': 'exp_tilt',
|
| 324 |
+
'beta': beta,
|
| 325 |
+
'num_candidates': num_candidates,
|
| 326 |
+
'proposal_score': aggregated_scores[selected_idx].item(),
|
| 327 |
+
'selected_score': selected_score,
|
| 328 |
+
'fallback': fallback,
|
| 329 |
+
'validity_rate': validity_mask.mean().item(),
|
| 330 |
+
'accepted': accepted
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
return selected_token, info
|
| 334 |
+
|
| 335 |
+
# =========================================================================
|
| 336 |
+
# Main Generation Loop
|
| 337 |
+
# =========================================================================
|
| 338 |
+
|
| 339 |
+
def generate(self) -> torch.Tensor:
|
| 340 |
+
"""Main generation loop with exponential tilting guidance"""
|
| 341 |
+
# Generate initial x0
|
| 342 |
+
x = self.generate_x0()
|
| 343 |
+
|
| 344 |
+
# Setup weights
|
| 345 |
+
if self.args.weights is None:
|
| 346 |
+
weights = torch.tensor(
|
| 347 |
+
[1.0] + [1.0 / (self.num_objectives - 1)] * (self.num_objectives - 1),
|
| 348 |
+
device=self.device
|
| 349 |
+
).view(-1, 1)
|
| 350 |
+
else:
|
| 351 |
+
weights = torch.tensor(self.args.weights, device=self.device).view(-1, 1)
|
| 352 |
+
if len(weights) != self.num_objectives:
|
| 353 |
+
raise ValueError("Number of weights must match number of objectives.")
|
| 354 |
+
|
| 355 |
+
print(f"\nGuidance Method: Exponential Tilting")
|
| 356 |
+
print(f"Weights: {weights.squeeze().tolist()}")
|
| 357 |
+
print(f"β range: [{self.args.beta_min}, {self.args.beta_max}]")
|
| 358 |
+
print(f"Acceptance criterion: {'ENABLED' if self.args.use_acceptance else 'DISABLED'}")
|
| 359 |
+
|
| 360 |
+
# Main optimization loop
|
| 361 |
+
with torch.no_grad():
|
| 362 |
+
for step in tqdm(range(self.args.optimization_steps), desc="Guided Generation"):
|
| 363 |
+
# Anneal guidance strength
|
| 364 |
+
beta = self.args.beta_min + (self.args.beta_max - self.args.beta_min) * (
|
| 365 |
+
step / max(self.args.optimization_steps - 1, 1)
|
| 366 |
+
)
|
| 367 |
+
|
| 368 |
+
# Select mutation position
|
| 369 |
+
if self.args.position_strategy == "random":
|
| 370 |
+
mut_idx = random.randint(1, self.args.gen_len - 2)
|
| 371 |
+
else: # sequential
|
| 372 |
+
mut_idx = (step % (self.args.gen_len - 2)) + 1
|
| 373 |
+
|
| 374 |
+
# Determine generation timestep
|
| 375 |
+
generation_step = step % self.args.optimization_steps
|
| 376 |
+
time_t = torch.full(
|
| 377 |
+
(self.args.num_samples,),
|
| 378 |
+
(generation_step / self.args.optimization_steps),
|
| 379 |
+
device=self.device
|
| 380 |
+
)
|
| 381 |
+
|
| 382 |
+
# Get proposal distribution from model
|
| 383 |
+
logits = self.model(x, time_t)
|
| 384 |
+
probs = F.softmax(logits, dim=-1)
|
| 385 |
+
pos_probs = probs[:, mut_idx, :]
|
| 386 |
+
|
| 387 |
+
# Zero out current token and invalid tokens
|
| 388 |
+
pos_probs[torch.arange(self.args.num_samples), x[:, mut_idx]] = 0
|
| 389 |
+
for invalid_tok in self.invalid_tokens:
|
| 390 |
+
pos_probs[:, invalid_tok] = 0
|
| 391 |
+
|
| 392 |
+
# Top-p filtering
|
| 393 |
+
sorted_probs, sorted_indices = torch.sort(pos_probs, descending=True)
|
| 394 |
+
cumulative_probs = torch.cumsum(sorted_probs, dim=-1)
|
| 395 |
+
remove_mask = cumulative_probs > self.args.top_p
|
| 396 |
+
remove_mask[..., 1:] = remove_mask[..., :-1].clone()
|
| 397 |
+
remove_mask[..., 0] = 0
|
| 398 |
+
|
| 399 |
+
# Get current scores
|
| 400 |
+
current_scores = self._get_scores(x)
|
| 401 |
+
current_aggregated = self._compute_aggregated_score(current_scores, weights)
|
| 402 |
+
|
| 403 |
+
if step == 0:
|
| 404 |
+
print(f"\nInitial Scores: {current_scores.cpu().numpy()}")
|
| 405 |
+
|
| 406 |
+
# Apply guidance for each sample
|
| 407 |
+
improved_count = 0
|
| 408 |
+
for i in range(self.args.num_samples):
|
| 409 |
+
sample_mask = remove_mask[i]
|
| 410 |
+
candidates = sorted_indices[i, ~sample_mask]
|
| 411 |
+
|
| 412 |
+
# Filter invalid tokens from candidates
|
| 413 |
+
valid_candidate_mask = ~torch.isin(
|
| 414 |
+
candidates,
|
| 415 |
+
torch.tensor(self.invalid_tokens, device=self.device)
|
| 416 |
+
)
|
| 417 |
+
candidates = candidates[valid_candidate_mask]
|
| 418 |
+
|
| 419 |
+
if len(candidates) == 0:
|
| 420 |
+
continue
|
| 421 |
+
|
| 422 |
+
# Apply exponential tilting
|
| 423 |
+
selected_token, guidance_info = self._exponential_tilt_guidance(
|
| 424 |
+
x, pos_probs, candidates, mut_idx, i, beta, weights,
|
| 425 |
+
current_score=current_aggregated[i].item()
|
| 426 |
+
)
|
| 427 |
+
|
| 428 |
+
if guidance_info['accepted']:
|
| 429 |
+
improved_count += 1
|
| 430 |
+
|
| 431 |
+
x[i, mut_idx] = selected_token
|
| 432 |
+
|
| 433 |
+
# Log progress
|
| 434 |
+
if step % 10 == 0 or improved_count > 0:
|
| 435 |
+
new_scores = self._get_scores(x)
|
| 436 |
+
if self.tracker:
|
| 437 |
+
self.tracker.log_step(step, new_scores[0])
|
| 438 |
+
|
| 439 |
+
if step % 20 == 0:
|
| 440 |
+
print(f"\nStep {step}: {improved_count}/{self.args.num_samples} improved")
|
| 441 |
+
print(f" β = {beta:.2f}")
|
| 442 |
+
print(f" Valid peptides: {(new_scores[0] > 0).sum().item()}/{self.args.num_samples}")
|
| 443 |
+
|
| 444 |
+
return x
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
# =============================================================================
|
| 448 |
+
# Main Execution
|
| 449 |
+
# =============================================================================
|
| 450 |
+
|
| 451 |
+
def main(args):
|
| 452 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 453 |
+
print(f"Using device: {device}")
|
| 454 |
+
|
| 455 |
+
tokenizer = SMILES_SPE_Tokenizer(
|
| 456 |
+
'./smiles/smiles_tokenizer/new_vocab.txt',
|
| 457 |
+
'./smiles/smiles_tokenizer/new_splits.txt'
|
| 458 |
+
)
|
| 459 |
+
|
| 460 |
+
# Initialize objectives
|
| 461 |
+
analyzer_model = Analyzer(tokenizer)
|
| 462 |
+
affinity_model = BindingAffinity(args.target, device)
|
| 463 |
+
OBJECTIVE_FUNCTIONS = [analyzer_model, affinity_model]
|
| 464 |
+
|
| 465 |
+
# Initialize tracker
|
| 466 |
+
tracker = GenerationTracker(output_dir=args.output_dir)
|
| 467 |
+
|
| 468 |
+
# Set random seeds
|
| 469 |
+
if args.seed is not None:
|
| 470 |
+
random.seed(args.seed)
|
| 471 |
+
np.random.seed(args.seed)
|
| 472 |
+
torch.manual_seed(args.seed)
|
| 473 |
+
if torch.cuda.is_available():
|
| 474 |
+
torch.cuda.manual_seed_all(args.seed)
|
| 475 |
+
print(f"Random seed: {args.seed}")
|
| 476 |
+
|
| 477 |
+
# Load model
|
| 478 |
+
print(f"Loading model from: {args.checkpoint}")
|
| 479 |
+
checkpoint = torch.load(args.checkpoint, map_location=device, weights_only=False)
|
| 480 |
+
model_hparams = checkpoint["hyper_parameters"]["args"]
|
| 481 |
+
|
| 482 |
+
model = MDLMLightningModule.load_from_checkpoint(
|
| 483 |
+
args.checkpoint,
|
| 484 |
+
args=model_hparams,
|
| 485 |
+
tokenizer=tokenizer,
|
| 486 |
+
map_location=device,
|
| 487 |
+
strict=False
|
| 488 |
+
)
|
| 489 |
+
model.to(device)
|
| 490 |
+
print("Model loaded successfully.")
|
| 491 |
+
|
| 492 |
+
# Create generator
|
| 493 |
+
generator = CupOfMoCAStaged(model, device, OBJECTIVE_FUNCTIONS, args, tracker=tracker)
|
| 494 |
+
|
| 495 |
+
all_sequences = []
|
| 496 |
+
all_scores = []
|
| 497 |
+
|
| 498 |
+
for batch_idx in range(args.num_batches):
|
| 499 |
+
print(f"\n{'=' * 60}")
|
| 500 |
+
print(f"BATCH {batch_idx + 1}/{args.num_batches}")
|
| 501 |
+
print(f"{'=' * 60}")
|
| 502 |
+
|
| 503 |
+
generated_tokens = generator.generate()
|
| 504 |
+
final_scores = generator._get_scores(generated_tokens.detach()).detach().cpu().numpy()
|
| 505 |
+
sequence_str = tokenizer.batch_decode(generated_tokens)
|
| 506 |
+
|
| 507 |
+
print("\n--- Batch Results ---")
|
| 508 |
+
for i, seq in enumerate(sequence_str):
|
| 509 |
+
print(f" {i}: {seq}")
|
| 510 |
+
print(f"Scores: {final_scores}")
|
| 511 |
+
|
| 512 |
+
all_sequences.extend(sequence_str)
|
| 513 |
+
all_scores.append(final_scores)
|
| 514 |
+
|
| 515 |
+
tracker.log_final(sequence_str, final_scores)
|
| 516 |
+
|
| 517 |
+
# Save results
|
| 518 |
+
tracker.print_final_summary()
|
| 519 |
+
tracker.save(filename_prefix='cupofmoca_staged')
|
| 520 |
+
|
| 521 |
+
with open(args.output_file, 'w') as f:
|
| 522 |
+
for seq in all_sequences:
|
| 523 |
+
f.write(seq + '\n')
|
| 524 |
+
print(f"\nSequences saved to: {args.output_file}")
|
| 525 |
+
print("\nGeneration complete.")
|
| 526 |
+
|
| 527 |
+
|
| 528 |
+
if __name__ == "__main__":
|
| 529 |
+
parser = argparse.ArgumentParser(
|
| 530 |
+
description="CupOfMoCA-style Staged Generation with Exponential Tilting"
|
| 531 |
+
)
|
| 532 |
+
|
| 533 |
+
# Model and data
|
| 534 |
+
parser.add_argument("--checkpoint", type=str, required=True,
|
| 535 |
+
help="Path to trained model checkpoint")
|
| 536 |
+
parser.add_argument("--target", type=str, required=True,
|
| 537 |
+
help="Target protein sequence for binding affinity")
|
| 538 |
+
|
| 539 |
+
# Generation settings
|
| 540 |
+
parser.add_argument("--num_samples", type=int, default=10,
|
| 541 |
+
help="Number of samples per batch")
|
| 542 |
+
parser.add_argument("--num_batches", type=int, default=10,
|
| 543 |
+
help="Number of batches to generate")
|
| 544 |
+
parser.add_argument("--gen_len", type=int, default=50,
|
| 545 |
+
help="Length of sequences to generate")
|
| 546 |
+
parser.add_argument("--optimization_steps", type=int, default=128,
|
| 547 |
+
help="Number of guided optimization steps")
|
| 548 |
+
|
| 549 |
+
# Guidance parameters
|
| 550 |
+
parser.add_argument("--beta_min", type=float, default=1.0,
|
| 551 |
+
help="Minimum guidance strength β")
|
| 552 |
+
parser.add_argument("--beta_max", type=float, default=20.0,
|
| 553 |
+
help="Maximum guidance strength β")
|
| 554 |
+
parser.add_argument("--use_acceptance", action="store_true",
|
| 555 |
+
help="Use acceptance criterion: only accept if new_score >= current_score")
|
| 556 |
+
parser.add_argument("--top_k", type=int, default=200,
|
| 557 |
+
help="Top-K candidates to consider for guidance")
|
| 558 |
+
parser.add_argument("--top_p", type=float, default=0.9,
|
| 559 |
+
help="Top-p threshold for candidate filtering")
|
| 560 |
+
parser.add_argument("--score_aggregation", type=str, default="weighted_sum",
|
| 561 |
+
choices=["weighted_sum", "weighted_min"],
|
| 562 |
+
help="How to aggregate multi-objective scores")
|
| 563 |
+
|
| 564 |
+
# Weights
|
| 565 |
+
parser.add_argument("--weights", type=float, nargs='+', default=None,
|
| 566 |
+
help="Weights for objectives (e.g., 1.0 1.0)")
|
| 567 |
+
|
| 568 |
+
# Position selection
|
| 569 |
+
parser.add_argument("--position_strategy", type=str, default="random",
|
| 570 |
+
choices=["random", "sequential"],
|
| 571 |
+
help="Strategy for selecting mutation positions")
|
| 572 |
+
|
| 573 |
+
# Output
|
| 574 |
+
parser.add_argument("--output_file", type=str, default="./smiles.txt",
|
| 575 |
+
help="File to save generated sequences")
|
| 576 |
+
parser.add_argument("--output_dir", type=str, default="./generation_logs",
|
| 577 |
+
help="Directory for detailed logs")
|
| 578 |
+
|
| 579 |
+
# Reproducibility
|
| 580 |
+
parser.add_argument("--seed", type=int, default=None,
|
| 581 |
+
help="Random seed")
|
| 582 |
+
|
| 583 |
+
args = parser.parse_args()
|
| 584 |
+
main(args)
|
smiles/generation/cupofmoca_staged_aa.py
ADDED
|
@@ -0,0 +1,724 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CupOfMoCA-style staged generation with the natural-AA foundation model."""
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import csv
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
import random
|
| 8 |
+
import sys
|
| 9 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
from typing import Dict, Optional, Tuple
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
import torch
|
| 15 |
+
import torch.nn as nn
|
| 16 |
+
import torch.nn.functional as F
|
| 17 |
+
from easydict import EasyDict as edict
|
| 18 |
+
from tqdm import tqdm
|
| 19 |
+
|
| 20 |
+
from smiles.models.train import MDLMLightningModule, PeptideAnalyzer
|
| 21 |
+
from smiles.smiles_tokenizer.my_tokenizers import SMILES_SPE_Tokenizer
|
| 22 |
+
from smiles.scoring.classifiers import Analyzer, BindingAffinity
|
| 23 |
+
|
| 24 |
+
# AA-mode shared utilities
|
| 25 |
+
from smiles.models.aa_helpers import (
|
| 26 |
+
ESM2AminoTokenizer as _AAEsmTokenizer,
|
| 27 |
+
MDLM_PepReDi as _AAMDLM,
|
| 28 |
+
make_affinity,
|
| 29 |
+
DEFAULT_AA_AFFINITY_CKPT,
|
| 30 |
+
INVALID_AA_TOKEN_IDS as _AA_INVALID_IDS,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
from third_party.SMILESToPeptide.peptide_to_smiles import aa_to_smiles
|
| 34 |
+
from third_party.SMILESToPeptide.aminoacid_selective import one_letter_to_three_letter
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# =============================================================================
|
| 38 |
+
# Amino Acid Foundation Model (DiT-based MDLM)
|
| 39 |
+
# =============================================================================
|
| 40 |
+
|
| 41 |
+
# --- AA-mode Model + Tokenizer (imported from aa_helpers) ---
|
| 42 |
+
MDLM = _AAMDLM
|
| 43 |
+
AminoTokenizer = _AAEsmTokenizer
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# =============================================================================
|
| 47 |
+
# Generation Tracker
|
| 48 |
+
# =============================================================================
|
| 49 |
+
|
| 50 |
+
class GenerationTracker:
|
| 51 |
+
def __init__(self, output_dir='./generation_logs'):
|
| 52 |
+
self.output_dir = output_dir
|
| 53 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 54 |
+
self.run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 55 |
+
self.step_logs = []
|
| 56 |
+
self.final_results = []
|
| 57 |
+
|
| 58 |
+
def log_step(self, step, scores, improved_indices=None, guidance_info: Optional[Dict] = None):
|
| 59 |
+
step_info = {
|
| 60 |
+
'step': step,
|
| 61 |
+
'scores': scores.cpu().numpy().tolist() if isinstance(scores, torch.Tensor) else scores,
|
| 62 |
+
'num_valid': int(sum(1 for s in scores if s > 0)) if hasattr(scores, '__iter__') else 0,
|
| 63 |
+
'improved_indices': improved_indices or [],
|
| 64 |
+
'guidance_info': guidance_info or {}
|
| 65 |
+
}
|
| 66 |
+
self.step_logs.append(step_info)
|
| 67 |
+
|
| 68 |
+
def log_final(self, sequences, scores, smiles_list=None):
|
| 69 |
+
for i, (seq, score_vec) in enumerate(zip(sequences, scores.T)):
|
| 70 |
+
self.final_results.append({
|
| 71 |
+
'sample_idx': i,
|
| 72 |
+
'aa_sequence': seq,
|
| 73 |
+
'peptide_smiles': smiles_list[i] if smiles_list else seq,
|
| 74 |
+
'is_valid_peptide': bool(score_vec[0] > 0),
|
| 75 |
+
'binding_score': float(score_vec[1]) if len(score_vec) > 1 else None,
|
| 76 |
+
})
|
| 77 |
+
|
| 78 |
+
def save(self, filename_prefix='generation'):
|
| 79 |
+
step_log_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_steps.json")
|
| 80 |
+
with open(step_log_path, 'w') as f:
|
| 81 |
+
json.dump(self.step_logs, f, indent=2, default=str)
|
| 82 |
+
|
| 83 |
+
final_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_final.json")
|
| 84 |
+
with open(final_path, 'w') as f:
|
| 85 |
+
json.dump(self.final_results, f, indent=2, default=str)
|
| 86 |
+
|
| 87 |
+
csv_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_summary.csv")
|
| 88 |
+
with open(csv_path, 'w', newline='') as f:
|
| 89 |
+
writer = csv.writer(f)
|
| 90 |
+
writer.writerow(['sample_idx', 'aa_sequence', 'peptide_smiles',
|
| 91 |
+
'is_valid_peptide', 'binding_score'])
|
| 92 |
+
for r in self.final_results:
|
| 93 |
+
writer.writerow([
|
| 94 |
+
r['sample_idx'], r.get('aa_sequence', ''), r['peptide_smiles'],
|
| 95 |
+
r['is_valid_peptide'], r.get('binding_score', '')
|
| 96 |
+
])
|
| 97 |
+
|
| 98 |
+
print(f"\n=== Generation logs saved ===")
|
| 99 |
+
print(f" Step logs: {step_log_path}")
|
| 100 |
+
print(f" Final results: {final_path}")
|
| 101 |
+
print(f" Summary CSV: {csv_path}")
|
| 102 |
+
return step_log_path, final_path, csv_path
|
| 103 |
+
|
| 104 |
+
def print_step_summary(self, step, scores):
|
| 105 |
+
scores_list = scores.cpu().numpy().tolist() if isinstance(scores, torch.Tensor) else list(scores)
|
| 106 |
+
num_valid = sum(1 for s in scores_list if s > 0)
|
| 107 |
+
print(f"\n--- Step {step} Summary ---")
|
| 108 |
+
print(f" Valid peptides: {num_valid}/{len(scores_list)}")
|
| 109 |
+
|
| 110 |
+
def print_final_summary(self):
|
| 111 |
+
print("\n" + "=" * 60)
|
| 112 |
+
print("FINAL GENERATION SUMMARY")
|
| 113 |
+
print("=" * 60)
|
| 114 |
+
total = len(self.final_results)
|
| 115 |
+
valid = sum(1 for r in self.final_results if r.get('is_valid_peptide', False))
|
| 116 |
+
print(f"\nTotal sequences: {total}")
|
| 117 |
+
print(f"Valid peptides: {valid}")
|
| 118 |
+
if any(r.get('binding_score') is not None for r in self.final_results):
|
| 119 |
+
avg = np.mean([r['binding_score'] for r in self.final_results
|
| 120 |
+
if r.get('binding_score') is not None])
|
| 121 |
+
print(f"Average binding score: {avg:.3f}")
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
# =============================================================================
|
| 125 |
+
# CupOfMoCA Staged Generator (UAA-aware)
|
| 126 |
+
# =============================================================================
|
| 127 |
+
|
| 128 |
+
class CupOfMoCAStagedUAA:
|
| 129 |
+
"""Exponential-tilting staged generator that operates in AA token space."""
|
| 130 |
+
|
| 131 |
+
def __init__(self, model, device, objectives, args, tokenizer, tokenizer_type='amino',
|
| 132 |
+
tracker=None):
|
| 133 |
+
self.model = model
|
| 134 |
+
self.device = device
|
| 135 |
+
self.objectives = objectives
|
| 136 |
+
self.args = args
|
| 137 |
+
self.num_objectives = len(objectives)
|
| 138 |
+
self.peptide_analyzer = PeptideAnalyzer()
|
| 139 |
+
self.tracker = tracker
|
| 140 |
+
self.tokenizer = tokenizer
|
| 141 |
+
self.tokenizer_type = tokenizer_type
|
| 142 |
+
|
| 143 |
+
if tokenizer_type == 'amino':
|
| 144 |
+
# PepReDi natural-AA vocab is 24 tokens; 0-3 = cls/pad/eos/unk,
|
| 145 |
+
# 4-23 = canonical AAs. Block specials only. (UAA used id 38
|
| 146 |
+
# for D-AA 'g'; that's out of PepReDi vocab range.)
|
| 147 |
+
self.invalid_tokens = [0, 1, 2, 3]
|
| 148 |
+
self.smiles_tokenizer = SMILES_SPE_Tokenizer(
|
| 149 |
+
args.smiles_vocab_path, args.smiles_splits_path
|
| 150 |
+
)
|
| 151 |
+
else:
|
| 152 |
+
self.invalid_tokens = [0, 1, 2, 3, 4, 585, 586]
|
| 153 |
+
self.smiles_tokenizer = None
|
| 154 |
+
|
| 155 |
+
self.analyzer_obj = None
|
| 156 |
+
for obj in objectives:
|
| 157 |
+
if isinstance(obj, Analyzer):
|
| 158 |
+
self.analyzer_obj = obj
|
| 159 |
+
break
|
| 160 |
+
|
| 161 |
+
# ------------------------------------------------------------------
|
| 162 |
+
# x0 generation
|
| 163 |
+
# ------------------------------------------------------------------
|
| 164 |
+
def generate_x0(self, n_steps=16, temperature=1.0):
|
| 165 |
+
print("Generating initial sequences (x0 from prior)...")
|
| 166 |
+
self.model.eval()
|
| 167 |
+
|
| 168 |
+
if self.tokenizer_type == 'amino':
|
| 169 |
+
vocab_size = self.model.vocab_size
|
| 170 |
+
valid_tokens = [i for i in range(vocab_size) if i not in self.invalid_tokens]
|
| 171 |
+
valid_tokens = torch.tensor(valid_tokens, device=self.device)
|
| 172 |
+
rand_indices = torch.randint(
|
| 173 |
+
0, len(valid_tokens),
|
| 174 |
+
(self.args.num_samples, self.args.gen_len),
|
| 175 |
+
device=self.device
|
| 176 |
+
)
|
| 177 |
+
body = valid_tokens[rand_indices]
|
| 178 |
+
cls_col = torch.full((self.args.num_samples, 1), self.tokenizer.cls_id, device=self.device)
|
| 179 |
+
eos_col = torch.full((self.args.num_samples, 1), self.tokenizer.eos_id, device=self.device)
|
| 180 |
+
x = torch.cat([cls_col, body, eos_col], dim=1)
|
| 181 |
+
else:
|
| 182 |
+
vocab_size = self.model.tokenizer.vocab_size
|
| 183 |
+
x = torch.randint(0, vocab_size,
|
| 184 |
+
(self.args.num_samples, self.args.gen_len),
|
| 185 |
+
device=self.device)
|
| 186 |
+
|
| 187 |
+
time_steps = torch.linspace(0.0, 1.0, n_steps + 1, device=self.device)
|
| 188 |
+
|
| 189 |
+
with torch.no_grad():
|
| 190 |
+
for i in tqdm(range(n_steps), desc="Initial Flow Matching", leave=False):
|
| 191 |
+
t_curr = time_steps[i]
|
| 192 |
+
t_next = time_steps[i + 1]
|
| 193 |
+
t_tensor = torch.full((self.args.num_samples,), t_curr, device=self.device)
|
| 194 |
+
|
| 195 |
+
logits = self.model(x, t_tensor) / temperature
|
| 196 |
+
for inv_tok in self.invalid_tokens:
|
| 197 |
+
if inv_tok < logits.shape[-1]:
|
| 198 |
+
logits[:, :, inv_tok] = -1000
|
| 199 |
+
|
| 200 |
+
pred_x1 = torch.argmax(logits, dim=-1)
|
| 201 |
+
if i == n_steps - 1:
|
| 202 |
+
x = pred_x1
|
| 203 |
+
break
|
| 204 |
+
|
| 205 |
+
noise_prob = 1.0 - t_next
|
| 206 |
+
mask = torch.rand(x.shape, device=self.device) < noise_prob
|
| 207 |
+
noise = torch.randint(0, vocab_size, x.shape, device=self.device)
|
| 208 |
+
x = torch.where(mask, noise, pred_x1)
|
| 209 |
+
|
| 210 |
+
generated_sequences = self.tokenizer.batch_decode(x)
|
| 211 |
+
for seq in generated_sequences:
|
| 212 |
+
print(f" {seq}")
|
| 213 |
+
return x
|
| 214 |
+
|
| 215 |
+
# ------------------------------------------------------------------
|
| 216 |
+
# Scoring
|
| 217 |
+
# ------------------------------------------------------------------
|
| 218 |
+
def _aa_to_smiles_batch(self, x_batch):
|
| 219 |
+
seqs = self.tokenizer.batch_decode(x_batch)
|
| 220 |
+
three_letter_seqs = [one_letter_to_three_letter(s) for s in seqs]
|
| 221 |
+
with ThreadPoolExecutor() as pool:
|
| 222 |
+
smiles_seqs = list(pool.map(
|
| 223 |
+
lambda s: aa_to_smiles(s, cyclic=self.args.cyclic),
|
| 224 |
+
three_letter_seqs
|
| 225 |
+
))
|
| 226 |
+
return seqs, smiles_seqs
|
| 227 |
+
|
| 228 |
+
def _get_scores(self, x_batch):
|
| 229 |
+
scores = []
|
| 230 |
+
|
| 231 |
+
if self.tokenizer_type == 'amino':
|
| 232 |
+
_, smiles_seqs = self._aa_to_smiles_batch(x_batch)
|
| 233 |
+
valid_mask = torch.tensor(
|
| 234 |
+
[1 if isinstance(s, str) else 0 for s in smiles_seqs],
|
| 235 |
+
device=self.device
|
| 236 |
+
)
|
| 237 |
+
for i in torch.where((1 - valid_mask) == 1)[0].tolist():
|
| 238 |
+
smiles_seqs[i] = 'C'
|
| 239 |
+
|
| 240 |
+
smiles_batch = self.smiles_tokenizer(
|
| 241 |
+
smiles_seqs, return_tensors='pt', padding=True
|
| 242 |
+
).to(self.device)
|
| 243 |
+
attention_mask = smiles_batch.get("attention_mask", None)
|
| 244 |
+
|
| 245 |
+
for obj_func in self.objectives:
|
| 246 |
+
if getattr(obj_func, "input_type", None) == "aa":
|
| 247 |
+
# AA-input objective: score raw AA tokens directly.
|
| 248 |
+
score = obj_func(x_batch.to(self.device)).to(self.device)
|
| 249 |
+
scores.append(score)
|
| 250 |
+
else:
|
| 251 |
+
try:
|
| 252 |
+
score = obj_func(
|
| 253 |
+
smiles_batch["input_ids"], attention_mask=attention_mask
|
| 254 |
+
).to(self.device)
|
| 255 |
+
except TypeError:
|
| 256 |
+
score = obj_func(smiles_batch["input_ids"]).to(self.device)
|
| 257 |
+
scores.append(score * valid_mask)
|
| 258 |
+
else:
|
| 259 |
+
for obj_func in self.objectives:
|
| 260 |
+
scores.append(obj_func(x_batch.to(self.device)))
|
| 261 |
+
|
| 262 |
+
return torch.stack(scores, dim=0).to(self.device)
|
| 263 |
+
|
| 264 |
+
def _compute_aggregated_score(self, scores, weights):
|
| 265 |
+
if self.args.score_aggregation == "weighted_min":
|
| 266 |
+
return torch.min(weights * scores, dim=0).values
|
| 267 |
+
return (weights * scores).sum(dim=0)
|
| 268 |
+
|
| 269 |
+
# ------------------------------------------------------------------
|
| 270 |
+
# Exponential tilting
|
| 271 |
+
# ------------------------------------------------------------------
|
| 272 |
+
def _exponential_tilt_guidance(self, x, pos_probs, candidates, mut_idx, sample_idx,
|
| 273 |
+
beta, weights, current_score=None, print_tilted=False,
|
| 274 |
+
step=None):
|
| 275 |
+
num_candidates = len(candidates)
|
| 276 |
+
current_token = x[sample_idx, mut_idx].item()
|
| 277 |
+
|
| 278 |
+
if num_candidates == 0:
|
| 279 |
+
return current_token, {'method': 'exp_tilt', 'fallback': True, 'accepted': False}
|
| 280 |
+
|
| 281 |
+
if num_candidates > self.args.top_k:
|
| 282 |
+
candidates = candidates[:self.args.top_k]
|
| 283 |
+
num_candidates = self.args.top_k
|
| 284 |
+
|
| 285 |
+
x_proposals = x[sample_idx].unsqueeze(0).repeat(num_candidates, 1)
|
| 286 |
+
x_proposals[:, mut_idx] = candidates
|
| 287 |
+
|
| 288 |
+
proposal_scores = self._get_scores(x_proposals)
|
| 289 |
+
aggregated_scores = self._compute_aggregated_score(proposal_scores, weights)
|
| 290 |
+
validity_mask = (proposal_scores[0] > 0).float()
|
| 291 |
+
base_probs = pos_probs[sample_idx, candidates]
|
| 292 |
+
|
| 293 |
+
log_base_probs = torch.log(base_probs + 1e-10)
|
| 294 |
+
log_exp_scores = beta * aggregated_scores
|
| 295 |
+
log_validity = torch.log(validity_mask + 1e-10)
|
| 296 |
+
log_tilted = log_base_probs + log_exp_scores + log_validity
|
| 297 |
+
|
| 298 |
+
log_tilted_max = log_tilted.max()
|
| 299 |
+
fallback = False
|
| 300 |
+
if torch.isinf(log_tilted_max) or torch.isnan(log_tilted_max):
|
| 301 |
+
fallback = True
|
| 302 |
+
final_probs = base_probs / (base_probs.sum() + 1e-9)
|
| 303 |
+
else:
|
| 304 |
+
log_tilted_stable = log_tilted - log_tilted_max
|
| 305 |
+
tilted_probs = torch.exp(log_tilted_stable)
|
| 306 |
+
prob_sum = tilted_probs.sum()
|
| 307 |
+
if prob_sum < 1e-10 or torch.isnan(prob_sum) or torch.isinf(prob_sum):
|
| 308 |
+
fallback = True
|
| 309 |
+
final_probs = base_probs / (base_probs.sum() + 1e-9)
|
| 310 |
+
else:
|
| 311 |
+
final_probs = tilted_probs / prob_sum
|
| 312 |
+
|
| 313 |
+
if torch.any(torch.isnan(final_probs)) or torch.any(torch.isinf(final_probs)):
|
| 314 |
+
fallback = True
|
| 315 |
+
final_probs = base_probs / (base_probs.sum() + 1e-9)
|
| 316 |
+
|
| 317 |
+
final_probs = torch.clamp(final_probs, min=1e-10)
|
| 318 |
+
final_probs = final_probs / final_probs.sum()
|
| 319 |
+
|
| 320 |
+
if print_tilted:
|
| 321 |
+
id_to_token = self.tokenizer.id_to_token
|
| 322 |
+
topk = min(self.args.print_logits_topk, num_candidates)
|
| 323 |
+
top_vals, top_local = torch.topk(final_probs, k=topk)
|
| 324 |
+
print(f"\n[tilted] step={step} sample={sample_idx} mut_idx={mut_idx} "
|
| 325 |
+
f"beta={beta:.2f} fallback={fallback}")
|
| 326 |
+
print(f" Top-{topk} candidates by TILTED prob "
|
| 327 |
+
f"(shows: base p, agg score S, β·S, valid, tilted p):")
|
| 328 |
+
for tv, li in zip(top_vals.tolist(), top_local.tolist()):
|
| 329 |
+
tid = candidates[li].item()
|
| 330 |
+
tok = id_to_token.get(int(tid), '?')
|
| 331 |
+
bp = base_probs[li].item()
|
| 332 |
+
sc = aggregated_scores[li].item()
|
| 333 |
+
bs = (beta * aggregated_scores[li]).item()
|
| 334 |
+
vd = validity_mask[li].item()
|
| 335 |
+
print(f" id={tid:>3} tok={tok!r:>6} base_p={bp:.3e} "
|
| 336 |
+
f"S={sc:+.3f} β·S={bs:+.3f} valid={vd:.0f} tilted_p={tv:.3e}")
|
| 337 |
+
d_local = [li for li in range(num_candidates)
|
| 338 |
+
if 33 <= candidates[li].item() <= 52
|
| 339 |
+
and candidates[li].item() not in self.invalid_tokens]
|
| 340 |
+
if d_local:
|
| 341 |
+
print(" D-AA lowercase candidates (if any survived top-p):")
|
| 342 |
+
for li in d_local:
|
| 343 |
+
tid = candidates[li].item()
|
| 344 |
+
tok = id_to_token.get(int(tid), '?')
|
| 345 |
+
print(f" id={tid:>3} tok={tok!r:>4} base_p={base_probs[li]:.3e} "
|
| 346 |
+
f"S={aggregated_scores[li]:+.3f} "
|
| 347 |
+
f"valid={validity_mask[li]:.0f} tilted_p={final_probs[li]:.3e}")
|
| 348 |
+
else:
|
| 349 |
+
print(" D-AA lowercase candidates: NONE made it into the top-p pool")
|
| 350 |
+
|
| 351 |
+
selected_idx = torch.multinomial(final_probs, 1).item()
|
| 352 |
+
selected_token = candidates[selected_idx].item()
|
| 353 |
+
selected_score = aggregated_scores[selected_idx].item()
|
| 354 |
+
|
| 355 |
+
accepted = True
|
| 356 |
+
if self.args.use_acceptance and current_score is not None:
|
| 357 |
+
if selected_score < current_score:
|
| 358 |
+
selected_token = current_token
|
| 359 |
+
selected_score = current_score
|
| 360 |
+
accepted = False
|
| 361 |
+
|
| 362 |
+
return selected_token, {
|
| 363 |
+
'method': 'exp_tilt', 'beta': beta, 'num_candidates': num_candidates,
|
| 364 |
+
'proposal_score': aggregated_scores[selected_idx].item(),
|
| 365 |
+
'selected_score': selected_score, 'fallback': fallback,
|
| 366 |
+
'validity_rate': validity_mask.mean().item(), 'accepted': accepted
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
# ------------------------------------------------------------------
|
| 370 |
+
# Logit inspection
|
| 371 |
+
# ------------------------------------------------------------------
|
| 372 |
+
def _print_logit_distribution(self, raw_probs, masked_probs, mut_idx, step, sample_idx):
|
| 373 |
+
"""Print the model's per-position probability distribution for inspection.
|
| 374 |
+
|
| 375 |
+
raw_probs: [vocab_size] probabilities BEFORE masking current/invalid tokens
|
| 376 |
+
masked_probs: [vocab_size] probabilities AFTER masking
|
| 377 |
+
"""
|
| 378 |
+
topk = self.args.print_logits_topk
|
| 379 |
+
id_to_token = self.tokenizer.id_to_token
|
| 380 |
+
|
| 381 |
+
# Token-category index ranges. Clip to model.vocab_size so we don't
|
| 382 |
+
# index out of bounds when the model uses a smaller vocab than the
|
| 383 |
+
# UAA tokenizer (e.g. PepReDi natural-AA vocab = 24).
|
| 384 |
+
V = int(raw_probs.shape[0])
|
| 385 |
+
cat_ranges = {
|
| 386 |
+
'special' : [i for i in range(0, 4) if i < V],
|
| 387 |
+
'L-AA upper': [i for i in range(4, 24) if i < V],
|
| 388 |
+
'non-canon' : [i for i in range(24, 33) if i < V],
|
| 389 |
+
'D-AA lower': [i for i in range(33, 53) if i < V],
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
print(f"\n[logits] step={step} sample={sample_idx} mut_idx={mut_idx}")
|
| 393 |
+
|
| 394 |
+
# Top-k from raw probs
|
| 395 |
+
top_vals, top_ids = torch.topk(raw_probs, k=min(topk, raw_probs.shape[0]))
|
| 396 |
+
print(f" Top-{topk} (raw model probs, pre-mask):")
|
| 397 |
+
for v, tid in zip(top_vals.tolist(), top_ids.tolist()):
|
| 398 |
+
tok = id_to_token.get(int(tid), '?')
|
| 399 |
+
print(f" id={tid:>3} tok={tok!r:>6} p={v:.4e}")
|
| 400 |
+
|
| 401 |
+
# Per-category mass
|
| 402 |
+
print(" Probability mass by category (raw / masked):")
|
| 403 |
+
for name, ids in cat_ranges.items():
|
| 404 |
+
ids_t = torch.tensor(ids, device=raw_probs.device)
|
| 405 |
+
raw_mass = raw_probs[ids_t].sum().item()
|
| 406 |
+
msk_mass = masked_probs[ids_t].sum().item()
|
| 407 |
+
print(f" {name:>10}: raw={raw_mass:.4e} masked={msk_mass:.4e}")
|
| 408 |
+
|
| 409 |
+
# Specifically: per-token probability for D-AA lowercase (excluding 'g'=38)
|
| 410 |
+
d_ids = [i for i in cat_ranges['D-AA lower'] if i not in self.invalid_tokens]
|
| 411 |
+
d_ids_t = torch.tensor(d_ids, device=raw_probs.device)
|
| 412 |
+
d_vals_raw = raw_probs[d_ids_t]
|
| 413 |
+
d_vals_msk = masked_probs[d_ids_t]
|
| 414 |
+
print(" D-AA lowercase per-token probs (raw / masked):")
|
| 415 |
+
for tid, vr, vm in zip(d_ids, d_vals_raw.tolist(), d_vals_msk.tolist()):
|
| 416 |
+
tok = id_to_token.get(int(tid), '?')
|
| 417 |
+
print(f" id={tid:>3} tok={tok!r:>4} raw={vr:.4e} masked={vm:.4e}")
|
| 418 |
+
|
| 419 |
+
# ------------------------------------------------------------------
|
| 420 |
+
# Main loop
|
| 421 |
+
# ------------------------------------------------------------------
|
| 422 |
+
def generate(self):
|
| 423 |
+
x = self.generate_x0()
|
| 424 |
+
|
| 425 |
+
if self.args.weights is None:
|
| 426 |
+
weights = torch.tensor(
|
| 427 |
+
[1.0] + [1.0 / (self.num_objectives - 1)] * (self.num_objectives - 1),
|
| 428 |
+
device=self.device
|
| 429 |
+
).view(-1, 1)
|
| 430 |
+
else:
|
| 431 |
+
weights = torch.tensor(self.args.weights, device=self.device).view(-1, 1)
|
| 432 |
+
if len(weights) != self.num_objectives:
|
| 433 |
+
raise ValueError("Number of weights must match number of objectives.")
|
| 434 |
+
|
| 435 |
+
print(f"\nGuidance Method: Exponential Tilting")
|
| 436 |
+
print(f"Weights: {weights.squeeze().tolist()}")
|
| 437 |
+
print(f"β range: [{self.args.beta_min}, {self.args.beta_max}]")
|
| 438 |
+
print(f"Acceptance criterion: {'ENABLED' if self.args.use_acceptance else 'DISABLED'}")
|
| 439 |
+
|
| 440 |
+
with torch.no_grad():
|
| 441 |
+
for step in tqdm(range(self.args.optimization_steps), desc="Guided Generation"):
|
| 442 |
+
beta = self.args.beta_min + (self.args.beta_max - self.args.beta_min) * (
|
| 443 |
+
step / max(self.args.optimization_steps - 1, 1)
|
| 444 |
+
)
|
| 445 |
+
|
| 446 |
+
if self.args.position_strategy == "random":
|
| 447 |
+
mut_idx = random.randint(1, self.args.gen_len - 2)
|
| 448 |
+
else:
|
| 449 |
+
mut_idx = (step % (self.args.gen_len - 2)) + 1
|
| 450 |
+
|
| 451 |
+
generation_step = step % self.args.optimization_steps
|
| 452 |
+
time_t = torch.full(
|
| 453 |
+
(self.args.num_samples,),
|
| 454 |
+
generation_step / self.args.optimization_steps,
|
| 455 |
+
device=self.device
|
| 456 |
+
)
|
| 457 |
+
|
| 458 |
+
logits = self.model(x, time_t)
|
| 459 |
+
probs = F.softmax(logits, dim=-1)
|
| 460 |
+
pos_probs = probs[:, mut_idx, :]
|
| 461 |
+
raw_pos_probs = pos_probs.clone()
|
| 462 |
+
|
| 463 |
+
pos_probs[torch.arange(self.args.num_samples), x[:, mut_idx]] = 0
|
| 464 |
+
for inv_tok in self.invalid_tokens:
|
| 465 |
+
pos_probs[:, inv_tok] = 0
|
| 466 |
+
|
| 467 |
+
should_print_logits = (
|
| 468 |
+
self.args.print_logits
|
| 469 |
+
and (step % max(self.args.print_logits_every, 1) == 0)
|
| 470 |
+
)
|
| 471 |
+
print_sample_idx = min(self.args.print_logits_sample,
|
| 472 |
+
self.args.num_samples - 1)
|
| 473 |
+
if should_print_logits:
|
| 474 |
+
self._print_logit_distribution(
|
| 475 |
+
raw_pos_probs[print_sample_idx], pos_probs[print_sample_idx],
|
| 476 |
+
mut_idx, step, print_sample_idx
|
| 477 |
+
)
|
| 478 |
+
|
| 479 |
+
sorted_probs, sorted_indices = torch.sort(pos_probs, descending=True)
|
| 480 |
+
cumulative_probs = torch.cumsum(sorted_probs, dim=-1)
|
| 481 |
+
remove_mask = cumulative_probs > self.args.top_p
|
| 482 |
+
remove_mask[..., 1:] = remove_mask[..., :-1].clone()
|
| 483 |
+
remove_mask[..., 0] = 0
|
| 484 |
+
|
| 485 |
+
current_scores = self._get_scores(x)
|
| 486 |
+
current_aggregated = self._compute_aggregated_score(current_scores, weights)
|
| 487 |
+
|
| 488 |
+
if step == 0:
|
| 489 |
+
print(f"\nInitial Scores: {current_scores.cpu().numpy()}")
|
| 490 |
+
|
| 491 |
+
improved = False
|
| 492 |
+
for i in range(self.args.num_samples):
|
| 493 |
+
sample_mask = remove_mask[i]
|
| 494 |
+
candidates = sorted_indices[i, ~sample_mask]
|
| 495 |
+
|
| 496 |
+
valid_candidate_mask = ~torch.isin(
|
| 497 |
+
candidates, torch.tensor(self.invalid_tokens, device=self.device)
|
| 498 |
+
)
|
| 499 |
+
candidates = candidates[valid_candidate_mask]
|
| 500 |
+
|
| 501 |
+
if len(candidates) == 0:
|
| 502 |
+
continue
|
| 503 |
+
|
| 504 |
+
prev_token = x[i, mut_idx].item()
|
| 505 |
+
prev_sample_scores = current_scores[:, i].clone()
|
| 506 |
+
prev_weighted_sum = (weights.squeeze(1) * prev_sample_scores).sum()
|
| 507 |
+
|
| 508 |
+
selected_token, info = self._exponential_tilt_guidance(
|
| 509 |
+
x, pos_probs, candidates, mut_idx, i, beta, weights,
|
| 510 |
+
current_score=current_aggregated[i].item(),
|
| 511 |
+
print_tilted=(should_print_logits and i == print_sample_idx),
|
| 512 |
+
step=step,
|
| 513 |
+
)
|
| 514 |
+
|
| 515 |
+
if selected_token == prev_token:
|
| 516 |
+
continue
|
| 517 |
+
|
| 518 |
+
x[i, mut_idx] = selected_token
|
| 519 |
+
|
| 520 |
+
new_sample_scores = self._get_scores(x[i].unsqueeze(0))[:, 0]
|
| 521 |
+
new_weighted_sum = (weights.squeeze(1) * new_sample_scores).sum()
|
| 522 |
+
|
| 523 |
+
if new_weighted_sum >= prev_weighted_sum:
|
| 524 |
+
improved = True
|
| 525 |
+
print(f"Previous Weighted Sum: {prev_weighted_sum}")
|
| 526 |
+
print(f"Previous Scores: {prev_sample_scores}")
|
| 527 |
+
print(f"New Weighted Sum: {new_weighted_sum}")
|
| 528 |
+
print(f"New Scores: {new_sample_scores}")
|
| 529 |
+
current_scores[:, i] = new_sample_scores
|
| 530 |
+
current_aggregated[i] = self._compute_aggregated_score(
|
| 531 |
+
new_sample_scores.unsqueeze(1), weights
|
| 532 |
+
)[0]
|
| 533 |
+
else:
|
| 534 |
+
x[i, mut_idx] = prev_token
|
| 535 |
+
|
| 536 |
+
if improved:
|
| 537 |
+
print(self.tokenizer.batch_decode(x))
|
| 538 |
+
if self.tracker:
|
| 539 |
+
self.tracker.log_step(
|
| 540 |
+
step, current_scores[0],
|
| 541 |
+
guidance_info={'method': 'exp_tilt', 'beta': beta}
|
| 542 |
+
)
|
| 543 |
+
|
| 544 |
+
return x
|
| 545 |
+
|
| 546 |
+
|
| 547 |
+
# =============================================================================
|
| 548 |
+
# Main
|
| 549 |
+
# =============================================================================
|
| 550 |
+
|
| 551 |
+
def main(args):
|
| 552 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 553 |
+
print(f"Using device: {device}")
|
| 554 |
+
print(f"Tokenizer type: {args.tokenizer_type}")
|
| 555 |
+
|
| 556 |
+
if args.seed is not None:
|
| 557 |
+
random.seed(args.seed)
|
| 558 |
+
np.random.seed(args.seed)
|
| 559 |
+
torch.manual_seed(args.seed)
|
| 560 |
+
if torch.cuda.is_available():
|
| 561 |
+
torch.cuda.manual_seed_all(args.seed)
|
| 562 |
+
print(f"Random seed: {args.seed}")
|
| 563 |
+
|
| 564 |
+
if args.tokenizer_type == 'amino':
|
| 565 |
+
print("Using Amino Acid tokenizer")
|
| 566 |
+
tokenizer = AminoTokenizer()
|
| 567 |
+
smiles_tokenizer = SMILES_SPE_Tokenizer(args.smiles_vocab_path, args.smiles_splits_path)
|
| 568 |
+
else:
|
| 569 |
+
print("Using SMILES tokenizer")
|
| 570 |
+
tokenizer = SMILES_SPE_Tokenizer(args.smiles_vocab_path, args.smiles_splits_path)
|
| 571 |
+
smiles_tokenizer = tokenizer
|
| 572 |
+
|
| 573 |
+
analyzer_model = Analyzer(smiles_tokenizer)
|
| 574 |
+
# Binding affinity: AA-input or SMILES-input depending on --affinity_type.
|
| 575 |
+
affinity_model = make_affinity(
|
| 576 |
+
args.affinity_type,
|
| 577 |
+
target_protein_seq=args.target,
|
| 578 |
+
device=device,
|
| 579 |
+
aa_ckpt_path=args.aa_affinity_ckpt,
|
| 580 |
+
)
|
| 581 |
+
print(f"Affinity scorer: {type(affinity_model).__name__} (input_type={getattr(affinity_model, 'input_type', '?')})")
|
| 582 |
+
OBJECTIVE_FUNCTIONS = [analyzer_model, affinity_model]
|
| 583 |
+
|
| 584 |
+
tracker = GenerationTracker(output_dir=args.output_dir)
|
| 585 |
+
|
| 586 |
+
print(f"Loading model from: {args.checkpoint}")
|
| 587 |
+
checkpoint = torch.load(args.checkpoint, map_location=device, weights_only=False)
|
| 588 |
+
|
| 589 |
+
if args.tokenizer_type == 'amino':
|
| 590 |
+
raw_model_args = checkpoint['args']
|
| 591 |
+
if isinstance(raw_model_args, argparse.Namespace):
|
| 592 |
+
raw_model_args = vars(raw_model_args)
|
| 593 |
+
model_args = edict(raw_model_args)
|
| 594 |
+
model = MDLM(
|
| 595 |
+
vocab_size=model_args.vocab_size,
|
| 596 |
+
seq_len=model_args.seq_len,
|
| 597 |
+
model_dim=model_args.model_dim,
|
| 598 |
+
n_heads=model_args.n_heads,
|
| 599 |
+
n_layers=model_args.n_layers,
|
| 600 |
+
).to(device)
|
| 601 |
+
model.load_state_dict(checkpoint['model_state_dict'])
|
| 602 |
+
print(f"Amino model loaded. Vocab size: {model_args.vocab_size}")
|
| 603 |
+
else:
|
| 604 |
+
model_hparams = checkpoint["hyper_parameters"]["args"]
|
| 605 |
+
model = MDLMLightningModule.load_from_checkpoint(
|
| 606 |
+
args.checkpoint, args=model_hparams, tokenizer=tokenizer,
|
| 607 |
+
map_location=device, strict=False
|
| 608 |
+
)
|
| 609 |
+
model.to(device)
|
| 610 |
+
model.eval()
|
| 611 |
+
print("Model loaded successfully.")
|
| 612 |
+
|
| 613 |
+
generator = CupOfMoCAStagedUAA(
|
| 614 |
+
model, device, OBJECTIVE_FUNCTIONS, args,
|
| 615 |
+
tokenizer=tokenizer, tokenizer_type=args.tokenizer_type, tracker=tracker
|
| 616 |
+
)
|
| 617 |
+
|
| 618 |
+
all_sequences = []
|
| 619 |
+
all_smiles = []
|
| 620 |
+
all_scores = []
|
| 621 |
+
|
| 622 |
+
for batch_idx in range(args.num_batches):
|
| 623 |
+
print(f"\n{'=' * 60}")
|
| 624 |
+
print(f"BATCH {batch_idx + 1}/{args.num_batches}")
|
| 625 |
+
print(f"{'=' * 60}")
|
| 626 |
+
|
| 627 |
+
generated_tokens = generator.generate()
|
| 628 |
+
final_scores = generator._get_scores(generated_tokens.detach()).detach().cpu().numpy()
|
| 629 |
+
sequence_str = tokenizer.batch_decode(generated_tokens)
|
| 630 |
+
|
| 631 |
+
print("\n--- Batch Results ---")
|
| 632 |
+
for i, seq in enumerate(sequence_str):
|
| 633 |
+
print(f" {i}: {seq}")
|
| 634 |
+
print(f"Scores: {final_scores}")
|
| 635 |
+
|
| 636 |
+
all_sequences.extend(sequence_str)
|
| 637 |
+
all_scores.append(final_scores)
|
| 638 |
+
|
| 639 |
+
if args.tokenizer_type == 'amino':
|
| 640 |
+
smiles_list = []
|
| 641 |
+
for seq in sequence_str:
|
| 642 |
+
three_letter = one_letter_to_three_letter(seq)
|
| 643 |
+
smiles = aa_to_smiles(three_letter, cyclic=args.cyclic)
|
| 644 |
+
smiles_list.append(smiles if isinstance(smiles, str) else 'INVALID_SMILES')
|
| 645 |
+
all_smiles.extend(smiles_list)
|
| 646 |
+
tracker.log_final(sequence_str, final_scores, smiles_list=smiles_list)
|
| 647 |
+
else:
|
| 648 |
+
tracker.log_final(sequence_str, final_scores)
|
| 649 |
+
|
| 650 |
+
tracker.print_final_summary()
|
| 651 |
+
tracker.save(filename_prefix='cupofmoca_staged_uaa')
|
| 652 |
+
|
| 653 |
+
with open(args.output_file, 'w') as f:
|
| 654 |
+
for i, seq in enumerate(all_sequences):
|
| 655 |
+
if args.tokenizer_type == 'amino':
|
| 656 |
+
smiles = all_smiles[i] if i < len(all_smiles) else ''
|
| 657 |
+
f.write(f"{seq}\t{smiles}\n")
|
| 658 |
+
else:
|
| 659 |
+
f.write(seq + '\n')
|
| 660 |
+
print(f"\nSequences saved to: {args.output_file}")
|
| 661 |
+
print("\nGeneration complete.")
|
| 662 |
+
|
| 663 |
+
|
| 664 |
+
if __name__ == "__main__":
|
| 665 |
+
parser = argparse.ArgumentParser(
|
| 666 |
+
description="CupOfMoCA-style Staged Generation with UAA Foundation Model"
|
| 667 |
+
)
|
| 668 |
+
|
| 669 |
+
parser.add_argument("--checkpoint", type=str, required=True)
|
| 670 |
+
parser.add_argument("--target", type=str, required=True)
|
| 671 |
+
|
| 672 |
+
parser.add_argument("--num_samples", type=int, default=10)
|
| 673 |
+
parser.add_argument("--num_batches", type=int, default=10)
|
| 674 |
+
parser.add_argument("--gen_len", type=int, default=50)
|
| 675 |
+
parser.add_argument("--optimization_steps", type=int, default=128)
|
| 676 |
+
|
| 677 |
+
parser.add_argument("--beta_min", type=float, default=1.0)
|
| 678 |
+
parser.add_argument("--beta_max", type=float, default=20.0)
|
| 679 |
+
parser.add_argument("--use_acceptance", action="store_true")
|
| 680 |
+
parser.add_argument("--top_k", type=int, default=200)
|
| 681 |
+
parser.add_argument("--top_p", type=float, default=0.9)
|
| 682 |
+
parser.add_argument("--score_aggregation", type=str, default="weighted_sum",
|
| 683 |
+
choices=["weighted_sum", "weighted_min"])
|
| 684 |
+
|
| 685 |
+
parser.add_argument("--weights", type=float, nargs='+', default=None)
|
| 686 |
+
|
| 687 |
+
parser.add_argument("--position_strategy", type=str, default="random",
|
| 688 |
+
choices=["random", "sequential"])
|
| 689 |
+
|
| 690 |
+
parser.add_argument("--tokenizer_type", type=str, default="amino",
|
| 691 |
+
choices=["smiles", "amino"])
|
| 692 |
+
parser.add_argument("--cyclic", action="store_true",
|
| 693 |
+
help="Generate cyclic peptides (only for amino tokenizer).")
|
| 694 |
+
parser.add_argument("--smiles_vocab_path", type=str,
|
| 695 |
+
default="./smiles/smiles_tokenizer/new_vocab.txt")
|
| 696 |
+
parser.add_argument("--smiles_splits_path", type=str,
|
| 697 |
+
default="./smiles/smiles_tokenizer/new_splits.txt")
|
| 698 |
+
|
| 699 |
+
# AA-mode binding-affinity selection (this is the WT-AA / PepReDi script).
|
| 700 |
+
parser.add_argument("--affinity_type", type=str, default="smiles",
|
| 701 |
+
choices=["aa", "smiles"],
|
| 702 |
+
help="Which binding-affinity oracle to use: "
|
| 703 |
+
"'aa' = AA-input UnpooledBindingPredictor, "
|
| 704 |
+
"'smiles' = SMILES-input BindingAffinity.")
|
| 705 |
+
parser.add_argument("--aa_affinity_ckpt", type=str,
|
| 706 |
+
default=DEFAULT_AA_AFFINITY_CKPT,
|
| 707 |
+
help="Path to AA-input affinity predictor checkpoint.")
|
| 708 |
+
|
| 709 |
+
parser.add_argument("--output_file", type=str, default="./smiles.txt")
|
| 710 |
+
parser.add_argument("--output_dir", type=str, default="./generation_logs")
|
| 711 |
+
|
| 712 |
+
parser.add_argument("--seed", type=int, default=None)
|
| 713 |
+
|
| 714 |
+
parser.add_argument("--print_logits", action="store_true",
|
| 715 |
+
help="Print per-step probability distribution at the mutation position.")
|
| 716 |
+
parser.add_argument("--print_logits_every", type=int, default=1,
|
| 717 |
+
help="Print logits every N optimization steps (requires --print_logits).")
|
| 718 |
+
parser.add_argument("--print_logits_topk", type=int, default=20,
|
| 719 |
+
help="Number of top tokens to show per logit printout.")
|
| 720 |
+
parser.add_argument("--print_logits_sample", type=int, default=0,
|
| 721 |
+
help="Which sample index to print logits for (default: 0).")
|
| 722 |
+
|
| 723 |
+
args = parser.parse_args()
|
| 724 |
+
main(args)
|
smiles/generation/cupofmoca_staged_uaa.py
ADDED
|
@@ -0,0 +1,797 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CupOfMoCA-style staged generation with the UAA foundation model."""
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import csv
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
import random
|
| 8 |
+
import sys
|
| 9 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
from typing import Dict, Optional, Tuple
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
import torch
|
| 15 |
+
import torch.nn as nn
|
| 16 |
+
import torch.nn.functional as F
|
| 17 |
+
from easydict import EasyDict as edict
|
| 18 |
+
from tqdm import tqdm
|
| 19 |
+
|
| 20 |
+
from smiles.models.train import MDLMLightningModule, PeptideAnalyzer
|
| 21 |
+
from smiles.smiles_tokenizer.my_tokenizers import SMILES_SPE_Tokenizer
|
| 22 |
+
from smiles.scoring.classifiers import Analyzer, BindingAffinity
|
| 23 |
+
|
| 24 |
+
from third_party.SMILESToPeptide.peptide_to_smiles import aa_to_smiles
|
| 25 |
+
from third_party.SMILESToPeptide.aminoacid_selective import one_letter_to_three_letter
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# =============================================================================
|
| 29 |
+
# Amino Acid Foundation Model (DiT-based MDLM)
|
| 30 |
+
# =============================================================================
|
| 31 |
+
|
| 32 |
+
def modulate(x, shift, scale):
|
| 33 |
+
return x * (1 + scale.unsqueeze(1)) + shift.unsqueeze(1)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class TimestepEmbedder(nn.Module):
|
| 37 |
+
def __init__(self, hidden_size):
|
| 38 |
+
super().__init__()
|
| 39 |
+
self.mlp = nn.Sequential(
|
| 40 |
+
nn.Linear(1, hidden_size, bias=True),
|
| 41 |
+
nn.SiLU(),
|
| 42 |
+
nn.Linear(hidden_size, hidden_size, bias=True),
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
def forward(self, t):
|
| 46 |
+
return self.mlp(t.unsqueeze(-1))
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class DiTBlock(nn.Module):
|
| 50 |
+
def __init__(self, hidden_size, n_heads):
|
| 51 |
+
super().__init__()
|
| 52 |
+
self.norm1 = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
|
| 53 |
+
self.attn = nn.MultiheadAttention(hidden_size, n_heads, batch_first=True)
|
| 54 |
+
self.norm2 = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
|
| 55 |
+
self.mlp = nn.Sequential(
|
| 56 |
+
nn.Linear(hidden_size, 4 * hidden_size),
|
| 57 |
+
nn.GELU(),
|
| 58 |
+
nn.Linear(4 * hidden_size, hidden_size),
|
| 59 |
+
)
|
| 60 |
+
self.adaLN_modulation = nn.Sequential(
|
| 61 |
+
nn.SiLU(),
|
| 62 |
+
nn.Linear(hidden_size, 6 * hidden_size, bias=True),
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
def forward(self, x, c):
|
| 66 |
+
shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = \
|
| 67 |
+
self.adaLN_modulation(c).chunk(6, dim=1)
|
| 68 |
+
x_norm1 = modulate(self.norm1(x), shift_msa, scale_msa)
|
| 69 |
+
attn_output, _ = self.attn(x_norm1, x_norm1, x_norm1)
|
| 70 |
+
x = x + gate_msa.unsqueeze(1) * attn_output
|
| 71 |
+
x_norm2 = modulate(self.norm2(x), shift_mlp, scale_mlp)
|
| 72 |
+
mlp_output = self.mlp(x_norm2)
|
| 73 |
+
x = x + gate_mlp.unsqueeze(1) * mlp_output
|
| 74 |
+
return x
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class MDLM(nn.Module):
|
| 78 |
+
def __init__(self, vocab_size, seq_len, model_dim, n_heads, n_layers):
|
| 79 |
+
super().__init__()
|
| 80 |
+
self.vocab_size = vocab_size
|
| 81 |
+
self.seq_len = seq_len
|
| 82 |
+
self.model_dim = model_dim
|
| 83 |
+
|
| 84 |
+
self.token_embedder = nn.Embedding(vocab_size, model_dim)
|
| 85 |
+
self.pos_embedder = nn.Parameter(torch.randn(1, seq_len, model_dim))
|
| 86 |
+
self.time_embedder = TimestepEmbedder(model_dim)
|
| 87 |
+
|
| 88 |
+
self.transformer_blocks = nn.ModuleList([
|
| 89 |
+
DiTBlock(model_dim, n_heads) for _ in range(n_layers)
|
| 90 |
+
])
|
| 91 |
+
self.final_norm = nn.LayerNorm(model_dim)
|
| 92 |
+
self.lm_head = nn.Linear(model_dim, vocab_size)
|
| 93 |
+
|
| 94 |
+
def forward(self, x, t):
|
| 95 |
+
seq_len = x.shape[1]
|
| 96 |
+
x_embed = self.token_embedder(x) + self.pos_embedder[:, :seq_len, :]
|
| 97 |
+
t_embed = self.time_embedder(t)
|
| 98 |
+
for block in self.transformer_blocks:
|
| 99 |
+
x_embed = block(x_embed, t_embed)
|
| 100 |
+
x_embed = self.final_norm(x_embed)
|
| 101 |
+
return self.lm_head(x_embed)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class AminoTokenizer:
|
| 105 |
+
def __init__(self):
|
| 106 |
+
self.vocab = {
|
| 107 |
+
'<cls>': 0, '<pad>': 1, '<eos>': 2, '<unk>': 3,
|
| 108 |
+
'L': 4, 'A': 5, 'G': 6, 'V': 7, 'S': 8, 'E': 9,
|
| 109 |
+
'R': 10, 'T': 11, 'I': 12, 'D': 13, 'P': 14, 'K': 15,
|
| 110 |
+
'Q': 16, 'N': 17, 'F': 18, 'Y': 19, 'M': 20, 'H': 21,
|
| 111 |
+
'W': 22, 'C': 23,
|
| 112 |
+
'Ŷ': 24, 'Ĉ': 25, 'Ĝ': 26, 'Ė': 27, 'Ƙ': 28,
|
| 113 |
+
'Ṃ': 29, 'Ł': 30, 'Ċ': 31, 'Ṯ': 32,
|
| 114 |
+
'a': 33, 'c': 34, 'd': 35, 'e': 36, 'f': 37,
|
| 115 |
+
'g': 38, 'h': 39, 'i': 40, 'k': 41, 'l': 42,
|
| 116 |
+
'm': 43, 'n': 44, 'p': 45, 'q': 46, 'r': 47,
|
| 117 |
+
's': 48, 't': 49, 'v': 50, 'w': 51, 'y': 52,
|
| 118 |
+
}
|
| 119 |
+
self.id_to_token = {i: t for t, i in self.vocab.items()}
|
| 120 |
+
self.cls_token, self.pad_token = '<cls>', '<pad>'
|
| 121 |
+
self.eos_token, self.unk_token = '<eos>', '<unk>'
|
| 122 |
+
self.cls_id = self.vocab[self.cls_token]
|
| 123 |
+
self.pad_id = self.vocab[self.pad_token]
|
| 124 |
+
self.eos_id = self.vocab[self.eos_token]
|
| 125 |
+
self.unk_id = self.vocab[self.unk_token]
|
| 126 |
+
self.vocab_size = len(self.vocab)
|
| 127 |
+
|
| 128 |
+
def decode(self, ids, skip_special_tokens=True):
|
| 129 |
+
if isinstance(ids, torch.Tensor):
|
| 130 |
+
ids = ids.tolist()
|
| 131 |
+
tokens = []
|
| 132 |
+
for i in ids:
|
| 133 |
+
tok = self.id_to_token.get(int(i), self.unk_token)
|
| 134 |
+
if skip_special_tokens and tok in {self.cls_token, self.eos_token, self.pad_token}:
|
| 135 |
+
continue
|
| 136 |
+
tokens.append(tok)
|
| 137 |
+
return ''.join(tokens)
|
| 138 |
+
|
| 139 |
+
def batch_decode(self, batch_ids, skip_special_tokens=True):
|
| 140 |
+
return [self.decode(ids, skip_special_tokens=skip_special_tokens) for ids in batch_ids]
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# =============================================================================
|
| 144 |
+
# Generation Tracker
|
| 145 |
+
# =============================================================================
|
| 146 |
+
|
| 147 |
+
class GenerationTracker:
|
| 148 |
+
def __init__(self, output_dir='./generation_logs'):
|
| 149 |
+
self.output_dir = output_dir
|
| 150 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 151 |
+
self.run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 152 |
+
self.step_logs = []
|
| 153 |
+
self.final_results = []
|
| 154 |
+
|
| 155 |
+
def log_step(self, step, scores, improved_indices=None, guidance_info: Optional[Dict] = None):
|
| 156 |
+
step_info = {
|
| 157 |
+
'step': step,
|
| 158 |
+
'scores': scores.cpu().numpy().tolist() if isinstance(scores, torch.Tensor) else scores,
|
| 159 |
+
'num_valid': int(sum(1 for s in scores if s > 0)) if hasattr(scores, '__iter__') else 0,
|
| 160 |
+
'improved_indices': improved_indices or [],
|
| 161 |
+
'guidance_info': guidance_info or {}
|
| 162 |
+
}
|
| 163 |
+
self.step_logs.append(step_info)
|
| 164 |
+
|
| 165 |
+
def log_final(self, sequences, scores, smiles_list=None):
|
| 166 |
+
for i, (seq, score_vec) in enumerate(zip(sequences, scores.T)):
|
| 167 |
+
self.final_results.append({
|
| 168 |
+
'sample_idx': i,
|
| 169 |
+
'aa_sequence': seq,
|
| 170 |
+
'peptide_smiles': smiles_list[i] if smiles_list else seq,
|
| 171 |
+
'is_valid_peptide': bool(score_vec[0] > 0),
|
| 172 |
+
'binding_score': float(score_vec[1]) if len(score_vec) > 1 else None,
|
| 173 |
+
})
|
| 174 |
+
|
| 175 |
+
def save(self, filename_prefix='generation'):
|
| 176 |
+
step_log_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_steps.json")
|
| 177 |
+
with open(step_log_path, 'w') as f:
|
| 178 |
+
json.dump(self.step_logs, f, indent=2, default=str)
|
| 179 |
+
|
| 180 |
+
final_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_final.json")
|
| 181 |
+
with open(final_path, 'w') as f:
|
| 182 |
+
json.dump(self.final_results, f, indent=2, default=str)
|
| 183 |
+
|
| 184 |
+
csv_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_summary.csv")
|
| 185 |
+
with open(csv_path, 'w', newline='') as f:
|
| 186 |
+
writer = csv.writer(f)
|
| 187 |
+
writer.writerow(['sample_idx', 'aa_sequence', 'peptide_smiles',
|
| 188 |
+
'is_valid_peptide', 'binding_score'])
|
| 189 |
+
for r in self.final_results:
|
| 190 |
+
writer.writerow([
|
| 191 |
+
r['sample_idx'], r.get('aa_sequence', ''), r['peptide_smiles'],
|
| 192 |
+
r['is_valid_peptide'], r.get('binding_score', '')
|
| 193 |
+
])
|
| 194 |
+
|
| 195 |
+
print(f"\n=== Generation logs saved ===")
|
| 196 |
+
print(f" Step logs: {step_log_path}")
|
| 197 |
+
print(f" Final results: {final_path}")
|
| 198 |
+
print(f" Summary CSV: {csv_path}")
|
| 199 |
+
return step_log_path, final_path, csv_path
|
| 200 |
+
|
| 201 |
+
def print_step_summary(self, step, scores):
|
| 202 |
+
scores_list = scores.cpu().numpy().tolist() if isinstance(scores, torch.Tensor) else list(scores)
|
| 203 |
+
num_valid = sum(1 for s in scores_list if s > 0)
|
| 204 |
+
print(f"\n--- Step {step} Summary ---")
|
| 205 |
+
print(f" Valid peptides: {num_valid}/{len(scores_list)}")
|
| 206 |
+
|
| 207 |
+
def print_final_summary(self):
|
| 208 |
+
print("\n" + "=" * 60)
|
| 209 |
+
print("FINAL GENERATION SUMMARY")
|
| 210 |
+
print("=" * 60)
|
| 211 |
+
total = len(self.final_results)
|
| 212 |
+
valid = sum(1 for r in self.final_results if r.get('is_valid_peptide', False))
|
| 213 |
+
print(f"\nTotal sequences: {total}")
|
| 214 |
+
print(f"Valid peptides: {valid}")
|
| 215 |
+
if any(r.get('binding_score') is not None for r in self.final_results):
|
| 216 |
+
avg = np.mean([r['binding_score'] for r in self.final_results
|
| 217 |
+
if r.get('binding_score') is not None])
|
| 218 |
+
print(f"Average binding score: {avg:.3f}")
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
# =============================================================================
|
| 222 |
+
# CupOfMoCA Staged Generator (UAA-aware)
|
| 223 |
+
# =============================================================================
|
| 224 |
+
|
| 225 |
+
class CupOfMoCAStagedUAA:
|
| 226 |
+
"""Exponential-tilting staged generator that operates in AA token space."""
|
| 227 |
+
|
| 228 |
+
def __init__(self, model, device, objectives, args, tokenizer, tokenizer_type='amino',
|
| 229 |
+
tracker=None):
|
| 230 |
+
self.model = model
|
| 231 |
+
self.device = device
|
| 232 |
+
self.objectives = objectives
|
| 233 |
+
self.args = args
|
| 234 |
+
self.num_objectives = len(objectives)
|
| 235 |
+
self.peptide_analyzer = PeptideAnalyzer()
|
| 236 |
+
self.tracker = tracker
|
| 237 |
+
self.tokenizer = tokenizer
|
| 238 |
+
self.tokenizer_type = tokenizer_type
|
| 239 |
+
|
| 240 |
+
if tokenizer_type == 'amino':
|
| 241 |
+
self.invalid_tokens = [0, 1, 2, 3, 38] # specials + 'g'
|
| 242 |
+
self.smiles_tokenizer = SMILES_SPE_Tokenizer(
|
| 243 |
+
args.smiles_vocab_path, args.smiles_splits_path
|
| 244 |
+
)
|
| 245 |
+
else:
|
| 246 |
+
self.invalid_tokens = [0, 1, 2, 3, 4, 585, 586]
|
| 247 |
+
self.smiles_tokenizer = None
|
| 248 |
+
|
| 249 |
+
self.analyzer_obj = None
|
| 250 |
+
for obj in objectives:
|
| 251 |
+
if isinstance(obj, Analyzer):
|
| 252 |
+
self.analyzer_obj = obj
|
| 253 |
+
break
|
| 254 |
+
|
| 255 |
+
# ------------------------------------------------------------------
|
| 256 |
+
# x0 generation
|
| 257 |
+
# ------------------------------------------------------------------
|
| 258 |
+
def generate_x0(self, n_steps=16, temperature=1.0):
|
| 259 |
+
print("Generating initial sequences (x0 from prior)...")
|
| 260 |
+
self.model.eval()
|
| 261 |
+
|
| 262 |
+
if self.tokenizer_type == 'amino':
|
| 263 |
+
vocab_size = self.model.vocab_size
|
| 264 |
+
valid_tokens = [i for i in range(vocab_size) if i not in self.invalid_tokens]
|
| 265 |
+
valid_tokens = torch.tensor(valid_tokens, device=self.device)
|
| 266 |
+
rand_indices = torch.randint(
|
| 267 |
+
0, len(valid_tokens),
|
| 268 |
+
(self.args.num_samples, self.args.gen_len),
|
| 269 |
+
device=self.device
|
| 270 |
+
)
|
| 271 |
+
body = valid_tokens[rand_indices]
|
| 272 |
+
cls_col = torch.full((self.args.num_samples, 1), self.tokenizer.cls_id, device=self.device)
|
| 273 |
+
eos_col = torch.full((self.args.num_samples, 1), self.tokenizer.eos_id, device=self.device)
|
| 274 |
+
x = torch.cat([cls_col, body, eos_col], dim=1)
|
| 275 |
+
else:
|
| 276 |
+
vocab_size = self.model.tokenizer.vocab_size
|
| 277 |
+
x = torch.randint(0, vocab_size,
|
| 278 |
+
(self.args.num_samples, self.args.gen_len),
|
| 279 |
+
device=self.device)
|
| 280 |
+
|
| 281 |
+
time_steps = torch.linspace(0.0, 1.0, n_steps + 1, device=self.device)
|
| 282 |
+
|
| 283 |
+
with torch.no_grad():
|
| 284 |
+
for i in tqdm(range(n_steps), desc="Initial Flow Matching", leave=False):
|
| 285 |
+
t_curr = time_steps[i]
|
| 286 |
+
t_next = time_steps[i + 1]
|
| 287 |
+
t_tensor = torch.full((self.args.num_samples,), t_curr, device=self.device)
|
| 288 |
+
|
| 289 |
+
logits = self.model(x, t_tensor) / temperature
|
| 290 |
+
for inv_tok in self.invalid_tokens:
|
| 291 |
+
if inv_tok < logits.shape[-1]:
|
| 292 |
+
logits[:, :, inv_tok] = -1000
|
| 293 |
+
|
| 294 |
+
pred_x1 = torch.argmax(logits, dim=-1)
|
| 295 |
+
if i == n_steps - 1:
|
| 296 |
+
x = pred_x1
|
| 297 |
+
break
|
| 298 |
+
|
| 299 |
+
noise_prob = 1.0 - t_next
|
| 300 |
+
mask = torch.rand(x.shape, device=self.device) < noise_prob
|
| 301 |
+
noise = torch.randint(0, vocab_size, x.shape, device=self.device)
|
| 302 |
+
x = torch.where(mask, noise, pred_x1)
|
| 303 |
+
|
| 304 |
+
generated_sequences = self.tokenizer.batch_decode(x)
|
| 305 |
+
for seq in generated_sequences:
|
| 306 |
+
print(f" {seq}")
|
| 307 |
+
return x
|
| 308 |
+
|
| 309 |
+
# ------------------------------------------------------------------
|
| 310 |
+
# Scoring
|
| 311 |
+
# ------------------------------------------------------------------
|
| 312 |
+
def _aa_to_smiles_batch(self, x_batch):
|
| 313 |
+
seqs = self.tokenizer.batch_decode(x_batch)
|
| 314 |
+
three_letter_seqs = [one_letter_to_three_letter(s) for s in seqs]
|
| 315 |
+
with ThreadPoolExecutor() as pool:
|
| 316 |
+
smiles_seqs = list(pool.map(
|
| 317 |
+
lambda s: aa_to_smiles(s, cyclic=self.args.cyclic),
|
| 318 |
+
three_letter_seqs
|
| 319 |
+
))
|
| 320 |
+
return seqs, smiles_seqs
|
| 321 |
+
|
| 322 |
+
def _get_scores(self, x_batch):
|
| 323 |
+
scores = []
|
| 324 |
+
|
| 325 |
+
if self.tokenizer_type == 'amino':
|
| 326 |
+
_, smiles_seqs = self._aa_to_smiles_batch(x_batch)
|
| 327 |
+
valid_mask = torch.tensor(
|
| 328 |
+
[1 if isinstance(s, str) else 0 for s in smiles_seqs],
|
| 329 |
+
device=self.device
|
| 330 |
+
)
|
| 331 |
+
for i in torch.where((1 - valid_mask) == 1)[0].tolist():
|
| 332 |
+
smiles_seqs[i] = 'C'
|
| 333 |
+
|
| 334 |
+
smiles_batch = self.smiles_tokenizer(
|
| 335 |
+
smiles_seqs, return_tensors='pt', padding=True
|
| 336 |
+
).to(self.device)
|
| 337 |
+
attention_mask = smiles_batch.get("attention_mask", None)
|
| 338 |
+
|
| 339 |
+
for obj_func in self.objectives:
|
| 340 |
+
try:
|
| 341 |
+
score = obj_func(
|
| 342 |
+
smiles_batch["input_ids"], attention_mask=attention_mask
|
| 343 |
+
).to(self.device)
|
| 344 |
+
except TypeError:
|
| 345 |
+
score = obj_func(smiles_batch["input_ids"]).to(self.device)
|
| 346 |
+
scores.append(score * valid_mask)
|
| 347 |
+
else:
|
| 348 |
+
for obj_func in self.objectives:
|
| 349 |
+
scores.append(obj_func(x_batch.to(self.device)))
|
| 350 |
+
|
| 351 |
+
return torch.stack(scores, dim=0).to(self.device)
|
| 352 |
+
|
| 353 |
+
def _compute_aggregated_score(self, scores, weights):
|
| 354 |
+
if self.args.score_aggregation == "weighted_min":
|
| 355 |
+
return torch.min(weights * scores, dim=0).values
|
| 356 |
+
return (weights * scores).sum(dim=0)
|
| 357 |
+
|
| 358 |
+
# ------------------------------------------------------------------
|
| 359 |
+
# Exponential tilting
|
| 360 |
+
# ------------------------------------------------------------------
|
| 361 |
+
def _exponential_tilt_guidance(self, x, pos_probs, candidates, mut_idx, sample_idx,
|
| 362 |
+
beta, weights, current_score=None, print_tilted=False,
|
| 363 |
+
step=None):
|
| 364 |
+
num_candidates = len(candidates)
|
| 365 |
+
current_token = x[sample_idx, mut_idx].item()
|
| 366 |
+
|
| 367 |
+
if num_candidates == 0:
|
| 368 |
+
return current_token, {'method': 'exp_tilt', 'fallback': True, 'accepted': False}
|
| 369 |
+
|
| 370 |
+
if num_candidates > self.args.top_k:
|
| 371 |
+
candidates = candidates[:self.args.top_k]
|
| 372 |
+
num_candidates = self.args.top_k
|
| 373 |
+
|
| 374 |
+
x_proposals = x[sample_idx].unsqueeze(0).repeat(num_candidates, 1)
|
| 375 |
+
x_proposals[:, mut_idx] = candidates
|
| 376 |
+
|
| 377 |
+
proposal_scores = self._get_scores(x_proposals)
|
| 378 |
+
aggregated_scores = self._compute_aggregated_score(proposal_scores, weights)
|
| 379 |
+
validity_mask = (proposal_scores[0] > 0).float()
|
| 380 |
+
base_probs = pos_probs[sample_idx, candidates]
|
| 381 |
+
|
| 382 |
+
log_base_probs = torch.log(base_probs + 1e-10)
|
| 383 |
+
log_exp_scores = beta * aggregated_scores
|
| 384 |
+
log_validity = torch.log(validity_mask + 1e-10)
|
| 385 |
+
log_tilted = log_base_probs + log_exp_scores + log_validity
|
| 386 |
+
|
| 387 |
+
log_tilted_max = log_tilted.max()
|
| 388 |
+
fallback = False
|
| 389 |
+
if torch.isinf(log_tilted_max) or torch.isnan(log_tilted_max):
|
| 390 |
+
fallback = True
|
| 391 |
+
final_probs = base_probs / (base_probs.sum() + 1e-9)
|
| 392 |
+
else:
|
| 393 |
+
log_tilted_stable = log_tilted - log_tilted_max
|
| 394 |
+
tilted_probs = torch.exp(log_tilted_stable)
|
| 395 |
+
prob_sum = tilted_probs.sum()
|
| 396 |
+
if prob_sum < 1e-10 or torch.isnan(prob_sum) or torch.isinf(prob_sum):
|
| 397 |
+
fallback = True
|
| 398 |
+
final_probs = base_probs / (base_probs.sum() + 1e-9)
|
| 399 |
+
else:
|
| 400 |
+
final_probs = tilted_probs / prob_sum
|
| 401 |
+
|
| 402 |
+
if torch.any(torch.isnan(final_probs)) or torch.any(torch.isinf(final_probs)):
|
| 403 |
+
fallback = True
|
| 404 |
+
final_probs = base_probs / (base_probs.sum() + 1e-9)
|
| 405 |
+
|
| 406 |
+
final_probs = torch.clamp(final_probs, min=1e-10)
|
| 407 |
+
final_probs = final_probs / final_probs.sum()
|
| 408 |
+
|
| 409 |
+
if print_tilted:
|
| 410 |
+
id_to_token = self.tokenizer.id_to_token
|
| 411 |
+
topk = min(self.args.print_logits_topk, num_candidates)
|
| 412 |
+
top_vals, top_local = torch.topk(final_probs, k=topk)
|
| 413 |
+
print(f"\n[tilted] step={step} sample={sample_idx} mut_idx={mut_idx} "
|
| 414 |
+
f"beta={beta:.2f} fallback={fallback}")
|
| 415 |
+
print(f" Top-{topk} candidates by TILTED prob "
|
| 416 |
+
f"(shows: base p, agg score S, β·S, valid, tilted p):")
|
| 417 |
+
for tv, li in zip(top_vals.tolist(), top_local.tolist()):
|
| 418 |
+
tid = candidates[li].item()
|
| 419 |
+
tok = id_to_token.get(int(tid), '?')
|
| 420 |
+
bp = base_probs[li].item()
|
| 421 |
+
sc = aggregated_scores[li].item()
|
| 422 |
+
bs = (beta * aggregated_scores[li]).item()
|
| 423 |
+
vd = validity_mask[li].item()
|
| 424 |
+
print(f" id={tid:>3} tok={tok!r:>6} base_p={bp:.3e} "
|
| 425 |
+
f"S={sc:+.3f} β·S={bs:+.3f} valid={vd:.0f} tilted_p={tv:.3e}")
|
| 426 |
+
d_local = [li for li in range(num_candidates)
|
| 427 |
+
if 33 <= candidates[li].item() <= 52
|
| 428 |
+
and candidates[li].item() not in self.invalid_tokens]
|
| 429 |
+
if d_local:
|
| 430 |
+
print(" D-AA lowercase candidates (if any survived top-p):")
|
| 431 |
+
for li in d_local:
|
| 432 |
+
tid = candidates[li].item()
|
| 433 |
+
tok = id_to_token.get(int(tid), '?')
|
| 434 |
+
print(f" id={tid:>3} tok={tok!r:>4} base_p={base_probs[li]:.3e} "
|
| 435 |
+
f"S={aggregated_scores[li]:+.3f} "
|
| 436 |
+
f"valid={validity_mask[li]:.0f} tilted_p={final_probs[li]:.3e}")
|
| 437 |
+
else:
|
| 438 |
+
print(" D-AA lowercase candidates: NONE made it into the top-p pool")
|
| 439 |
+
|
| 440 |
+
selected_idx = torch.multinomial(final_probs, 1).item()
|
| 441 |
+
selected_token = candidates[selected_idx].item()
|
| 442 |
+
selected_score = aggregated_scores[selected_idx].item()
|
| 443 |
+
|
| 444 |
+
accepted = True
|
| 445 |
+
if self.args.use_acceptance and current_score is not None:
|
| 446 |
+
if selected_score < current_score:
|
| 447 |
+
selected_token = current_token
|
| 448 |
+
selected_score = current_score
|
| 449 |
+
accepted = False
|
| 450 |
+
|
| 451 |
+
return selected_token, {
|
| 452 |
+
'method': 'exp_tilt', 'beta': beta, 'num_candidates': num_candidates,
|
| 453 |
+
'proposal_score': aggregated_scores[selected_idx].item(),
|
| 454 |
+
'selected_score': selected_score, 'fallback': fallback,
|
| 455 |
+
'validity_rate': validity_mask.mean().item(), 'accepted': accepted
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
# ------------------------------------------------------------------
|
| 459 |
+
# Logit inspection
|
| 460 |
+
# ------------------------------------------------------------------
|
| 461 |
+
def _print_logit_distribution(self, raw_probs, masked_probs, mut_idx, step, sample_idx):
|
| 462 |
+
"""Print the model's per-position probability distribution for inspection.
|
| 463 |
+
|
| 464 |
+
raw_probs: [vocab_size] probabilities BEFORE masking current/invalid tokens
|
| 465 |
+
masked_probs: [vocab_size] probabilities AFTER masking
|
| 466 |
+
"""
|
| 467 |
+
topk = self.args.print_logits_topk
|
| 468 |
+
id_to_token = self.tokenizer.id_to_token
|
| 469 |
+
|
| 470 |
+
# Token-category index ranges (AminoTokenizer):
|
| 471 |
+
# specials : 0-3
|
| 472 |
+
# L-AA upper: 4-23
|
| 473 |
+
# non-canon : 24-32
|
| 474 |
+
# D-AA lower: 33-52 (38='g' is invalid)
|
| 475 |
+
cat_ranges = {
|
| 476 |
+
'special' : list(range(0, 4)),
|
| 477 |
+
'L-AA upper': list(range(4, 24)),
|
| 478 |
+
'non-canon' : list(range(24, 33)),
|
| 479 |
+
'D-AA lower': list(range(33, 53)),
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
print(f"\n[logits] step={step} sample={sample_idx} mut_idx={mut_idx}")
|
| 483 |
+
|
| 484 |
+
# Top-k from raw probs
|
| 485 |
+
top_vals, top_ids = torch.topk(raw_probs, k=min(topk, raw_probs.shape[0]))
|
| 486 |
+
print(f" Top-{topk} (raw model probs, pre-mask):")
|
| 487 |
+
for v, tid in zip(top_vals.tolist(), top_ids.tolist()):
|
| 488 |
+
tok = id_to_token.get(int(tid), '?')
|
| 489 |
+
print(f" id={tid:>3} tok={tok!r:>6} p={v:.4e}")
|
| 490 |
+
|
| 491 |
+
# Per-category mass
|
| 492 |
+
print(" Probability mass by category (raw / masked):")
|
| 493 |
+
for name, ids in cat_ranges.items():
|
| 494 |
+
ids_t = torch.tensor(ids, device=raw_probs.device)
|
| 495 |
+
raw_mass = raw_probs[ids_t].sum().item()
|
| 496 |
+
msk_mass = masked_probs[ids_t].sum().item()
|
| 497 |
+
print(f" {name:>10}: raw={raw_mass:.4e} masked={msk_mass:.4e}")
|
| 498 |
+
|
| 499 |
+
# Specifically: per-token probability for D-AA lowercase (excluding 'g'=38)
|
| 500 |
+
d_ids = [i for i in cat_ranges['D-AA lower'] if i not in self.invalid_tokens]
|
| 501 |
+
d_ids_t = torch.tensor(d_ids, device=raw_probs.device)
|
| 502 |
+
d_vals_raw = raw_probs[d_ids_t]
|
| 503 |
+
d_vals_msk = masked_probs[d_ids_t]
|
| 504 |
+
print(" D-AA lowercase per-token probs (raw / masked):")
|
| 505 |
+
for tid, vr, vm in zip(d_ids, d_vals_raw.tolist(), d_vals_msk.tolist()):
|
| 506 |
+
tok = id_to_token.get(int(tid), '?')
|
| 507 |
+
print(f" id={tid:>3} tok={tok!r:>4} raw={vr:.4e} masked={vm:.4e}")
|
| 508 |
+
|
| 509 |
+
# ------------------------------------------------------------------
|
| 510 |
+
# Main loop
|
| 511 |
+
# ------------------------------------------------------------------
|
| 512 |
+
def generate(self):
|
| 513 |
+
x = self.generate_x0()
|
| 514 |
+
|
| 515 |
+
if self.args.weights is None:
|
| 516 |
+
weights = torch.tensor(
|
| 517 |
+
[1.0] + [1.0 / (self.num_objectives - 1)] * (self.num_objectives - 1),
|
| 518 |
+
device=self.device
|
| 519 |
+
).view(-1, 1)
|
| 520 |
+
else:
|
| 521 |
+
weights = torch.tensor(self.args.weights, device=self.device).view(-1, 1)
|
| 522 |
+
if len(weights) != self.num_objectives:
|
| 523 |
+
raise ValueError("Number of weights must match number of objectives.")
|
| 524 |
+
|
| 525 |
+
print(f"\nGuidance Method: Exponential Tilting")
|
| 526 |
+
print(f"Weights: {weights.squeeze().tolist()}")
|
| 527 |
+
print(f"β range: [{self.args.beta_min}, {self.args.beta_max}]")
|
| 528 |
+
print(f"Acceptance criterion: {'ENABLED' if self.args.use_acceptance else 'DISABLED'}")
|
| 529 |
+
|
| 530 |
+
with torch.no_grad():
|
| 531 |
+
for step in tqdm(range(self.args.optimization_steps), desc="Guided Generation"):
|
| 532 |
+
beta = self.args.beta_min + (self.args.beta_max - self.args.beta_min) * (
|
| 533 |
+
step / max(self.args.optimization_steps - 1, 1)
|
| 534 |
+
)
|
| 535 |
+
|
| 536 |
+
if self.args.position_strategy == "random":
|
| 537 |
+
mut_idx = random.randint(1, self.args.gen_len - 2)
|
| 538 |
+
else:
|
| 539 |
+
mut_idx = (step % (self.args.gen_len - 2)) + 1
|
| 540 |
+
|
| 541 |
+
generation_step = step % self.args.optimization_steps
|
| 542 |
+
time_t = torch.full(
|
| 543 |
+
(self.args.num_samples,),
|
| 544 |
+
generation_step / self.args.optimization_steps,
|
| 545 |
+
device=self.device
|
| 546 |
+
)
|
| 547 |
+
|
| 548 |
+
logits = self.model(x, time_t)
|
| 549 |
+
probs = F.softmax(logits, dim=-1)
|
| 550 |
+
pos_probs = probs[:, mut_idx, :]
|
| 551 |
+
raw_pos_probs = pos_probs.clone()
|
| 552 |
+
|
| 553 |
+
pos_probs[torch.arange(self.args.num_samples), x[:, mut_idx]] = 0
|
| 554 |
+
for inv_tok in self.invalid_tokens:
|
| 555 |
+
pos_probs[:, inv_tok] = 0
|
| 556 |
+
|
| 557 |
+
should_print_logits = (
|
| 558 |
+
self.args.print_logits
|
| 559 |
+
and (step % max(self.args.print_logits_every, 1) == 0)
|
| 560 |
+
)
|
| 561 |
+
print_sample_idx = min(self.args.print_logits_sample,
|
| 562 |
+
self.args.num_samples - 1)
|
| 563 |
+
if should_print_logits:
|
| 564 |
+
self._print_logit_distribution(
|
| 565 |
+
raw_pos_probs[print_sample_idx], pos_probs[print_sample_idx],
|
| 566 |
+
mut_idx, step, print_sample_idx
|
| 567 |
+
)
|
| 568 |
+
|
| 569 |
+
sorted_probs, sorted_indices = torch.sort(pos_probs, descending=True)
|
| 570 |
+
cumulative_probs = torch.cumsum(sorted_probs, dim=-1)
|
| 571 |
+
remove_mask = cumulative_probs > self.args.top_p
|
| 572 |
+
remove_mask[..., 1:] = remove_mask[..., :-1].clone()
|
| 573 |
+
remove_mask[..., 0] = 0
|
| 574 |
+
|
| 575 |
+
current_scores = self._get_scores(x)
|
| 576 |
+
current_aggregated = self._compute_aggregated_score(current_scores, weights)
|
| 577 |
+
|
| 578 |
+
if step == 0:
|
| 579 |
+
print(f"\nInitial Scores: {current_scores.cpu().numpy()}")
|
| 580 |
+
|
| 581 |
+
improved = False
|
| 582 |
+
for i in range(self.args.num_samples):
|
| 583 |
+
sample_mask = remove_mask[i]
|
| 584 |
+
candidates = sorted_indices[i, ~sample_mask]
|
| 585 |
+
|
| 586 |
+
valid_candidate_mask = ~torch.isin(
|
| 587 |
+
candidates, torch.tensor(self.invalid_tokens, device=self.device)
|
| 588 |
+
)
|
| 589 |
+
candidates = candidates[valid_candidate_mask]
|
| 590 |
+
|
| 591 |
+
if len(candidates) == 0:
|
| 592 |
+
continue
|
| 593 |
+
|
| 594 |
+
prev_token = x[i, mut_idx].item()
|
| 595 |
+
prev_sample_scores = current_scores[:, i].clone()
|
| 596 |
+
prev_weighted_sum = (weights.squeeze(1) * prev_sample_scores).sum()
|
| 597 |
+
|
| 598 |
+
selected_token, info = self._exponential_tilt_guidance(
|
| 599 |
+
x, pos_probs, candidates, mut_idx, i, beta, weights,
|
| 600 |
+
current_score=current_aggregated[i].item(),
|
| 601 |
+
print_tilted=(should_print_logits and i == print_sample_idx),
|
| 602 |
+
step=step,
|
| 603 |
+
)
|
| 604 |
+
|
| 605 |
+
if selected_token == prev_token:
|
| 606 |
+
continue
|
| 607 |
+
|
| 608 |
+
x[i, mut_idx] = selected_token
|
| 609 |
+
|
| 610 |
+
new_sample_scores = self._get_scores(x[i].unsqueeze(0))[:, 0]
|
| 611 |
+
new_weighted_sum = (weights.squeeze(1) * new_sample_scores).sum()
|
| 612 |
+
|
| 613 |
+
if new_weighted_sum >= prev_weighted_sum:
|
| 614 |
+
improved = True
|
| 615 |
+
print(f"Previous Weighted Sum: {prev_weighted_sum}")
|
| 616 |
+
print(f"Previous Scores: {prev_sample_scores}")
|
| 617 |
+
print(f"New Weighted Sum: {new_weighted_sum}")
|
| 618 |
+
print(f"New Scores: {new_sample_scores}")
|
| 619 |
+
current_scores[:, i] = new_sample_scores
|
| 620 |
+
current_aggregated[i] = self._compute_aggregated_score(
|
| 621 |
+
new_sample_scores.unsqueeze(1), weights
|
| 622 |
+
)[0]
|
| 623 |
+
else:
|
| 624 |
+
x[i, mut_idx] = prev_token
|
| 625 |
+
|
| 626 |
+
if improved:
|
| 627 |
+
print(self.tokenizer.batch_decode(x))
|
| 628 |
+
if self.tracker:
|
| 629 |
+
self.tracker.log_step(
|
| 630 |
+
step, current_scores[0],
|
| 631 |
+
guidance_info={'method': 'exp_tilt', 'beta': beta}
|
| 632 |
+
)
|
| 633 |
+
|
| 634 |
+
return x
|
| 635 |
+
|
| 636 |
+
|
| 637 |
+
# =============================================================================
|
| 638 |
+
# Main
|
| 639 |
+
# =============================================================================
|
| 640 |
+
|
| 641 |
+
def main(args):
|
| 642 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 643 |
+
print(f"Using device: {device}")
|
| 644 |
+
print(f"Tokenizer type: {args.tokenizer_type}")
|
| 645 |
+
|
| 646 |
+
if args.seed is not None:
|
| 647 |
+
random.seed(args.seed)
|
| 648 |
+
np.random.seed(args.seed)
|
| 649 |
+
torch.manual_seed(args.seed)
|
| 650 |
+
if torch.cuda.is_available():
|
| 651 |
+
torch.cuda.manual_seed_all(args.seed)
|
| 652 |
+
print(f"Random seed: {args.seed}")
|
| 653 |
+
|
| 654 |
+
if args.tokenizer_type == 'amino':
|
| 655 |
+
print("Using Amino Acid tokenizer")
|
| 656 |
+
tokenizer = AminoTokenizer()
|
| 657 |
+
smiles_tokenizer = SMILES_SPE_Tokenizer(args.smiles_vocab_path, args.smiles_splits_path)
|
| 658 |
+
else:
|
| 659 |
+
print("Using SMILES tokenizer")
|
| 660 |
+
tokenizer = SMILES_SPE_Tokenizer(args.smiles_vocab_path, args.smiles_splits_path)
|
| 661 |
+
smiles_tokenizer = tokenizer
|
| 662 |
+
|
| 663 |
+
analyzer_model = Analyzer(smiles_tokenizer)
|
| 664 |
+
affinity_model = BindingAffinity(args.target, device)
|
| 665 |
+
OBJECTIVE_FUNCTIONS = [analyzer_model, affinity_model]
|
| 666 |
+
|
| 667 |
+
tracker = GenerationTracker(output_dir=args.output_dir)
|
| 668 |
+
|
| 669 |
+
print(f"Loading model from: {args.checkpoint}")
|
| 670 |
+
checkpoint = torch.load(args.checkpoint, map_location=device, weights_only=False)
|
| 671 |
+
|
| 672 |
+
if args.tokenizer_type == 'amino':
|
| 673 |
+
raw_model_args = checkpoint['args']
|
| 674 |
+
if isinstance(raw_model_args, argparse.Namespace):
|
| 675 |
+
raw_model_args = vars(raw_model_args)
|
| 676 |
+
model_args = edict(raw_model_args)
|
| 677 |
+
model = MDLM(
|
| 678 |
+
vocab_size=model_args.vocab_size,
|
| 679 |
+
seq_len=model_args.seq_len,
|
| 680 |
+
model_dim=model_args.model_dim,
|
| 681 |
+
n_heads=model_args.n_heads,
|
| 682 |
+
n_layers=model_args.n_layers,
|
| 683 |
+
).to(device)
|
| 684 |
+
model.load_state_dict(checkpoint['model_state_dict'])
|
| 685 |
+
print(f"Amino model loaded. Vocab size: {model_args.vocab_size}")
|
| 686 |
+
else:
|
| 687 |
+
model_hparams = checkpoint["hyper_parameters"]["args"]
|
| 688 |
+
model = MDLMLightningModule.load_from_checkpoint(
|
| 689 |
+
args.checkpoint, args=model_hparams, tokenizer=tokenizer,
|
| 690 |
+
map_location=device, strict=False
|
| 691 |
+
)
|
| 692 |
+
model.to(device)
|
| 693 |
+
model.eval()
|
| 694 |
+
print("Model loaded successfully.")
|
| 695 |
+
|
| 696 |
+
generator = CupOfMoCAStagedUAA(
|
| 697 |
+
model, device, OBJECTIVE_FUNCTIONS, args,
|
| 698 |
+
tokenizer=tokenizer, tokenizer_type=args.tokenizer_type, tracker=tracker
|
| 699 |
+
)
|
| 700 |
+
|
| 701 |
+
all_sequences = []
|
| 702 |
+
all_smiles = []
|
| 703 |
+
all_scores = []
|
| 704 |
+
|
| 705 |
+
for batch_idx in range(args.num_batches):
|
| 706 |
+
print(f"\n{'=' * 60}")
|
| 707 |
+
print(f"BATCH {batch_idx + 1}/{args.num_batches}")
|
| 708 |
+
print(f"{'=' * 60}")
|
| 709 |
+
|
| 710 |
+
generated_tokens = generator.generate()
|
| 711 |
+
final_scores = generator._get_scores(generated_tokens.detach()).detach().cpu().numpy()
|
| 712 |
+
sequence_str = tokenizer.batch_decode(generated_tokens)
|
| 713 |
+
|
| 714 |
+
print("\n--- Batch Results ---")
|
| 715 |
+
for i, seq in enumerate(sequence_str):
|
| 716 |
+
print(f" {i}: {seq}")
|
| 717 |
+
print(f"Scores: {final_scores}")
|
| 718 |
+
|
| 719 |
+
all_sequences.extend(sequence_str)
|
| 720 |
+
all_scores.append(final_scores)
|
| 721 |
+
|
| 722 |
+
if args.tokenizer_type == 'amino':
|
| 723 |
+
smiles_list = []
|
| 724 |
+
for seq in sequence_str:
|
| 725 |
+
three_letter = one_letter_to_three_letter(seq)
|
| 726 |
+
smiles = aa_to_smiles(three_letter, cyclic=args.cyclic)
|
| 727 |
+
smiles_list.append(smiles if isinstance(smiles, str) else 'INVALID_SMILES')
|
| 728 |
+
all_smiles.extend(smiles_list)
|
| 729 |
+
tracker.log_final(sequence_str, final_scores, smiles_list=smiles_list)
|
| 730 |
+
else:
|
| 731 |
+
tracker.log_final(sequence_str, final_scores)
|
| 732 |
+
|
| 733 |
+
tracker.print_final_summary()
|
| 734 |
+
tracker.save(filename_prefix='cupofmoca_staged_uaa')
|
| 735 |
+
|
| 736 |
+
with open(args.output_file, 'w') as f:
|
| 737 |
+
for i, seq in enumerate(all_sequences):
|
| 738 |
+
if args.tokenizer_type == 'amino':
|
| 739 |
+
smiles = all_smiles[i] if i < len(all_smiles) else ''
|
| 740 |
+
f.write(f"{seq}\t{smiles}\n")
|
| 741 |
+
else:
|
| 742 |
+
f.write(seq + '\n')
|
| 743 |
+
print(f"\nSequences saved to: {args.output_file}")
|
| 744 |
+
print("\nGeneration complete.")
|
| 745 |
+
|
| 746 |
+
|
| 747 |
+
if __name__ == "__main__":
|
| 748 |
+
parser = argparse.ArgumentParser(
|
| 749 |
+
description="CupOfMoCA-style Staged Generation with UAA Foundation Model"
|
| 750 |
+
)
|
| 751 |
+
|
| 752 |
+
parser.add_argument("--checkpoint", type=str, required=True)
|
| 753 |
+
parser.add_argument("--target", type=str, required=True)
|
| 754 |
+
|
| 755 |
+
parser.add_argument("--num_samples", type=int, default=10)
|
| 756 |
+
parser.add_argument("--num_batches", type=int, default=10)
|
| 757 |
+
parser.add_argument("--gen_len", type=int, default=50)
|
| 758 |
+
parser.add_argument("--optimization_steps", type=int, default=128)
|
| 759 |
+
|
| 760 |
+
parser.add_argument("--beta_min", type=float, default=1.0)
|
| 761 |
+
parser.add_argument("--beta_max", type=float, default=20.0)
|
| 762 |
+
parser.add_argument("--use_acceptance", action="store_true")
|
| 763 |
+
parser.add_argument("--top_k", type=int, default=200)
|
| 764 |
+
parser.add_argument("--top_p", type=float, default=0.9)
|
| 765 |
+
parser.add_argument("--score_aggregation", type=str, default="weighted_sum",
|
| 766 |
+
choices=["weighted_sum", "weighted_min"])
|
| 767 |
+
|
| 768 |
+
parser.add_argument("--weights", type=float, nargs='+', default=None)
|
| 769 |
+
|
| 770 |
+
parser.add_argument("--position_strategy", type=str, default="random",
|
| 771 |
+
choices=["random", "sequential"])
|
| 772 |
+
|
| 773 |
+
parser.add_argument("--tokenizer_type", type=str, default="amino",
|
| 774 |
+
choices=["smiles", "amino"])
|
| 775 |
+
parser.add_argument("--cyclic", action="store_true",
|
| 776 |
+
help="Generate cyclic peptides (only for amino tokenizer).")
|
| 777 |
+
parser.add_argument("--smiles_vocab_path", type=str,
|
| 778 |
+
default="./smiles/smiles_tokenizer/new_vocab.txt")
|
| 779 |
+
parser.add_argument("--smiles_splits_path", type=str,
|
| 780 |
+
default="./smiles/smiles_tokenizer/new_splits.txt")
|
| 781 |
+
|
| 782 |
+
parser.add_argument("--output_file", type=str, default="./smiles.txt")
|
| 783 |
+
parser.add_argument("--output_dir", type=str, default="./generation_logs")
|
| 784 |
+
|
| 785 |
+
parser.add_argument("--seed", type=int, default=None)
|
| 786 |
+
|
| 787 |
+
parser.add_argument("--print_logits", action="store_true",
|
| 788 |
+
help="Print per-step probability distribution at the mutation position.")
|
| 789 |
+
parser.add_argument("--print_logits_every", type=int, default=1,
|
| 790 |
+
help="Print logits every N optimization steps (requires --print_logits).")
|
| 791 |
+
parser.add_argument("--print_logits_topk", type=int, default=20,
|
| 792 |
+
help="Number of top tokens to show per logit printout.")
|
| 793 |
+
parser.add_argument("--print_logits_sample", type=int, default=0,
|
| 794 |
+
help="Which sample index to print logits for (default: 0).")
|
| 795 |
+
|
| 796 |
+
args = parser.parse_args()
|
| 797 |
+
main(args)
|
smiles/generation/guided.py
ADDED
|
@@ -0,0 +1,651 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import re
|
| 3 |
+
import random
|
| 4 |
+
from collections import Counter
|
| 5 |
+
import csv
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
import torch
|
| 10 |
+
import torch.nn as nn
|
| 11 |
+
import torch.nn.functional as F
|
| 12 |
+
from tqdm import tqdm
|
| 13 |
+
from smiles.models.train import MDLMLightningModule
|
| 14 |
+
from smiles.smiles_tokenizer.my_tokenizers import SMILES_SPE_Tokenizer
|
| 15 |
+
import numpy as np
|
| 16 |
+
|
| 17 |
+
from smiles.evaluation.peptide_analyzer import PeptideAnalyzer
|
| 18 |
+
from smiles.evaluation.pdc_analyzer import PDCAnalyzer
|
| 19 |
+
from smiles.scoring.classifiers import Conjugation, BindingAffinity
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class GenerationTracker:
|
| 23 |
+
"""
|
| 24 |
+
Tracks detailed validity information during generation
|
| 25 |
+
"""
|
| 26 |
+
def __init__(self, output_dir='./generation_logs'):
|
| 27 |
+
self.output_dir = output_dir
|
| 28 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 29 |
+
|
| 30 |
+
self.run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 31 |
+
self.step_logs = []
|
| 32 |
+
self.final_results = []
|
| 33 |
+
|
| 34 |
+
def log_step(self, step, scores, detailed_results=None, improved_indices=None):
|
| 35 |
+
"""Log information for a generation step"""
|
| 36 |
+
step_info = {
|
| 37 |
+
'step': step,
|
| 38 |
+
'scores': scores.cpu().numpy().tolist() if isinstance(scores, torch.Tensor) else scores,
|
| 39 |
+
'num_valid': int(sum(1 for s in scores if s > 0)) if hasattr(scores, '__iter__') else 0,
|
| 40 |
+
'improved_indices': improved_indices or []
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
if detailed_results:
|
| 44 |
+
step_info['detailed'] = []
|
| 45 |
+
for i, result in enumerate(detailed_results):
|
| 46 |
+
step_info['detailed'].append({
|
| 47 |
+
'sample_idx': i,
|
| 48 |
+
'is_valid_peptide': result.get('is_valid_peptide', False),
|
| 49 |
+
'has_conjugation_site': result.get('has_conjugation_site', False),
|
| 50 |
+
'conjugation_success': result.get('conjugation_success', False),
|
| 51 |
+
'pdc_valid': result.get('pdc_valid', False),
|
| 52 |
+
'num_conjugates': len(result.get('conjugates', [])),
|
| 53 |
+
'error': result.get('error')
|
| 54 |
+
})
|
| 55 |
+
|
| 56 |
+
self.step_logs.append(step_info)
|
| 57 |
+
|
| 58 |
+
def log_final(self, sequences, detailed_results_with_props):
|
| 59 |
+
"""Log final results with properties"""
|
| 60 |
+
for i, (seq, result) in enumerate(zip(sequences, detailed_results_with_props)):
|
| 61 |
+
final_info = {
|
| 62 |
+
'sample_idx': i,
|
| 63 |
+
'peptide_smiles': seq,
|
| 64 |
+
'is_valid_peptide': result.get('is_valid_peptide', False),
|
| 65 |
+
'has_conjugation_site': result.get('has_conjugation_site', False),
|
| 66 |
+
'num_conjugates': len(result.get('conjugates', [])),
|
| 67 |
+
'num_valid_pdcs': len(result.get('valid_pdcs', [])),
|
| 68 |
+
'best_pdc': None,
|
| 69 |
+
'properties': result.get('properties', {})
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
if result.get('best_pdc'):
|
| 73 |
+
final_info['best_pdc'] = {
|
| 74 |
+
'smiles': result['best_pdc'].get('smiles', ''),
|
| 75 |
+
'checks': result['best_pdc'].get('checks', {})
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
self.final_results.append(final_info)
|
| 79 |
+
|
| 80 |
+
def save(self, filename_prefix='generation'):
|
| 81 |
+
"""Save all logs to files"""
|
| 82 |
+
# Save step logs
|
| 83 |
+
step_log_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_steps.json")
|
| 84 |
+
with open(step_log_path, 'w') as f:
|
| 85 |
+
json.dump(self.step_logs, f, indent=2, default=str)
|
| 86 |
+
|
| 87 |
+
# Save final results
|
| 88 |
+
final_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_final.json")
|
| 89 |
+
with open(final_path, 'w') as f:
|
| 90 |
+
json.dump(self.final_results, f, indent=2, default=str)
|
| 91 |
+
|
| 92 |
+
# Save summary CSV
|
| 93 |
+
csv_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_summary.csv")
|
| 94 |
+
with open(csv_path, 'w', newline='') as f:
|
| 95 |
+
writer = csv.writer(f)
|
| 96 |
+
writer.writerow([
|
| 97 |
+
'sample_idx', 'peptide_smiles', 'is_valid_peptide', 'has_conjugation_site',
|
| 98 |
+
'num_conjugates', 'num_valid_pdcs', 'best_pdc_smiles',
|
| 99 |
+
'mf', 'polararea', 'xlogp', 'heavycnt', 'rotbonds'
|
| 100 |
+
])
|
| 101 |
+
for result in self.final_results:
|
| 102 |
+
props = result.get('properties', {})
|
| 103 |
+
writer.writerow([
|
| 104 |
+
result['sample_idx'],
|
| 105 |
+
result['peptide_smiles'],
|
| 106 |
+
result['is_valid_peptide'],
|
| 107 |
+
result['has_conjugation_site'],
|
| 108 |
+
result['num_conjugates'],
|
| 109 |
+
result['num_valid_pdcs'],
|
| 110 |
+
result['best_pdc']['smiles'] if result.get('best_pdc') else '',
|
| 111 |
+
props.get('mf', ''),
|
| 112 |
+
props.get('polararea', ''),
|
| 113 |
+
props.get('xlogp', ''),
|
| 114 |
+
props.get('heavycnt', ''),
|
| 115 |
+
props.get('rotbonds', '')
|
| 116 |
+
])
|
| 117 |
+
|
| 118 |
+
print(f"\n=== Generation logs saved ===")
|
| 119 |
+
print(f" Step logs: {step_log_path}")
|
| 120 |
+
print(f" Final results: {final_path}")
|
| 121 |
+
print(f" Summary CSV: {csv_path}")
|
| 122 |
+
|
| 123 |
+
return step_log_path, final_path, csv_path
|
| 124 |
+
|
| 125 |
+
def print_step_summary(self, step, scores, detailed_results=None):
|
| 126 |
+
"""Print a summary for the current step"""
|
| 127 |
+
if isinstance(scores, torch.Tensor):
|
| 128 |
+
scores_list = scores.cpu().numpy().tolist()
|
| 129 |
+
else:
|
| 130 |
+
scores_list = list(scores)
|
| 131 |
+
|
| 132 |
+
num_valid = sum(1 for s in scores_list if s > 0)
|
| 133 |
+
|
| 134 |
+
print(f"\n--- Step {step} Summary ---")
|
| 135 |
+
print(f" Valid: {num_valid}/{len(scores_list)}")
|
| 136 |
+
|
| 137 |
+
if detailed_results:
|
| 138 |
+
valid_peptide = sum(1 for r in detailed_results if r.get('is_valid_peptide'))
|
| 139 |
+
has_site = sum(1 for r in detailed_results if r.get('has_conjugation_site'))
|
| 140 |
+
conj_success = sum(1 for r in detailed_results if r.get('conjugation_success'))
|
| 141 |
+
pdc_valid = sum(1 for r in detailed_results if r.get('pdc_valid'))
|
| 142 |
+
|
| 143 |
+
print(f" Breakdown:")
|
| 144 |
+
print(f" Valid peptides: {valid_peptide}")
|
| 145 |
+
print(f" Has conjugation site: {has_site}")
|
| 146 |
+
print(f" Conjugation success: {conj_success}")
|
| 147 |
+
print(f" PDC valid: {pdc_valid}")
|
| 148 |
+
|
| 149 |
+
def print_final_summary(self):
|
| 150 |
+
"""Print final summary"""
|
| 151 |
+
print("\n" + "=" * 60)
|
| 152 |
+
print("FINAL GENERATION SUMMARY")
|
| 153 |
+
print("=" * 60)
|
| 154 |
+
|
| 155 |
+
total = len(self.final_results)
|
| 156 |
+
valid_pdcs = sum(1 for r in self.final_results if r.get('num_valid_pdcs', 0) > 0)
|
| 157 |
+
|
| 158 |
+
print(f"\nTotal sequences: {total}")
|
| 159 |
+
print(f"With valid PDCs: {valid_pdcs}")
|
| 160 |
+
|
| 161 |
+
print("\nDetailed results:")
|
| 162 |
+
for result in self.final_results:
|
| 163 |
+
status = "" if result.get('num_valid_pdcs', 0) > 0 else ""
|
| 164 |
+
print(f"\n [{status}] Sample {result['sample_idx']}:")
|
| 165 |
+
print(f" Peptide: {result['peptide_smiles'][:60]}...")
|
| 166 |
+
print(f" Valid peptide: {result['is_valid_peptide']}")
|
| 167 |
+
print(f" Has conjugation site: {result['has_conjugation_site']}")
|
| 168 |
+
print(f" Conjugates: {result['num_conjugates']}")
|
| 169 |
+
print(f" Valid PDCs: {result['num_valid_pdcs']}")
|
| 170 |
+
|
| 171 |
+
if result.get('properties'):
|
| 172 |
+
props = result['properties']
|
| 173 |
+
print(f" Properties:")
|
| 174 |
+
for k, v in props.items():
|
| 175 |
+
print(f" {k}: {v}")
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def peptide_bond_mask(smiles_list):
|
| 179 |
+
# Initialize the batch mask
|
| 180 |
+
batch_size = len(smiles_list)
|
| 181 |
+
max_seq_length = 1035 #max(len(smiles) for smiles in smiles_list) # Find the longest SMILES
|
| 182 |
+
mask = torch.zeros((batch_size, max_seq_length), dtype=torch.int) # Mask filled with zeros
|
| 183 |
+
|
| 184 |
+
bond_patterns = [
|
| 185 |
+
(r'OC\(=O\)', 'ester'),
|
| 186 |
+
(r'N\(C\)C\(=O\)', 'n_methyl'),
|
| 187 |
+
(r'N[12]C\(=O\)', 'peptide'), # Pro peptide bonds
|
| 188 |
+
(r'NC\(=O\)', 'peptide'), # Regular peptide bonds
|
| 189 |
+
(r'C\(=O\)N\(C\)', 'n_methyl'),
|
| 190 |
+
(r'C\(=O\)N[12]?', 'peptide')
|
| 191 |
+
]
|
| 192 |
+
|
| 193 |
+
for batch_idx, smiles in enumerate(smiles_list):
|
| 194 |
+
positions = []
|
| 195 |
+
used = set()
|
| 196 |
+
|
| 197 |
+
# Identify bonds
|
| 198 |
+
for pattern, bond_type in bond_patterns:
|
| 199 |
+
for match in re.finditer(pattern, smiles):
|
| 200 |
+
if not any(p in range(match.start(), match.end()) for p in used):
|
| 201 |
+
positions.append({
|
| 202 |
+
'start': match.start(),
|
| 203 |
+
'end': match.end(),
|
| 204 |
+
'type': bond_type,
|
| 205 |
+
'pattern': match.group()
|
| 206 |
+
})
|
| 207 |
+
used.update(range(match.start(), match.end()))
|
| 208 |
+
|
| 209 |
+
# Update the mask for the current SMILES
|
| 210 |
+
for pos in positions:
|
| 211 |
+
mask[batch_idx, pos['start']:pos['end']] = 1
|
| 212 |
+
|
| 213 |
+
return mask
|
| 214 |
+
|
| 215 |
+
def peptide_token_mask(smiles_list, token_lists):
|
| 216 |
+
# Initialize the batch mask
|
| 217 |
+
batch_size = len(smiles_list)
|
| 218 |
+
token_seq_length = max(len(tokens) for tokens in token_lists) # Find the longest tokenized sequence
|
| 219 |
+
tokenized_masks = torch.zeros((batch_size, token_seq_length), dtype=torch.int) # Mask filled with zeros
|
| 220 |
+
atomwise_masks = peptide_bond_mask(smiles_list)
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
for batch_idx, atomwise_mask in enumerate(atomwise_masks):
|
| 224 |
+
token_seq = token_lists[batch_idx]
|
| 225 |
+
atom_idx = 0
|
| 226 |
+
|
| 227 |
+
for token_idx, token in enumerate(token_seq):
|
| 228 |
+
if token_idx != 0 and token_idx != len(token_seq) - 1:
|
| 229 |
+
if torch.sum(atomwise_mask[atom_idx:atom_idx+len(token)]) >= 1:
|
| 230 |
+
tokenized_masks[batch_idx][token_idx] = 1
|
| 231 |
+
atom_idx += len(token)
|
| 232 |
+
|
| 233 |
+
return tokenized_masks
|
| 234 |
+
|
| 235 |
+
class MOGGenerator:
|
| 236 |
+
def __init__(self, model, device, objectives, args, tracker=None):
|
| 237 |
+
self.model = model
|
| 238 |
+
self.device = device
|
| 239 |
+
self.objectives = objectives
|
| 240 |
+
self.args = args
|
| 241 |
+
self.num_objectives = len(objectives)
|
| 242 |
+
self.peptide_analyzer = PeptideAnalyzer()
|
| 243 |
+
self.pdc_analyzer = PDCAnalyzer(device)
|
| 244 |
+
self.invalid = [0, 1, 2, 3, 4, 585, 586]
|
| 245 |
+
self.tracker = tracker
|
| 246 |
+
|
| 247 |
+
# Find the Conjugation objective for detailed tracking
|
| 248 |
+
self.conjugation_obj = None
|
| 249 |
+
for obj in objectives:
|
| 250 |
+
if isinstance(obj, Conjugation):
|
| 251 |
+
self.conjugation_obj = obj
|
| 252 |
+
break
|
| 253 |
+
|
| 254 |
+
def _generate_single_x0(self, n_steps=16, temperature=1.0):
|
| 255 |
+
"""Generate a single batch of x0 sequences."""
|
| 256 |
+
self.model.eval()
|
| 257 |
+
|
| 258 |
+
# 1. Start with a tensor of random tokens (pure noise at t=0)
|
| 259 |
+
x = torch.randint(
|
| 260 |
+
0,
|
| 261 |
+
self.model.tokenizer.vocab_size,
|
| 262 |
+
(self.args.num_samples, self.args.gen_len),
|
| 263 |
+
device=self.device
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
# 2. Define the time schedule for the forward process (0.0 to 1.0)
|
| 267 |
+
time_steps = torch.linspace(0.0, 1.0, n_steps + 1, device=self.device)
|
| 268 |
+
|
| 269 |
+
# 3. Iteratively follow the flow from noise to data
|
| 270 |
+
with torch.no_grad():
|
| 271 |
+
for i in tqdm(range(n_steps), desc="Flow Matching Steps", leave=False):
|
| 272 |
+
t_curr = time_steps[i]
|
| 273 |
+
t_next = time_steps[i+1]
|
| 274 |
+
|
| 275 |
+
# Prepare the current timestep tensor for the model
|
| 276 |
+
t_tensor = torch.full((self.args.num_samples,), t_curr, device=self.device)
|
| 277 |
+
|
| 278 |
+
# Get the model's prediction for the final clean sequence (at t=1)
|
| 279 |
+
logits = self.model(x, t_tensor)
|
| 280 |
+
logits = logits / temperature
|
| 281 |
+
logits[:, :, 586] = -1000
|
| 282 |
+
|
| 283 |
+
pred_x1 = torch.argmax(logits, dim=-1)
|
| 284 |
+
|
| 285 |
+
# On the last step, the result is the final prediction
|
| 286 |
+
if i == n_steps - 1:
|
| 287 |
+
x = pred_x1
|
| 288 |
+
break
|
| 289 |
+
|
| 290 |
+
# --- Construct the next state x_{t_next} ---
|
| 291 |
+
# The probability of a token being noise at time t_next is (1 - t_next).
|
| 292 |
+
noise_prob = 1.0 - t_next
|
| 293 |
+
mask = torch.rand(x.shape, device=self.device) < noise_prob
|
| 294 |
+
|
| 295 |
+
# Generate new random tokens for the noise positions
|
| 296 |
+
noise = torch.randint(
|
| 297 |
+
0,
|
| 298 |
+
self.model.tokenizer.vocab_size,
|
| 299 |
+
x.shape,
|
| 300 |
+
device=self.device
|
| 301 |
+
)
|
| 302 |
+
|
| 303 |
+
# Combine the final prediction with noise to form the next intermediate state
|
| 304 |
+
x = torch.where(mask, noise, pred_x1)
|
| 305 |
+
|
| 306 |
+
return x
|
| 307 |
+
|
| 308 |
+
def _check_conjugation_validity(self, x):
|
| 309 |
+
"""
|
| 310 |
+
Check if sequences can be conjugated.
|
| 311 |
+
Returns a boolean tensor indicating validity for each sample.
|
| 312 |
+
"""
|
| 313 |
+
if self.conjugation_obj is None:
|
| 314 |
+
# Fall back to peptide validity check
|
| 315 |
+
sequences = self.model.tokenizer.batch_decode(x)
|
| 316 |
+
return torch.tensor([self.peptide_analyzer.is_peptide(seq) for seq in sequences], device=self.device)
|
| 317 |
+
|
| 318 |
+
# Use conjugation objective - score > 0 means valid
|
| 319 |
+
scores = self.conjugation_obj(x)
|
| 320 |
+
return scores > 0
|
| 321 |
+
|
| 322 |
+
def generate_x0(self, n_steps=16, temperature=1.0, require_valid=False, max_retries=10):
|
| 323 |
+
"""
|
| 324 |
+
Generate initial x0 sequences.
|
| 325 |
+
|
| 326 |
+
Args:
|
| 327 |
+
n_steps: Number of flow matching steps
|
| 328 |
+
temperature: Sampling temperature
|
| 329 |
+
require_valid: If True, regenerate invalid samples until all are valid conjugatable peptides
|
| 330 |
+
max_retries: Maximum number of regeneration attempts per sample
|
| 331 |
+
"""
|
| 332 |
+
print("Starting initial SMILES generation...")
|
| 333 |
+
|
| 334 |
+
x = self._generate_single_x0(n_steps, temperature)
|
| 335 |
+
|
| 336 |
+
if require_valid:
|
| 337 |
+
print("Checking conjugation validity and regenerating invalid samples...")
|
| 338 |
+
|
| 339 |
+
for retry in range(max_retries):
|
| 340 |
+
validity = self._check_conjugation_validity(x)
|
| 341 |
+
num_valid = validity.sum().item()
|
| 342 |
+
num_invalid = self.args.num_samples - num_valid
|
| 343 |
+
|
| 344 |
+
if num_invalid == 0:
|
| 345 |
+
print(f"All {self.args.num_samples} samples are valid conjugatable peptides!")
|
| 346 |
+
break
|
| 347 |
+
|
| 348 |
+
print(f"Retry {retry + 1}/{max_retries}: {num_valid}/{self.args.num_samples} valid, regenerating {num_invalid} invalid samples...")
|
| 349 |
+
|
| 350 |
+
# Get indices of invalid samples
|
| 351 |
+
invalid_indices = torch.where(~validity)[0]
|
| 352 |
+
|
| 353 |
+
# Generate new candidates for invalid samples
|
| 354 |
+
# Temporarily adjust num_samples to generate replacements
|
| 355 |
+
original_num_samples = self.args.num_samples
|
| 356 |
+
self.args.num_samples = num_invalid
|
| 357 |
+
|
| 358 |
+
new_x = self._generate_single_x0(n_steps, temperature)
|
| 359 |
+
|
| 360 |
+
self.args.num_samples = original_num_samples
|
| 361 |
+
|
| 362 |
+
# Replace invalid samples with new ones
|
| 363 |
+
x[invalid_indices] = new_x
|
| 364 |
+
|
| 365 |
+
# Final validity check
|
| 366 |
+
final_validity = self._check_conjugation_validity(x)
|
| 367 |
+
num_final_valid = final_validity.sum().item()
|
| 368 |
+
if num_final_valid < self.args.num_samples:
|
| 369 |
+
print(f"Warning: After {max_retries} retries, only {num_final_valid}/{self.args.num_samples} samples are valid.")
|
| 370 |
+
else:
|
| 371 |
+
print(f"Success: All {self.args.num_samples} samples are valid conjugatable peptides.")
|
| 372 |
+
|
| 373 |
+
# 4. Decode the final token IDs into SMILES strings
|
| 374 |
+
generated_sequences = self.model.tokenizer.batch_decode(x)
|
| 375 |
+
|
| 376 |
+
# 5. Analyze the validity of the generated sequences
|
| 377 |
+
validities = []
|
| 378 |
+
for seq in generated_sequences:
|
| 379 |
+
validities.append(self.peptide_analyzer.is_peptide(seq))
|
| 380 |
+
print(seq)
|
| 381 |
+
print(f"Initial Sequence Validity: {validities}")
|
| 382 |
+
return x
|
| 383 |
+
|
| 384 |
+
def _get_scores(self, x_batch):
|
| 385 |
+
"""Calculates the normalized scores for a batch of sequences."""
|
| 386 |
+
scores = []
|
| 387 |
+
for obj_func in self.objectives:
|
| 388 |
+
scores.append(obj_func(x_batch.to(self.device)))
|
| 389 |
+
return torch.stack(scores, dim=0).to(self.device)
|
| 390 |
+
|
| 391 |
+
def _barker_g(self, u):
|
| 392 |
+
"""Barker balancing function."""
|
| 393 |
+
return u / (1 + u)
|
| 394 |
+
|
| 395 |
+
def validity(self, x):
|
| 396 |
+
sampled_sequences = self.model.tokenizer.batch_decode(x)
|
| 397 |
+
|
| 398 |
+
return [1.0 if self.peptide_analyzer.is_peptide(seq) else 0.0 for seq in sampled_sequences]
|
| 399 |
+
|
| 400 |
+
def generate(self):
|
| 401 |
+
"""Main generation loop with tracking."""
|
| 402 |
+
shape = (self.args.num_samples, self.args.gen_len)
|
| 403 |
+
x = self.generate_x0(
|
| 404 |
+
require_valid=self.args.require_valid_x0,
|
| 405 |
+
max_retries=self.args.max_x0_retries
|
| 406 |
+
)
|
| 407 |
+
print(x)
|
| 408 |
+
|
| 409 |
+
if args.weights is None:
|
| 410 |
+
# The first weight is for conjugation (peptide + PDC validity)
|
| 411 |
+
# We need to ensure the SMILES sequences are valid peptides with valid PDCs
|
| 412 |
+
weights = torch.tensor([1] + [1/(self.num_objectives-1)] * (self.num_objectives-1), device=self.device).view(-1,1)
|
| 413 |
+
else:
|
| 414 |
+
weights = torch.tensor(self.args.weights, device=self.device).view(-1, 1)
|
| 415 |
+
if len(weights) != self.num_objectives:
|
| 416 |
+
raise ValueError("Number of weights must match number of objectives.")
|
| 417 |
+
print(f"Weights: {weights}")
|
| 418 |
+
|
| 419 |
+
with torch.no_grad():
|
| 420 |
+
for t in tqdm(range(self.args.optimization_steps), desc="MOG Generation"):
|
| 421 |
+
improved = False
|
| 422 |
+
improved_indices = []
|
| 423 |
+
|
| 424 |
+
# Anneal guidance strength
|
| 425 |
+
eta_t = self.args.eta_min + (self.args.eta_max - self.args.eta_min) * (t / (self.args.optimization_steps - 1))
|
| 426 |
+
# Choose a random position to mutate
|
| 427 |
+
mut_idx = random.randint(1, self.args.gen_len-2)
|
| 428 |
+
|
| 429 |
+
# Determine the generation timestep
|
| 430 |
+
# We cycle through the timesteps to ensure all are visited
|
| 431 |
+
generation_step = t % self.args.optimization_steps
|
| 432 |
+
time_t = torch.full((self.args.num_samples,), (generation_step / self.args.optimization_steps), device=self.device)
|
| 433 |
+
|
| 434 |
+
# Get proposal distribution from ReDi model for the chosen position
|
| 435 |
+
logits = self.model(x, time_t)
|
| 436 |
+
probs = F.softmax(logits, dim=-1)
|
| 437 |
+
pos_probs = probs[:, mut_idx, :]
|
| 438 |
+
pos_probs[:, x[:, mut_idx]] = 0 # We don't evalute the same token
|
| 439 |
+
pos_probs[:, self.invalid] = 0
|
| 440 |
+
|
| 441 |
+
# Prune candidate vocabulary using top-p sampling
|
| 442 |
+
sorted_probs, sorted_indices = torch.sort(pos_probs, descending=True)
|
| 443 |
+
cumulative_probs = torch.cumsum(sorted_probs, dim=-1)
|
| 444 |
+
remove_mask = cumulative_probs > self.args.top_p
|
| 445 |
+
remove_mask[..., 1:] = remove_mask[..., :-1].clone()
|
| 446 |
+
remove_mask[..., 0] = 0
|
| 447 |
+
|
| 448 |
+
# Get the set of candidate tokens for each sample in the batch
|
| 449 |
+
candidate_tokens_list = []
|
| 450 |
+
for i in range(self.args.num_samples):
|
| 451 |
+
sample_mask = remove_mask[i]
|
| 452 |
+
candidates = sorted_indices[i, ~sample_mask]
|
| 453 |
+
candidate_tokens_list.append(candidates)
|
| 454 |
+
|
| 455 |
+
# Get current scores
|
| 456 |
+
current_scores = self._get_scores(x)
|
| 457 |
+
w_current = torch.exp(eta_t * torch.min(weights * current_scores, dim=0).values)
|
| 458 |
+
|
| 459 |
+
if t == 0:
|
| 460 |
+
print(f"Initial Scores: {current_scores}")
|
| 461 |
+
# Log initial step with detailed results
|
| 462 |
+
if self.tracker and self.conjugation_obj:
|
| 463 |
+
detailed = self.conjugation_obj.get_last_detailed_results()
|
| 464 |
+
self.tracker.log_step(t, current_scores[0], detailed)
|
| 465 |
+
self.tracker.print_step_summary(t, current_scores[0], detailed)
|
| 466 |
+
|
| 467 |
+
# Evaluate all candidate tokens for each sample
|
| 468 |
+
final_proposal_tokens = []
|
| 469 |
+
for i in range(self.args.num_samples):
|
| 470 |
+
candidates = candidate_tokens_list[i]
|
| 471 |
+
candidates = torch.tensor([token for token in candidates if token not in self.invalid], device=candidates.device)
|
| 472 |
+
if len(candidates) >= 200:
|
| 473 |
+
candidates = candidates[:200]
|
| 474 |
+
num_candidates = len(candidates)
|
| 475 |
+
|
| 476 |
+
# Create a batch of proposed sequences for the current sample
|
| 477 |
+
x_prop_batch = x[i].repeat(num_candidates, 1)
|
| 478 |
+
x_prop_batch[:, mut_idx] = candidates
|
| 479 |
+
|
| 480 |
+
# Evaluate all proposals
|
| 481 |
+
proposal_scores = self._get_scores(x_prop_batch)
|
| 482 |
+
proposal_s_omega = torch.min(weights * proposal_scores, dim=0).values
|
| 483 |
+
w_proposal = torch.exp(eta_t * proposal_s_omega)
|
| 484 |
+
|
| 485 |
+
# Get ReDi probabilities for the candidates
|
| 486 |
+
redi_probs = pos_probs[i, candidates]
|
| 487 |
+
|
| 488 |
+
# Calculate unnormalized guided probabilities
|
| 489 |
+
tilde_q = redi_probs * self._barker_g(w_proposal / w_current[i])
|
| 490 |
+
|
| 491 |
+
# Normalize and sample the final token
|
| 492 |
+
final_probs = tilde_q / (torch.sum(tilde_q) + 1e-9)
|
| 493 |
+
|
| 494 |
+
index = torch.multinomial(final_probs, 1).item()
|
| 495 |
+
|
| 496 |
+
if (random.uniform(0,1) > 1 and proposal_scores[:,index][0] == 1) or torch.sum(weights.squeeze(1) * proposal_scores[:, index]) >= torch.sum(weights.squeeze(1) * current_scores[:,i]):
|
| 497 |
+
final_token = candidates[index]
|
| 498 |
+
print(f"Previous Weighted Sum: {torch.sum(weights.squeeze(1) * current_scores[:,i])}")
|
| 499 |
+
print(f"Previous Scores: {current_scores[:,i]}")
|
| 500 |
+
|
| 501 |
+
print(f"New Weighted Sum: {torch.sum(weights.squeeze(1) * proposal_scores[:, index])}")
|
| 502 |
+
print(f"New Scores: {proposal_scores[:,index]}")
|
| 503 |
+
improved = True
|
| 504 |
+
improved_indices.append(i)
|
| 505 |
+
|
| 506 |
+
else:
|
| 507 |
+
final_token = x[i][mut_idx]
|
| 508 |
+
|
| 509 |
+
final_proposal_tokens.append(final_token)
|
| 510 |
+
|
| 511 |
+
# Update the sequences with the chosen tokens
|
| 512 |
+
x[torch.arange(self.args.num_samples), mut_idx] = torch.stack(final_proposal_tokens)
|
| 513 |
+
if improved:
|
| 514 |
+
print(self.model.tokenizer.batch_decode(x))
|
| 515 |
+
|
| 516 |
+
# Log step with tracking (every 4 steps to reduce overhead)
|
| 517 |
+
if self.tracker and self.conjugation_obj and (t % 4 == 0 or t == self.args.optimization_steps - 1):
|
| 518 |
+
# Re-evaluate for detailed logging
|
| 519 |
+
step_scores = self._get_scores(x)
|
| 520 |
+
detailed = self.conjugation_obj.get_last_detailed_results()
|
| 521 |
+
self.tracker.log_step(t, step_scores[0], detailed, improved_indices)
|
| 522 |
+
|
| 523 |
+
# Print summary for final step
|
| 524 |
+
if t == self.args.optimization_steps - 1:
|
| 525 |
+
self.tracker.print_step_summary(t, step_scores[0], detailed)
|
| 526 |
+
|
| 527 |
+
return x
|
| 528 |
+
|
| 529 |
+
|
| 530 |
+
# --- Main Execution ---
|
| 531 |
+
def main(args):
|
| 532 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 533 |
+
print(f"Using device: {device}")
|
| 534 |
+
|
| 535 |
+
target = args.target
|
| 536 |
+
tokenizer = SMILES_SPE_Tokenizer('./smiles/smiles_tokenizer/new_vocab.txt', './smiles/smiles_tokenizer/new_splits.txt')
|
| 537 |
+
|
| 538 |
+
# Initialize Conjugation model with specified builder type
|
| 539 |
+
conjugation_model = Conjugation(tokenizer, device=device, builder_type=args.builder_type)
|
| 540 |
+
# hemolysis_model = Hemolysis(device)
|
| 541 |
+
# nonfouling_model = Nonfouling(device)
|
| 542 |
+
# solubility_model = Solubility(device)
|
| 543 |
+
# permeability_model = Permeability(device)
|
| 544 |
+
affinity_model = BindingAffinity(target, device)
|
| 545 |
+
|
| 546 |
+
# List of all objective functions
|
| 547 |
+
OBJECTIVE_FUNCTIONS = [conjugation_model, affinity_model]
|
| 548 |
+
|
| 549 |
+
# Initialize tracker for logging
|
| 550 |
+
tracker = GenerationTracker(output_dir=args.output_dir)
|
| 551 |
+
|
| 552 |
+
# --- Set random seeds for reproducibility ---
|
| 553 |
+
if args.seed is not None:
|
| 554 |
+
import random
|
| 555 |
+
import numpy as np
|
| 556 |
+
random.seed(args.seed)
|
| 557 |
+
np.random.seed(args.seed)
|
| 558 |
+
torch.manual_seed(args.seed)
|
| 559 |
+
if torch.cuda.is_available():
|
| 560 |
+
torch.cuda.manual_seed(args.seed)
|
| 561 |
+
torch.cuda.manual_seed_all(args.seed)
|
| 562 |
+
print(f"Random seed set to: {args.seed}")
|
| 563 |
+
|
| 564 |
+
# --- Load Model ---
|
| 565 |
+
print(f"Loading model from checkpoint: {args.checkpoint}")
|
| 566 |
+
checkpoint = torch.load(args.checkpoint, map_location=device, weights_only=False)
|
| 567 |
+
model_hparams = checkpoint["hyper_parameters"]["args"]
|
| 568 |
+
|
| 569 |
+
model = MDLMLightningModule.load_from_checkpoint(
|
| 570 |
+
args.checkpoint,
|
| 571 |
+
args=model_hparams,
|
| 572 |
+
tokenizer=tokenizer,
|
| 573 |
+
map_location=device,
|
| 574 |
+
strict=False
|
| 575 |
+
)
|
| 576 |
+
model.to(device)
|
| 577 |
+
print("Model loaded successfully.")
|
| 578 |
+
|
| 579 |
+
mog_generator = MOGGenerator(model, device, OBJECTIVE_FUNCTIONS, args, tracker=tracker)
|
| 580 |
+
|
| 581 |
+
all_sequences = []
|
| 582 |
+
all_final_results = []
|
| 583 |
+
|
| 584 |
+
for batch_idx in range(args.num_batches):
|
| 585 |
+
print(f"\n{'='*60}")
|
| 586 |
+
print(f"BATCH {batch_idx + 1}/{args.num_batches}")
|
| 587 |
+
print(f"{'='*60}")
|
| 588 |
+
|
| 589 |
+
generated_tokens = mog_generator.generate()
|
| 590 |
+
final_scores = mog_generator._get_scores(generated_tokens.detach()).detach().cpu().numpy()
|
| 591 |
+
sequence_str = tokenizer.batch_decode(generated_tokens)
|
| 592 |
+
|
| 593 |
+
print("\n--- Batch Results ---")
|
| 594 |
+
print(f"Sequences: {sequence_str}")
|
| 595 |
+
print(f"Scores: {final_scores}")
|
| 596 |
+
|
| 597 |
+
all_sequences.extend(sequence_str)
|
| 598 |
+
|
| 599 |
+
# Get detailed results with properties for final sequences
|
| 600 |
+
detailed_with_props = conjugation_model.get_detailed_scores_with_properties(generated_tokens)
|
| 601 |
+
tracker.log_final(sequence_str, detailed_with_props)
|
| 602 |
+
all_final_results.extend(detailed_with_props)
|
| 603 |
+
|
| 604 |
+
# Print and save final summary
|
| 605 |
+
tracker.print_final_summary()
|
| 606 |
+
tracker.save(filename_prefix='pdc_generation')
|
| 607 |
+
|
| 608 |
+
# Also save sequences to simple text file
|
| 609 |
+
with open(args.output_file, 'w') as f:
|
| 610 |
+
for seq in all_sequences:
|
| 611 |
+
f.write(seq + '\n')
|
| 612 |
+
print(f"\nSequences saved to: {args.output_file}")
|
| 613 |
+
|
| 614 |
+
print("\nGeneration complete.")
|
| 615 |
+
|
| 616 |
+
|
| 617 |
+
|
| 618 |
+
if __name__ == "__main__":
|
| 619 |
+
parser = argparse.ArgumentParser(description="Multi-Objective Generation with LBP-MOG-ReDi (Single Mutation).")
|
| 620 |
+
|
| 621 |
+
parser.add_argument("--checkpoint", type=str, required=True, help="Path to the trained ReDi model checkpoint.")
|
| 622 |
+
parser.add_argument("--num_samples", type=int, default=10, help="Number of samples to generate.")
|
| 623 |
+
parser.add_argument("--num_batches", type=int, default=10, help="Number of batches to generate.")
|
| 624 |
+
parser.add_argument("--output_file", type=str, default="./smiles.txt", help="File to save the generated sequences.")
|
| 625 |
+
parser.add_argument("--output_dir", type=str, default="./generation_logs", help="Directory to save detailed generation logs.")
|
| 626 |
+
parser.add_argument("--gen_len", type=int, default=50, help="Length of the sequences to generate.")
|
| 627 |
+
parser.add_argument("--optimization_steps", type=int, default=16, help="Number of optimization steps per generation.")
|
| 628 |
+
parser.add_argument("--weights", type=float, nargs='+', required=False, help="Weights for the objectives (e.g., 0.5 0.5).")
|
| 629 |
+
parser.add_argument("--eta_min", type=float, default=1.0, help="Minimum guidance strength for annealing.")
|
| 630 |
+
parser.add_argument("--eta_max", type=float, default=20.0, help="Maximum guidance strength for annealing.")
|
| 631 |
+
parser.add_argument("--top_p", type=float, default=0.9, help="Top-p for pruning candidate tokens.")
|
| 632 |
+
|
| 633 |
+
# PDC builder
|
| 634 |
+
parser.add_argument("--builder_type", type=str, default="AoaDau",
|
| 635 |
+
choices=["AoaDau", "ValAlaDau", "ValCitDau", "GlutarylDau", "Dau",
|
| 636 |
+
"SSCpt", "VcMMAE", "DSPACpt", "MalHexCpt", "DSECCpt", "Cpt",
|
| 637 |
+
"DSPASN38", "DSEBSN38", "DSEPSN38", "SN38"],
|
| 638 |
+
help="PDC builder type for conjugation. "
|
| 639 |
+
"'Dau' samples linker by GNRHR+Dau frequency. "
|
| 640 |
+
"'Cpt' samples linker by ERBB2+Cpt frequency. "
|
| 641 |
+
"'SN38' samples linker by NRP1+SN38 frequency.")
|
| 642 |
+
|
| 643 |
+
parser.add_argument("--seed", type=int, default=None, help="Random seed for reproducibility.")
|
| 644 |
+
parser.add_argument("--require_valid_x0", action="store_true",
|
| 645 |
+
help="If set, regenerate x0 samples until they are valid conjugatable peptides.")
|
| 646 |
+
parser.add_argument("--max_x0_retries", type=int, default=10,
|
| 647 |
+
help="Maximum retries when require_valid_x0 is enabled.")
|
| 648 |
+
|
| 649 |
+
parser.add_argument("--target", type=str, required=True, help="Target protein sequence for binding affinity.")
|
| 650 |
+
args = parser.parse_args()
|
| 651 |
+
main(args)
|
smiles/generation/guided_aa.py
ADDED
|
@@ -0,0 +1,872 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Natural-AA variant of guided_uaa.py."""
|
| 2 |
+
import argparse
|
| 3 |
+
import re
|
| 4 |
+
import random
|
| 5 |
+
import sys
|
| 6 |
+
from collections import Counter
|
| 7 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 8 |
+
import csv
|
| 9 |
+
import json
|
| 10 |
+
import os
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
import torch
|
| 13 |
+
import torch.nn as nn
|
| 14 |
+
import torch.nn.functional as F
|
| 15 |
+
from tqdm import tqdm
|
| 16 |
+
from smiles.models.train import MDLMLightningModule
|
| 17 |
+
from smiles.smiles_tokenizer.my_tokenizers import SMILES_SPE_Tokenizer
|
| 18 |
+
import numpy as np
|
| 19 |
+
from easydict import EasyDict as edict
|
| 20 |
+
|
| 21 |
+
from smiles.evaluation.peptide_analyzer import PeptideAnalyzer
|
| 22 |
+
from smiles.evaluation.pdc_analyzer import PDCAnalyzer
|
| 23 |
+
from smiles.scoring.classifiers import Conjugation, BindingAffinity
|
| 24 |
+
|
| 25 |
+
from smiles.models.aa_helpers import (
|
| 26 |
+
ESM2AminoTokenizer as _AAEsmTokenizer,
|
| 27 |
+
MDLM_PepReDi as _AAMDLM,
|
| 28 |
+
make_affinity,
|
| 29 |
+
DEFAULT_AA_AFFINITY_CKPT,
|
| 30 |
+
INVALID_AA_TOKEN_IDS as _AA_INVALID_IDS,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
from third_party.SMILESToPeptide.peptide_to_smiles import aa_to_smiles
|
| 34 |
+
from third_party.SMILESToPeptide.aminoacid_selective import one_letter_to_three_letter
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
MDLM = _AAMDLM
|
| 38 |
+
AminoTokenizer = _AAEsmTokenizer
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class GenerationTracker:
|
| 42 |
+
"""
|
| 43 |
+
Tracks detailed validity information during generation
|
| 44 |
+
"""
|
| 45 |
+
def __init__(self, output_dir='./generation_logs'):
|
| 46 |
+
self.output_dir = output_dir
|
| 47 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 48 |
+
|
| 49 |
+
self.run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 50 |
+
self.step_logs = []
|
| 51 |
+
self.final_results = []
|
| 52 |
+
|
| 53 |
+
def log_step(self, step, scores, detailed_results=None, improved_indices=None):
|
| 54 |
+
"""Log information for a generation step"""
|
| 55 |
+
step_info = {
|
| 56 |
+
'step': step,
|
| 57 |
+
'scores': scores.cpu().numpy().tolist() if isinstance(scores, torch.Tensor) else scores,
|
| 58 |
+
'num_valid': int(sum(1 for s in scores if s > 0)) if hasattr(scores, '__iter__') else 0,
|
| 59 |
+
'improved_indices': improved_indices or []
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
if detailed_results:
|
| 63 |
+
step_info['detailed'] = []
|
| 64 |
+
for i, result in enumerate(detailed_results):
|
| 65 |
+
step_info['detailed'].append({
|
| 66 |
+
'sample_idx': i,
|
| 67 |
+
'is_valid_peptide': result.get('is_valid_peptide', False),
|
| 68 |
+
'has_conjugation_site': result.get('has_conjugation_site', False),
|
| 69 |
+
'conjugation_success': result.get('conjugation_success', False),
|
| 70 |
+
'pdc_valid': result.get('pdc_valid', False),
|
| 71 |
+
'num_conjugates': len(result.get('conjugates', [])),
|
| 72 |
+
'error': result.get('error')
|
| 73 |
+
})
|
| 74 |
+
|
| 75 |
+
self.step_logs.append(step_info)
|
| 76 |
+
|
| 77 |
+
def log_final(self, sequences, detailed_results_with_props):
|
| 78 |
+
"""Log final results with properties"""
|
| 79 |
+
for i, (seq, result) in enumerate(zip(sequences, detailed_results_with_props)):
|
| 80 |
+
final_info = {
|
| 81 |
+
'sample_idx': i,
|
| 82 |
+
'peptide_smiles': seq,
|
| 83 |
+
'is_valid_peptide': result.get('is_valid_peptide', False),
|
| 84 |
+
'has_conjugation_site': result.get('has_conjugation_site', False),
|
| 85 |
+
'num_conjugates': len(result.get('conjugates', [])),
|
| 86 |
+
'num_valid_pdcs': len(result.get('valid_pdcs', [])),
|
| 87 |
+
'best_pdc': None,
|
| 88 |
+
'properties': result.get('properties', {})
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
if result.get('best_pdc'):
|
| 92 |
+
final_info['best_pdc'] = {
|
| 93 |
+
'smiles': result['best_pdc'].get('smiles', ''),
|
| 94 |
+
'checks': result['best_pdc'].get('checks', {})
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
self.final_results.append(final_info)
|
| 98 |
+
|
| 99 |
+
def save(self, filename_prefix='generation'):
|
| 100 |
+
"""Save all logs to files"""
|
| 101 |
+
# Save step logs
|
| 102 |
+
step_log_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_steps.json")
|
| 103 |
+
with open(step_log_path, 'w') as f:
|
| 104 |
+
json.dump(self.step_logs, f, indent=2, default=str)
|
| 105 |
+
|
| 106 |
+
# Save final results
|
| 107 |
+
final_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_final.json")
|
| 108 |
+
with open(final_path, 'w') as f:
|
| 109 |
+
json.dump(self.final_results, f, indent=2, default=str)
|
| 110 |
+
|
| 111 |
+
# Save summary CSV
|
| 112 |
+
csv_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_summary.csv")
|
| 113 |
+
with open(csv_path, 'w', newline='') as f:
|
| 114 |
+
writer = csv.writer(f)
|
| 115 |
+
writer.writerow([
|
| 116 |
+
'sample_idx', 'peptide_smiles', 'is_valid_peptide', 'has_conjugation_site',
|
| 117 |
+
'num_conjugates', 'num_valid_pdcs', 'best_pdc_smiles',
|
| 118 |
+
'mf', 'polararea', 'xlogp', 'heavycnt', 'rotbonds'
|
| 119 |
+
])
|
| 120 |
+
for result in self.final_results:
|
| 121 |
+
props = result.get('properties', {})
|
| 122 |
+
writer.writerow([
|
| 123 |
+
result['sample_idx'],
|
| 124 |
+
result['peptide_smiles'],
|
| 125 |
+
result['is_valid_peptide'],
|
| 126 |
+
result['has_conjugation_site'],
|
| 127 |
+
result['num_conjugates'],
|
| 128 |
+
result['num_valid_pdcs'],
|
| 129 |
+
result['best_pdc']['smiles'] if result.get('best_pdc') else '',
|
| 130 |
+
props.get('mf', ''),
|
| 131 |
+
props.get('polararea', ''),
|
| 132 |
+
props.get('xlogp', ''),
|
| 133 |
+
props.get('heavycnt', ''),
|
| 134 |
+
props.get('rotbonds', '')
|
| 135 |
+
])
|
| 136 |
+
|
| 137 |
+
print(f"\n=== Generation logs saved ===")
|
| 138 |
+
print(f" Step logs: {step_log_path}")
|
| 139 |
+
print(f" Final results: {final_path}")
|
| 140 |
+
print(f" Summary CSV: {csv_path}")
|
| 141 |
+
|
| 142 |
+
return step_log_path, final_path, csv_path
|
| 143 |
+
|
| 144 |
+
def print_step_summary(self, step, scores, detailed_results=None):
|
| 145 |
+
"""Print a summary for the current step"""
|
| 146 |
+
if isinstance(scores, torch.Tensor):
|
| 147 |
+
scores_list = scores.cpu().numpy().tolist()
|
| 148 |
+
else:
|
| 149 |
+
scores_list = list(scores)
|
| 150 |
+
|
| 151 |
+
num_valid = sum(1 for s in scores_list if s > 0)
|
| 152 |
+
|
| 153 |
+
print(f"\n--- Step {step} Summary ---")
|
| 154 |
+
print(f" Valid: {num_valid}/{len(scores_list)}")
|
| 155 |
+
|
| 156 |
+
if detailed_results:
|
| 157 |
+
valid_peptide = sum(1 for r in detailed_results if r.get('is_valid_peptide'))
|
| 158 |
+
has_site = sum(1 for r in detailed_results if r.get('has_conjugation_site'))
|
| 159 |
+
conj_success = sum(1 for r in detailed_results if r.get('conjugation_success'))
|
| 160 |
+
pdc_valid = sum(1 for r in detailed_results if r.get('pdc_valid'))
|
| 161 |
+
|
| 162 |
+
print(f" Breakdown:")
|
| 163 |
+
print(f" Valid peptides: {valid_peptide}")
|
| 164 |
+
print(f" Has conjugation site: {has_site}")
|
| 165 |
+
print(f" Conjugation success: {conj_success}")
|
| 166 |
+
print(f" PDC valid: {pdc_valid}")
|
| 167 |
+
|
| 168 |
+
def print_final_summary(self):
|
| 169 |
+
"""Print final summary"""
|
| 170 |
+
print("\n" + "=" * 60)
|
| 171 |
+
print("FINAL GENERATION SUMMARY")
|
| 172 |
+
print("=" * 60)
|
| 173 |
+
|
| 174 |
+
total = len(self.final_results)
|
| 175 |
+
valid_pdcs = sum(1 for r in self.final_results if r.get('num_valid_pdcs', 0) > 0)
|
| 176 |
+
|
| 177 |
+
print(f"\nTotal sequences: {total}")
|
| 178 |
+
print(f"With valid PDCs: {valid_pdcs}")
|
| 179 |
+
|
| 180 |
+
print("\nDetailed results:")
|
| 181 |
+
for result in self.final_results:
|
| 182 |
+
status = "" if result.get('num_valid_pdcs', 0) > 0 else ""
|
| 183 |
+
print(f"\n [{status}] Sample {result['sample_idx']}:")
|
| 184 |
+
print(f" Peptide: {result['peptide_smiles'][:60]}...")
|
| 185 |
+
print(f" Valid peptide: {result['is_valid_peptide']}")
|
| 186 |
+
print(f" Has conjugation site: {result['has_conjugation_site']}")
|
| 187 |
+
print(f" Conjugates: {result['num_conjugates']}")
|
| 188 |
+
print(f" Valid PDCs: {result['num_valid_pdcs']}")
|
| 189 |
+
|
| 190 |
+
if result.get('properties'):
|
| 191 |
+
props = result['properties']
|
| 192 |
+
print(f" Properties:")
|
| 193 |
+
for k, v in props.items():
|
| 194 |
+
print(f" {k}: {v}")
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def peptide_bond_mask(smiles_list):
|
| 198 |
+
# Initialize the batch mask
|
| 199 |
+
batch_size = len(smiles_list)
|
| 200 |
+
max_seq_length = 1035 #max(len(smiles) for smiles in smiles_list) # Find the longest SMILES
|
| 201 |
+
mask = torch.zeros((batch_size, max_seq_length), dtype=torch.int) # Mask filled with zeros
|
| 202 |
+
|
| 203 |
+
bond_patterns = [
|
| 204 |
+
(r'OC\(=O\)', 'ester'),
|
| 205 |
+
(r'N\(C\)C\(=O\)', 'n_methyl'),
|
| 206 |
+
(r'N[12]C\(=O\)', 'peptide'), # Pro peptide bonds
|
| 207 |
+
(r'NC\(=O\)', 'peptide'), # Regular peptide bonds
|
| 208 |
+
(r'C\(=O\)N\(C\)', 'n_methyl'),
|
| 209 |
+
(r'C\(=O\)N[12]?', 'peptide')
|
| 210 |
+
]
|
| 211 |
+
|
| 212 |
+
for batch_idx, smiles in enumerate(smiles_list):
|
| 213 |
+
positions = []
|
| 214 |
+
used = set()
|
| 215 |
+
|
| 216 |
+
# Identify bonds
|
| 217 |
+
for pattern, bond_type in bond_patterns:
|
| 218 |
+
for match in re.finditer(pattern, smiles):
|
| 219 |
+
if not any(p in range(match.start(), match.end()) for p in used):
|
| 220 |
+
positions.append({
|
| 221 |
+
'start': match.start(),
|
| 222 |
+
'end': match.end(),
|
| 223 |
+
'type': bond_type,
|
| 224 |
+
'pattern': match.group()
|
| 225 |
+
})
|
| 226 |
+
used.update(range(match.start(), match.end()))
|
| 227 |
+
|
| 228 |
+
# Update the mask for the current SMILES
|
| 229 |
+
for pos in positions:
|
| 230 |
+
mask[batch_idx, pos['start']:pos['end']] = 1
|
| 231 |
+
|
| 232 |
+
return mask
|
| 233 |
+
|
| 234 |
+
def peptide_token_mask(smiles_list, token_lists):
|
| 235 |
+
# Initialize the batch mask
|
| 236 |
+
batch_size = len(smiles_list)
|
| 237 |
+
token_seq_length = max(len(tokens) for tokens in token_lists) # Find the longest tokenized sequence
|
| 238 |
+
tokenized_masks = torch.zeros((batch_size, token_seq_length), dtype=torch.int) # Mask filled with zeros
|
| 239 |
+
atomwise_masks = peptide_bond_mask(smiles_list)
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
for batch_idx, atomwise_mask in enumerate(atomwise_masks):
|
| 243 |
+
token_seq = token_lists[batch_idx]
|
| 244 |
+
atom_idx = 0
|
| 245 |
+
|
| 246 |
+
for token_idx, token in enumerate(token_seq):
|
| 247 |
+
if token_idx != 0 and token_idx != len(token_seq) - 1:
|
| 248 |
+
if torch.sum(atomwise_mask[atom_idx:atom_idx+len(token)]) >= 1:
|
| 249 |
+
tokenized_masks[batch_idx][token_idx] = 1
|
| 250 |
+
atom_idx += len(token)
|
| 251 |
+
|
| 252 |
+
return tokenized_masks
|
| 253 |
+
|
| 254 |
+
class MOGGenerator:
|
| 255 |
+
def __init__(self, model, device, objectives, args, tracker=None, tokenizer=None, tokenizer_type='smiles'):
|
| 256 |
+
self.model = model
|
| 257 |
+
self.device = device
|
| 258 |
+
self.objectives = objectives
|
| 259 |
+
self.args = args
|
| 260 |
+
self.num_objectives = len(objectives)
|
| 261 |
+
self.peptide_analyzer = PeptideAnalyzer()
|
| 262 |
+
self.pdc_analyzer = PDCAnalyzer(device)
|
| 263 |
+
self.tracker = tracker
|
| 264 |
+
self.tokenizer = tokenizer
|
| 265 |
+
self.tokenizer_type = tokenizer_type
|
| 266 |
+
|
| 267 |
+
# Set invalid tokens based on tokenizer type
|
| 268 |
+
if tokenizer_type == 'amino':
|
| 269 |
+
# PepReDi (natural-AA) vocab is 24 tokens: 0-3 are specials
|
| 270 |
+
# (cls/pad/eos/unk) and 4-23 are the 20 canonical AAs. The UAA
|
| 271 |
+
# script also blocked id 38 ('g' D-AA), but that's out of range
|
| 272 |
+
# for PepReDi and would crash the `pos_probs[:, self.invalid] = 0`
|
| 273 |
+
# masking. Block special tokens only.
|
| 274 |
+
self.invalid = [0, 1, 2, 3]
|
| 275 |
+
# Initialize SMILES tokenizer for AA-to-SMILES conversion
|
| 276 |
+
self.smiles_tokenizer = SMILES_SPE_Tokenizer(
|
| 277 |
+
args.smiles_vocab_path,
|
| 278 |
+
args.smiles_splits_path
|
| 279 |
+
)
|
| 280 |
+
else:
|
| 281 |
+
self.invalid = [0, 1, 2, 3, 4, 585, 586] # SMILES special tokens
|
| 282 |
+
self.smiles_tokenizer = None
|
| 283 |
+
|
| 284 |
+
# Find the Conjugation objective for detailed tracking
|
| 285 |
+
self.conjugation_obj = None
|
| 286 |
+
for obj in objectives:
|
| 287 |
+
if isinstance(obj, Conjugation):
|
| 288 |
+
self.conjugation_obj = obj
|
| 289 |
+
break
|
| 290 |
+
|
| 291 |
+
def _generate_single_x0(self, n_steps=16, temperature=1.0):
|
| 292 |
+
"""Generate a single batch of x0 sequences."""
|
| 293 |
+
self.model.eval()
|
| 294 |
+
|
| 295 |
+
# Get vocab size based on tokenizer type
|
| 296 |
+
if self.tokenizer_type == 'amino':
|
| 297 |
+
vocab_size = self.model.vocab_size
|
| 298 |
+
else:
|
| 299 |
+
vocab_size = self.model.tokenizer.vocab_size
|
| 300 |
+
|
| 301 |
+
# 1. Start with a tensor of random tokens (pure noise at t=0)
|
| 302 |
+
# For amino tokenizer, exclude invalid tokens from initialization
|
| 303 |
+
if self.tokenizer_type == 'amino':
|
| 304 |
+
valid_tokens = [i for i in range(vocab_size) if i not in self.invalid]
|
| 305 |
+
valid_tokens = torch.tensor(valid_tokens, device=self.device)
|
| 306 |
+
rand_indices = torch.randint(0, len(valid_tokens), (self.args.num_samples, self.args.gen_len), device=self.device)
|
| 307 |
+
body = valid_tokens[rand_indices]
|
| 308 |
+
# Add CLS and EOS tokens
|
| 309 |
+
cls_col = torch.full((self.args.num_samples, 1), self.tokenizer.cls_id, device=self.device)
|
| 310 |
+
eos_col = torch.full((self.args.num_samples, 1), self.tokenizer.eos_id, device=self.device)
|
| 311 |
+
x = torch.cat([cls_col, body, eos_col], dim=1)
|
| 312 |
+
else:
|
| 313 |
+
x = torch.randint(
|
| 314 |
+
0,
|
| 315 |
+
vocab_size,
|
| 316 |
+
(self.args.num_samples, self.args.gen_len),
|
| 317 |
+
device=self.device
|
| 318 |
+
)
|
| 319 |
+
|
| 320 |
+
# 2. Define the time schedule for the forward process (0.0 to 1.0)
|
| 321 |
+
time_steps = torch.linspace(0.0, 1.0, n_steps + 1, device=self.device)
|
| 322 |
+
|
| 323 |
+
# 3. Iteratively follow the flow from noise to data
|
| 324 |
+
with torch.no_grad():
|
| 325 |
+
for i in tqdm(range(n_steps), desc="Flow Matching Steps", leave=False):
|
| 326 |
+
t_curr = time_steps[i]
|
| 327 |
+
t_next = time_steps[i+1]
|
| 328 |
+
|
| 329 |
+
# Prepare the current timestep tensor for the model
|
| 330 |
+
t_tensor = torch.full((self.args.num_samples,), t_curr, device=self.device)
|
| 331 |
+
|
| 332 |
+
# Get the model's prediction for the final clean sequence (at t=1)
|
| 333 |
+
logits = self.model(x, t_tensor)
|
| 334 |
+
logits = logits / temperature
|
| 335 |
+
|
| 336 |
+
# Mask out invalid tokens
|
| 337 |
+
for inv_tok in self.invalid:
|
| 338 |
+
if inv_tok < logits.shape[-1]:
|
| 339 |
+
logits[:, :, inv_tok] = -1000
|
| 340 |
+
|
| 341 |
+
pred_x1 = torch.argmax(logits, dim=-1)
|
| 342 |
+
|
| 343 |
+
# On the last step, the result is the final prediction
|
| 344 |
+
if i == n_steps - 1:
|
| 345 |
+
x = pred_x1
|
| 346 |
+
break
|
| 347 |
+
|
| 348 |
+
# --- Construct the next state x_{t_next} ---
|
| 349 |
+
# The probability of a token being noise at time t_next is (1 - t_next).
|
| 350 |
+
noise_prob = 1.0 - t_next
|
| 351 |
+
mask = torch.rand(x.shape, device=self.device) < noise_prob
|
| 352 |
+
|
| 353 |
+
# Generate new random tokens for the noise positions
|
| 354 |
+
noise = torch.randint(
|
| 355 |
+
0,
|
| 356 |
+
vocab_size,
|
| 357 |
+
x.shape,
|
| 358 |
+
device=self.device
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
# Combine the final prediction with noise to form the next intermediate state
|
| 362 |
+
x = torch.where(mask, noise, pred_x1)
|
| 363 |
+
|
| 364 |
+
return x
|
| 365 |
+
|
| 366 |
+
def _check_conjugation_validity(self, x):
|
| 367 |
+
"""
|
| 368 |
+
Check if sequences can be conjugated.
|
| 369 |
+
Returns a boolean tensor indicating validity for each sample.
|
| 370 |
+
"""
|
| 371 |
+
if self.tokenizer_type == 'amino':
|
| 372 |
+
# For amino tokenizer, convert to SMILES first
|
| 373 |
+
seqs = self.tokenizer.batch_decode(x)
|
| 374 |
+
three_letter_seqs = [one_letter_to_three_letter(seq) for seq in seqs]
|
| 375 |
+
smiles_seqs = [aa_to_smiles(three_letter_seq, cyclic=self.args.cyclic)
|
| 376 |
+
for three_letter_seq in three_letter_seqs]
|
| 377 |
+
|
| 378 |
+
# Check if SMILES conversion succeeded and result is valid peptide
|
| 379 |
+
validities = []
|
| 380 |
+
for smiles in smiles_seqs:
|
| 381 |
+
if isinstance(smiles, str):
|
| 382 |
+
validities.append(self.peptide_analyzer.is_peptide(smiles))
|
| 383 |
+
else:
|
| 384 |
+
validities.append(False)
|
| 385 |
+
return torch.tensor(validities, device=self.device)
|
| 386 |
+
|
| 387 |
+
if self.conjugation_obj is None:
|
| 388 |
+
# Fall back to peptide validity check
|
| 389 |
+
sequences = self.tokenizer.batch_decode(x)
|
| 390 |
+
return torch.tensor([self.peptide_analyzer.is_peptide(seq) for seq in sequences], device=self.device)
|
| 391 |
+
|
| 392 |
+
# Use conjugation objective - score > 0 means valid
|
| 393 |
+
scores = self.conjugation_obj(x)
|
| 394 |
+
return scores > 0
|
| 395 |
+
|
| 396 |
+
def generate_x0(self, n_steps=16, temperature=1.0, require_valid=False, max_retries=10):
|
| 397 |
+
"""
|
| 398 |
+
Generate initial x0 sequences.
|
| 399 |
+
|
| 400 |
+
Args:
|
| 401 |
+
n_steps: Number of flow matching steps
|
| 402 |
+
temperature: Sampling temperature
|
| 403 |
+
require_valid: If True, regenerate invalid samples until all are valid conjugatable peptides
|
| 404 |
+
max_retries: Maximum number of regeneration attempts per sample
|
| 405 |
+
"""
|
| 406 |
+
print("Starting initial SMILES generation...")
|
| 407 |
+
|
| 408 |
+
x = self._generate_single_x0(n_steps, temperature)
|
| 409 |
+
|
| 410 |
+
if require_valid:
|
| 411 |
+
print("Checking conjugation validity and regenerating invalid samples...")
|
| 412 |
+
|
| 413 |
+
for retry in range(max_retries):
|
| 414 |
+
validity = self._check_conjugation_validity(x)
|
| 415 |
+
num_valid = validity.sum().item()
|
| 416 |
+
num_invalid = self.args.num_samples - num_valid
|
| 417 |
+
|
| 418 |
+
if num_invalid == 0:
|
| 419 |
+
print(f"All {self.args.num_samples} samples are valid conjugatable peptides!")
|
| 420 |
+
break
|
| 421 |
+
|
| 422 |
+
print(f"Retry {retry + 1}/{max_retries}: {num_valid}/{self.args.num_samples} valid, regenerating {num_invalid} invalid samples...")
|
| 423 |
+
|
| 424 |
+
# Get indices of invalid samples
|
| 425 |
+
invalid_indices = torch.where(~validity)[0]
|
| 426 |
+
|
| 427 |
+
# Generate new candidates for invalid samples
|
| 428 |
+
# Temporarily adjust num_samples to generate replacements
|
| 429 |
+
original_num_samples = self.args.num_samples
|
| 430 |
+
self.args.num_samples = num_invalid
|
| 431 |
+
|
| 432 |
+
new_x = self._generate_single_x0(n_steps, temperature)
|
| 433 |
+
|
| 434 |
+
self.args.num_samples = original_num_samples
|
| 435 |
+
|
| 436 |
+
# Replace invalid samples with new ones
|
| 437 |
+
x[invalid_indices] = new_x
|
| 438 |
+
|
| 439 |
+
# Final validity check
|
| 440 |
+
final_validity = self._check_conjugation_validity(x)
|
| 441 |
+
num_final_valid = final_validity.sum().item()
|
| 442 |
+
if num_final_valid < self.args.num_samples:
|
| 443 |
+
print(f"Warning: After {max_retries} retries, only {num_final_valid}/{self.args.num_samples} samples are valid.")
|
| 444 |
+
else:
|
| 445 |
+
print(f"Success: All {self.args.num_samples} samples are valid conjugatable peptides.")
|
| 446 |
+
|
| 447 |
+
# 4. Decode the final token IDs into strings
|
| 448 |
+
generated_sequences = self.tokenizer.batch_decode(x)
|
| 449 |
+
|
| 450 |
+
# 5. Analyze the validity of the generated sequences
|
| 451 |
+
validities = []
|
| 452 |
+
if self.tokenizer_type == 'amino':
|
| 453 |
+
# For amino mode, report conjugation-relevant validity after AA->SMILES conversion.
|
| 454 |
+
validity_tensor = self._check_conjugation_validity(x)
|
| 455 |
+
validities = [bool(v.item()) for v in validity_tensor]
|
| 456 |
+
for seq in generated_sequences:
|
| 457 |
+
print(seq)
|
| 458 |
+
else:
|
| 459 |
+
for seq in generated_sequences:
|
| 460 |
+
validities.append(self.peptide_analyzer.is_peptide(seq))
|
| 461 |
+
print(seq)
|
| 462 |
+
if self.tokenizer_type == 'amino':
|
| 463 |
+
print(f"Initial Peptide Validity (AA->SMILES): {validities}")
|
| 464 |
+
else:
|
| 465 |
+
print(f"Initial Sequence Validity: {validities}")
|
| 466 |
+
return x
|
| 467 |
+
|
| 468 |
+
def _get_scores(self, x_batch):
|
| 469 |
+
"""Calculates the normalized scores for a batch of sequences."""
|
| 470 |
+
scores = []
|
| 471 |
+
|
| 472 |
+
if self.tokenizer_type == 'amino':
|
| 473 |
+
# Convert AA tokens to SMILES for scoring
|
| 474 |
+
seqs = self.tokenizer.batch_decode(x_batch)
|
| 475 |
+
|
| 476 |
+
# Convert one-letter AA codes to three-letter, then to SMILES
|
| 477 |
+
three_letter_seqs = [one_letter_to_three_letter(seq) for seq in seqs]
|
| 478 |
+
with ThreadPoolExecutor() as pool:
|
| 479 |
+
smiles_seqs = list(pool.map(
|
| 480 |
+
lambda s: aa_to_smiles(s, cyclic=self.args.cyclic),
|
| 481 |
+
three_letter_seqs
|
| 482 |
+
))
|
| 483 |
+
|
| 484 |
+
# Track valid conversions
|
| 485 |
+
valid_mask = torch.tensor(
|
| 486 |
+
[1 if isinstance(seq, str) else 0 for seq in smiles_seqs],
|
| 487 |
+
device=self.device
|
| 488 |
+
)
|
| 489 |
+
|
| 490 |
+
# Replace invalid SMILES with dummy 'C' for tokenization
|
| 491 |
+
for i in torch.where((1 - valid_mask) == 1)[0].tolist():
|
| 492 |
+
smiles_seqs[i] = 'C'
|
| 493 |
+
|
| 494 |
+
# Tokenize SMILES for objective functions
|
| 495 |
+
smiles_batch = self.smiles_tokenizer(
|
| 496 |
+
smiles_seqs, return_tensors='pt', padding=True
|
| 497 |
+
).to(self.device)
|
| 498 |
+
attention_mask = smiles_batch.get("attention_mask", None)
|
| 499 |
+
|
| 500 |
+
# Evaluate each objective. Objectives may declare `.input_type`:
|
| 501 |
+
# 'aa' -> feed raw AA tokens (no SMILES conversion)
|
| 502 |
+
# 'smiles' -> feed SMILES tokens (default for the existing
|
| 503 |
+
# Conjugation / SMILES BindingAffinity)
|
| 504 |
+
for obj_func in self.objectives:
|
| 505 |
+
if getattr(obj_func, "input_type", None) == "aa":
|
| 506 |
+
# AA-input objective (e.g. UnpooledBindingPredictor):
|
| 507 |
+
# score directly on the original AA tokens; do not multiply
|
| 508 |
+
# by valid_mask (the AA->SMILES conversion outcome doesn't
|
| 509 |
+
# affect this scorer).
|
| 510 |
+
score = obj_func(x_batch.to(self.device)).to(self.device)
|
| 511 |
+
scores.append(score)
|
| 512 |
+
else:
|
| 513 |
+
try:
|
| 514 |
+
score = obj_func(
|
| 515 |
+
smiles_batch["input_ids"],
|
| 516 |
+
attention_mask=attention_mask
|
| 517 |
+
).to(self.device)
|
| 518 |
+
except TypeError:
|
| 519 |
+
score = obj_func(smiles_batch["input_ids"]).to(self.device)
|
| 520 |
+
scores.append(score * valid_mask) # Zero out invalid conversions
|
| 521 |
+
else:
|
| 522 |
+
# Original SMILES-based scoring
|
| 523 |
+
for obj_func in self.objectives:
|
| 524 |
+
scores.append(obj_func(x_batch.to(self.device)))
|
| 525 |
+
|
| 526 |
+
return torch.stack(scores, dim=0).to(self.device)
|
| 527 |
+
|
| 528 |
+
def _barker_g(self, u):
|
| 529 |
+
"""Barker balancing function."""
|
| 530 |
+
return u / (1 + u)
|
| 531 |
+
|
| 532 |
+
def validity(self, x):
|
| 533 |
+
sampled_sequences = self.tokenizer.batch_decode(x)
|
| 534 |
+
|
| 535 |
+
if self.tokenizer_type == 'amino':
|
| 536 |
+
# For amino tokenizer, convert to SMILES first and check validity
|
| 537 |
+
three_letter_seqs = [one_letter_to_three_letter(seq) for seq in sampled_sequences]
|
| 538 |
+
smiles_seqs = [aa_to_smiles(three_letter_seq, cyclic=self.args.cyclic)
|
| 539 |
+
for three_letter_seq in three_letter_seqs]
|
| 540 |
+
return [1.0 if isinstance(seq, str) and self.peptide_analyzer.is_peptide(seq) else 0.0
|
| 541 |
+
for seq in smiles_seqs]
|
| 542 |
+
else:
|
| 543 |
+
return [1.0 if self.peptide_analyzer.is_peptide(seq) else 0.0 for seq in sampled_sequences]
|
| 544 |
+
|
| 545 |
+
def generate(self):
|
| 546 |
+
"""Main generation loop with tracking."""
|
| 547 |
+
shape = (self.args.num_samples, self.args.gen_len)
|
| 548 |
+
x = self.generate_x0(
|
| 549 |
+
require_valid=self.args.require_valid_x0,
|
| 550 |
+
max_retries=self.args.max_x0_retries
|
| 551 |
+
)
|
| 552 |
+
print(x)
|
| 553 |
+
|
| 554 |
+
if self.args.weights is None:
|
| 555 |
+
# The first weight is for conjugation (peptide + PDC validity)
|
| 556 |
+
# We need to ensure the SMILES sequences are valid peptides with valid PDCs
|
| 557 |
+
weights = torch.tensor([1] + [1/(self.num_objectives-1)] * (self.num_objectives-1), device=self.device).view(-1,1)
|
| 558 |
+
else:
|
| 559 |
+
weights = torch.tensor(self.args.weights, device=self.device).view(-1, 1)
|
| 560 |
+
if len(weights) != self.num_objectives:
|
| 561 |
+
raise ValueError("Number of weights must match number of objectives.")
|
| 562 |
+
print(f"Weights: {weights}")
|
| 563 |
+
|
| 564 |
+
with torch.no_grad():
|
| 565 |
+
current_scores = self._get_scores(x)
|
| 566 |
+
for t in tqdm(range(self.args.optimization_steps), desc="MOG Generation"):
|
| 567 |
+
improved = False
|
| 568 |
+
improved_indices = []
|
| 569 |
+
|
| 570 |
+
# Anneal guidance strength
|
| 571 |
+
eta_t = self.args.eta_min + (self.args.eta_max - self.args.eta_min) * (t / (self.args.optimization_steps - 1))
|
| 572 |
+
# Choose a random position to mutate
|
| 573 |
+
mut_idx = random.randint(1, self.args.gen_len-2)
|
| 574 |
+
|
| 575 |
+
# Determine the generation timestep
|
| 576 |
+
# We cycle through the timesteps to ensure all are visited
|
| 577 |
+
generation_step = t % self.args.optimization_steps
|
| 578 |
+
time_t = torch.full((self.args.num_samples,), (generation_step / self.args.optimization_steps), device=self.device)
|
| 579 |
+
|
| 580 |
+
# Get proposal distribution from ReDi model for the chosen position
|
| 581 |
+
logits = self.model(x, time_t)
|
| 582 |
+
probs = F.softmax(logits, dim=-1)
|
| 583 |
+
pos_probs = probs[:, mut_idx, :]
|
| 584 |
+
pos_probs[:, x[:, mut_idx]] = 0 # We don't evalute the same token
|
| 585 |
+
pos_probs[:, self.invalid] = 0
|
| 586 |
+
|
| 587 |
+
# Prune candidate vocabulary using top-p sampling
|
| 588 |
+
sorted_probs, sorted_indices = torch.sort(pos_probs, descending=True)
|
| 589 |
+
cumulative_probs = torch.cumsum(sorted_probs, dim=-1)
|
| 590 |
+
remove_mask = cumulative_probs > self.args.top_p
|
| 591 |
+
remove_mask[..., 1:] = remove_mask[..., :-1].clone()
|
| 592 |
+
remove_mask[..., 0] = 0
|
| 593 |
+
|
| 594 |
+
# Get the set of candidate tokens for each sample in the batch
|
| 595 |
+
candidate_tokens_list = []
|
| 596 |
+
for i in range(self.args.num_samples):
|
| 597 |
+
sample_mask = remove_mask[i]
|
| 598 |
+
candidates = sorted_indices[i, ~sample_mask]
|
| 599 |
+
candidate_tokens_list.append(candidates)
|
| 600 |
+
|
| 601 |
+
w_current = torch.exp(eta_t * torch.min(weights * current_scores, dim=0).values)
|
| 602 |
+
|
| 603 |
+
if t == 0:
|
| 604 |
+
print(f"Initial Scores: {current_scores}")
|
| 605 |
+
# Log initial step with detailed results
|
| 606 |
+
if self.tracker and self.conjugation_obj:
|
| 607 |
+
detailed = self.conjugation_obj.get_last_detailed_results()
|
| 608 |
+
self.tracker.log_step(t, current_scores[0], detailed)
|
| 609 |
+
self.tracker.print_step_summary(t, current_scores[0], detailed)
|
| 610 |
+
|
| 611 |
+
# Evaluate all candidate tokens for each sample
|
| 612 |
+
final_proposal_tokens = []
|
| 613 |
+
for i in range(self.args.num_samples):
|
| 614 |
+
candidates = candidate_tokens_list[i]
|
| 615 |
+
candidates = torch.tensor([token for token in candidates if token not in self.invalid], device=candidates.device)
|
| 616 |
+
if len(candidates) >= 200:
|
| 617 |
+
candidates = candidates[:200]
|
| 618 |
+
num_candidates = len(candidates)
|
| 619 |
+
|
| 620 |
+
# Create a batch of proposed sequences for the current sample
|
| 621 |
+
x_prop_batch = x[i].repeat(num_candidates, 1)
|
| 622 |
+
x_prop_batch[:, mut_idx] = candidates
|
| 623 |
+
|
| 624 |
+
# Evaluate all proposals
|
| 625 |
+
proposal_scores = self._get_scores(x_prop_batch)
|
| 626 |
+
proposal_s_omega = torch.min(weights * proposal_scores, dim=0).values
|
| 627 |
+
w_proposal = torch.exp(eta_t * proposal_s_omega)
|
| 628 |
+
|
| 629 |
+
# Get ReDi probabilities for the candidates
|
| 630 |
+
redi_probs = pos_probs[i, candidates]
|
| 631 |
+
|
| 632 |
+
# Calculate unnormalized guided probabilities
|
| 633 |
+
tilde_q = redi_probs * self._barker_g(w_proposal / w_current[i])
|
| 634 |
+
|
| 635 |
+
# Normalize and sample the final token
|
| 636 |
+
final_probs = tilde_q / (torch.sum(tilde_q) + 1e-9)
|
| 637 |
+
|
| 638 |
+
index = torch.multinomial(final_probs, 1).item()
|
| 639 |
+
|
| 640 |
+
if (random.uniform(0,1) > 1 and proposal_scores[:,index][0] == 1) or torch.sum(weights.squeeze(1) * proposal_scores[:, index]) >= torch.sum(weights.squeeze(1) * current_scores[:,i]):
|
| 641 |
+
final_token = candidates[index]
|
| 642 |
+
print(f"Previous Weighted Sum: {torch.sum(weights.squeeze(1) * current_scores[:,i])}")
|
| 643 |
+
print(f"Previous Scores: {current_scores[:,i]}")
|
| 644 |
+
|
| 645 |
+
print(f"New Weighted Sum: {torch.sum(weights.squeeze(1) * proposal_scores[:, index])}")
|
| 646 |
+
print(f"New Scores: {proposal_scores[:,index]}")
|
| 647 |
+
improved = True
|
| 648 |
+
improved_indices.append(i)
|
| 649 |
+
|
| 650 |
+
else:
|
| 651 |
+
final_token = x[i][mut_idx]
|
| 652 |
+
|
| 653 |
+
final_proposal_tokens.append(final_token)
|
| 654 |
+
|
| 655 |
+
# Update the sequences with the chosen tokens
|
| 656 |
+
x[torch.arange(self.args.num_samples), mut_idx] = torch.stack(final_proposal_tokens)
|
| 657 |
+
if improved:
|
| 658 |
+
current_scores = self._get_scores(x)
|
| 659 |
+
print(self.tokenizer.batch_decode(x))
|
| 660 |
+
|
| 661 |
+
# Log step with tracking (every 4 steps to reduce overhead)
|
| 662 |
+
if self.tracker and self.conjugation_obj and (t % 4 == 0 or t == self.args.optimization_steps - 1):
|
| 663 |
+
detailed = self.conjugation_obj.get_last_detailed_results()
|
| 664 |
+
self.tracker.log_step(t, current_scores[0], detailed, improved_indices)
|
| 665 |
+
|
| 666 |
+
# Print summary for final step
|
| 667 |
+
if t == self.args.optimization_steps - 1:
|
| 668 |
+
self.tracker.print_step_summary(t, current_scores[0], detailed)
|
| 669 |
+
|
| 670 |
+
return x
|
| 671 |
+
|
| 672 |
+
|
| 673 |
+
# --- Main Execution ---
|
| 674 |
+
def main(args):
|
| 675 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 676 |
+
print(f"Using device: {device}")
|
| 677 |
+
|
| 678 |
+
target = args.target
|
| 679 |
+
|
| 680 |
+
# --- Initialize tokenizer based on type ---
|
| 681 |
+
if args.tokenizer_type == 'amino':
|
| 682 |
+
print("Using Amino Acid tokenizer")
|
| 683 |
+
tokenizer = AminoTokenizer()
|
| 684 |
+
smiles_tokenizer = SMILES_SPE_Tokenizer(args.smiles_vocab_path, args.smiles_splits_path)
|
| 685 |
+
else:
|
| 686 |
+
print("Using SMILES tokenizer")
|
| 687 |
+
tokenizer = SMILES_SPE_Tokenizer(args.smiles_vocab_path, args.smiles_splits_path)
|
| 688 |
+
smiles_tokenizer = tokenizer
|
| 689 |
+
|
| 690 |
+
# --- Initialize objective functions ---
|
| 691 |
+
# Conjugation always uses SMILES tokens (AA->SMILES->builder).
|
| 692 |
+
conjugation_model = Conjugation(smiles_tokenizer, device=device, builder_type=args.builder_type)
|
| 693 |
+
# Binding affinity: AA-input or SMILES-input, depending on --affinity_type.
|
| 694 |
+
# The returned object carries `.input_type` ('aa' or 'smiles') so the
|
| 695 |
+
# generator routes inputs correctly in _get_scores.
|
| 696 |
+
affinity_model = make_affinity(
|
| 697 |
+
args.affinity_type,
|
| 698 |
+
target_protein_seq=target,
|
| 699 |
+
device=device,
|
| 700 |
+
aa_ckpt_path=args.aa_affinity_ckpt,
|
| 701 |
+
)
|
| 702 |
+
print(f"Affinity scorer: {type(affinity_model).__name__} (input_type={getattr(affinity_model, 'input_type', '?')})")
|
| 703 |
+
|
| 704 |
+
# List of all objective functions
|
| 705 |
+
OBJECTIVE_FUNCTIONS = [conjugation_model, affinity_model]
|
| 706 |
+
|
| 707 |
+
# Initialize tracker for logging
|
| 708 |
+
tracker = GenerationTracker(output_dir=args.output_dir)
|
| 709 |
+
|
| 710 |
+
# --- Set random seeds for reproducibility ---
|
| 711 |
+
if args.seed is not None:
|
| 712 |
+
import random
|
| 713 |
+
import numpy as np
|
| 714 |
+
random.seed(args.seed)
|
| 715 |
+
np.random.seed(args.seed)
|
| 716 |
+
torch.manual_seed(args.seed)
|
| 717 |
+
if torch.cuda.is_available():
|
| 718 |
+
torch.cuda.manual_seed(args.seed)
|
| 719 |
+
torch.cuda.manual_seed_all(args.seed)
|
| 720 |
+
print(f"Random seed set to: {args.seed}")
|
| 721 |
+
|
| 722 |
+
# --- Load Model ---
|
| 723 |
+
print(f"Loading model from checkpoint: {args.checkpoint}")
|
| 724 |
+
checkpoint = torch.load(args.checkpoint, map_location=device, weights_only=False)
|
| 725 |
+
|
| 726 |
+
if args.tokenizer_type == 'amino':
|
| 727 |
+
# Vanilla PyTorch checkpoint for amino model
|
| 728 |
+
raw_model_args = checkpoint['args']
|
| 729 |
+
if isinstance(raw_model_args, argparse.Namespace):
|
| 730 |
+
raw_model_args = vars(raw_model_args)
|
| 731 |
+
model_args = edict(raw_model_args)
|
| 732 |
+
model = MDLM(
|
| 733 |
+
vocab_size=model_args.vocab_size,
|
| 734 |
+
seq_len=model_args.seq_len,
|
| 735 |
+
model_dim=model_args.model_dim,
|
| 736 |
+
n_heads=model_args.n_heads,
|
| 737 |
+
n_layers=model_args.n_layers
|
| 738 |
+
).to(device)
|
| 739 |
+
model.load_state_dict(checkpoint['model_state_dict'])
|
| 740 |
+
print(f"Amino model loaded. Vocab size: {model_args.vocab_size}")
|
| 741 |
+
else:
|
| 742 |
+
# PyTorch Lightning checkpoint for SMILES model
|
| 743 |
+
model_hparams = checkpoint["hyper_parameters"]["args"]
|
| 744 |
+
model = MDLMLightningModule.load_from_checkpoint(
|
| 745 |
+
args.checkpoint,
|
| 746 |
+
args=model_hparams,
|
| 747 |
+
tokenizer=tokenizer,
|
| 748 |
+
map_location=device,
|
| 749 |
+
strict=False
|
| 750 |
+
)
|
| 751 |
+
model.to(device)
|
| 752 |
+
model.eval()
|
| 753 |
+
print("Model loaded successfully.")
|
| 754 |
+
|
| 755 |
+
mog_generator = MOGGenerator(
|
| 756 |
+
model, device, OBJECTIVE_FUNCTIONS, args, tracker=tracker,
|
| 757 |
+
tokenizer=tokenizer, tokenizer_type=args.tokenizer_type
|
| 758 |
+
)
|
| 759 |
+
|
| 760 |
+
all_sequences = []
|
| 761 |
+
all_final_results = []
|
| 762 |
+
|
| 763 |
+
for batch_idx in range(args.num_batches):
|
| 764 |
+
print(f"\n{'='*60}")
|
| 765 |
+
print(f"BATCH {batch_idx + 1}/{args.num_batches}")
|
| 766 |
+
print(f"{'='*60}")
|
| 767 |
+
|
| 768 |
+
generated_tokens = mog_generator.generate()
|
| 769 |
+
final_scores = mog_generator._get_scores(generated_tokens.detach()).detach().cpu().numpy()
|
| 770 |
+
sequence_str = tokenizer.batch_decode(generated_tokens)
|
| 771 |
+
|
| 772 |
+
print("\n--- Batch Results ---")
|
| 773 |
+
print(f"Sequences: {sequence_str}")
|
| 774 |
+
print(f"Scores: {final_scores}")
|
| 775 |
+
|
| 776 |
+
all_sequences.extend(sequence_str)
|
| 777 |
+
|
| 778 |
+
# Get detailed results with properties for final sequences
|
| 779 |
+
# For amino tokenizer, we need to convert to SMILES first for detailed scoring
|
| 780 |
+
if args.tokenizer_type == 'amino':
|
| 781 |
+
# Convert AA to SMILES for detailed conjugation analysis
|
| 782 |
+
smiles_list = []
|
| 783 |
+
for seq in sequence_str:
|
| 784 |
+
three_letter = one_letter_to_three_letter(seq)
|
| 785 |
+
smiles = aa_to_smiles(three_letter, cyclic=args.cyclic)
|
| 786 |
+
smiles_list.append(smiles if isinstance(smiles, str) else 'C')
|
| 787 |
+
# Tokenize SMILES for conjugation model
|
| 788 |
+
smiles_tokens = smiles_tokenizer(smiles_list, return_tensors='pt', padding=True).to(device)
|
| 789 |
+
detailed_with_props = conjugation_model.get_detailed_scores_with_properties(smiles_tokens["input_ids"])
|
| 790 |
+
else:
|
| 791 |
+
detailed_with_props = conjugation_model.get_detailed_scores_with_properties(generated_tokens)
|
| 792 |
+
|
| 793 |
+
tracker.log_final(sequence_str, detailed_with_props)
|
| 794 |
+
all_final_results.extend(detailed_with_props)
|
| 795 |
+
|
| 796 |
+
# Print and save final summary
|
| 797 |
+
tracker.print_final_summary()
|
| 798 |
+
tracker.save(filename_prefix='pdc_generation')
|
| 799 |
+
|
| 800 |
+
# Save sequences to text file
|
| 801 |
+
with open(args.output_file, 'w') as f:
|
| 802 |
+
for i, seq in enumerate(all_sequences):
|
| 803 |
+
if args.tokenizer_type == 'amino':
|
| 804 |
+
# Also save converted SMILES for amino sequences
|
| 805 |
+
three_letter = one_letter_to_three_letter(seq)
|
| 806 |
+
smiles = aa_to_smiles(three_letter, cyclic=args.cyclic)
|
| 807 |
+
if isinstance(smiles, str):
|
| 808 |
+
f.write(f"{seq}\t{smiles}\n")
|
| 809 |
+
else:
|
| 810 |
+
f.write(f"{seq}\tINVALID_SMILES\n")
|
| 811 |
+
else:
|
| 812 |
+
f.write(seq + '\n')
|
| 813 |
+
print(f"\nSequences saved to: {args.output_file}")
|
| 814 |
+
|
| 815 |
+
print("\nGeneration complete.")
|
| 816 |
+
|
| 817 |
+
|
| 818 |
+
|
| 819 |
+
if __name__ == "__main__":
|
| 820 |
+
parser = argparse.ArgumentParser(description="Multi-Objective Generation with LBP-MOG-ReDi (Single Mutation).")
|
| 821 |
+
|
| 822 |
+
parser.add_argument("--checkpoint", type=str, required=True, help="Path to the trained ReDi model checkpoint.")
|
| 823 |
+
parser.add_argument("--num_samples", type=int, default=10, help="Number of samples to generate.")
|
| 824 |
+
parser.add_argument("--num_batches", type=int, default=10, help="Number of batches to generate.")
|
| 825 |
+
parser.add_argument("--output_file", type=str, default="./smiles.txt", help="File to save the generated sequences.")
|
| 826 |
+
parser.add_argument("--output_dir", type=str, default="./generation_logs", help="Directory to save detailed generation logs.")
|
| 827 |
+
parser.add_argument("--gen_len", type=int, default=50, help="Length of the sequences to generate.")
|
| 828 |
+
parser.add_argument("--optimization_steps", type=int, default=16, help="Number of optimization steps per generation.")
|
| 829 |
+
parser.add_argument("--weights", type=float, nargs='+', required=False, help="Weights for the objectives.")
|
| 830 |
+
parser.add_argument("--eta_min", type=float, default=1.0, help="Minimum guidance strength.")
|
| 831 |
+
parser.add_argument("--eta_max", type=float, default=20.0, help="Maximum guidance strength.")
|
| 832 |
+
parser.add_argument("--top_p", type=float, default=0.9, help="Top-p for pruning candidate tokens.")
|
| 833 |
+
parser.add_argument("--builder_type", type=str, default="AoaDau",
|
| 834 |
+
choices=["AoaDau", "SSCpt", "VcMMAE", "DSPACpt", "MalHexCpt",
|
| 835 |
+
"DSECCpt", "Cpt", "DSPASN38", "DSEBSN38", "DSEPSN38", "SN38"],
|
| 836 |
+
help="PDC builder type for conjugation.")
|
| 837 |
+
parser.add_argument("--seed", type=int, default=None, help="Random seed for reproducibility.")
|
| 838 |
+
parser.add_argument("--require_valid_x0", action="store_true",
|
| 839 |
+
help="If set, regenerate x0 samples until they are valid conjugatable peptides.")
|
| 840 |
+
parser.add_argument("--max_x0_retries", type=int, default=10,
|
| 841 |
+
help="Maximum retries when require_valid_x0 is enabled.")
|
| 842 |
+
|
| 843 |
+
parser.add_argument("--target", type=str, required=True, help="Target protein sequence for binding affinity.")
|
| 844 |
+
|
| 845 |
+
# Tokenizer and model type arguments
|
| 846 |
+
parser.add_argument("--tokenizer_type", type=str, default="smiles", choices=["smiles", "amino"],
|
| 847 |
+
help="Type of tokenizer: 'smiles' for SMILES SPE tokenizer, 'amino' for amino acid tokenizer.")
|
| 848 |
+
parser.add_argument("--cyclic", action="store_true",
|
| 849 |
+
help="Generate cyclic peptides (only for amino tokenizer).")
|
| 850 |
+
parser.add_argument("--smiles_vocab_path", type=str,
|
| 851 |
+
default="./smiles/smiles_tokenizer/new_vocab.txt",
|
| 852 |
+
help="Path to SMILES tokenizer vocabulary file.")
|
| 853 |
+
parser.add_argument("--smiles_splits_path", type=str,
|
| 854 |
+
default="./smiles/smiles_tokenizer/new_splits.txt",
|
| 855 |
+
help="Path to SMILES tokenizer splits file.")
|
| 856 |
+
|
| 857 |
+
# AA-mode binding-affinity selection (this is the WT-AA / PepReDi script).
|
| 858 |
+
parser.add_argument("--affinity_type", type=str, default="smiles",
|
| 859 |
+
choices=["aa", "smiles"],
|
| 860 |
+
help="Which binding-affinity oracle to use during "
|
| 861 |
+
"guidance: 'aa' = AA-input UnpooledBindingPredictor "
|
| 862 |
+
"(matches moPPIt's setup), 'smiles' = SMILES-input "
|
| 863 |
+
"BindingAffinity (matches the SMILES CupOFMoCA "
|
| 864 |
+
"tables). Defaults to 'aa' since this script is "
|
| 865 |
+
"the WT-AA pipeline.")
|
| 866 |
+
parser.add_argument("--aa_affinity_ckpt", type=str,
|
| 867 |
+
default=DEFAULT_AA_AFFINITY_CKPT,
|
| 868 |
+
help="Path to the AA-input affinity predictor "
|
| 869 |
+
"checkpoint (binding_affinity_unpooled.pt).")
|
| 870 |
+
|
| 871 |
+
args = parser.parse_args()
|
| 872 |
+
main(args)
|
smiles/generation/guided_uaa.py
ADDED
|
@@ -0,0 +1,981 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import re
|
| 3 |
+
import random
|
| 4 |
+
import sys
|
| 5 |
+
from collections import Counter
|
| 6 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 7 |
+
import csv
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
import torch
|
| 12 |
+
import torch.nn as nn
|
| 13 |
+
import torch.nn.functional as F
|
| 14 |
+
from tqdm import tqdm
|
| 15 |
+
from smiles.models.train import MDLMLightningModule
|
| 16 |
+
from smiles.smiles_tokenizer.my_tokenizers import SMILES_SPE_Tokenizer
|
| 17 |
+
import numpy as np
|
| 18 |
+
from easydict import EasyDict as edict
|
| 19 |
+
|
| 20 |
+
from smiles.evaluation.peptide_analyzer import PeptideAnalyzer
|
| 21 |
+
from smiles.evaluation.pdc_analyzer import PDCAnalyzer
|
| 22 |
+
from smiles.scoring.classifiers import Conjugation, BindingAffinity
|
| 23 |
+
|
| 24 |
+
# Import AA-to-SMILES conversion utilities
|
| 25 |
+
from third_party.SMILESToPeptide.peptide_to_smiles import aa_to_smiles
|
| 26 |
+
from third_party.SMILESToPeptide.aminoacid_selective import one_letter_to_three_letter
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# --- Model Architecture for Amino Acid Model ---
|
| 30 |
+
def modulate(x, shift, scale):
|
| 31 |
+
"""Modulates the input tensor x with a shift and scale."""
|
| 32 |
+
return x * (1 + scale.unsqueeze(1)) + shift.unsqueeze(1)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class TimestepEmbedder(nn.Module):
|
| 36 |
+
"""Embeds a continuous scalar timestep t in [0, 1] into a vector representation."""
|
| 37 |
+
def __init__(self, hidden_size):
|
| 38 |
+
super().__init__()
|
| 39 |
+
self.mlp = nn.Sequential(
|
| 40 |
+
nn.Linear(1, hidden_size, bias=True),
|
| 41 |
+
nn.SiLU(),
|
| 42 |
+
nn.Linear(hidden_size, hidden_size, bias=True),
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
def forward(self, t):
|
| 46 |
+
return self.mlp(t.unsqueeze(-1))
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class DiTBlock(nn.Module):
|
| 50 |
+
"""A single block of the Diffusion Transformer."""
|
| 51 |
+
def __init__(self, hidden_size, n_heads):
|
| 52 |
+
super().__init__()
|
| 53 |
+
self.norm1 = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
|
| 54 |
+
self.attn = nn.MultiheadAttention(hidden_size, n_heads, batch_first=True)
|
| 55 |
+
self.norm2 = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6)
|
| 56 |
+
self.mlp = nn.Sequential(
|
| 57 |
+
nn.Linear(hidden_size, 4 * hidden_size),
|
| 58 |
+
nn.GELU(),
|
| 59 |
+
nn.Linear(4 * hidden_size, hidden_size)
|
| 60 |
+
)
|
| 61 |
+
self.adaLN_modulation = nn.Sequential(
|
| 62 |
+
nn.SiLU(),
|
| 63 |
+
nn.Linear(hidden_size, 6 * hidden_size, bias=True)
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
def forward(self, x, c):
|
| 67 |
+
shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = self.adaLN_modulation(c).chunk(6, dim=1)
|
| 68 |
+
x_norm1 = modulate(self.norm1(x), shift_msa, scale_msa)
|
| 69 |
+
attn_output, _ = self.attn(x_norm1, x_norm1, x_norm1)
|
| 70 |
+
x = x + gate_msa.unsqueeze(1) * attn_output
|
| 71 |
+
x_norm2 = modulate(self.norm2(x), shift_mlp, scale_mlp)
|
| 72 |
+
mlp_output = self.mlp(x_norm2)
|
| 73 |
+
x = x + gate_mlp.unsqueeze(1) * mlp_output
|
| 74 |
+
return x
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class MDLM(nn.Module):
|
| 78 |
+
"""Masked Diffusion Language Model (MDLM) using a DiT backbone."""
|
| 79 |
+
def __init__(self, vocab_size, seq_len, model_dim, n_heads, n_layers):
|
| 80 |
+
super().__init__()
|
| 81 |
+
self.vocab_size = vocab_size
|
| 82 |
+
self.seq_len = seq_len
|
| 83 |
+
self.model_dim = model_dim
|
| 84 |
+
|
| 85 |
+
self.token_embedder = nn.Embedding(vocab_size, model_dim)
|
| 86 |
+
self.pos_embedder = nn.Parameter(torch.randn(1, seq_len, model_dim))
|
| 87 |
+
self.time_embedder = TimestepEmbedder(model_dim)
|
| 88 |
+
|
| 89 |
+
self.transformer_blocks = nn.ModuleList([
|
| 90 |
+
DiTBlock(model_dim, n_heads) for _ in range(n_layers)
|
| 91 |
+
])
|
| 92 |
+
|
| 93 |
+
self.final_norm = nn.LayerNorm(model_dim)
|
| 94 |
+
self.lm_head = nn.Linear(model_dim, vocab_size)
|
| 95 |
+
|
| 96 |
+
def forward(self, x, t):
|
| 97 |
+
seq_len = x.shape[1]
|
| 98 |
+
x_embed = self.token_embedder(x) + self.pos_embedder[:, :seq_len, :]
|
| 99 |
+
t_embed = self.time_embedder(t)
|
| 100 |
+
for block in self.transformer_blocks:
|
| 101 |
+
x_embed = block(x_embed, t_embed)
|
| 102 |
+
x_embed = self.final_norm(x_embed)
|
| 103 |
+
logits = self.lm_head(x_embed)
|
| 104 |
+
return logits
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
class AminoTokenizer:
|
| 108 |
+
"""Tokenizer for amino acid sequences (natural and unnatural AAs)."""
|
| 109 |
+
def __init__(self):
|
| 110 |
+
self.vocab = {
|
| 111 |
+
'<cls>': 0, '<pad>': 1, '<eos>': 2, '<unk>': 3,
|
| 112 |
+
'L': 4, 'A': 5, 'G': 6, 'V': 7, 'S': 8, 'E': 9,
|
| 113 |
+
'R': 10, 'T': 11, 'I': 12, 'D': 13, 'P': 14, 'K': 15,
|
| 114 |
+
'Q': 16, 'N': 17, 'F': 18, 'Y': 19, 'M': 20, 'H': 21,
|
| 115 |
+
'W': 22, 'C': 23,
|
| 116 |
+
# Non-canonical amino acids
|
| 117 |
+
'Ŷ': 24, 'Ĉ': 25, 'Ĝ': 26, 'Ė': 27, 'Ƙ': 28,
|
| 118 |
+
'Ṃ': 29, 'Ł': 30, 'Ċ': 31, 'Ṯ': 32,
|
| 119 |
+
# Lowercase tokens (D-amino acids)
|
| 120 |
+
'a': 33, 'c': 34, 'd': 35, 'e': 36, 'f': 37,
|
| 121 |
+
'g': 38, 'h': 39, 'i': 40, 'k': 41, 'l': 42,
|
| 122 |
+
'm': 43, 'n': 44, 'p': 45, 'q': 46, 'r': 47,
|
| 123 |
+
's': 48, 't': 49, 'v': 50, 'w': 51, 'y': 52,
|
| 124 |
+
}
|
| 125 |
+
self.id_to_token = {i: t for t, i in self.vocab.items()}
|
| 126 |
+
|
| 127 |
+
self.cls_token = '<cls>'
|
| 128 |
+
self.pad_token = '<pad>'
|
| 129 |
+
self.eos_token = '<eos>'
|
| 130 |
+
self.unk_token = '<unk>'
|
| 131 |
+
|
| 132 |
+
self.cls_id = self.vocab[self.cls_token]
|
| 133 |
+
self.pad_id = self.vocab[self.pad_token]
|
| 134 |
+
self.eos_id = self.vocab[self.eos_token]
|
| 135 |
+
self.unk_id = self.vocab[self.unk_token]
|
| 136 |
+
|
| 137 |
+
self.vocab_size = len(self.vocab.keys())
|
| 138 |
+
|
| 139 |
+
self._ncaa_to_nat_token = {
|
| 140 |
+
'Ŷ': 'Y', 'Ĉ': 'C', 'Ċ': 'C', 'Ĝ': 'G', 'Ė': 'E',
|
| 141 |
+
'Ƙ': 'K', 'Ṃ': 'M', 'Ł': 'L', 'Ṯ': 'T',
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
id_map = torch.arange(self.vocab_size, dtype=torch.long)
|
| 145 |
+
for ncaa_tok, nat_tok in self._ncaa_to_nat_token.items():
|
| 146 |
+
if ncaa_tok in self.vocab and nat_tok in self.vocab:
|
| 147 |
+
id_map[self.vocab[ncaa_tok]] = self.vocab[nat_tok]
|
| 148 |
+
self._ncaa_to_nat_id_map = id_map
|
| 149 |
+
|
| 150 |
+
def encode(self, seq: str, add_special_tokens: bool = True):
|
| 151 |
+
token_ids = []
|
| 152 |
+
if add_special_tokens:
|
| 153 |
+
token_ids.append(self.cls_id)
|
| 154 |
+
for ch in seq:
|
| 155 |
+
token_ids.append(self.vocab.get(ch, self.unk_id))
|
| 156 |
+
if add_special_tokens:
|
| 157 |
+
token_ids.append(self.eos_id)
|
| 158 |
+
return token_ids
|
| 159 |
+
|
| 160 |
+
def batch_encode(self, seqs, add_special_tokens: bool = True):
|
| 161 |
+
return torch.tensor([self.encode(s, add_special_tokens=add_special_tokens) for s in seqs])
|
| 162 |
+
|
| 163 |
+
def decode(self, ids, skip_special_tokens: bool = True):
|
| 164 |
+
if isinstance(ids, torch.Tensor):
|
| 165 |
+
ids = ids.tolist()
|
| 166 |
+
tokens = []
|
| 167 |
+
for i in ids:
|
| 168 |
+
tok = self.id_to_token.get(int(i), self.unk_token)
|
| 169 |
+
if skip_special_tokens and tok in {self.cls_token, self.eos_token, self.pad_token}:
|
| 170 |
+
continue
|
| 171 |
+
tokens.append(tok)
|
| 172 |
+
return ''.join(tokens)
|
| 173 |
+
|
| 174 |
+
def batch_decode(self, batch_ids, skip_special_tokens: bool = True):
|
| 175 |
+
return [self.decode(ids, skip_special_tokens=skip_special_tokens) for ids in batch_ids]
|
| 176 |
+
|
| 177 |
+
def to_natural_ids(self, batch_ids: torch.Tensor) -> torch.Tensor:
|
| 178 |
+
"""Map non-canonical AA token IDs to natural AA token IDs."""
|
| 179 |
+
if not torch.is_tensor(batch_ids):
|
| 180 |
+
batch_ids = torch.tensor(batch_ids, dtype=torch.long)
|
| 181 |
+
id_map = self._ncaa_to_nat_id_map.to(device=batch_ids.device)
|
| 182 |
+
out = batch_ids.clone()
|
| 183 |
+
valid = (out >= 0) & (out < id_map.numel())
|
| 184 |
+
out[valid] = id_map[out[valid]]
|
| 185 |
+
out[~valid] = self.unk_id
|
| 186 |
+
return out
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
class GenerationTracker:
|
| 190 |
+
"""
|
| 191 |
+
Tracks detailed validity information during generation
|
| 192 |
+
"""
|
| 193 |
+
def __init__(self, output_dir='./generation_logs'):
|
| 194 |
+
self.output_dir = output_dir
|
| 195 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 196 |
+
|
| 197 |
+
self.run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 198 |
+
self.step_logs = []
|
| 199 |
+
self.final_results = []
|
| 200 |
+
|
| 201 |
+
def log_step(self, step, scores, detailed_results=None, improved_indices=None):
|
| 202 |
+
"""Log information for a generation step"""
|
| 203 |
+
step_info = {
|
| 204 |
+
'step': step,
|
| 205 |
+
'scores': scores.cpu().numpy().tolist() if isinstance(scores, torch.Tensor) else scores,
|
| 206 |
+
'num_valid': int(sum(1 for s in scores if s > 0)) if hasattr(scores, '__iter__') else 0,
|
| 207 |
+
'improved_indices': improved_indices or []
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
if detailed_results:
|
| 211 |
+
step_info['detailed'] = []
|
| 212 |
+
for i, result in enumerate(detailed_results):
|
| 213 |
+
step_info['detailed'].append({
|
| 214 |
+
'sample_idx': i,
|
| 215 |
+
'is_valid_peptide': result.get('is_valid_peptide', False),
|
| 216 |
+
'has_conjugation_site': result.get('has_conjugation_site', False),
|
| 217 |
+
'conjugation_success': result.get('conjugation_success', False),
|
| 218 |
+
'pdc_valid': result.get('pdc_valid', False),
|
| 219 |
+
'num_conjugates': len(result.get('conjugates', [])),
|
| 220 |
+
'error': result.get('error')
|
| 221 |
+
})
|
| 222 |
+
|
| 223 |
+
self.step_logs.append(step_info)
|
| 224 |
+
|
| 225 |
+
def log_final(self, sequences, detailed_results_with_props):
|
| 226 |
+
"""Log final results with properties"""
|
| 227 |
+
for i, (seq, result) in enumerate(zip(sequences, detailed_results_with_props)):
|
| 228 |
+
final_info = {
|
| 229 |
+
'sample_idx': i,
|
| 230 |
+
'peptide_smiles': seq,
|
| 231 |
+
'is_valid_peptide': result.get('is_valid_peptide', False),
|
| 232 |
+
'has_conjugation_site': result.get('has_conjugation_site', False),
|
| 233 |
+
'num_conjugates': len(result.get('conjugates', [])),
|
| 234 |
+
'num_valid_pdcs': len(result.get('valid_pdcs', [])),
|
| 235 |
+
'best_pdc': None,
|
| 236 |
+
'properties': result.get('properties', {})
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
if result.get('best_pdc'):
|
| 240 |
+
final_info['best_pdc'] = {
|
| 241 |
+
'smiles': result['best_pdc'].get('smiles', ''),
|
| 242 |
+
'checks': result['best_pdc'].get('checks', {})
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
self.final_results.append(final_info)
|
| 246 |
+
|
| 247 |
+
def save(self, filename_prefix='generation'):
|
| 248 |
+
"""Save all logs to files"""
|
| 249 |
+
# Save step logs
|
| 250 |
+
step_log_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_steps.json")
|
| 251 |
+
with open(step_log_path, 'w') as f:
|
| 252 |
+
json.dump(self.step_logs, f, indent=2, default=str)
|
| 253 |
+
|
| 254 |
+
# Save final results
|
| 255 |
+
final_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_final.json")
|
| 256 |
+
with open(final_path, 'w') as f:
|
| 257 |
+
json.dump(self.final_results, f, indent=2, default=str)
|
| 258 |
+
|
| 259 |
+
# Save summary CSV
|
| 260 |
+
csv_path = os.path.join(self.output_dir, f"{filename_prefix}_{self.run_id}_summary.csv")
|
| 261 |
+
with open(csv_path, 'w', newline='') as f:
|
| 262 |
+
writer = csv.writer(f)
|
| 263 |
+
writer.writerow([
|
| 264 |
+
'sample_idx', 'peptide_smiles', 'is_valid_peptide', 'has_conjugation_site',
|
| 265 |
+
'num_conjugates', 'num_valid_pdcs', 'best_pdc_smiles',
|
| 266 |
+
'mf', 'polararea', 'xlogp', 'heavycnt', 'rotbonds'
|
| 267 |
+
])
|
| 268 |
+
for result in self.final_results:
|
| 269 |
+
props = result.get('properties', {})
|
| 270 |
+
writer.writerow([
|
| 271 |
+
result['sample_idx'],
|
| 272 |
+
result['peptide_smiles'],
|
| 273 |
+
result['is_valid_peptide'],
|
| 274 |
+
result['has_conjugation_site'],
|
| 275 |
+
result['num_conjugates'],
|
| 276 |
+
result['num_valid_pdcs'],
|
| 277 |
+
result['best_pdc']['smiles'] if result.get('best_pdc') else '',
|
| 278 |
+
props.get('mf', ''),
|
| 279 |
+
props.get('polararea', ''),
|
| 280 |
+
props.get('xlogp', ''),
|
| 281 |
+
props.get('heavycnt', ''),
|
| 282 |
+
props.get('rotbonds', '')
|
| 283 |
+
])
|
| 284 |
+
|
| 285 |
+
print(f"\n=== Generation logs saved ===")
|
| 286 |
+
print(f" Step logs: {step_log_path}")
|
| 287 |
+
print(f" Final results: {final_path}")
|
| 288 |
+
print(f" Summary CSV: {csv_path}")
|
| 289 |
+
|
| 290 |
+
return step_log_path, final_path, csv_path
|
| 291 |
+
|
| 292 |
+
def print_step_summary(self, step, scores, detailed_results=None):
|
| 293 |
+
"""Print a summary for the current step"""
|
| 294 |
+
if isinstance(scores, torch.Tensor):
|
| 295 |
+
scores_list = scores.cpu().numpy().tolist()
|
| 296 |
+
else:
|
| 297 |
+
scores_list = list(scores)
|
| 298 |
+
|
| 299 |
+
num_valid = sum(1 for s in scores_list if s > 0)
|
| 300 |
+
|
| 301 |
+
print(f"\n--- Step {step} Summary ---")
|
| 302 |
+
print(f" Valid: {num_valid}/{len(scores_list)}")
|
| 303 |
+
|
| 304 |
+
if detailed_results:
|
| 305 |
+
valid_peptide = sum(1 for r in detailed_results if r.get('is_valid_peptide'))
|
| 306 |
+
has_site = sum(1 for r in detailed_results if r.get('has_conjugation_site'))
|
| 307 |
+
conj_success = sum(1 for r in detailed_results if r.get('conjugation_success'))
|
| 308 |
+
pdc_valid = sum(1 for r in detailed_results if r.get('pdc_valid'))
|
| 309 |
+
|
| 310 |
+
print(f" Breakdown:")
|
| 311 |
+
print(f" Valid peptides: {valid_peptide}")
|
| 312 |
+
print(f" Has conjugation site: {has_site}")
|
| 313 |
+
print(f" Conjugation success: {conj_success}")
|
| 314 |
+
print(f" PDC valid: {pdc_valid}")
|
| 315 |
+
|
| 316 |
+
def print_final_summary(self):
|
| 317 |
+
"""Print final summary"""
|
| 318 |
+
print("\n" + "=" * 60)
|
| 319 |
+
print("FINAL GENERATION SUMMARY")
|
| 320 |
+
print("=" * 60)
|
| 321 |
+
|
| 322 |
+
total = len(self.final_results)
|
| 323 |
+
valid_pdcs = sum(1 for r in self.final_results if r.get('num_valid_pdcs', 0) > 0)
|
| 324 |
+
|
| 325 |
+
print(f"\nTotal sequences: {total}")
|
| 326 |
+
print(f"With valid PDCs: {valid_pdcs}")
|
| 327 |
+
|
| 328 |
+
print("\nDetailed results:")
|
| 329 |
+
for result in self.final_results:
|
| 330 |
+
status = "" if result.get('num_valid_pdcs', 0) > 0 else ""
|
| 331 |
+
print(f"\n [{status}] Sample {result['sample_idx']}:")
|
| 332 |
+
print(f" Peptide: {result['peptide_smiles'][:60]}...")
|
| 333 |
+
print(f" Valid peptide: {result['is_valid_peptide']}")
|
| 334 |
+
print(f" Has conjugation site: {result['has_conjugation_site']}")
|
| 335 |
+
print(f" Conjugates: {result['num_conjugates']}")
|
| 336 |
+
print(f" Valid PDCs: {result['num_valid_pdcs']}")
|
| 337 |
+
|
| 338 |
+
if result.get('properties'):
|
| 339 |
+
props = result['properties']
|
| 340 |
+
print(f" Properties:")
|
| 341 |
+
for k, v in props.items():
|
| 342 |
+
print(f" {k}: {v}")
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def peptide_bond_mask(smiles_list):
|
| 346 |
+
# Initialize the batch mask
|
| 347 |
+
batch_size = len(smiles_list)
|
| 348 |
+
max_seq_length = 1035 #max(len(smiles) for smiles in smiles_list) # Find the longest SMILES
|
| 349 |
+
mask = torch.zeros((batch_size, max_seq_length), dtype=torch.int) # Mask filled with zeros
|
| 350 |
+
|
| 351 |
+
bond_patterns = [
|
| 352 |
+
(r'OC\(=O\)', 'ester'),
|
| 353 |
+
(r'N\(C\)C\(=O\)', 'n_methyl'),
|
| 354 |
+
(r'N[12]C\(=O\)', 'peptide'), # Pro peptide bonds
|
| 355 |
+
(r'NC\(=O\)', 'peptide'), # Regular peptide bonds
|
| 356 |
+
(r'C\(=O\)N\(C\)', 'n_methyl'),
|
| 357 |
+
(r'C\(=O\)N[12]?', 'peptide')
|
| 358 |
+
]
|
| 359 |
+
|
| 360 |
+
for batch_idx, smiles in enumerate(smiles_list):
|
| 361 |
+
positions = []
|
| 362 |
+
used = set()
|
| 363 |
+
|
| 364 |
+
# Identify bonds
|
| 365 |
+
for pattern, bond_type in bond_patterns:
|
| 366 |
+
for match in re.finditer(pattern, smiles):
|
| 367 |
+
if not any(p in range(match.start(), match.end()) for p in used):
|
| 368 |
+
positions.append({
|
| 369 |
+
'start': match.start(),
|
| 370 |
+
'end': match.end(),
|
| 371 |
+
'type': bond_type,
|
| 372 |
+
'pattern': match.group()
|
| 373 |
+
})
|
| 374 |
+
used.update(range(match.start(), match.end()))
|
| 375 |
+
|
| 376 |
+
# Update the mask for the current SMILES
|
| 377 |
+
for pos in positions:
|
| 378 |
+
mask[batch_idx, pos['start']:pos['end']] = 1
|
| 379 |
+
|
| 380 |
+
return mask
|
| 381 |
+
|
| 382 |
+
def peptide_token_mask(smiles_list, token_lists):
|
| 383 |
+
# Initialize the batch mask
|
| 384 |
+
batch_size = len(smiles_list)
|
| 385 |
+
token_seq_length = max(len(tokens) for tokens in token_lists) # Find the longest tokenized sequence
|
| 386 |
+
tokenized_masks = torch.zeros((batch_size, token_seq_length), dtype=torch.int) # Mask filled with zeros
|
| 387 |
+
atomwise_masks = peptide_bond_mask(smiles_list)
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
for batch_idx, atomwise_mask in enumerate(atomwise_masks):
|
| 391 |
+
token_seq = token_lists[batch_idx]
|
| 392 |
+
atom_idx = 0
|
| 393 |
+
|
| 394 |
+
for token_idx, token in enumerate(token_seq):
|
| 395 |
+
if token_idx != 0 and token_idx != len(token_seq) - 1:
|
| 396 |
+
if torch.sum(atomwise_mask[atom_idx:atom_idx+len(token)]) >= 1:
|
| 397 |
+
tokenized_masks[batch_idx][token_idx] = 1
|
| 398 |
+
atom_idx += len(token)
|
| 399 |
+
|
| 400 |
+
return tokenized_masks
|
| 401 |
+
|
| 402 |
+
class MOGGenerator:
|
| 403 |
+
def __init__(self, model, device, objectives, args, tracker=None, tokenizer=None, tokenizer_type='smiles'):
|
| 404 |
+
self.model = model
|
| 405 |
+
self.device = device
|
| 406 |
+
self.objectives = objectives
|
| 407 |
+
self.args = args
|
| 408 |
+
self.num_objectives = len(objectives)
|
| 409 |
+
self.peptide_analyzer = PeptideAnalyzer()
|
| 410 |
+
self.pdc_analyzer = PDCAnalyzer(device)
|
| 411 |
+
self.tracker = tracker
|
| 412 |
+
self.tokenizer = tokenizer
|
| 413 |
+
self.tokenizer_type = tokenizer_type
|
| 414 |
+
|
| 415 |
+
# Set invalid tokens based on tokenizer type
|
| 416 |
+
if tokenizer_type == 'amino':
|
| 417 |
+
self.invalid = [0, 1, 2, 3, 38] # special tokens + 'g' for amino
|
| 418 |
+
# Initialize SMILES tokenizer for AA-to-SMILES conversion
|
| 419 |
+
self.smiles_tokenizer = SMILES_SPE_Tokenizer(
|
| 420 |
+
args.smiles_vocab_path,
|
| 421 |
+
args.smiles_splits_path
|
| 422 |
+
)
|
| 423 |
+
else:
|
| 424 |
+
self.invalid = [0, 1, 2, 3, 4, 585, 586] # SMILES special tokens
|
| 425 |
+
self.smiles_tokenizer = None
|
| 426 |
+
|
| 427 |
+
# Find the Conjugation objective for detailed tracking
|
| 428 |
+
self.conjugation_obj = None
|
| 429 |
+
for obj in objectives:
|
| 430 |
+
if isinstance(obj, Conjugation):
|
| 431 |
+
self.conjugation_obj = obj
|
| 432 |
+
break
|
| 433 |
+
|
| 434 |
+
def _generate_single_x0(self, n_steps=16, temperature=1.0):
|
| 435 |
+
"""Generate a single batch of x0 sequences."""
|
| 436 |
+
self.model.eval()
|
| 437 |
+
|
| 438 |
+
# Get vocab size based on tokenizer type
|
| 439 |
+
if self.tokenizer_type == 'amino':
|
| 440 |
+
vocab_size = self.model.vocab_size
|
| 441 |
+
else:
|
| 442 |
+
vocab_size = self.model.tokenizer.vocab_size
|
| 443 |
+
|
| 444 |
+
# 1. Start with a tensor of random tokens (pure noise at t=0)
|
| 445 |
+
# For amino tokenizer, exclude invalid tokens from initialization
|
| 446 |
+
if self.tokenizer_type == 'amino':
|
| 447 |
+
valid_tokens = [i for i in range(vocab_size) if i not in self.invalid]
|
| 448 |
+
valid_tokens = torch.tensor(valid_tokens, device=self.device)
|
| 449 |
+
rand_indices = torch.randint(0, len(valid_tokens), (self.args.num_samples, self.args.gen_len), device=self.device)
|
| 450 |
+
body = valid_tokens[rand_indices]
|
| 451 |
+
# Add CLS and EOS tokens
|
| 452 |
+
cls_col = torch.full((self.args.num_samples, 1), self.tokenizer.cls_id, device=self.device)
|
| 453 |
+
eos_col = torch.full((self.args.num_samples, 1), self.tokenizer.eos_id, device=self.device)
|
| 454 |
+
x = torch.cat([cls_col, body, eos_col], dim=1)
|
| 455 |
+
else:
|
| 456 |
+
x = torch.randint(
|
| 457 |
+
0,
|
| 458 |
+
vocab_size,
|
| 459 |
+
(self.args.num_samples, self.args.gen_len),
|
| 460 |
+
device=self.device
|
| 461 |
+
)
|
| 462 |
+
|
| 463 |
+
# 2. Define the time schedule for the forward process (0.0 to 1.0)
|
| 464 |
+
time_steps = torch.linspace(0.0, 1.0, n_steps + 1, device=self.device)
|
| 465 |
+
|
| 466 |
+
# 3. Iteratively follow the flow from noise to data
|
| 467 |
+
with torch.no_grad():
|
| 468 |
+
for i in tqdm(range(n_steps), desc="Flow Matching Steps", leave=False):
|
| 469 |
+
t_curr = time_steps[i]
|
| 470 |
+
t_next = time_steps[i+1]
|
| 471 |
+
|
| 472 |
+
# Prepare the current timestep tensor for the model
|
| 473 |
+
t_tensor = torch.full((self.args.num_samples,), t_curr, device=self.device)
|
| 474 |
+
|
| 475 |
+
# Get the model's prediction for the final clean sequence (at t=1)
|
| 476 |
+
logits = self.model(x, t_tensor)
|
| 477 |
+
logits = logits / temperature
|
| 478 |
+
|
| 479 |
+
# Mask out invalid tokens
|
| 480 |
+
for inv_tok in self.invalid:
|
| 481 |
+
if inv_tok < logits.shape[-1]:
|
| 482 |
+
logits[:, :, inv_tok] = -1000
|
| 483 |
+
|
| 484 |
+
pred_x1 = torch.argmax(logits, dim=-1)
|
| 485 |
+
|
| 486 |
+
# On the last step, the result is the final prediction
|
| 487 |
+
if i == n_steps - 1:
|
| 488 |
+
x = pred_x1
|
| 489 |
+
break
|
| 490 |
+
|
| 491 |
+
# --- Construct the next state x_{t_next} ---
|
| 492 |
+
# The probability of a token being noise at time t_next is (1 - t_next).
|
| 493 |
+
noise_prob = 1.0 - t_next
|
| 494 |
+
mask = torch.rand(x.shape, device=self.device) < noise_prob
|
| 495 |
+
|
| 496 |
+
# Generate new random tokens for the noise positions
|
| 497 |
+
noise = torch.randint(
|
| 498 |
+
0,
|
| 499 |
+
vocab_size,
|
| 500 |
+
x.shape,
|
| 501 |
+
device=self.device
|
| 502 |
+
)
|
| 503 |
+
|
| 504 |
+
# Combine the final prediction with noise to form the next intermediate state
|
| 505 |
+
x = torch.where(mask, noise, pred_x1)
|
| 506 |
+
|
| 507 |
+
return x
|
| 508 |
+
|
| 509 |
+
def _check_conjugation_validity(self, x):
|
| 510 |
+
"""
|
| 511 |
+
Check if sequences can be conjugated.
|
| 512 |
+
Returns a boolean tensor indicating validity for each sample.
|
| 513 |
+
"""
|
| 514 |
+
if self.tokenizer_type == 'amino':
|
| 515 |
+
# For amino tokenizer, convert to SMILES first
|
| 516 |
+
seqs = self.tokenizer.batch_decode(x)
|
| 517 |
+
three_letter_seqs = [one_letter_to_three_letter(seq) for seq in seqs]
|
| 518 |
+
smiles_seqs = [aa_to_smiles(three_letter_seq, cyclic=self.args.cyclic)
|
| 519 |
+
for three_letter_seq in three_letter_seqs]
|
| 520 |
+
|
| 521 |
+
# Check if SMILES conversion succeeded and result is valid peptide
|
| 522 |
+
validities = []
|
| 523 |
+
for smiles in smiles_seqs:
|
| 524 |
+
if isinstance(smiles, str):
|
| 525 |
+
validities.append(self.peptide_analyzer.is_peptide(smiles))
|
| 526 |
+
else:
|
| 527 |
+
validities.append(False)
|
| 528 |
+
return torch.tensor(validities, device=self.device)
|
| 529 |
+
|
| 530 |
+
if self.conjugation_obj is None:
|
| 531 |
+
# Fall back to peptide validity check
|
| 532 |
+
sequences = self.tokenizer.batch_decode(x)
|
| 533 |
+
return torch.tensor([self.peptide_analyzer.is_peptide(seq) for seq in sequences], device=self.device)
|
| 534 |
+
|
| 535 |
+
# Use conjugation objective - score > 0 means valid
|
| 536 |
+
scores = self.conjugation_obj(x)
|
| 537 |
+
return scores > 0
|
| 538 |
+
|
| 539 |
+
def generate_x0(self, n_steps=16, temperature=1.0, require_valid=False, max_retries=10):
|
| 540 |
+
"""
|
| 541 |
+
Generate initial x0 sequences.
|
| 542 |
+
|
| 543 |
+
Args:
|
| 544 |
+
n_steps: Number of flow matching steps
|
| 545 |
+
temperature: Sampling temperature
|
| 546 |
+
require_valid: If True, regenerate invalid samples until all are valid conjugatable peptides
|
| 547 |
+
max_retries: Maximum number of regeneration attempts per sample
|
| 548 |
+
"""
|
| 549 |
+
print("Starting initial SMILES generation...")
|
| 550 |
+
|
| 551 |
+
x = self._generate_single_x0(n_steps, temperature)
|
| 552 |
+
|
| 553 |
+
if require_valid:
|
| 554 |
+
print("Checking conjugation validity and regenerating invalid samples...")
|
| 555 |
+
|
| 556 |
+
for retry in range(max_retries):
|
| 557 |
+
validity = self._check_conjugation_validity(x)
|
| 558 |
+
num_valid = validity.sum().item()
|
| 559 |
+
num_invalid = self.args.num_samples - num_valid
|
| 560 |
+
|
| 561 |
+
if num_invalid == 0:
|
| 562 |
+
print(f"All {self.args.num_samples} samples are valid conjugatable peptides!")
|
| 563 |
+
break
|
| 564 |
+
|
| 565 |
+
print(f"Retry {retry + 1}/{max_retries}: {num_valid}/{self.args.num_samples} valid, regenerating {num_invalid} invalid samples...")
|
| 566 |
+
|
| 567 |
+
# Get indices of invalid samples
|
| 568 |
+
invalid_indices = torch.where(~validity)[0]
|
| 569 |
+
|
| 570 |
+
# Generate new candidates for invalid samples
|
| 571 |
+
# Temporarily adjust num_samples to generate replacements
|
| 572 |
+
original_num_samples = self.args.num_samples
|
| 573 |
+
self.args.num_samples = num_invalid
|
| 574 |
+
|
| 575 |
+
new_x = self._generate_single_x0(n_steps, temperature)
|
| 576 |
+
|
| 577 |
+
self.args.num_samples = original_num_samples
|
| 578 |
+
|
| 579 |
+
# Replace invalid samples with new ones
|
| 580 |
+
x[invalid_indices] = new_x
|
| 581 |
+
|
| 582 |
+
# Final validity check
|
| 583 |
+
final_validity = self._check_conjugation_validity(x)
|
| 584 |
+
num_final_valid = final_validity.sum().item()
|
| 585 |
+
if num_final_valid < self.args.num_samples:
|
| 586 |
+
print(f"Warning: After {max_retries} retries, only {num_final_valid}/{self.args.num_samples} samples are valid.")
|
| 587 |
+
else:
|
| 588 |
+
print(f"Success: All {self.args.num_samples} samples are valid conjugatable peptides.")
|
| 589 |
+
|
| 590 |
+
# 4. Decode the final token IDs into strings
|
| 591 |
+
generated_sequences = self.tokenizer.batch_decode(x)
|
| 592 |
+
|
| 593 |
+
# 5. Analyze the validity of the generated sequences
|
| 594 |
+
validities = []
|
| 595 |
+
if self.tokenizer_type == 'amino':
|
| 596 |
+
# For amino mode, report conjugation-relevant validity after AA->SMILES conversion.
|
| 597 |
+
validity_tensor = self._check_conjugation_validity(x)
|
| 598 |
+
validities = [bool(v.item()) for v in validity_tensor]
|
| 599 |
+
for seq in generated_sequences:
|
| 600 |
+
print(seq)
|
| 601 |
+
else:
|
| 602 |
+
for seq in generated_sequences:
|
| 603 |
+
validities.append(self.peptide_analyzer.is_peptide(seq))
|
| 604 |
+
print(seq)
|
| 605 |
+
if self.tokenizer_type == 'amino':
|
| 606 |
+
print(f"Initial Peptide Validity (AA->SMILES): {validities}")
|
| 607 |
+
else:
|
| 608 |
+
print(f"Initial Sequence Validity: {validities}")
|
| 609 |
+
return x
|
| 610 |
+
|
| 611 |
+
def _get_scores(self, x_batch):
|
| 612 |
+
"""Calculates the normalized scores for a batch of sequences."""
|
| 613 |
+
scores = []
|
| 614 |
+
|
| 615 |
+
if self.tokenizer_type == 'amino':
|
| 616 |
+
# Convert AA tokens to SMILES for scoring
|
| 617 |
+
seqs = self.tokenizer.batch_decode(x_batch)
|
| 618 |
+
|
| 619 |
+
# Convert one-letter AA codes to three-letter, then to SMILES
|
| 620 |
+
three_letter_seqs = [one_letter_to_three_letter(seq) for seq in seqs]
|
| 621 |
+
with ThreadPoolExecutor() as pool:
|
| 622 |
+
smiles_seqs = list(pool.map(
|
| 623 |
+
lambda s: aa_to_smiles(s, cyclic=self.args.cyclic),
|
| 624 |
+
three_letter_seqs
|
| 625 |
+
))
|
| 626 |
+
|
| 627 |
+
# Track valid conversions
|
| 628 |
+
valid_mask = torch.tensor(
|
| 629 |
+
[1 if isinstance(seq, str) else 0 for seq in smiles_seqs],
|
| 630 |
+
device=self.device
|
| 631 |
+
)
|
| 632 |
+
|
| 633 |
+
# Replace invalid SMILES with dummy 'C' for tokenization
|
| 634 |
+
for i in torch.where((1 - valid_mask) == 1)[0].tolist():
|
| 635 |
+
smiles_seqs[i] = 'C'
|
| 636 |
+
|
| 637 |
+
# Tokenize SMILES for objective functions
|
| 638 |
+
smiles_batch = self.smiles_tokenizer(
|
| 639 |
+
smiles_seqs, return_tensors='pt', padding=True
|
| 640 |
+
).to(self.device)
|
| 641 |
+
attention_mask = smiles_batch.get("attention_mask", None)
|
| 642 |
+
|
| 643 |
+
# Evaluate each objective function on SMILES tokens
|
| 644 |
+
for obj_func in self.objectives:
|
| 645 |
+
try:
|
| 646 |
+
score = obj_func(
|
| 647 |
+
smiles_batch["input_ids"],
|
| 648 |
+
attention_mask=attention_mask
|
| 649 |
+
).to(self.device)
|
| 650 |
+
except TypeError:
|
| 651 |
+
score = obj_func(smiles_batch["input_ids"]).to(self.device)
|
| 652 |
+
scores.append(score * valid_mask) # Zero out invalid conversions
|
| 653 |
+
else:
|
| 654 |
+
# Original SMILES-based scoring
|
| 655 |
+
for obj_func in self.objectives:
|
| 656 |
+
scores.append(obj_func(x_batch.to(self.device)))
|
| 657 |
+
|
| 658 |
+
return torch.stack(scores, dim=0).to(self.device)
|
| 659 |
+
|
| 660 |
+
def _barker_g(self, u):
|
| 661 |
+
"""Barker balancing function."""
|
| 662 |
+
return u / (1 + u)
|
| 663 |
+
|
| 664 |
+
def validity(self, x):
|
| 665 |
+
sampled_sequences = self.tokenizer.batch_decode(x)
|
| 666 |
+
|
| 667 |
+
if self.tokenizer_type == 'amino':
|
| 668 |
+
# For amino tokenizer, convert to SMILES first and check validity
|
| 669 |
+
three_letter_seqs = [one_letter_to_three_letter(seq) for seq in sampled_sequences]
|
| 670 |
+
smiles_seqs = [aa_to_smiles(three_letter_seq, cyclic=self.args.cyclic)
|
| 671 |
+
for three_letter_seq in three_letter_seqs]
|
| 672 |
+
return [1.0 if isinstance(seq, str) and self.peptide_analyzer.is_peptide(seq) else 0.0
|
| 673 |
+
for seq in smiles_seqs]
|
| 674 |
+
else:
|
| 675 |
+
return [1.0 if self.peptide_analyzer.is_peptide(seq) else 0.0 for seq in sampled_sequences]
|
| 676 |
+
|
| 677 |
+
def generate(self):
|
| 678 |
+
"""Main generation loop with tracking."""
|
| 679 |
+
shape = (self.args.num_samples, self.args.gen_len)
|
| 680 |
+
x = self.generate_x0(
|
| 681 |
+
require_valid=self.args.require_valid_x0,
|
| 682 |
+
max_retries=self.args.max_x0_retries
|
| 683 |
+
)
|
| 684 |
+
print(x)
|
| 685 |
+
|
| 686 |
+
if self.args.weights is None:
|
| 687 |
+
# The first weight is for conjugation (peptide + PDC validity)
|
| 688 |
+
# We need to ensure the SMILES sequences are valid peptides with valid PDCs
|
| 689 |
+
weights = torch.tensor([1] + [1/(self.num_objectives-1)] * (self.num_objectives-1), device=self.device).view(-1,1)
|
| 690 |
+
else:
|
| 691 |
+
weights = torch.tensor(self.args.weights, device=self.device).view(-1, 1)
|
| 692 |
+
if len(weights) != self.num_objectives:
|
| 693 |
+
raise ValueError("Number of weights must match number of objectives.")
|
| 694 |
+
print(f"Weights: {weights}")
|
| 695 |
+
|
| 696 |
+
with torch.no_grad():
|
| 697 |
+
current_scores = self._get_scores(x)
|
| 698 |
+
for t in tqdm(range(self.args.optimization_steps), desc="MOG Generation"):
|
| 699 |
+
improved = False
|
| 700 |
+
improved_indices = []
|
| 701 |
+
|
| 702 |
+
# Anneal guidance strength
|
| 703 |
+
eta_t = self.args.eta_min + (self.args.eta_max - self.args.eta_min) * (t / (self.args.optimization_steps - 1))
|
| 704 |
+
# Choose a random position to mutate
|
| 705 |
+
mut_idx = random.randint(1, self.args.gen_len-2)
|
| 706 |
+
|
| 707 |
+
# Determine the generation timestep
|
| 708 |
+
# We cycle through the timesteps to ensure all are visited
|
| 709 |
+
generation_step = t % self.args.optimization_steps
|
| 710 |
+
time_t = torch.full((self.args.num_samples,), (generation_step / self.args.optimization_steps), device=self.device)
|
| 711 |
+
|
| 712 |
+
# Get proposal distribution from ReDi model for the chosen position
|
| 713 |
+
logits = self.model(x, time_t)
|
| 714 |
+
probs = F.softmax(logits, dim=-1)
|
| 715 |
+
pos_probs = probs[:, mut_idx, :]
|
| 716 |
+
pos_probs[:, x[:, mut_idx]] = 0 # We don't evalute the same token
|
| 717 |
+
pos_probs[:, self.invalid] = 0
|
| 718 |
+
|
| 719 |
+
# Prune candidate vocabulary using top-p sampling
|
| 720 |
+
sorted_probs, sorted_indices = torch.sort(pos_probs, descending=True)
|
| 721 |
+
cumulative_probs = torch.cumsum(sorted_probs, dim=-1)
|
| 722 |
+
remove_mask = cumulative_probs > self.args.top_p
|
| 723 |
+
remove_mask[..., 1:] = remove_mask[..., :-1].clone()
|
| 724 |
+
remove_mask[..., 0] = 0
|
| 725 |
+
|
| 726 |
+
# Get the set of candidate tokens for each sample in the batch
|
| 727 |
+
candidate_tokens_list = []
|
| 728 |
+
for i in range(self.args.num_samples):
|
| 729 |
+
sample_mask = remove_mask[i]
|
| 730 |
+
candidates = sorted_indices[i, ~sample_mask]
|
| 731 |
+
candidate_tokens_list.append(candidates)
|
| 732 |
+
|
| 733 |
+
w_current = torch.exp(eta_t * torch.min(weights * current_scores, dim=0).values)
|
| 734 |
+
|
| 735 |
+
if t == 0:
|
| 736 |
+
print(f"Initial Scores: {current_scores}")
|
| 737 |
+
# Log initial step with detailed results
|
| 738 |
+
if self.tracker and self.conjugation_obj:
|
| 739 |
+
detailed = self.conjugation_obj.get_last_detailed_results()
|
| 740 |
+
self.tracker.log_step(t, current_scores[0], detailed)
|
| 741 |
+
self.tracker.print_step_summary(t, current_scores[0], detailed)
|
| 742 |
+
|
| 743 |
+
# Evaluate all candidate tokens for each sample
|
| 744 |
+
final_proposal_tokens = []
|
| 745 |
+
for i in range(self.args.num_samples):
|
| 746 |
+
candidates = candidate_tokens_list[i]
|
| 747 |
+
candidates = torch.tensor([token for token in candidates if token not in self.invalid], device=candidates.device)
|
| 748 |
+
if len(candidates) >= 200:
|
| 749 |
+
candidates = candidates[:200]
|
| 750 |
+
num_candidates = len(candidates)
|
| 751 |
+
|
| 752 |
+
# Create a batch of proposed sequences for the current sample
|
| 753 |
+
x_prop_batch = x[i].repeat(num_candidates, 1)
|
| 754 |
+
x_prop_batch[:, mut_idx] = candidates
|
| 755 |
+
|
| 756 |
+
# Evaluate all proposals
|
| 757 |
+
proposal_scores = self._get_scores(x_prop_batch)
|
| 758 |
+
proposal_s_omega = torch.min(weights * proposal_scores, dim=0).values
|
| 759 |
+
w_proposal = torch.exp(eta_t * proposal_s_omega)
|
| 760 |
+
|
| 761 |
+
# Get ReDi probabilities for the candidates
|
| 762 |
+
redi_probs = pos_probs[i, candidates]
|
| 763 |
+
|
| 764 |
+
# Calculate unnormalized guided probabilities
|
| 765 |
+
tilde_q = redi_probs * self._barker_g(w_proposal / w_current[i])
|
| 766 |
+
|
| 767 |
+
# Normalize and sample the final token
|
| 768 |
+
final_probs = tilde_q / (torch.sum(tilde_q) + 1e-9)
|
| 769 |
+
|
| 770 |
+
index = torch.multinomial(final_probs, 1).item()
|
| 771 |
+
|
| 772 |
+
if (random.uniform(0,1) > 1 and proposal_scores[:,index][0] == 1) or torch.sum(weights.squeeze(1) * proposal_scores[:, index]) >= torch.sum(weights.squeeze(1) * current_scores[:,i]):
|
| 773 |
+
final_token = candidates[index]
|
| 774 |
+
print(f"Previous Weighted Sum: {torch.sum(weights.squeeze(1) * current_scores[:,i])}")
|
| 775 |
+
print(f"Previous Scores: {current_scores[:,i]}")
|
| 776 |
+
|
| 777 |
+
print(f"New Weighted Sum: {torch.sum(weights.squeeze(1) * proposal_scores[:, index])}")
|
| 778 |
+
print(f"New Scores: {proposal_scores[:,index]}")
|
| 779 |
+
improved = True
|
| 780 |
+
improved_indices.append(i)
|
| 781 |
+
|
| 782 |
+
else:
|
| 783 |
+
final_token = x[i][mut_idx]
|
| 784 |
+
|
| 785 |
+
final_proposal_tokens.append(final_token)
|
| 786 |
+
|
| 787 |
+
# Update the sequences with the chosen tokens
|
| 788 |
+
x[torch.arange(self.args.num_samples), mut_idx] = torch.stack(final_proposal_tokens)
|
| 789 |
+
if improved:
|
| 790 |
+
current_scores = self._get_scores(x)
|
| 791 |
+
print(self.tokenizer.batch_decode(x))
|
| 792 |
+
|
| 793 |
+
# Log step with tracking (every 4 steps to reduce overhead)
|
| 794 |
+
if self.tracker and self.conjugation_obj and (t % 4 == 0 or t == self.args.optimization_steps - 1):
|
| 795 |
+
detailed = self.conjugation_obj.get_last_detailed_results()
|
| 796 |
+
self.tracker.log_step(t, current_scores[0], detailed, improved_indices)
|
| 797 |
+
|
| 798 |
+
# Print summary for final step
|
| 799 |
+
if t == self.args.optimization_steps - 1:
|
| 800 |
+
self.tracker.print_step_summary(t, current_scores[0], detailed)
|
| 801 |
+
|
| 802 |
+
return x
|
| 803 |
+
|
| 804 |
+
|
| 805 |
+
# --- Main Execution ---
|
| 806 |
+
def main(args):
|
| 807 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 808 |
+
print(f"Using device: {device}")
|
| 809 |
+
|
| 810 |
+
target = args.target
|
| 811 |
+
|
| 812 |
+
# --- Initialize tokenizer based on type ---
|
| 813 |
+
if args.tokenizer_type == 'amino':
|
| 814 |
+
print("Using Amino Acid tokenizer")
|
| 815 |
+
tokenizer = AminoTokenizer()
|
| 816 |
+
smiles_tokenizer = SMILES_SPE_Tokenizer(args.smiles_vocab_path, args.smiles_splits_path)
|
| 817 |
+
else:
|
| 818 |
+
print("Using SMILES tokenizer")
|
| 819 |
+
tokenizer = SMILES_SPE_Tokenizer(args.smiles_vocab_path, args.smiles_splits_path)
|
| 820 |
+
smiles_tokenizer = tokenizer
|
| 821 |
+
|
| 822 |
+
# --- Initialize objective functions ---
|
| 823 |
+
# Use SMILES tokenizer for objectives (they expect SMILES tokens)
|
| 824 |
+
conjugation_model = Conjugation(smiles_tokenizer, device=device, builder_type=args.builder_type)
|
| 825 |
+
affinity_model = BindingAffinity(target, device)
|
| 826 |
+
|
| 827 |
+
# List of all objective functions
|
| 828 |
+
OBJECTIVE_FUNCTIONS = [conjugation_model, affinity_model]
|
| 829 |
+
|
| 830 |
+
# Initialize tracker for logging
|
| 831 |
+
tracker = GenerationTracker(output_dir=args.output_dir)
|
| 832 |
+
|
| 833 |
+
# --- Set random seeds for reproducibility ---
|
| 834 |
+
if args.seed is not None:
|
| 835 |
+
import random
|
| 836 |
+
import numpy as np
|
| 837 |
+
random.seed(args.seed)
|
| 838 |
+
np.random.seed(args.seed)
|
| 839 |
+
torch.manual_seed(args.seed)
|
| 840 |
+
if torch.cuda.is_available():
|
| 841 |
+
torch.cuda.manual_seed(args.seed)
|
| 842 |
+
torch.cuda.manual_seed_all(args.seed)
|
| 843 |
+
print(f"Random seed set to: {args.seed}")
|
| 844 |
+
|
| 845 |
+
# --- Load Model ---
|
| 846 |
+
print(f"Loading model from checkpoint: {args.checkpoint}")
|
| 847 |
+
checkpoint = torch.load(args.checkpoint, map_location=device, weights_only=False)
|
| 848 |
+
|
| 849 |
+
if args.tokenizer_type == 'amino':
|
| 850 |
+
# Vanilla PyTorch checkpoint for amino model
|
| 851 |
+
raw_model_args = checkpoint['args']
|
| 852 |
+
if isinstance(raw_model_args, argparse.Namespace):
|
| 853 |
+
raw_model_args = vars(raw_model_args)
|
| 854 |
+
model_args = edict(raw_model_args)
|
| 855 |
+
model = MDLM(
|
| 856 |
+
vocab_size=model_args.vocab_size,
|
| 857 |
+
seq_len=model_args.seq_len,
|
| 858 |
+
model_dim=model_args.model_dim,
|
| 859 |
+
n_heads=model_args.n_heads,
|
| 860 |
+
n_layers=model_args.n_layers
|
| 861 |
+
).to(device)
|
| 862 |
+
model.load_state_dict(checkpoint['model_state_dict'])
|
| 863 |
+
print(f"Amino model loaded. Vocab size: {model_args.vocab_size}")
|
| 864 |
+
else:
|
| 865 |
+
# PyTorch Lightning checkpoint for SMILES model
|
| 866 |
+
model_hparams = checkpoint["hyper_parameters"]["args"]
|
| 867 |
+
model = MDLMLightningModule.load_from_checkpoint(
|
| 868 |
+
args.checkpoint,
|
| 869 |
+
args=model_hparams,
|
| 870 |
+
tokenizer=tokenizer,
|
| 871 |
+
map_location=device,
|
| 872 |
+
strict=False
|
| 873 |
+
)
|
| 874 |
+
model.to(device)
|
| 875 |
+
model.eval()
|
| 876 |
+
print("Model loaded successfully.")
|
| 877 |
+
|
| 878 |
+
mog_generator = MOGGenerator(
|
| 879 |
+
model, device, OBJECTIVE_FUNCTIONS, args, tracker=tracker,
|
| 880 |
+
tokenizer=tokenizer, tokenizer_type=args.tokenizer_type
|
| 881 |
+
)
|
| 882 |
+
|
| 883 |
+
all_sequences = []
|
| 884 |
+
all_final_results = []
|
| 885 |
+
|
| 886 |
+
for batch_idx in range(args.num_batches):
|
| 887 |
+
print(f"\n{'='*60}")
|
| 888 |
+
print(f"BATCH {batch_idx + 1}/{args.num_batches}")
|
| 889 |
+
print(f"{'='*60}")
|
| 890 |
+
|
| 891 |
+
generated_tokens = mog_generator.generate()
|
| 892 |
+
final_scores = mog_generator._get_scores(generated_tokens.detach()).detach().cpu().numpy()
|
| 893 |
+
sequence_str = tokenizer.batch_decode(generated_tokens)
|
| 894 |
+
|
| 895 |
+
print("\n--- Batch Results ---")
|
| 896 |
+
print(f"Sequences: {sequence_str}")
|
| 897 |
+
print(f"Scores: {final_scores}")
|
| 898 |
+
|
| 899 |
+
all_sequences.extend(sequence_str)
|
| 900 |
+
|
| 901 |
+
# Get detailed results with properties for final sequences
|
| 902 |
+
# For amino tokenizer, we need to convert to SMILES first for detailed scoring
|
| 903 |
+
if args.tokenizer_type == 'amino':
|
| 904 |
+
# Convert AA to SMILES for detailed conjugation analysis
|
| 905 |
+
smiles_list = []
|
| 906 |
+
for seq in sequence_str:
|
| 907 |
+
three_letter = one_letter_to_three_letter(seq)
|
| 908 |
+
smiles = aa_to_smiles(three_letter, cyclic=args.cyclic)
|
| 909 |
+
smiles_list.append(smiles if isinstance(smiles, str) else 'C')
|
| 910 |
+
# Tokenize SMILES for conjugation model
|
| 911 |
+
smiles_tokens = smiles_tokenizer(smiles_list, return_tensors='pt', padding=True).to(device)
|
| 912 |
+
detailed_with_props = conjugation_model.get_detailed_scores_with_properties(smiles_tokens["input_ids"])
|
| 913 |
+
else:
|
| 914 |
+
detailed_with_props = conjugation_model.get_detailed_scores_with_properties(generated_tokens)
|
| 915 |
+
|
| 916 |
+
tracker.log_final(sequence_str, detailed_with_props)
|
| 917 |
+
all_final_results.extend(detailed_with_props)
|
| 918 |
+
|
| 919 |
+
# Print and save final summary
|
| 920 |
+
tracker.print_final_summary()
|
| 921 |
+
tracker.save(filename_prefix='pdc_generation')
|
| 922 |
+
|
| 923 |
+
# Save sequences to text file
|
| 924 |
+
with open(args.output_file, 'w') as f:
|
| 925 |
+
for i, seq in enumerate(all_sequences):
|
| 926 |
+
if args.tokenizer_type == 'amino':
|
| 927 |
+
# Also save converted SMILES for amino sequences
|
| 928 |
+
three_letter = one_letter_to_three_letter(seq)
|
| 929 |
+
smiles = aa_to_smiles(three_letter, cyclic=args.cyclic)
|
| 930 |
+
if isinstance(smiles, str):
|
| 931 |
+
f.write(f"{seq}\t{smiles}\n")
|
| 932 |
+
else:
|
| 933 |
+
f.write(f"{seq}\tINVALID_SMILES\n")
|
| 934 |
+
else:
|
| 935 |
+
f.write(seq + '\n')
|
| 936 |
+
print(f"\nSequences saved to: {args.output_file}")
|
| 937 |
+
|
| 938 |
+
print("\nGeneration complete.")
|
| 939 |
+
|
| 940 |
+
|
| 941 |
+
|
| 942 |
+
if __name__ == "__main__":
|
| 943 |
+
parser = argparse.ArgumentParser(description="Multi-Objective Generation with LBP-MOG-ReDi (Single Mutation).")
|
| 944 |
+
|
| 945 |
+
parser.add_argument("--checkpoint", type=str, required=True, help="Path to the trained ReDi model checkpoint.")
|
| 946 |
+
parser.add_argument("--num_samples", type=int, default=10, help="Number of samples to generate.")
|
| 947 |
+
parser.add_argument("--num_batches", type=int, default=10, help="Number of batches to generate.")
|
| 948 |
+
parser.add_argument("--output_file", type=str, default="./smiles.txt", help="File to save the generated sequences.")
|
| 949 |
+
parser.add_argument("--output_dir", type=str, default="./generation_logs", help="Directory to save detailed generation logs.")
|
| 950 |
+
parser.add_argument("--gen_len", type=int, default=50, help="Length of the sequences to generate.")
|
| 951 |
+
parser.add_argument("--optimization_steps", type=int, default=16, help="Number of optimization steps per generation.")
|
| 952 |
+
parser.add_argument("--weights", type=float, nargs='+', required=False, help="Weights for the objectives.")
|
| 953 |
+
parser.add_argument("--eta_min", type=float, default=1.0, help="Minimum guidance strength.")
|
| 954 |
+
parser.add_argument("--eta_max", type=float, default=20.0, help="Maximum guidance strength.")
|
| 955 |
+
parser.add_argument("--top_p", type=float, default=0.9, help="Top-p for pruning candidate tokens.")
|
| 956 |
+
parser.add_argument("--builder_type", type=str, default="AoaDau",
|
| 957 |
+
choices=["AoaDau", "SSCpt", "VcMMAE", "DSPACpt", "MalHexCpt",
|
| 958 |
+
"DSECCpt", "Cpt", "DSPASN38", "DSEBSN38", "DSEPSN38", "SN38"],
|
| 959 |
+
help="PDC builder type for conjugation.")
|
| 960 |
+
parser.add_argument("--seed", type=int, default=None, help="Random seed for reproducibility.")
|
| 961 |
+
parser.add_argument("--require_valid_x0", action="store_true",
|
| 962 |
+
help="If set, regenerate x0 samples until they are valid conjugatable peptides.")
|
| 963 |
+
parser.add_argument("--max_x0_retries", type=int, default=10,
|
| 964 |
+
help="Maximum retries when require_valid_x0 is enabled.")
|
| 965 |
+
|
| 966 |
+
parser.add_argument("--target", type=str, required=True, help="Target protein sequence for binding affinity.")
|
| 967 |
+
|
| 968 |
+
# Tokenizer and model type arguments
|
| 969 |
+
parser.add_argument("--tokenizer_type", type=str, default="smiles", choices=["smiles", "amino"],
|
| 970 |
+
help="Type of tokenizer: 'smiles' for SMILES SPE tokenizer, 'amino' for amino acid tokenizer.")
|
| 971 |
+
parser.add_argument("--cyclic", action="store_true",
|
| 972 |
+
help="Generate cyclic peptides (only for amino tokenizer).")
|
| 973 |
+
parser.add_argument("--smiles_vocab_path", type=str,
|
| 974 |
+
default="./smiles/smiles_tokenizer/new_vocab.txt",
|
| 975 |
+
help="Path to SMILES tokenizer vocabulary file.")
|
| 976 |
+
parser.add_argument("--smiles_splits_path", type=str,
|
| 977 |
+
default="./smiles/smiles_tokenizer/new_splits.txt",
|
| 978 |
+
help="Path to SMILES tokenizer splits file.")
|
| 979 |
+
|
| 980 |
+
args = parser.parse_args()
|
| 981 |
+
main(args)
|