Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +23 -9
- LICENSE +203 -0
- README.md +221 -0
- demo/_parse_config.py +115 -0
- demo/configs/oam_l_finetune.yaml +104 -0
- demo/configs/oam_l_finetune_smoke.yaml +95 -0
- demo/configs/tutorial_fcu.yaml +130 -0
- demo/configs/tutorial_fcu_8dcu.yaml +140 -0
- demo/configs/tutorial_fcu_full.yaml +131 -0
- demo/configs/tutorial_smoke.yaml +146 -0
- demo/configs/tutorial_smoke_8dcu.yaml +133 -0
- demo/download_tutorial_data.py +64 -0
- demo/prepare_smoke_data.py +35 -0
- demo/run.sh +162 -0
- energy_volume.py +129 -0
- model/__init__.py +41 -0
- model/_version.py +5 -0
- model/model/__init__.py +29 -0
- model/model/energy_modules.py +35 -0
- model/model/inference_models/__init__.py +7 -0
- model/model/inference_models/aotinductor.py +128 -0
- model/model/inference_models/compiled.py +60 -0
- model/model/inference_models/torchscript.py +73 -0
- model/model/modify_utils.py +131 -0
- model/model/nequip_models.py +399 -0
- model/model/pair_potential.py +50 -0
- model/model/param_groups.py +97 -0
- model/model/saved_models/__init__.py +12 -0
- model/model/saved_models/_utils.py +33 -0
- model/model/saved_models/checkpoint.py +148 -0
- model/model/saved_models/load_utils.py +150 -0
- model/model/saved_models/package.py +190 -0
- model/model/utils.py +230 -0
- model/nn/__init__.py +45 -0
- model/nn/_ghost_exchange_base.py +57 -0
- model/nn/_ghost_exchange_lmp_mliap.py +64 -0
- model/nn/_graph_mixin.py +238 -0
- model/nn/_tp_scatter_base.py +109 -0
- model/nn/_tp_scatter_cueq.py +122 -0
- model/nn/_tp_scatter_oeq.py +57 -0
- model/nn/atomwise.py +378 -0
- model/nn/compile.py +236 -0
- model/nn/convnetlayer.py +170 -0
- model/nn/embedding/__init__.py +20 -0
- model/nn/embedding/_edge.py +223 -0
- model/nn/embedding/cutoffs.py +27 -0
- model/nn/embedding/node.py +175 -0
- model/nn/embedding/node_tensor.py +171 -0
- model/nn/embedding/utils.py +150 -0
- model/nn/grad_output.py +320 -0
.gitattributes
CHANGED
|
@@ -1,35 +1,49 @@
|
|
| 1 |
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.bin filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 4 |
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
| 1 |
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bin.* filter=lfs diff=lfs merge=lfs -text
|
| 5 |
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 6 |
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 11 |
*.model filter=lfs diff=lfs merge=lfs -text
|
| 12 |
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
| 13 |
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 14 |
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 15 |
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 16 |
*.pb filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
| 17 |
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 18 |
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 19 |
*.rar filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 20 |
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 21 |
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 22 |
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 23 |
*.tgz filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 24 |
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 25 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
*.zstandard filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.db* filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.ark* filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
**/*ckpt*data* filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
**/*ckpt*.meta filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
**/*ckpt*.index filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*.gguf* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
*.ggml filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
*.llamafile* filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
*.pt2 filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 42 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 43 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 44 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 45 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 46 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 47 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 48 |
+
weight/NequIP-OAM-L-0.1.nequip.pth filter=lfs diff=lfs merge=lfs -text
|
| 49 |
+
weight/NequIP-OAM-L-0.1.nequip.zip filter=lfs diff=lfs merge=lfs -text
|
LICENSE
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Copyright 2025 Onescience Authors. All rights reserved.
|
| 2 |
+
|
| 3 |
+
Apache License
|
| 4 |
+
Version 2.0, January 2004
|
| 5 |
+
http://www.apache.org/licenses/
|
| 6 |
+
|
| 7 |
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
| 8 |
+
|
| 9 |
+
1. Definitions.
|
| 10 |
+
|
| 11 |
+
"License" shall mean the terms and conditions for use, reproduction,
|
| 12 |
+
and distribution as defined by Sections 1 through 9 of this document.
|
| 13 |
+
|
| 14 |
+
"Licensor" shall mean the copyright owner or entity authorized by
|
| 15 |
+
the copyright owner that is granting the License.
|
| 16 |
+
|
| 17 |
+
"Legal Entity" shall mean the union of the acting entity and all
|
| 18 |
+
other entities that control, are controlled by, or are under common
|
| 19 |
+
control with that entity. For the purposes of this definition,
|
| 20 |
+
"control" means (i) the power, direct or indirect, to cause the
|
| 21 |
+
direction or management of such entity, whether by contract or
|
| 22 |
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
| 23 |
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
| 24 |
+
|
| 25 |
+
"You" (or "Your") shall mean an individual or Legal Entity
|
| 26 |
+
exercising permissions granted by this License.
|
| 27 |
+
|
| 28 |
+
"Source" form shall mean the preferred form for making modifications,
|
| 29 |
+
including but not limited to software source code, documentation
|
| 30 |
+
source, and configuration files.
|
| 31 |
+
|
| 32 |
+
"Object" form shall mean any form resulting from mechanical
|
| 33 |
+
transformation or translation of a Source form, including but
|
| 34 |
+
not limited to compiled object code, generated documentation,
|
| 35 |
+
and conversions to other media types.
|
| 36 |
+
|
| 37 |
+
"Work" shall mean the work of authorship, whether in Source or
|
| 38 |
+
Object form, made available under the License, as indicated by a
|
| 39 |
+
copyright notice that is included in or attached to the work
|
| 40 |
+
(an example is provided in the Appendix below).
|
| 41 |
+
|
| 42 |
+
"Derivative Works" shall mean any work, whether in Source or Object
|
| 43 |
+
form, that is based on (or derived from) the Work and for which the
|
| 44 |
+
editorial revisions, annotations, elaborations, or other modifications
|
| 45 |
+
represent, as a whole, an original work of authorship. For the purposes
|
| 46 |
+
of this License, Derivative Works shall not include works that remain
|
| 47 |
+
separable from, or merely link (or bind by name) to the interfaces of,
|
| 48 |
+
the Work and Derivative Works thereof.
|
| 49 |
+
|
| 50 |
+
"Contribution" shall mean any work of authorship, including
|
| 51 |
+
the original version of the Work and any modifications or additions
|
| 52 |
+
to that Work or Derivative Works thereof, that is intentionally
|
| 53 |
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
| 54 |
+
or by an individual or Legal Entity authorized to submit on behalf of
|
| 55 |
+
the copyright owner. For the purposes of this definition, "submitted"
|
| 56 |
+
means any form of electronic, verbal, or written communication sent
|
| 57 |
+
to the Licensor or its representatives, including but not limited to
|
| 58 |
+
communication on electronic mailing lists, source code control systems,
|
| 59 |
+
and issue tracking systems that are managed by, or on behalf of, the
|
| 60 |
+
Licensor for the purpose of discussing and improving the Work, but
|
| 61 |
+
excluding communication that is conspicuously marked or otherwise
|
| 62 |
+
designated in writing by the copyright owner as "Not a Contribution."
|
| 63 |
+
|
| 64 |
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
| 65 |
+
on behalf of whom a Contribution has been received by Licensor and
|
| 66 |
+
subsequently incorporated within the Work.
|
| 67 |
+
|
| 68 |
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
| 69 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 70 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 71 |
+
copyright license to reproduce, prepare Derivative Works of,
|
| 72 |
+
publicly display, publicly perform, sublicense, and distribute the
|
| 73 |
+
Work and such Derivative Works in Source or Object form.
|
| 74 |
+
|
| 75 |
+
3. Grant of Patent License. Subject to the terms and conditions of
|
| 76 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 77 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 78 |
+
(except as stated in this section) patent license to make, have made,
|
| 79 |
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
| 80 |
+
where such license applies only to those patent claims licensable
|
| 81 |
+
by such Contributor that are necessarily infringed by their
|
| 82 |
+
Contribution(s) alone or by combination of their Contribution(s)
|
| 83 |
+
with the Work to which such Contribution(s) was submitted. If You
|
| 84 |
+
institute patent litigation against any entity (including a
|
| 85 |
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
| 86 |
+
or a Contribution incorporated within the Work constitutes direct
|
| 87 |
+
or contributory patent infringement, then any patent licenses
|
| 88 |
+
granted to You under this License for that Work shall terminate
|
| 89 |
+
as of the date such litigation is filed.
|
| 90 |
+
|
| 91 |
+
4. Redistribution. You may reproduce and distribute copies of the
|
| 92 |
+
Work or Derivative Works thereof in any medium, with or without
|
| 93 |
+
modifications, and in Source or Object form, provided that You
|
| 94 |
+
meet the following conditions:
|
| 95 |
+
|
| 96 |
+
(a) You must give any other recipients of the Work or
|
| 97 |
+
Derivative Works a copy of this License; and
|
| 98 |
+
|
| 99 |
+
(b) You must cause any modified files to carry prominent notices
|
| 100 |
+
stating that You changed the files; and
|
| 101 |
+
|
| 102 |
+
(c) You must retain, in the Source form of any Derivative Works
|
| 103 |
+
that You distribute, all copyright, patent, trademark, and
|
| 104 |
+
attribution notices from the Source form of the Work,
|
| 105 |
+
excluding those notices that do not pertain to any part of
|
| 106 |
+
the Derivative Works; and
|
| 107 |
+
|
| 108 |
+
(d) If the Work includes a "NOTICE" text file as part of its
|
| 109 |
+
distribution, then any Derivative Works that You distribute must
|
| 110 |
+
include a readable copy of the attribution notices contained
|
| 111 |
+
within such NOTICE file, excluding those notices that do not
|
| 112 |
+
pertain to any part of the Derivative Works, in at least one
|
| 113 |
+
of the following places: within a NOTICE text file distributed
|
| 114 |
+
as part of the Derivative Works; within the Source form or
|
| 115 |
+
documentation, if provided along with the Derivative Works; or,
|
| 116 |
+
within a display generated by the Derivative Works, if and
|
| 117 |
+
wherever such third-party notices normally appear. The contents
|
| 118 |
+
of the NOTICE file are for informational purposes only and
|
| 119 |
+
do not modify the License. You may add Your own attribution
|
| 120 |
+
notices within Derivative Works that You distribute, alongside
|
| 121 |
+
or as an addendum to the NOTICE text from the Work, provided
|
| 122 |
+
that such additional attribution notices cannot be construed
|
| 123 |
+
as modifying the License.
|
| 124 |
+
|
| 125 |
+
You may add Your own copyright statement to Your modifications and
|
| 126 |
+
may provide additional or different license terms and conditions
|
| 127 |
+
for use, reproduction, or distribution of Your modifications, or
|
| 128 |
+
for any such Derivative Works as a whole, provided Your use,
|
| 129 |
+
reproduction, and distribution of the Work otherwise complies with
|
| 130 |
+
the conditions stated in this License.
|
| 131 |
+
|
| 132 |
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
| 133 |
+
any Contribution intentionally submitted for inclusion in the Work
|
| 134 |
+
by You to the Licensor shall be under the terms and conditions of
|
| 135 |
+
this License, without any additional terms or conditions.
|
| 136 |
+
Notwithstanding the above, nothing herein shall supersede or modify
|
| 137 |
+
the terms of any separate license agreement you may have executed
|
| 138 |
+
with Licensor regarding such Contributions.
|
| 139 |
+
|
| 140 |
+
6. Trademarks. This License does not grant permission to use the trade
|
| 141 |
+
names, trademarks, service marks, or product names of the Licensor,
|
| 142 |
+
except as required for reasonable and customary use in describing the
|
| 143 |
+
origin of the Work and reproducing the content of the NOTICE file.
|
| 144 |
+
|
| 145 |
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
| 146 |
+
agreed to in writing, Licensor provides the Work (and each
|
| 147 |
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
| 148 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
| 149 |
+
implied, including, without limitation, any warranties or conditions
|
| 150 |
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
| 151 |
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
| 152 |
+
appropriateness of using or redistributing the Work and assume any
|
| 153 |
+
risks associated with Your exercise of permissions under this License.
|
| 154 |
+
|
| 155 |
+
8. Limitation of Liability. In no event and under no legal theory,
|
| 156 |
+
whether in tort (including negligence), contract, or otherwise,
|
| 157 |
+
unless required by applicable law (such as deliberate and grossly
|
| 158 |
+
negligent acts) or agreed to in writing, shall any Contributor be
|
| 159 |
+
liable to You for damages, including any direct, indirect, special,
|
| 160 |
+
incidental, or consequential damages of any character arising as a
|
| 161 |
+
result of this License or out of the use or inability to use the
|
| 162 |
+
Work (including but not limited to damages for loss of goodwill,
|
| 163 |
+
work stoppage, computer failure or malfunction, or any and all
|
| 164 |
+
other commercial damages or losses), even if such Contributor
|
| 165 |
+
has been advised of the possibility of such damages.
|
| 166 |
+
|
| 167 |
+
9. Accepting Warranty or Additional Liability. While redistributing
|
| 168 |
+
the Work or Derivative Works thereof, You may choose to offer,
|
| 169 |
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
| 170 |
+
or other liability obligations and/or rights consistent with this
|
| 171 |
+
License. However, in accepting such obligations, You may act only
|
| 172 |
+
on Your own behalf and on Your sole responsibility, not on behalf
|
| 173 |
+
of any other Contributor, and only if You agree to indemnify,
|
| 174 |
+
defend, and hold each Contributor harmless for any liability
|
| 175 |
+
incurred by, or claims asserted against, such Contributor by reason
|
| 176 |
+
of your accepting any such warranty or additional liability.
|
| 177 |
+
|
| 178 |
+
END OF TERMS AND CONDITIONS
|
| 179 |
+
|
| 180 |
+
APPENDIX: How to apply the Apache License to your work.
|
| 181 |
+
|
| 182 |
+
To apply the Apache License to your work, attach the following
|
| 183 |
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
| 184 |
+
replaced with your own identifying information. (Don't include
|
| 185 |
+
the brackets!) The text should be enclosed in the appropriate
|
| 186 |
+
comment syntax for the file format. We also recommend that a
|
| 187 |
+
file or class name and description of purpose be included on the
|
| 188 |
+
same "printed page" as the copyright notice for easier
|
| 189 |
+
identification within third-party archives.
|
| 190 |
+
|
| 191 |
+
Copyright 2025 Onescience Authors.
|
| 192 |
+
|
| 193 |
+
Licensed under the Apache License, Version 2.0 (the "License");
|
| 194 |
+
you may not use this file except in compliance with the License.
|
| 195 |
+
You may obtain a copy of the License at
|
| 196 |
+
|
| 197 |
+
http://www.apache.org/licenses/LICENSE-2.0
|
| 198 |
+
|
| 199 |
+
Unless required by applicable law or agreed to in writing, software
|
| 200 |
+
distributed under the License is distributed on an "AS IS" BASIS,
|
| 201 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 202 |
+
See the License for the specific language governing permissions and
|
| 203 |
+
limitations under the License.
|
README.md
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: apache-2.0
|
| 3 |
+
tasks:
|
| 4 |
+
- materials-simulation
|
| 5 |
+
- molecular-dynamics
|
| 6 |
+
- energy-prediction
|
| 7 |
+
- force-prediction
|
| 8 |
+
frameworks:
|
| 9 |
+
- pytorch
|
| 10 |
+
language:
|
| 11 |
+
- en
|
| 12 |
+
tags:
|
| 13 |
+
- OneScience
|
| 14 |
+
- NequIP
|
| 15 |
+
- machine-learning-potential
|
| 16 |
+
- molecular-simulation
|
| 17 |
+
- materials-computing
|
| 18 |
+
- graph-neural-network
|
| 19 |
+
- equivariant-neural-network
|
| 20 |
+
- training
|
| 21 |
+
- fine-tuning
|
| 22 |
+
- inference
|
| 23 |
+
datasets:
|
| 24 |
+
- OneScience-Group/FCC_Cu
|
| 25 |
+
---
|
| 26 |
+
<p align="center">
|
| 27 |
+
<strong>
|
| 28 |
+
<span style="font-size: 30px;">NequIP</span>
|
| 29 |
+
</strong>
|
| 30 |
+
</p>
|
| 31 |
+
|
| 32 |
+
# Model Introduction
|
| 33 |
+
|
| 34 |
+
NequIP is a machine-learning interatomic potential (MLIP) for molecular and materials systems. Built on an E(3)-equivariant graph neural network, it predicts the energies and forces of atomic structures.
|
| 35 |
+
|
| 36 |
+
Reference implementation: https://github.com/mir-group/nequip
|
| 37 |
+
|
| 38 |
+
# Model Description
|
| 39 |
+
|
| 40 |
+
This repository provides the OneScience-integrated NequIP model code, OAM-L model weights, and runnable examples for training, fine-tuning, and inference. The `model/` directory corresponds only to `src/onescience/models/nequip/` in the main OneScience repository; training utilities, data-processing tools, and other shared modules are provided by the installed OneScience package.
|
| 41 |
+
|
| 42 |
+
The included OAM-L weights are:
|
| 43 |
+
|
| 44 |
+
| File | Purpose |
|
| 45 |
+
| --- | --- |
|
| 46 |
+
| `weight/NequIP-OAM-L-0.1.nequip.pth` | Compiled model for ASE single-point energy, atomic force, and stress inference |
|
| 47 |
+
| `weight/NequIP-OAM-L-0.1.nequip.zip` | NequIP package for OAM-L fine-tuning and checkpoint inference |
|
| 48 |
+
|
| 49 |
+
# Use Cases
|
| 50 |
+
|
| 51 |
+
| Use case | Description |
|
| 52 |
+
| :---: | :--- |
|
| 53 |
+
| Interatomic-potential training | Train a NequIP model using the example configurations and ASE extxyz data |
|
| 54 |
+
| Pretrained-model fine-tuning | Fine-tune the OAM-L package using data labeled with energy and forces |
|
| 55 |
+
| Single-point energy and force inference | Predict the energy, atomic forces, and stress of a structure with a compiled model or fine-tuned checkpoint |
|
| 56 |
+
| Structure relaxation | Optimize atomic positions with ASE |
|
| 57 |
+
| Energy-volume curve | Scan the volume of a periodic crystal and calculate the corresponding energy |
|
| 58 |
+
| Slurm/DCU training | Submit single-device or multi-device jobs using the included configurations and launch scripts |
|
| 59 |
+
|
| 60 |
+
# Usage
|
| 61 |
+
|
| 62 |
+
## 1. Using OneCode
|
| 63 |
+
|
| 64 |
+
Try intelligent, one-click AI4S programming in the OneCode online environment:
|
| 65 |
+
|
| 66 |
+
[Try intelligent, one-click AI4S programming](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
|
| 67 |
+
|
| 68 |
+
## 2. Manual Installation and Usage
|
| 69 |
+
|
| 70 |
+
**Hardware requirements**
|
| 71 |
+
|
| 72 |
+
- A GPU or DCU is recommended for training.
|
| 73 |
+
- A CPU can be used for import checks and small-configuration connectivity tests, but full training will be slow.
|
| 74 |
+
- DCU users must install DTK in advance. DTK 25.04.2 or later, or the OneScience-recommended version matching the current cluster, is recommended.
|
| 75 |
+
|
| 76 |
+
### Download the Model Package
|
| 77 |
+
|
| 78 |
+
```bash
|
| 79 |
+
hf download --model OneScience-Group/NequIP --local-dir ./NequIP
|
| 80 |
+
cd NequIP
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
### Install the Runtime Environment
|
| 84 |
+
|
| 85 |
+
**DCU environment**
|
| 86 |
+
|
| 87 |
+
```bash
|
| 88 |
+
# Activate DTK and Conda first
|
| 89 |
+
conda create -n onescience311 python=3.11 -y
|
| 90 |
+
conda activate onescience311
|
| 91 |
+
# uv installation is also supported
|
| 92 |
+
pip install onescience[matchem-dcu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
**GPU environment**
|
| 96 |
+
|
| 97 |
+
```bash
|
| 98 |
+
# Activate Conda first
|
| 99 |
+
conda create -n onescience311 python=3.11 -y libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
|
| 100 |
+
conda activate onescience311
|
| 101 |
+
# uv installation is also supported
|
| 102 |
+
pip install onescience[matchem-gpu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
### Training Data
|
| 106 |
+
|
| 107 |
+
Training data is not bundled with this repository. Using the introductory FCC Cu dataset as an example, download it from Hugging Face to `data/` in the repository root:
|
| 108 |
+
|
| 109 |
+
```bash
|
| 110 |
+
hf download --dataset OneScience-Group/FCC_Cu --local-dir ./data
|
| 111 |
+
```
|
| 112 |
+
|
| 113 |
+
After downloading, the raw data is located at `data/data/FCC_Cu/raw/fcu.xyz`. The dataset contains 6,855 structures, each with 52 atoms of C, H, O, and Cu. It uses an ASE-readable extxyz format and includes periodic cells together with energy and force labels. For production training or fine-tuning, use data consistent with the target system, label definitions, and units.
|
| 114 |
+
|
| 115 |
+
The training scripts read models and data from shared directories. Set the paths for your cluster before training or fine-tuning:
|
| 116 |
+
|
| 117 |
+
```bash
|
| 118 |
+
export ONESCIENCE_MODELS_DIR=/path/to/onescience-models
|
| 119 |
+
export ONESCIENCE_DATASETS_DIR=/path/to/onescience-datasets
|
| 120 |
+
```
|
| 121 |
+
|
| 122 |
+
To use the OAM-L weights included in this repository, copy them into the shared model directory:
|
| 123 |
+
|
| 124 |
+
```bash
|
| 125 |
+
mkdir -p "$ONESCIENCE_MODELS_DIR/NequIP"
|
| 126 |
+
cp weight/NequIP-OAM-L-0.1.nequip.pth "$ONESCIENCE_MODELS_DIR/NequIP/"
|
| 127 |
+
cp weight/NequIP-OAM-L-0.1.nequip.zip "$ONESCIENCE_MODELS_DIR/NequIP/"
|
| 128 |
+
```
|
| 129 |
+
|
| 130 |
+
### Training
|
| 131 |
+
|
| 132 |
+
Generate minimal smoke-test data and run local training:
|
| 133 |
+
|
| 134 |
+
```bash
|
| 135 |
+
python demo/prepare_smoke_data.py
|
| 136 |
+
bash demo/run.sh --config configs/tutorial_smoke.yaml
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
Download the official FCU tutorial data and submit a training job:
|
| 140 |
+
|
| 141 |
+
```bash
|
| 142 |
+
python demo/download_tutorial_data.py
|
| 143 |
+
bash demo/run.sh --config configs/tutorial_fcu.yaml --submit
|
| 144 |
+
```
|
| 145 |
+
|
| 146 |
+
The eight-DCU configurations run locally or submit to Slurm automatically, depending on the currently available resources:
|
| 147 |
+
|
| 148 |
+
```bash
|
| 149 |
+
bash demo/run.sh --config configs/tutorial_smoke_8dcu.yaml
|
| 150 |
+
bash demo/run.sh --config configs/tutorial_fcu_8dcu.yaml
|
| 151 |
+
```
|
| 152 |
+
|
| 153 |
+
Outputs are written to `outputs/` by default. The actual wait time for a training job depends on the cluster queue and available resources.
|
| 154 |
+
|
| 155 |
+
### Model Weights
|
| 156 |
+
|
| 157 |
+
This repository includes the OAM-L trained weights:
|
| 158 |
+
|
| 159 |
+
```text
|
| 160 |
+
e83a1d656f8b19b55d2f05708c83e054612f713e9a1b06266aa010db58e56517 weight/NequIP-OAM-L-0.1.nequip.pth
|
| 161 |
+
5d01a4fab228abb3cdb6ace0033f93993729956bca6a42234a2a8816825b9a0f weight/NequIP-OAM-L-0.1.nequip.zip
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
### Fine-Tuning
|
| 165 |
+
|
| 166 |
+
Validate the OAM-L fine-tuning workflow with generated smoke-test data:
|
| 167 |
+
|
| 168 |
+
```bash
|
| 169 |
+
python demo/prepare_smoke_data.py
|
| 170 |
+
bash demo/run.sh --config configs/oam_l_finetune_smoke.yaml --submit
|
| 171 |
+
```
|
| 172 |
+
|
| 173 |
+
Use the production fine-tuning configuration:
|
| 174 |
+
|
| 175 |
+
```bash
|
| 176 |
+
bash demo/run.sh --config configs/oam_l_finetune.yaml --submit
|
| 177 |
+
```
|
| 178 |
+
|
| 179 |
+
Provide production fine-tuning data through `ONESCIENCE_DATASETS_DIR` in an ASE-readable extxyz format. Every frame must contain at least `energy` and `forces`; element types, units, and label definitions must be consistent with the OAM-L package and configuration.
|
| 180 |
+
|
| 181 |
+
### Inference
|
| 182 |
+
|
| 183 |
+
Use the compiled model for single-point energy, atomic force, and stress prediction:
|
| 184 |
+
|
| 185 |
+
```bash
|
| 186 |
+
python single_point.py --compiled-model weight/NequIP-OAM-L-0.1.nequip.pth
|
| 187 |
+
python single_point.py \
|
| 188 |
+
--compiled-model weight/NequIP-OAM-L-0.1.nequip.pth \
|
| 189 |
+
--input structure.cif \
|
| 190 |
+
--output outputs/single_point.json
|
| 191 |
+
```
|
| 192 |
+
|
| 193 |
+
Calculate an energy-volume curve and perform structure relaxation:
|
| 194 |
+
|
| 195 |
+
```bash
|
| 196 |
+
python energy_volume.py
|
| 197 |
+
python structure_relaxation.py --fmax 0.05 --steps 100 --output-dir outputs/oam_l_relax
|
| 198 |
+
```
|
| 199 |
+
|
| 200 |
+
Run inference with a checkpoint produced by fine-tuning:
|
| 201 |
+
|
| 202 |
+
```bash
|
| 203 |
+
python single_point.py \
|
| 204 |
+
--checkpoint outputs/<run>/checkpoints/best.ckpt \
|
| 205 |
+
--package weight/NequIP-OAM-L-0.1.nequip.zip \
|
| 206 |
+
--output outputs/<run>/single_point.json
|
| 207 |
+
```
|
| 208 |
+
|
| 209 |
+
# Official OneScience Resources
|
| 210 |
+
|
| 211 |
+
| Platform | OneScience Main Repository | Skills Repository |
|
| 212 |
+
| --- | --- | --- |
|
| 213 |
+
| Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
|
| 214 |
+
| GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
|
| 215 |
+
|
| 216 |
+
# Citation and License
|
| 217 |
+
|
| 218 |
+
- The NequIP-related code comes from the OneScience MatChem integration and refers to the upstream NequIP project (https://github.com/mir-group/nequip). The OneScience integration code follows the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0) used by the main repository.
|
| 219 |
+
- If you use NequIP or OAM-L training results in research, please cite the original NequIP paper, the relevant OneScience projects, and the datasets used.
|
| 220 |
+
- Redistribution rights for the OAM-L model weights are governed by the original OneScience/OAM-L release terms. Confirm the applicable rights and restrictions before use.
|
| 221 |
+
- The FCC Cu dataset is published separately at [OneScience-Group/FCC_Cu](https://huggingface.co/datasets/OneScience-Group/FCC_Cu). Its license and provenance are documented on the dataset card and by its upstream source.
|
demo/_parse_config.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Extract NequIP demo launch metadata and its Hydra training config."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import re
|
| 7 |
+
import shlex
|
| 8 |
+
import sys
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import yaml
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
META_KEYS = {"name", "launch", "slurm", "env"}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _assignment(name: str, value) -> None:
|
| 18 |
+
print(f"{name}={shlex.quote(str(value))}")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _positive_int(value, field: str) -> int:
|
| 22 |
+
try:
|
| 23 |
+
parsed = int(value)
|
| 24 |
+
except (TypeError, ValueError) as error:
|
| 25 |
+
raise ValueError(f"{field} must be a positive integer") from error
|
| 26 |
+
if parsed < 1:
|
| 27 |
+
raise ValueError(f"{field} must be a positive integer")
|
| 28 |
+
return parsed
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _config(path: str) -> dict:
|
| 32 |
+
text = Path(path).read_text(encoding="utf-8")
|
| 33 |
+
text = re.sub(
|
| 34 |
+
r"\$\{demo_dir:([^}]+)\}",
|
| 35 |
+
lambda match: str(Path(__file__).parent.resolve() / match.group(1)),
|
| 36 |
+
text,
|
| 37 |
+
)
|
| 38 |
+
return yaml.safe_load(text) or {}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _print_launch(cfg: dict) -> None:
|
| 42 |
+
launch = cfg.get("launch", {}) or {}
|
| 43 |
+
trainer = cfg.get("trainer", {}) or {}
|
| 44 |
+
mode = launch.get("mode", "local")
|
| 45 |
+
if mode not in {"auto", "local", "submit"}:
|
| 46 |
+
raise ValueError("launch.mode must be 'auto', 'local', or 'submit'")
|
| 47 |
+
|
| 48 |
+
nodes = _positive_int(launch.get("num_nodes", 1), "launch.num_nodes")
|
| 49 |
+
devices = _positive_int(launch.get("num_gpus", 1), "launch.num_gpus")
|
| 50 |
+
trainer_nodes = _positive_int(trainer.get("num_nodes", 1), "trainer.num_nodes")
|
| 51 |
+
trainer_devices = _positive_int(trainer.get("devices", 1), "trainer.devices")
|
| 52 |
+
if nodes != trainer_nodes:
|
| 53 |
+
raise ValueError("launch.num_nodes must equal trainer.num_nodes")
|
| 54 |
+
if devices != trainer_devices:
|
| 55 |
+
raise ValueError("launch.num_gpus must equal trainer.devices")
|
| 56 |
+
_assignment("RUN_MODE", mode)
|
| 57 |
+
_assignment("NODES", nodes)
|
| 58 |
+
_assignment("GPUS_PER_NODE", devices)
|
| 59 |
+
_assignment("WORLD_SIZE", nodes * devices)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _print_slurm(cfg: dict) -> None:
|
| 63 |
+
slurm = cfg.get("slurm", {}) or {}
|
| 64 |
+
_assignment("PARTITION", slurm.get("partition", "hx1hdnormal01"))
|
| 65 |
+
_assignment("TIME", slurm.get("time", "01:00:00"))
|
| 66 |
+
_assignment(
|
| 67 |
+
"CPUS_PER_TASK",
|
| 68 |
+
_positive_int(slurm.get("cpus_per_task", 8), "slurm.cpus_per_task"),
|
| 69 |
+
)
|
| 70 |
+
_assignment("NODELIST", slurm.get("nodelist", ""))
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _print_env(cfg: dict) -> None:
|
| 74 |
+
env = cfg.get("env", {}) or {}
|
| 75 |
+
for name, value in env.items():
|
| 76 |
+
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
|
| 77 |
+
raise ValueError(f"invalid environment variable name: {name}")
|
| 78 |
+
_assignment(f"export {name}", value)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _print_training_config(cfg: dict) -> None:
|
| 82 |
+
training = {key: value for key, value in cfg.items() if key not in META_KEYS}
|
| 83 |
+
yaml.safe_dump(
|
| 84 |
+
training,
|
| 85 |
+
sys.stdout,
|
| 86 |
+
sort_keys=False,
|
| 87 |
+
default_flow_style=False,
|
| 88 |
+
allow_unicode=True,
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def main() -> None:
|
| 93 |
+
if len(sys.argv) != 3:
|
| 94 |
+
raise SystemExit(
|
| 95 |
+
"usage: _parse_config.py <config.yaml> "
|
| 96 |
+
"<name|launch|slurm|env|training-config>"
|
| 97 |
+
)
|
| 98 |
+
cfg = _config(sys.argv[1])
|
| 99 |
+
action = sys.argv[2]
|
| 100 |
+
actions = {
|
| 101 |
+
"name": lambda: print(cfg.get("name", "nequip_run")),
|
| 102 |
+
"launch": lambda: _print_launch(cfg),
|
| 103 |
+
"slurm": lambda: _print_slurm(cfg),
|
| 104 |
+
"env": lambda: _print_env(cfg),
|
| 105 |
+
"training-config": lambda: _print_training_config(cfg),
|
| 106 |
+
"finetune-config": lambda: _print_training_config(cfg),
|
| 107 |
+
}
|
| 108 |
+
try:
|
| 109 |
+
actions[action]()
|
| 110 |
+
except KeyError as error:
|
| 111 |
+
raise SystemExit(f"unknown action: {action}") from error
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
if __name__ == "__main__":
|
| 115 |
+
main()
|
demo/configs/oam_l_finetune.yaml
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Production-shaped fine-tuning template for the official NequIP OAM-L model.
|
| 2 |
+
# NequIP does not prescribe a universal fine-tuning dataset or epoch count.
|
| 3 |
+
# Supply consistent energy/force reference calculations at the path below.
|
| 4 |
+
run: [train, test]
|
| 5 |
+
|
| 6 |
+
package_path: ${oc.env:ONESCIENCE_MODELS_DIR}/NequIP/NequIP-OAM-L-0.1.nequip.zip
|
| 7 |
+
model_type_names: ${type_names_from_package:${package_path}}
|
| 8 |
+
cutoff_radius: ${cutoff_radius_from_package:${package_path}}
|
| 9 |
+
monitored_metric: val0_epoch/weighted_sum
|
| 10 |
+
|
| 11 |
+
data:
|
| 12 |
+
_target_: onescience.datapipes.materials.nequip.datamodule.ASEDataModule
|
| 13 |
+
seed: 456
|
| 14 |
+
split_dataset:
|
| 15 |
+
file_path: ${oc.env:ONESCIENCE_DATASETS_DIR}/matchem/NequIP/oam_l_finetune.xyz
|
| 16 |
+
train: 0.8
|
| 17 |
+
val: 0.1
|
| 18 |
+
test: 0.1
|
| 19 |
+
transforms:
|
| 20 |
+
- _target_: onescience.datapipes.materials.nequip.transforms.ChemicalSpeciesToAtomTypeMapper
|
| 21 |
+
model_type_names: ${model_type_names}
|
| 22 |
+
- _target_: onescience.datapipes.materials.nequip.transforms.NeighborListTransform
|
| 23 |
+
r_max: ${cutoff_radius}
|
| 24 |
+
train_dataloader:
|
| 25 |
+
_target_: torch.utils.data.DataLoader
|
| 26 |
+
batch_size: 1
|
| 27 |
+
num_workers: 0
|
| 28 |
+
shuffle: true
|
| 29 |
+
val_dataloader:
|
| 30 |
+
_target_: torch.utils.data.DataLoader
|
| 31 |
+
batch_size: 4
|
| 32 |
+
num_workers: 0
|
| 33 |
+
test_dataloader: ${data.val_dataloader}
|
| 34 |
+
|
| 35 |
+
trainer:
|
| 36 |
+
_target_: lightning.Trainer
|
| 37 |
+
accelerator: gpu
|
| 38 |
+
devices: 1
|
| 39 |
+
num_nodes: 1
|
| 40 |
+
max_epochs: 100
|
| 41 |
+
enable_checkpointing: true
|
| 42 |
+
logger:
|
| 43 |
+
_target_: lightning.pytorch.loggers.CSVLogger
|
| 44 |
+
save_dir: ${hydra:runtime.output_dir}
|
| 45 |
+
name: metrics
|
| 46 |
+
version: 0
|
| 47 |
+
enable_progress_bar: true
|
| 48 |
+
log_every_n_steps: 10
|
| 49 |
+
callbacks:
|
| 50 |
+
- _target_: onescience.utils.nequip.train.callbacks.PlainTextMetricsLogger
|
| 51 |
+
- _target_: lightning.pytorch.callbacks.EarlyStopping
|
| 52 |
+
monitor: ${monitored_metric}
|
| 53 |
+
min_delta: 1e-4
|
| 54 |
+
patience: 10
|
| 55 |
+
- _target_: lightning.pytorch.callbacks.ModelCheckpoint
|
| 56 |
+
monitor: ${monitored_metric}
|
| 57 |
+
dirpath: ${hydra:runtime.output_dir}/checkpoints
|
| 58 |
+
filename: best
|
| 59 |
+
save_last: true
|
| 60 |
+
|
| 61 |
+
training_module:
|
| 62 |
+
_target_: onescience.utils.nequip.train.EMALightningModule
|
| 63 |
+
ema_decay: 0.999
|
| 64 |
+
loss:
|
| 65 |
+
_target_: onescience.utils.nequip.train.EnergyForceLoss
|
| 66 |
+
per_atom_energy: true
|
| 67 |
+
coeffs:
|
| 68 |
+
total_energy: 1.0
|
| 69 |
+
forces: 1.0
|
| 70 |
+
train_metrics:
|
| 71 |
+
_target_: onescience.utils.nequip.train.EnergyForceMetrics
|
| 72 |
+
coeffs:
|
| 73 |
+
total_energy_mae: 1.0
|
| 74 |
+
forces_mae: 1.0
|
| 75 |
+
val_metrics: ${training_module.train_metrics}
|
| 76 |
+
test_metrics: ${training_module.train_metrics}
|
| 77 |
+
optimizer:
|
| 78 |
+
_target_: torch.optim.Adam
|
| 79 |
+
lr: 1.0e-5
|
| 80 |
+
lr_scheduler:
|
| 81 |
+
scheduler:
|
| 82 |
+
_target_: torch.optim.lr_scheduler.ReduceLROnPlateau
|
| 83 |
+
factor: 0.5
|
| 84 |
+
patience: 5
|
| 85 |
+
min_lr: 1.0e-7
|
| 86 |
+
monitor: ${monitored_metric}
|
| 87 |
+
interval: epoch
|
| 88 |
+
frequency: 1
|
| 89 |
+
model:
|
| 90 |
+
_target_: onescience.models.nequip.model.ModelFromPackage
|
| 91 |
+
package_path: ${package_path}
|
| 92 |
+
|
| 93 |
+
name: nequip_oam_l_finetune
|
| 94 |
+
launch:
|
| 95 |
+
mode: local
|
| 96 |
+
num_nodes: 1
|
| 97 |
+
num_gpus: 1
|
| 98 |
+
slurm:
|
| 99 |
+
partition: hx1hdnormal01
|
| 100 |
+
nodelist: ""
|
| 101 |
+
time: "1-00:00:00"
|
| 102 |
+
cpus_per_task: 8
|
| 103 |
+
env:
|
| 104 |
+
OMP_NUM_THREADS: 8
|
demo/configs/oam_l_finetune_smoke.yaml
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Fine-tuning smoke test for the official NequIP OAM-L package.
|
| 2 |
+
# The bundled Cu data only validates the workflow; replace it with consistent
|
| 3 |
+
# reference calculations and remove the batch limits for a scientific run.
|
| 4 |
+
run: [train, test]
|
| 5 |
+
|
| 6 |
+
package_path: ${oc.env:ONESCIENCE_MODELS_DIR}/NequIP/NequIP-OAM-L-0.1.nequip.zip
|
| 7 |
+
model_type_names: ${type_names_from_package:${package_path}}
|
| 8 |
+
cutoff_radius: ${cutoff_radius_from_package:${package_path}}
|
| 9 |
+
monitored_metric: val0_epoch/weighted_sum
|
| 10 |
+
|
| 11 |
+
data:
|
| 12 |
+
_target_: onescience.datapipes.materials.nequip.datamodule.ASEDataModule
|
| 13 |
+
seed: 456
|
| 14 |
+
split_dataset:
|
| 15 |
+
file_path: ${demo_dir:reference_data/smoke.xyz}
|
| 16 |
+
train: 0.75
|
| 17 |
+
val: 0.125
|
| 18 |
+
test: 0.125
|
| 19 |
+
transforms:
|
| 20 |
+
- _target_: onescience.datapipes.materials.nequip.transforms.ChemicalSpeciesToAtomTypeMapper
|
| 21 |
+
model_type_names: ${model_type_names}
|
| 22 |
+
- _target_: onescience.datapipes.materials.nequip.transforms.NeighborListTransform
|
| 23 |
+
r_max: ${cutoff_radius}
|
| 24 |
+
train_dataloader:
|
| 25 |
+
_target_: torch.utils.data.DataLoader
|
| 26 |
+
batch_size: 1
|
| 27 |
+
num_workers: 0
|
| 28 |
+
shuffle: true
|
| 29 |
+
val_dataloader:
|
| 30 |
+
_target_: torch.utils.data.DataLoader
|
| 31 |
+
batch_size: 1
|
| 32 |
+
num_workers: 0
|
| 33 |
+
test_dataloader: ${data.val_dataloader}
|
| 34 |
+
|
| 35 |
+
trainer:
|
| 36 |
+
_target_: lightning.Trainer
|
| 37 |
+
accelerator: gpu
|
| 38 |
+
devices: 1
|
| 39 |
+
num_nodes: 1
|
| 40 |
+
max_epochs: 1
|
| 41 |
+
limit_train_batches: 1
|
| 42 |
+
limit_val_batches: 1
|
| 43 |
+
limit_test_batches: 1
|
| 44 |
+
num_sanity_val_steps: 0
|
| 45 |
+
enable_checkpointing: true
|
| 46 |
+
logger:
|
| 47 |
+
_target_: lightning.pytorch.loggers.CSVLogger
|
| 48 |
+
save_dir: ${hydra:runtime.output_dir}
|
| 49 |
+
name: metrics
|
| 50 |
+
version: 0
|
| 51 |
+
enable_progress_bar: true
|
| 52 |
+
log_every_n_steps: 1
|
| 53 |
+
callbacks:
|
| 54 |
+
- _target_: onescience.utils.nequip.train.callbacks.PlainTextMetricsLogger
|
| 55 |
+
- _target_: lightning.pytorch.callbacks.ModelCheckpoint
|
| 56 |
+
monitor: ${monitored_metric}
|
| 57 |
+
dirpath: ${hydra:runtime.output_dir}/checkpoints
|
| 58 |
+
filename: best
|
| 59 |
+
save_last: true
|
| 60 |
+
|
| 61 |
+
training_module:
|
| 62 |
+
_target_: onescience.utils.nequip.train.EMALightningModule
|
| 63 |
+
ema_decay: 0.999
|
| 64 |
+
loss:
|
| 65 |
+
_target_: onescience.utils.nequip.train.EnergyForceLoss
|
| 66 |
+
per_atom_energy: true
|
| 67 |
+
coeffs:
|
| 68 |
+
total_energy: 1.0
|
| 69 |
+
forces: 1.0
|
| 70 |
+
train_metrics:
|
| 71 |
+
_target_: onescience.utils.nequip.train.EnergyForceMetrics
|
| 72 |
+
coeffs:
|
| 73 |
+
total_energy_mae: 1.0
|
| 74 |
+
forces_mae: 1.0
|
| 75 |
+
val_metrics: ${training_module.train_metrics}
|
| 76 |
+
test_metrics: ${training_module.train_metrics}
|
| 77 |
+
optimizer:
|
| 78 |
+
_target_: torch.optim.Adam
|
| 79 |
+
lr: 1.0e-5
|
| 80 |
+
model:
|
| 81 |
+
_target_: onescience.models.nequip.model.ModelFromPackage
|
| 82 |
+
package_path: ${package_path}
|
| 83 |
+
|
| 84 |
+
name: nequip_oam_l_finetune_smoke
|
| 85 |
+
launch:
|
| 86 |
+
mode: local
|
| 87 |
+
num_nodes: 1
|
| 88 |
+
num_gpus: 1
|
| 89 |
+
slurm:
|
| 90 |
+
partition: hx1hdnormal01
|
| 91 |
+
nodelist: ""
|
| 92 |
+
time: "00:30:00"
|
| 93 |
+
cpus_per_task: 8
|
| 94 |
+
env:
|
| 95 |
+
OMP_NUM_THREADS: 8
|
demo/configs/tutorial_fcu.yaml
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# NequIP 0.19 tutorial reproduction using the official fcu.xyz dataset.
|
| 2 |
+
# The validated smoke schedule uses two complete epochs. Set max_epochs to 1000
|
| 3 |
+
# to match the upstream tutorial's full training schedule.
|
| 4 |
+
run: [train, test]
|
| 5 |
+
|
| 6 |
+
cutoff_radius: 5.0
|
| 7 |
+
num_layers: 4
|
| 8 |
+
l_max: 1
|
| 9 |
+
num_features: 32
|
| 10 |
+
model_type_names: [C, H, O, Cu]
|
| 11 |
+
chemical_species: ${model_type_names}
|
| 12 |
+
monitored_metric: val0_epoch/weighted_sum
|
| 13 |
+
|
| 14 |
+
data:
|
| 15 |
+
_target_: onescience.datapipes.materials.nequip.datamodule.ASEDataModule
|
| 16 |
+
seed: 456
|
| 17 |
+
split_dataset:
|
| 18 |
+
file_path: ${oc.env:ONESCIENCE_DATASETS_DIR}/matchem/NequIP/fcu.xyz
|
| 19 |
+
train: 0.8
|
| 20 |
+
val: 0.1
|
| 21 |
+
test: 0.1
|
| 22 |
+
transforms:
|
| 23 |
+
- _target_: onescience.datapipes.materials.nequip.transforms.ChemicalSpeciesToAtomTypeMapper
|
| 24 |
+
model_type_names: ${model_type_names}
|
| 25 |
+
- _target_: onescience.datapipes.materials.nequip.transforms.NeighborListTransform
|
| 26 |
+
r_max: ${cutoff_radius}
|
| 27 |
+
train_dataloader:
|
| 28 |
+
_target_: torch.utils.data.DataLoader
|
| 29 |
+
batch_size: 5
|
| 30 |
+
num_workers: 0
|
| 31 |
+
shuffle: true
|
| 32 |
+
val_dataloader:
|
| 33 |
+
_target_: torch.utils.data.DataLoader
|
| 34 |
+
batch_size: 10
|
| 35 |
+
num_workers: 0
|
| 36 |
+
test_dataloader: ${data.val_dataloader}
|
| 37 |
+
stats_manager:
|
| 38 |
+
_target_: onescience.datapipes.materials.nequip.CommonDataStatisticsManager
|
| 39 |
+
dataloader_kwargs:
|
| 40 |
+
batch_size: 10
|
| 41 |
+
type_names: ${model_type_names}
|
| 42 |
+
|
| 43 |
+
trainer:
|
| 44 |
+
_target_: lightning.Trainer
|
| 45 |
+
accelerator: gpu
|
| 46 |
+
devices: 1
|
| 47 |
+
num_nodes: 1
|
| 48 |
+
enable_checkpointing: true
|
| 49 |
+
max_epochs: 2
|
| 50 |
+
log_every_n_steps: 1
|
| 51 |
+
logger: false
|
| 52 |
+
enable_progress_bar: false
|
| 53 |
+
callbacks:
|
| 54 |
+
- _target_: lightning.pytorch.callbacks.EarlyStopping
|
| 55 |
+
monitor: ${monitored_metric}
|
| 56 |
+
min_delta: 1e-3
|
| 57 |
+
patience: 20
|
| 58 |
+
- _target_: lightning.pytorch.callbacks.ModelCheckpoint
|
| 59 |
+
monitor: ${monitored_metric}
|
| 60 |
+
dirpath: ${hydra:runtime.output_dir}/checkpoints
|
| 61 |
+
filename: best
|
| 62 |
+
save_last: true
|
| 63 |
+
|
| 64 |
+
training_module:
|
| 65 |
+
_target_: onescience.utils.nequip.train.EMALightningModule
|
| 66 |
+
ema_decay: 0.999
|
| 67 |
+
loss:
|
| 68 |
+
_target_: onescience.utils.nequip.train.EnergyForceLoss
|
| 69 |
+
per_atom_energy: true
|
| 70 |
+
coeffs:
|
| 71 |
+
total_energy: 1.0
|
| 72 |
+
forces: 1.0
|
| 73 |
+
val_metrics:
|
| 74 |
+
_target_: onescience.utils.nequip.train.EnergyForceMetrics
|
| 75 |
+
coeffs:
|
| 76 |
+
total_energy_mae: 1.0
|
| 77 |
+
forces_mae: 1.0
|
| 78 |
+
train_metrics: ${training_module.val_metrics}
|
| 79 |
+
test_metrics: ${training_module.val_metrics}
|
| 80 |
+
optimizer:
|
| 81 |
+
_target_: torch.optim.Adam
|
| 82 |
+
lr: 0.01
|
| 83 |
+
lr_scheduler:
|
| 84 |
+
scheduler:
|
| 85 |
+
_target_: torch.optim.lr_scheduler.ReduceLROnPlateau
|
| 86 |
+
factor: 0.6
|
| 87 |
+
patience: 5
|
| 88 |
+
threshold: 0.2
|
| 89 |
+
min_lr: 1e-6
|
| 90 |
+
monitor: ${monitored_metric}
|
| 91 |
+
interval: epoch
|
| 92 |
+
frequency: 1
|
| 93 |
+
model:
|
| 94 |
+
_target_: onescience.models.nequip.model.NequIPGNNModel
|
| 95 |
+
compile_mode: eager
|
| 96 |
+
seed: 456
|
| 97 |
+
model_dtype: float32
|
| 98 |
+
type_names: ${model_type_names}
|
| 99 |
+
r_max: ${cutoff_radius}
|
| 100 |
+
num_bessels: 8
|
| 101 |
+
bessel_trainable: false
|
| 102 |
+
polynomial_cutoff_p: 6
|
| 103 |
+
num_layers: ${num_layers}
|
| 104 |
+
l_max: ${l_max}
|
| 105 |
+
parity: true
|
| 106 |
+
num_features: ${num_features}
|
| 107 |
+
radial_mlp_depth: 2
|
| 108 |
+
radial_mlp_width: 64
|
| 109 |
+
avg_num_neighbors: ${training_data_stats:num_neighbors_mean}
|
| 110 |
+
per_type_energy_scales: ${training_data_stats:per_type_forces_rms}
|
| 111 |
+
per_type_energy_shifts: ${training_data_stats:per_atom_energy_mean}
|
| 112 |
+
per_type_energy_scales_trainable: false
|
| 113 |
+
per_type_energy_shifts_trainable: false
|
| 114 |
+
pair_potential:
|
| 115 |
+
_target_: onescience.models.nequip.nn.pair_potential.ZBL
|
| 116 |
+
units: metal
|
| 117 |
+
chemical_species: ${chemical_species}
|
| 118 |
+
|
| 119 |
+
name: nequip_fcu_tutorial
|
| 120 |
+
launch:
|
| 121 |
+
mode: local
|
| 122 |
+
num_nodes: 1
|
| 123 |
+
num_gpus: 1
|
| 124 |
+
slurm:
|
| 125 |
+
partition: hx1hdnormal01
|
| 126 |
+
nodelist: a01r1n02
|
| 127 |
+
time: "00:30:00"
|
| 128 |
+
cpus_per_task: 8
|
| 129 |
+
env:
|
| 130 |
+
OMP_NUM_THREADS: 8
|
demo/configs/tutorial_fcu_8dcu.yaml
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Eight-DCU DDP training plan for the official NequIP fcu.xyz tutorial.
|
| 2 |
+
# DataLoader batch size is per rank: batch_size=5 gives a global batch of 40.
|
| 3 |
+
# The learning rate remains conservative at the official 0.01; tune it for
|
| 4 |
+
# production based on validation behavior rather than scaling it blindly.
|
| 5 |
+
run: [train, test]
|
| 6 |
+
|
| 7 |
+
cutoff_radius: 5.0
|
| 8 |
+
num_layers: 4
|
| 9 |
+
l_max: 1
|
| 10 |
+
num_features: 32
|
| 11 |
+
model_type_names: [C, H, O, Cu]
|
| 12 |
+
chemical_species: ${model_type_names}
|
| 13 |
+
monitored_metric: val0_epoch/weighted_sum
|
| 14 |
+
|
| 15 |
+
data:
|
| 16 |
+
_target_: onescience.datapipes.materials.nequip.datamodule.ASEDataModule
|
| 17 |
+
seed: 456
|
| 18 |
+
split_dataset:
|
| 19 |
+
file_path: ${oc.env:ONESCIENCE_DATASETS_DIR}/matchem/NequIP/fcu.xyz
|
| 20 |
+
train: 0.8
|
| 21 |
+
val: 0.1
|
| 22 |
+
test: 0.1
|
| 23 |
+
transforms:
|
| 24 |
+
- _target_: onescience.datapipes.materials.nequip.transforms.ChemicalSpeciesToAtomTypeMapper
|
| 25 |
+
model_type_names: ${model_type_names}
|
| 26 |
+
- _target_: onescience.datapipes.materials.nequip.transforms.NeighborListTransform
|
| 27 |
+
r_max: ${cutoff_radius}
|
| 28 |
+
train_dataloader:
|
| 29 |
+
_target_: torch.utils.data.DataLoader
|
| 30 |
+
batch_size: 5
|
| 31 |
+
num_workers: 0
|
| 32 |
+
shuffle: true
|
| 33 |
+
val_dataloader:
|
| 34 |
+
_target_: torch.utils.data.DataLoader
|
| 35 |
+
batch_size: 10
|
| 36 |
+
num_workers: 0
|
| 37 |
+
test_dataloader: ${data.val_dataloader}
|
| 38 |
+
stats_manager:
|
| 39 |
+
_target_: onescience.datapipes.materials.nequip.CommonDataStatisticsManager
|
| 40 |
+
dataloader_kwargs:
|
| 41 |
+
batch_size: 10
|
| 42 |
+
type_names: ${model_type_names}
|
| 43 |
+
|
| 44 |
+
trainer:
|
| 45 |
+
_target_: lightning.Trainer
|
| 46 |
+
accelerator: gpu
|
| 47 |
+
devices: 8
|
| 48 |
+
num_nodes: 1
|
| 49 |
+
strategy:
|
| 50 |
+
_target_: lightning.pytorch.strategies.DDPStrategy
|
| 51 |
+
enable_checkpointing: true
|
| 52 |
+
max_epochs: 1000
|
| 53 |
+
max_time: "03:00:00:00"
|
| 54 |
+
log_every_n_steps: 20
|
| 55 |
+
logger:
|
| 56 |
+
_target_: lightning.pytorch.loggers.CSVLogger
|
| 57 |
+
save_dir: ${hydra:runtime.output_dir}
|
| 58 |
+
name: metrics
|
| 59 |
+
version: 0
|
| 60 |
+
enable_progress_bar: true
|
| 61 |
+
callbacks:
|
| 62 |
+
- _target_: onescience.utils.nequip.train.callbacks.PlainTextMetricsLogger
|
| 63 |
+
- _target_: lightning.pytorch.callbacks.EarlyStopping
|
| 64 |
+
monitor: ${monitored_metric}
|
| 65 |
+
min_delta: 1e-3
|
| 66 |
+
patience: 20
|
| 67 |
+
- _target_: lightning.pytorch.callbacks.ModelCheckpoint
|
| 68 |
+
monitor: ${monitored_metric}
|
| 69 |
+
dirpath: ${hydra:runtime.output_dir}/checkpoints
|
| 70 |
+
filename: best
|
| 71 |
+
save_last: true
|
| 72 |
+
|
| 73 |
+
training_module:
|
| 74 |
+
_target_: onescience.utils.nequip.train.EMALightningModule
|
| 75 |
+
ema_decay: 0.999
|
| 76 |
+
loss:
|
| 77 |
+
_target_: onescience.utils.nequip.train.EnergyForceLoss
|
| 78 |
+
per_atom_energy: true
|
| 79 |
+
coeffs:
|
| 80 |
+
total_energy: 1.0
|
| 81 |
+
forces: 1.0
|
| 82 |
+
val_metrics:
|
| 83 |
+
_target_: onescience.utils.nequip.train.EnergyForceMetrics
|
| 84 |
+
coeffs:
|
| 85 |
+
total_energy_mae: 1.0
|
| 86 |
+
forces_mae: 1.0
|
| 87 |
+
train_metrics: ${training_module.val_metrics}
|
| 88 |
+
test_metrics: ${training_module.val_metrics}
|
| 89 |
+
optimizer:
|
| 90 |
+
_target_: torch.optim.Adam
|
| 91 |
+
lr: 0.01
|
| 92 |
+
lr_scheduler:
|
| 93 |
+
scheduler:
|
| 94 |
+
_target_: torch.optim.lr_scheduler.ReduceLROnPlateau
|
| 95 |
+
factor: 0.6
|
| 96 |
+
patience: 5
|
| 97 |
+
threshold: 0.2
|
| 98 |
+
min_lr: 1.0e-6
|
| 99 |
+
monitor: ${monitored_metric}
|
| 100 |
+
interval: epoch
|
| 101 |
+
frequency: 1
|
| 102 |
+
model:
|
| 103 |
+
_target_: onescience.models.nequip.model.NequIPGNNModel
|
| 104 |
+
compile_mode: eager
|
| 105 |
+
seed: 456
|
| 106 |
+
model_dtype: float32
|
| 107 |
+
type_names: ${model_type_names}
|
| 108 |
+
r_max: ${cutoff_radius}
|
| 109 |
+
num_bessels: 8
|
| 110 |
+
bessel_trainable: false
|
| 111 |
+
polynomial_cutoff_p: 6
|
| 112 |
+
num_layers: ${num_layers}
|
| 113 |
+
l_max: ${l_max}
|
| 114 |
+
parity: true
|
| 115 |
+
num_features: ${num_features}
|
| 116 |
+
radial_mlp_depth: 2
|
| 117 |
+
radial_mlp_width: 64
|
| 118 |
+
avg_num_neighbors: ${training_data_stats:num_neighbors_mean}
|
| 119 |
+
per_type_energy_scales: ${training_data_stats:per_type_forces_rms}
|
| 120 |
+
per_type_energy_shifts: ${training_data_stats:per_atom_energy_mean}
|
| 121 |
+
per_type_energy_scales_trainable: false
|
| 122 |
+
per_type_energy_shifts_trainable: false
|
| 123 |
+
pair_potential:
|
| 124 |
+
_target_: onescience.models.nequip.nn.pair_potential.ZBL
|
| 125 |
+
units: metal
|
| 126 |
+
chemical_species: ${chemical_species}
|
| 127 |
+
|
| 128 |
+
name: nequip_fcu_tutorial_8dcu
|
| 129 |
+
launch:
|
| 130 |
+
mode: auto
|
| 131 |
+
num_nodes: 1
|
| 132 |
+
num_gpus: 8
|
| 133 |
+
slurm:
|
| 134 |
+
partition: hx1hdnormal01
|
| 135 |
+
nodelist: ""
|
| 136 |
+
time: "3-00:00:00"
|
| 137 |
+
cpus_per_task: 8
|
| 138 |
+
env:
|
| 139 |
+
OMP_NUM_THREADS: 8
|
| 140 |
+
NCCL_DEBUG: "WARN"
|
demo/configs/tutorial_fcu_full.yaml
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Full OneScience run of the official NequIP fcu.xyz tutorial configuration.
|
| 2 |
+
# The model, data split, losses, and 1000-epoch schedule follow upstream. Eager
|
| 3 |
+
# mode and local logging are retained for the validated PyTorch 2.5 DTK stack.
|
| 4 |
+
run: [train, test]
|
| 5 |
+
|
| 6 |
+
cutoff_radius: 5.0
|
| 7 |
+
num_layers: 4
|
| 8 |
+
l_max: 1
|
| 9 |
+
num_features: 32
|
| 10 |
+
model_type_names: [C, H, O, Cu]
|
| 11 |
+
chemical_species: ${model_type_names}
|
| 12 |
+
monitored_metric: val0_epoch/weighted_sum
|
| 13 |
+
|
| 14 |
+
data:
|
| 15 |
+
_target_: onescience.datapipes.materials.nequip.datamodule.ASEDataModule
|
| 16 |
+
seed: 456
|
| 17 |
+
split_dataset:
|
| 18 |
+
file_path: ${oc.env:ONESCIENCE_DATASETS_DIR}/matchem/NequIP/fcu.xyz
|
| 19 |
+
train: 0.8
|
| 20 |
+
val: 0.1
|
| 21 |
+
test: 0.1
|
| 22 |
+
transforms:
|
| 23 |
+
- _target_: onescience.datapipes.materials.nequip.transforms.ChemicalSpeciesToAtomTypeMapper
|
| 24 |
+
model_type_names: ${model_type_names}
|
| 25 |
+
- _target_: onescience.datapipes.materials.nequip.transforms.NeighborListTransform
|
| 26 |
+
r_max: ${cutoff_radius}
|
| 27 |
+
train_dataloader:
|
| 28 |
+
_target_: torch.utils.data.DataLoader
|
| 29 |
+
batch_size: 5
|
| 30 |
+
num_workers: 0
|
| 31 |
+
shuffle: true
|
| 32 |
+
val_dataloader:
|
| 33 |
+
_target_: torch.utils.data.DataLoader
|
| 34 |
+
batch_size: 10
|
| 35 |
+
num_workers: 0
|
| 36 |
+
test_dataloader: ${data.val_dataloader}
|
| 37 |
+
stats_manager:
|
| 38 |
+
_target_: onescience.datapipes.materials.nequip.CommonDataStatisticsManager
|
| 39 |
+
dataloader_kwargs:
|
| 40 |
+
batch_size: 10
|
| 41 |
+
type_names: ${model_type_names}
|
| 42 |
+
|
| 43 |
+
trainer:
|
| 44 |
+
_target_: lightning.Trainer
|
| 45 |
+
accelerator: gpu
|
| 46 |
+
devices: 1
|
| 47 |
+
num_nodes: 1
|
| 48 |
+
enable_checkpointing: true
|
| 49 |
+
max_epochs: 1000
|
| 50 |
+
max_time: "03:00:00:00"
|
| 51 |
+
log_every_n_steps: 20
|
| 52 |
+
logger: false
|
| 53 |
+
enable_progress_bar: false
|
| 54 |
+
callbacks:
|
| 55 |
+
- _target_: lightning.pytorch.callbacks.EarlyStopping
|
| 56 |
+
monitor: ${monitored_metric}
|
| 57 |
+
min_delta: 1e-3
|
| 58 |
+
patience: 20
|
| 59 |
+
- _target_: lightning.pytorch.callbacks.ModelCheckpoint
|
| 60 |
+
monitor: ${monitored_metric}
|
| 61 |
+
dirpath: ${hydra:runtime.output_dir}/checkpoints
|
| 62 |
+
filename: best
|
| 63 |
+
save_last: true
|
| 64 |
+
|
| 65 |
+
training_module:
|
| 66 |
+
_target_: onescience.utils.nequip.train.EMALightningModule
|
| 67 |
+
ema_decay: 0.999
|
| 68 |
+
loss:
|
| 69 |
+
_target_: onescience.utils.nequip.train.EnergyForceLoss
|
| 70 |
+
per_atom_energy: true
|
| 71 |
+
coeffs:
|
| 72 |
+
total_energy: 1.0
|
| 73 |
+
forces: 1.0
|
| 74 |
+
val_metrics:
|
| 75 |
+
_target_: onescience.utils.nequip.train.EnergyForceMetrics
|
| 76 |
+
coeffs:
|
| 77 |
+
total_energy_mae: 1.0
|
| 78 |
+
forces_mae: 1.0
|
| 79 |
+
train_metrics: ${training_module.val_metrics}
|
| 80 |
+
test_metrics: ${training_module.val_metrics}
|
| 81 |
+
optimizer:
|
| 82 |
+
_target_: torch.optim.Adam
|
| 83 |
+
lr: 0.01
|
| 84 |
+
lr_scheduler:
|
| 85 |
+
scheduler:
|
| 86 |
+
_target_: torch.optim.lr_scheduler.ReduceLROnPlateau
|
| 87 |
+
factor: 0.6
|
| 88 |
+
patience: 5
|
| 89 |
+
threshold: 0.2
|
| 90 |
+
min_lr: 1.0e-6
|
| 91 |
+
monitor: ${monitored_metric}
|
| 92 |
+
interval: epoch
|
| 93 |
+
frequency: 1
|
| 94 |
+
model:
|
| 95 |
+
_target_: onescience.models.nequip.model.NequIPGNNModel
|
| 96 |
+
compile_mode: eager
|
| 97 |
+
seed: 456
|
| 98 |
+
model_dtype: float32
|
| 99 |
+
type_names: ${model_type_names}
|
| 100 |
+
r_max: ${cutoff_radius}
|
| 101 |
+
num_bessels: 8
|
| 102 |
+
bessel_trainable: false
|
| 103 |
+
polynomial_cutoff_p: 6
|
| 104 |
+
num_layers: ${num_layers}
|
| 105 |
+
l_max: ${l_max}
|
| 106 |
+
parity: true
|
| 107 |
+
num_features: ${num_features}
|
| 108 |
+
radial_mlp_depth: 2
|
| 109 |
+
radial_mlp_width: 64
|
| 110 |
+
avg_num_neighbors: ${training_data_stats:num_neighbors_mean}
|
| 111 |
+
per_type_energy_scales: ${training_data_stats:per_type_forces_rms}
|
| 112 |
+
per_type_energy_shifts: ${training_data_stats:per_atom_energy_mean}
|
| 113 |
+
per_type_energy_scales_trainable: false
|
| 114 |
+
per_type_energy_shifts_trainable: false
|
| 115 |
+
pair_potential:
|
| 116 |
+
_target_: onescience.models.nequip.nn.pair_potential.ZBL
|
| 117 |
+
units: metal
|
| 118 |
+
chemical_species: ${chemical_species}
|
| 119 |
+
|
| 120 |
+
name: nequip_fcu_tutorial_full
|
| 121 |
+
launch:
|
| 122 |
+
mode: local
|
| 123 |
+
num_nodes: 1
|
| 124 |
+
num_gpus: 1
|
| 125 |
+
slurm:
|
| 126 |
+
partition: hx1hdnormal01
|
| 127 |
+
nodelist: a01r1n02
|
| 128 |
+
time: "3-00:00:00"
|
| 129 |
+
cpus_per_task: 8
|
| 130 |
+
env:
|
| 131 |
+
OMP_NUM_THREADS: 8
|
demo/configs/tutorial_smoke.yaml
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# yamllint disable rule:line-length
|
| 2 |
+
# Smoke-test config for NequIP on OneScience.
|
| 3 |
+
# This config uses a tiny synthetic Cu dataset and a small model to verify the
|
| 4 |
+
# training pipeline (data loading, model build, forward, backward, checkpoint).
|
| 5 |
+
|
| 6 |
+
run: [train, test]
|
| 7 |
+
|
| 8 |
+
cutoff_radius: 4.0
|
| 9 |
+
|
| 10 |
+
num_layers: 2
|
| 11 |
+
l_max: 1
|
| 12 |
+
num_features: 8
|
| 13 |
+
|
| 14 |
+
model_type_names: [Cu]
|
| 15 |
+
chemical_species: ${model_type_names}
|
| 16 |
+
|
| 17 |
+
monitored_metric: val0_epoch/weighted_sum
|
| 18 |
+
|
| 19 |
+
# ============
|
| 20 |
+
# DATA
|
| 21 |
+
# ============
|
| 22 |
+
data:
|
| 23 |
+
_target_: onescience.datapipes.materials.nequip.datamodule.ASEDataModule
|
| 24 |
+
seed: 456
|
| 25 |
+
|
| 26 |
+
split_dataset:
|
| 27 |
+
file_path: ${demo_dir:reference_data/smoke.xyz}
|
| 28 |
+
train: 0.75
|
| 29 |
+
val: 0.125
|
| 30 |
+
test: 0.125
|
| 31 |
+
|
| 32 |
+
transforms:
|
| 33 |
+
- _target_: onescience.datapipes.materials.nequip.transforms.ChemicalSpeciesToAtomTypeMapper
|
| 34 |
+
model_type_names: ${model_type_names}
|
| 35 |
+
- _target_: onescience.datapipes.materials.nequip.transforms.NeighborListTransform
|
| 36 |
+
r_max: ${cutoff_radius}
|
| 37 |
+
|
| 38 |
+
train_dataloader:
|
| 39 |
+
_target_: torch.utils.data.DataLoader
|
| 40 |
+
batch_size: 2
|
| 41 |
+
num_workers: 0
|
| 42 |
+
shuffle: true
|
| 43 |
+
val_dataloader:
|
| 44 |
+
_target_: torch.utils.data.DataLoader
|
| 45 |
+
batch_size: 2
|
| 46 |
+
num_workers: 0
|
| 47 |
+
test_dataloader: ${data.val_dataloader}
|
| 48 |
+
|
| 49 |
+
stats_manager:
|
| 50 |
+
_target_: onescience.datapipes.materials.nequip.CommonDataStatisticsManager
|
| 51 |
+
dataloader_kwargs:
|
| 52 |
+
batch_size: 2
|
| 53 |
+
type_names: ${model_type_names}
|
| 54 |
+
|
| 55 |
+
# =============
|
| 56 |
+
# TRAINER
|
| 57 |
+
# =============
|
| 58 |
+
trainer:
|
| 59 |
+
_target_: lightning.Trainer
|
| 60 |
+
accelerator: gpu
|
| 61 |
+
devices: 1
|
| 62 |
+
num_nodes: 1
|
| 63 |
+
enable_checkpointing: true
|
| 64 |
+
max_epochs: 2
|
| 65 |
+
log_every_n_steps: 1
|
| 66 |
+
logger: false
|
| 67 |
+
enable_progress_bar: true
|
| 68 |
+
|
| 69 |
+
callbacks:
|
| 70 |
+
- _target_: lightning.pytorch.callbacks.ModelCheckpoint
|
| 71 |
+
monitor: ${monitored_metric}
|
| 72 |
+
dirpath: ${hydra:runtime.output_dir}/checkpoints
|
| 73 |
+
filename: best
|
| 74 |
+
save_last: true
|
| 75 |
+
|
| 76 |
+
# =====================
|
| 77 |
+
# TRAINING MODULE
|
| 78 |
+
# =====================
|
| 79 |
+
training_module:
|
| 80 |
+
_target_: onescience.utils.nequip.train.EMALightningModule
|
| 81 |
+
ema_decay: 0.999
|
| 82 |
+
|
| 83 |
+
loss:
|
| 84 |
+
_target_: onescience.utils.nequip.train.EnergyForceLoss
|
| 85 |
+
per_atom_energy: true
|
| 86 |
+
coeffs:
|
| 87 |
+
total_energy: 1.0
|
| 88 |
+
forces: 1.0
|
| 89 |
+
|
| 90 |
+
val_metrics:
|
| 91 |
+
_target_: onescience.utils.nequip.train.EnergyForceMetrics
|
| 92 |
+
coeffs:
|
| 93 |
+
total_energy_mae: 1.0
|
| 94 |
+
forces_mae: 1.0
|
| 95 |
+
train_metrics: ${training_module.val_metrics}
|
| 96 |
+
test_metrics: ${training_module.val_metrics}
|
| 97 |
+
|
| 98 |
+
optimizer:
|
| 99 |
+
_target_: torch.optim.Adam
|
| 100 |
+
lr: 0.01
|
| 101 |
+
|
| 102 |
+
lr_scheduler:
|
| 103 |
+
scheduler:
|
| 104 |
+
_target_: torch.optim.lr_scheduler.ReduceLROnPlateau
|
| 105 |
+
factor: 0.6
|
| 106 |
+
patience: 5
|
| 107 |
+
threshold: 0.2
|
| 108 |
+
min_lr: 1e-6
|
| 109 |
+
monitor: ${monitored_metric}
|
| 110 |
+
interval: epoch
|
| 111 |
+
frequency: 1
|
| 112 |
+
|
| 113 |
+
model:
|
| 114 |
+
_target_: onescience.models.nequip.model.NequIPGNNModel
|
| 115 |
+
seed: 456
|
| 116 |
+
model_dtype: float32
|
| 117 |
+
type_names: ${model_type_names}
|
| 118 |
+
r_max: ${cutoff_radius}
|
| 119 |
+
num_bessels: 4
|
| 120 |
+
bessel_trainable: false
|
| 121 |
+
polynomial_cutoff_p: 6
|
| 122 |
+
num_layers: ${num_layers}
|
| 123 |
+
l_max: ${l_max}
|
| 124 |
+
parity: false
|
| 125 |
+
num_features: ${num_features}
|
| 126 |
+
radial_mlp_depth: 1
|
| 127 |
+
radial_mlp_width: 16
|
| 128 |
+
avg_num_neighbors: ${training_data_stats:num_neighbors_mean}
|
| 129 |
+
per_type_energy_scales: ${training_data_stats:per_type_forces_rms}
|
| 130 |
+
per_type_energy_shifts: ${training_data_stats:per_atom_energy_mean}
|
| 131 |
+
per_type_energy_scales_trainable: false
|
| 132 |
+
per_type_energy_shifts_trainable: false
|
| 133 |
+
|
| 134 |
+
# Slurm / launch metadata used by demo/run.sh
|
| 135 |
+
name: nequip_smoke
|
| 136 |
+
launch:
|
| 137 |
+
mode: local
|
| 138 |
+
num_nodes: 1
|
| 139 |
+
num_gpus: 1
|
| 140 |
+
slurm:
|
| 141 |
+
partition: hx1hdnormal01
|
| 142 |
+
nodelist: a01r1n02
|
| 143 |
+
time: "00:10:00"
|
| 144 |
+
cpus_per_task: 8
|
| 145 |
+
env:
|
| 146 |
+
OMP_NUM_THREADS: 1
|
demo/configs/tutorial_smoke_8dcu.yaml
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DDP smoke test for the vendored NequIP trainer on one node with eight DCUs.
|
| 2 |
+
# Batch size is per rank, so batch_size=1 gives a global batch size of 8.
|
| 3 |
+
run: [train, test]
|
| 4 |
+
|
| 5 |
+
cutoff_radius: 4.0
|
| 6 |
+
num_layers: 2
|
| 7 |
+
l_max: 1
|
| 8 |
+
num_features: 8
|
| 9 |
+
model_type_names: [Cu]
|
| 10 |
+
chemical_species: ${model_type_names}
|
| 11 |
+
monitored_metric: val0_epoch/weighted_sum
|
| 12 |
+
|
| 13 |
+
data:
|
| 14 |
+
_target_: onescience.datapipes.materials.nequip.datamodule.ASEDataModule
|
| 15 |
+
seed: 456
|
| 16 |
+
split_dataset:
|
| 17 |
+
file_path: ${demo_dir:reference_data/smoke.xyz}
|
| 18 |
+
train: 0.75
|
| 19 |
+
val: 0.125
|
| 20 |
+
test: 0.125
|
| 21 |
+
transforms:
|
| 22 |
+
- _target_: onescience.datapipes.materials.nequip.transforms.ChemicalSpeciesToAtomTypeMapper
|
| 23 |
+
model_type_names: ${model_type_names}
|
| 24 |
+
- _target_: onescience.datapipes.materials.nequip.transforms.NeighborListTransform
|
| 25 |
+
r_max: ${cutoff_radius}
|
| 26 |
+
train_dataloader:
|
| 27 |
+
_target_: torch.utils.data.DataLoader
|
| 28 |
+
batch_size: 1
|
| 29 |
+
num_workers: 0
|
| 30 |
+
shuffle: true
|
| 31 |
+
val_dataloader:
|
| 32 |
+
_target_: torch.utils.data.DataLoader
|
| 33 |
+
batch_size: 1
|
| 34 |
+
num_workers: 0
|
| 35 |
+
test_dataloader: ${data.val_dataloader}
|
| 36 |
+
stats_manager:
|
| 37 |
+
_target_: onescience.datapipes.materials.nequip.CommonDataStatisticsManager
|
| 38 |
+
dataloader_kwargs:
|
| 39 |
+
batch_size: 2
|
| 40 |
+
type_names: ${model_type_names}
|
| 41 |
+
|
| 42 |
+
trainer:
|
| 43 |
+
_target_: lightning.Trainer
|
| 44 |
+
accelerator: gpu
|
| 45 |
+
devices: 8
|
| 46 |
+
num_nodes: 1
|
| 47 |
+
strategy:
|
| 48 |
+
_target_: lightning.pytorch.strategies.DDPStrategy
|
| 49 |
+
enable_checkpointing: true
|
| 50 |
+
max_epochs: 1
|
| 51 |
+
limit_train_batches: 1
|
| 52 |
+
limit_val_batches: 1
|
| 53 |
+
limit_test_batches: 1
|
| 54 |
+
num_sanity_val_steps: 0
|
| 55 |
+
log_every_n_steps: 1
|
| 56 |
+
logger:
|
| 57 |
+
_target_: lightning.pytorch.loggers.CSVLogger
|
| 58 |
+
save_dir: ${hydra:runtime.output_dir}
|
| 59 |
+
name: metrics
|
| 60 |
+
version: 0
|
| 61 |
+
enable_progress_bar: true
|
| 62 |
+
callbacks:
|
| 63 |
+
- _target_: onescience.utils.nequip.train.callbacks.PlainTextMetricsLogger
|
| 64 |
+
- _target_: lightning.pytorch.callbacks.ModelCheckpoint
|
| 65 |
+
monitor: ${monitored_metric}
|
| 66 |
+
dirpath: ${hydra:runtime.output_dir}/checkpoints
|
| 67 |
+
filename: best
|
| 68 |
+
save_last: true
|
| 69 |
+
|
| 70 |
+
training_module:
|
| 71 |
+
_target_: onescience.utils.nequip.train.EMALightningModule
|
| 72 |
+
ema_decay: 0.999
|
| 73 |
+
loss:
|
| 74 |
+
_target_: onescience.utils.nequip.train.EnergyForceLoss
|
| 75 |
+
per_atom_energy: true
|
| 76 |
+
coeffs:
|
| 77 |
+
total_energy: 1.0
|
| 78 |
+
forces: 1.0
|
| 79 |
+
val_metrics:
|
| 80 |
+
_target_: onescience.utils.nequip.train.EnergyForceMetrics
|
| 81 |
+
coeffs:
|
| 82 |
+
total_energy_mae: 1.0
|
| 83 |
+
forces_mae: 1.0
|
| 84 |
+
train_metrics: ${training_module.val_metrics}
|
| 85 |
+
test_metrics: ${training_module.val_metrics}
|
| 86 |
+
optimizer:
|
| 87 |
+
_target_: torch.optim.Adam
|
| 88 |
+
lr: 0.01
|
| 89 |
+
lr_scheduler:
|
| 90 |
+
scheduler:
|
| 91 |
+
_target_: torch.optim.lr_scheduler.ReduceLROnPlateau
|
| 92 |
+
factor: 0.6
|
| 93 |
+
patience: 5
|
| 94 |
+
threshold: 0.2
|
| 95 |
+
min_lr: 1.0e-6
|
| 96 |
+
monitor: ${monitored_metric}
|
| 97 |
+
interval: epoch
|
| 98 |
+
frequency: 1
|
| 99 |
+
model:
|
| 100 |
+
_target_: onescience.models.nequip.model.NequIPGNNModel
|
| 101 |
+
compile_mode: eager
|
| 102 |
+
seed: 456
|
| 103 |
+
model_dtype: float32
|
| 104 |
+
type_names: ${model_type_names}
|
| 105 |
+
r_max: ${cutoff_radius}
|
| 106 |
+
num_bessels: 4
|
| 107 |
+
bessel_trainable: false
|
| 108 |
+
polynomial_cutoff_p: 6
|
| 109 |
+
num_layers: ${num_layers}
|
| 110 |
+
l_max: ${l_max}
|
| 111 |
+
parity: false
|
| 112 |
+
num_features: ${num_features}
|
| 113 |
+
radial_mlp_depth: 1
|
| 114 |
+
radial_mlp_width: 16
|
| 115 |
+
avg_num_neighbors: ${training_data_stats:num_neighbors_mean}
|
| 116 |
+
per_type_energy_scales: ${training_data_stats:per_type_forces_rms}
|
| 117 |
+
per_type_energy_shifts: ${training_data_stats:per_atom_energy_mean}
|
| 118 |
+
per_type_energy_scales_trainable: false
|
| 119 |
+
per_type_energy_shifts_trainable: false
|
| 120 |
+
|
| 121 |
+
name: nequip_smoke_8dcu
|
| 122 |
+
launch:
|
| 123 |
+
mode: auto
|
| 124 |
+
num_nodes: 1
|
| 125 |
+
num_gpus: 8
|
| 126 |
+
slurm:
|
| 127 |
+
partition: hx1hdnormal01
|
| 128 |
+
nodelist: ""
|
| 129 |
+
time: "00:10:00"
|
| 130 |
+
cpus_per_task: 8
|
| 131 |
+
env:
|
| 132 |
+
OMP_NUM_THREADS: 1
|
| 133 |
+
NCCL_DEBUG: INFO
|
demo/download_tutorial_data.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Download and verify the official NequIP fcu.xyz tutorial dataset."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import hashlib
|
| 7 |
+
import os
|
| 8 |
+
import tempfile
|
| 9 |
+
import urllib.request
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
URL = "https://archive.materialscloud.org/records/ycbvx-knj69/files/fcu.xyz?download=1"
|
| 14 |
+
SHA256 = "57f00395d6945a3018a873d229fd7fbb7352a44a66f00f3c6e8a36247e0851e5"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def sha256(path: Path) -> str:
|
| 18 |
+
digest = hashlib.sha256()
|
| 19 |
+
with path.open("rb") as stream:
|
| 20 |
+
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
| 21 |
+
digest.update(block)
|
| 22 |
+
return digest.hexdigest()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def default_output() -> Path:
|
| 26 |
+
datasets_dir = os.environ.get("ONESCIENCE_DATASETS_DIR")
|
| 27 |
+
if not datasets_dir:
|
| 28 |
+
raise RuntimeError("ONESCIENCE_DATASETS_DIR is not set; load matchem_env.sh first")
|
| 29 |
+
return Path(datasets_dir) / "matchem" / "NequIP" / "fcu.xyz"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def main() -> None:
|
| 33 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 34 |
+
parser.add_argument("--output", type=Path, help="Destination for fcu.xyz")
|
| 35 |
+
args = parser.parse_args()
|
| 36 |
+
output = (args.output or default_output()).expanduser().resolve()
|
| 37 |
+
output.parent.mkdir(parents=True, exist_ok=True)
|
| 38 |
+
|
| 39 |
+
if output.is_file() and sha256(output) == SHA256:
|
| 40 |
+
print(f"Using verified dataset: {output}")
|
| 41 |
+
return
|
| 42 |
+
|
| 43 |
+
request = urllib.request.Request(URL, headers={"User-Agent": "OneScience-NequIP/0.19"})
|
| 44 |
+
temporary_path: Path | None = None
|
| 45 |
+
try:
|
| 46 |
+
with tempfile.NamedTemporaryFile(
|
| 47 |
+
prefix="fcu_", suffix=".xyz.part", dir=output.parent, delete=False
|
| 48 |
+
) as temporary:
|
| 49 |
+
temporary_path = Path(temporary.name)
|
| 50 |
+
with urllib.request.urlopen(request) as response:
|
| 51 |
+
while block := response.read(1024 * 1024):
|
| 52 |
+
temporary.write(block)
|
| 53 |
+
actual = sha256(temporary_path)
|
| 54 |
+
if actual != SHA256:
|
| 55 |
+
raise RuntimeError(f"fcu.xyz SHA256 mismatch: expected {SHA256}, got {actual}")
|
| 56 |
+
temporary_path.replace(output)
|
| 57 |
+
finally:
|
| 58 |
+
if temporary_path is not None and temporary_path.exists():
|
| 59 |
+
temporary_path.unlink()
|
| 60 |
+
print(f"Downloaded verified dataset: {output}")
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
if __name__ == "__main__":
|
| 64 |
+
main()
|
demo/prepare_smoke_data.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generate a tiny extxyz smoke dataset for NequIP demo training."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
from ase import Atoms
|
| 10 |
+
from ase.build import bulk
|
| 11 |
+
from ase.io import write
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def main() -> None:
|
| 15 |
+
out_dir = Path(__file__).parent / "reference_data"
|
| 16 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 17 |
+
out_file = out_dir / "smoke.xyz"
|
| 18 |
+
|
| 19 |
+
rng = np.random.default_rng(123)
|
| 20 |
+
structures = []
|
| 21 |
+
# A few small Cu clusters with random displacements.
|
| 22 |
+
base = bulk("Cu", "fcc", a=3.6) * (2, 2, 2)
|
| 23 |
+
for i in range(8):
|
| 24 |
+
atoms = base.copy()
|
| 25 |
+
atoms.positions += rng.normal(scale=0.05, size=atoms.positions.shape)
|
| 26 |
+
atoms.info["energy"] = float(-len(atoms) * 3.5 + rng.normal(scale=0.5))
|
| 27 |
+
atoms.arrays["forces"] = rng.normal(scale=0.1, size=atoms.positions.shape)
|
| 28 |
+
structures.append(atoms)
|
| 29 |
+
|
| 30 |
+
write(out_file, structures, format="extxyz")
|
| 31 |
+
print(f"Wrote {len(structures)} structures to {out_file}")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
if __name__ == "__main__":
|
| 35 |
+
main()
|
demo/run.sh
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Run NequIP training locally or submit it to Slurm from one YAML file.
|
| 3 |
+
set -euo pipefail
|
| 4 |
+
|
| 5 |
+
DEMO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
| 6 |
+
NEQUIP_DIR="$(cd "$DEMO_DIR/.." && pwd)"
|
| 7 |
+
PARSER="$DEMO_DIR/_parse_config.py"
|
| 8 |
+
CONFIG=""
|
| 9 |
+
SUBMIT=false
|
| 10 |
+
|
| 11 |
+
while [[ $# -gt 0 ]]; do
|
| 12 |
+
case "$1" in
|
| 13 |
+
--config) CONFIG="$2"; shift 2 ;;
|
| 14 |
+
--config=*) CONFIG="${1#*=}"; shift ;;
|
| 15 |
+
--submit) SUBMIT=true; shift ;;
|
| 16 |
+
-h|--help)
|
| 17 |
+
echo "Usage: bash demo/run.sh --config configs/<name>.yaml [--submit]"
|
| 18 |
+
echo "launch.mode: auto uses matching resources or submits when needed."
|
| 19 |
+
echo "launch.mode: local runs directly; submit always submits to Slurm."
|
| 20 |
+
exit 0
|
| 21 |
+
;;
|
| 22 |
+
*) echo "Unknown argument: $1" >&2; exit 2 ;;
|
| 23 |
+
esac
|
| 24 |
+
done
|
| 25 |
+
|
| 26 |
+
[[ -n "$CONFIG" ]] || { echo "Please specify --config configs/<name>.yaml" >&2; exit 2; }
|
| 27 |
+
[[ "$CONFIG" = /* ]] || CONFIG="$DEMO_DIR/$CONFIG"
|
| 28 |
+
[[ -f "$CONFIG" ]] || { echo "Config not found: $CONFIG" >&2; exit 2; }
|
| 29 |
+
|
| 30 |
+
if [[ -z "${CONDA_PREFIX:-}" ]]; then
|
| 31 |
+
echo "Activate a OneScience MatChem conda environment before running this script." >&2
|
| 32 |
+
exit 2
|
| 33 |
+
fi
|
| 34 |
+
if [[ -z "${ONESCIENCE_MODELS_DIR:-}" || -z "${ONESCIENCE_DATASETS_DIR:-}" ]]; then
|
| 35 |
+
echo "Set ONESCIENCE_MODELS_DIR and ONESCIENCE_DATASETS_DIR before running this script." >&2
|
| 36 |
+
exit 2
|
| 37 |
+
fi
|
| 38 |
+
export MATCHEM_CONDA_NAME="${MATCHEM_CONDA_NAME:-$(basename "$CONDA_PREFIX")}"
|
| 39 |
+
|
| 40 |
+
NAME="$(python3 "$PARSER" "$CONFIG" name)"
|
| 41 |
+
eval "$(python3 "$PARSER" "$CONFIG" launch)"
|
| 42 |
+
eval "$(python3 "$PARSER" "$CONFIG" slurm)"
|
| 43 |
+
ENV_EXPORTS="$(python3 "$PARSER" "$CONFIG" env)"
|
| 44 |
+
if [[ "$RUN_MODE" == "submit" ]]; then
|
| 45 |
+
SUBMIT=true
|
| 46 |
+
fi
|
| 47 |
+
|
| 48 |
+
if [[ "$RUN_MODE" == "auto" ]] && ! $SUBMIT; then
|
| 49 |
+
IN_SLURM_ALLOCATION=false
|
| 50 |
+
AVAILABLE_NODES=1
|
| 51 |
+
if [[ -n "${SLURM_JOB_ID:-}" ]]; then
|
| 52 |
+
IN_SLURM_ALLOCATION=true
|
| 53 |
+
AVAILABLE_NODES="${SLURM_NNODES:-${SLURM_JOB_NUM_NODES:-1}}"
|
| 54 |
+
if ! [[ "$AVAILABLE_NODES" =~ ^[1-9][0-9]*$ ]]; then
|
| 55 |
+
echo "Cannot determine allocated nodes from Slurm: $AVAILABLE_NODES" >&2
|
| 56 |
+
exit 2
|
| 57 |
+
fi
|
| 58 |
+
fi
|
| 59 |
+
|
| 60 |
+
AVAILABLE_GPUS="$(
|
| 61 |
+
python3 -c 'import torch; print(torch.cuda.device_count() if torch.cuda.is_available() else 0)' \
|
| 62 |
+
2>/dev/null || true
|
| 63 |
+
)"
|
| 64 |
+
if ! [[ "$AVAILABLE_GPUS" =~ ^[0-9]+$ ]]; then
|
| 65 |
+
AVAILABLE_GPUS=0
|
| 66 |
+
fi
|
| 67 |
+
|
| 68 |
+
RESOURCE_MISMATCH=""
|
| 69 |
+
if (( AVAILABLE_NODES < NODES )); then
|
| 70 |
+
RESOURCE_MISMATCH="the config requests $NODES nodes but only $AVAILABLE_NODES are available"
|
| 71 |
+
elif (( AVAILABLE_GPUS < GPUS_PER_NODE )); then
|
| 72 |
+
RESOURCE_MISMATCH="the config requests $GPUS_PER_NODE DCUs per node but only $AVAILABLE_GPUS are visible"
|
| 73 |
+
fi
|
| 74 |
+
|
| 75 |
+
if [[ -n "$RESOURCE_MISMATCH" ]]; then
|
| 76 |
+
if ! command -v sbatch >/dev/null 2>&1; then
|
| 77 |
+
echo "Current resources are insufficient: $RESOURCE_MISMATCH, and sbatch is unavailable." >&2
|
| 78 |
+
exit 2
|
| 79 |
+
fi
|
| 80 |
+
if $IN_SLURM_ALLOCATION; then
|
| 81 |
+
echo "Current Slurm allocation is insufficient: $RESOURCE_MISMATCH. Submitting a new Slurm job."
|
| 82 |
+
else
|
| 83 |
+
echo "Current resources are insufficient: $RESOURCE_MISMATCH. Submitting to Slurm."
|
| 84 |
+
fi
|
| 85 |
+
SUBMIT=true
|
| 86 |
+
else
|
| 87 |
+
echo "Current resources satisfy the config: nodes=$NODES, DCUs/node=$GPUS_PER_NODE."
|
| 88 |
+
fi
|
| 89 |
+
fi
|
| 90 |
+
|
| 91 |
+
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
|
| 92 |
+
OUTPUT_ROOT="${ONESCIENCE_NEQUIP_OUTPUT_ROOT:-$NEQUIP_DIR/outputs}"
|
| 93 |
+
OUTPUT_DIR="$OUTPUT_ROOT/${NAME}_${TIMESTAMP}"
|
| 94 |
+
mkdir -p "$OUTPUT_DIR/checkpoints"
|
| 95 |
+
cp "$CONFIG" "$OUTPUT_DIR/source_config.yaml"
|
| 96 |
+
python3 "$PARSER" "$CONFIG" training-config > "$OUTPUT_DIR/config.yaml"
|
| 97 |
+
|
| 98 |
+
if $SUBMIT; then
|
| 99 |
+
SLURM_SCRIPT="$OUTPUT_DIR/submit.sh"
|
| 100 |
+
cat > "$SLURM_SCRIPT" <<EOF
|
| 101 |
+
#!/bin/bash
|
| 102 |
+
#SBATCH --job-name=$NAME
|
| 103 |
+
#SBATCH --partition=$PARTITION
|
| 104 |
+
#SBATCH --nodes=$NODES
|
| 105 |
+
#SBATCH --ntasks-per-node=$GPUS_PER_NODE
|
| 106 |
+
#SBATCH --cpus-per-task=$CPUS_PER_TASK
|
| 107 |
+
#SBATCH --gres=dcu:$GPUS_PER_NODE
|
| 108 |
+
#SBATCH --time=$TIME
|
| 109 |
+
#SBATCH --output=$OUTPUT_DIR/slurm_%j.out
|
| 110 |
+
#SBATCH --error=$OUTPUT_DIR/slurm_%j.err
|
| 111 |
+
EOF
|
| 112 |
+
if [[ -n "$NODELIST" ]]; then
|
| 113 |
+
printf '#SBATCH --nodelist=%s\n' "$NODELIST" >> "$SLURM_SCRIPT"
|
| 114 |
+
fi
|
| 115 |
+
cat >> "$SLURM_SCRIPT" <<EOF
|
| 116 |
+
|
| 117 |
+
set -euo pipefail
|
| 118 |
+
export MATCHEM_CONDA_NAME="$MATCHEM_CONDA_NAME"
|
| 119 |
+
export ONESCIENCE_MODELS_DIR="$ONESCIENCE_MODELS_DIR"
|
| 120 |
+
export ONESCIENCE_DATASETS_DIR="$ONESCIENCE_DATASETS_DIR"
|
| 121 |
+
export HSA_FORCE_FINE_GRAIN_PCIE=1
|
| 122 |
+
if (( $GPUS_PER_NODE > 1 )); then
|
| 123 |
+
unset CUDA_VISIBLE_DEVICES HIP_VISIBLE_DEVICES ROCR_VISIBLE_DEVICES
|
| 124 |
+
fi
|
| 125 |
+
$ENV_EXPORTS
|
| 126 |
+
cd "$OUTPUT_DIR"
|
| 127 |
+
EOF
|
| 128 |
+
if (( WORLD_SIZE > 1 )); then
|
| 129 |
+
cat >> "$SLURM_SCRIPT" <<EOF
|
| 130 |
+
exec srun --kill-on-bad-exit=1 \
|
| 131 |
+
--nodes=$NODES \
|
| 132 |
+
--ntasks=$WORLD_SIZE \
|
| 133 |
+
--ntasks-per-node=$GPUS_PER_NODE \
|
| 134 |
+
python "$NEQUIP_DIR/train.py" "hydra.run.dir=$OUTPUT_DIR"
|
| 135 |
+
EOF
|
| 136 |
+
else
|
| 137 |
+
echo "exec python \"$NEQUIP_DIR/train.py\" \"hydra.run.dir=$OUTPUT_DIR\"" >> "$SLURM_SCRIPT"
|
| 138 |
+
fi
|
| 139 |
+
chmod u+x "$SLURM_SCRIPT"
|
| 140 |
+
echo "Submitting NequIP job: $SLURM_SCRIPT"
|
| 141 |
+
sbatch "$SLURM_SCRIPT"
|
| 142 |
+
exit 0
|
| 143 |
+
fi
|
| 144 |
+
|
| 145 |
+
eval "$ENV_EXPORTS"
|
| 146 |
+
cd "$OUTPUT_DIR"
|
| 147 |
+
if (( WORLD_SIZE > 1 )); then
|
| 148 |
+
if (( NODES > 1 )); then
|
| 149 |
+
if [[ "$RUN_MODE" == "auto" && -n "${SLURM_JOB_ID:-}" ]]; then
|
| 150 |
+
exec srun --kill-on-bad-exit=1 \
|
| 151 |
+
--nodes="$NODES" \
|
| 152 |
+
--ntasks="$WORLD_SIZE" \
|
| 153 |
+
--ntasks-per-node="$GPUS_PER_NODE" \
|
| 154 |
+
python "$NEQUIP_DIR/train.py" "hydra.run.dir=$OUTPUT_DIR"
|
| 155 |
+
fi
|
| 156 |
+
echo "Multi-node NequIP training must be launched through Slurm (--submit)." >&2
|
| 157 |
+
exit 2
|
| 158 |
+
fi
|
| 159 |
+
exec torchrun --standalone --nproc_per_node="$GPUS_PER_NODE" \
|
| 160 |
+
"$NEQUIP_DIR/train.py" "hydra.run.dir=$OUTPUT_DIR"
|
| 161 |
+
fi
|
| 162 |
+
exec python "$NEQUIP_DIR/train.py" "hydra.run.dir=$OUTPUT_DIR"
|
energy_volume.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compute the ASE energy-volume curve from the official NequIP example."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
import torch
|
| 12 |
+
from ase.build import bulk
|
| 13 |
+
|
| 14 |
+
from onescience.utils.nequip.integrations.ase import NequIPCalculator
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def default_compiled_model() -> str | None:
|
| 18 |
+
models_dir = os.environ.get("ONESCIENCE_MODELS_DIR")
|
| 19 |
+
if not models_dir:
|
| 20 |
+
return None
|
| 21 |
+
return str(Path(models_dir) / "NequIP" / "NequIP-OAM-L-0.1.nequip.pth")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def main() -> None:
|
| 25 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 26 |
+
parser.add_argument("--compiled-model", default=default_compiled_model())
|
| 27 |
+
parser.add_argument("--device", default="cuda")
|
| 28 |
+
parser.add_argument("--element", default="Si")
|
| 29 |
+
parser.add_argument("--crystal-structure", default="diamond")
|
| 30 |
+
parser.add_argument("--lattice-constant", type=float, default=5.43)
|
| 31 |
+
parser.add_argument("--supercell", type=int, default=3)
|
| 32 |
+
parser.add_argument("--scale-min", type=float, default=0.95)
|
| 33 |
+
parser.add_argument("--scale-max", type=float, default=1.05)
|
| 34 |
+
parser.add_argument("--num-points", type=int, default=10)
|
| 35 |
+
parser.add_argument("--output", default="outputs/energy_volume.json")
|
| 36 |
+
parser.add_argument("--plot", default="outputs/energy_volume.png")
|
| 37 |
+
args = parser.parse_args()
|
| 38 |
+
|
| 39 |
+
if not args.compiled_model:
|
| 40 |
+
parser.error("--compiled-model is required when ONESCIENCE_MODELS_DIR is unset")
|
| 41 |
+
compiled_model = Path(args.compiled_model).expanduser().resolve()
|
| 42 |
+
if not compiled_model.is_file():
|
| 43 |
+
parser.error(f"compiled model not found: {compiled_model}")
|
| 44 |
+
if args.num_points < 2:
|
| 45 |
+
parser.error("--num-points must be at least 2")
|
| 46 |
+
if args.supercell < 1:
|
| 47 |
+
parser.error("--supercell must be positive")
|
| 48 |
+
|
| 49 |
+
calculator = NequIPCalculator.from_compiled_model(
|
| 50 |
+
compile_path=str(compiled_model),
|
| 51 |
+
chemical_species_to_atom_type_map={args.element: args.element},
|
| 52 |
+
device=args.device,
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
points = []
|
| 56 |
+
for scale in np.linspace(args.scale_min, args.scale_max, args.num_points):
|
| 57 |
+
atoms = bulk(
|
| 58 |
+
args.element,
|
| 59 |
+
crystalstructure=args.crystal_structure,
|
| 60 |
+
a=args.lattice_constant * float(scale),
|
| 61 |
+
cubic=True,
|
| 62 |
+
)
|
| 63 |
+
atoms *= (args.supercell,) * 3
|
| 64 |
+
atoms.calc = calculator
|
| 65 |
+
energy = float(atoms.get_potential_energy())
|
| 66 |
+
forces = atoms.get_forces()
|
| 67 |
+
points.append(
|
| 68 |
+
{
|
| 69 |
+
"scale": float(scale),
|
| 70 |
+
"volume_angstrom3": float(atoms.get_volume()),
|
| 71 |
+
"energy_ev": energy,
|
| 72 |
+
"energy_ev_per_atom": energy / len(atoms),
|
| 73 |
+
"max_force_ev_per_angstrom": float(
|
| 74 |
+
np.linalg.norm(forces, axis=1).max()
|
| 75 |
+
),
|
| 76 |
+
}
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
energies = np.asarray([point["energy_ev"] for point in points])
|
| 80 |
+
volumes = np.asarray([point["volume_angstrom3"] for point in points])
|
| 81 |
+
minimum_index = int(np.argmin(energies))
|
| 82 |
+
result = {
|
| 83 |
+
"compiled_model": str(compiled_model),
|
| 84 |
+
"device": args.device,
|
| 85 |
+
"device_name": torch.cuda.get_device_name(0)
|
| 86 |
+
if args.device.startswith("cuda") and torch.cuda.is_available()
|
| 87 |
+
else "cpu",
|
| 88 |
+
"element": args.element,
|
| 89 |
+
"crystal_structure": args.crystal_structure,
|
| 90 |
+
"base_lattice_constant_angstrom": args.lattice_constant,
|
| 91 |
+
"supercell": [args.supercell] * 3,
|
| 92 |
+
"num_atoms": len(atoms),
|
| 93 |
+
"points": points,
|
| 94 |
+
"sampled_minimum": points[minimum_index],
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
output_path = Path(args.output).expanduser().resolve()
|
| 98 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 99 |
+
output_path.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
|
| 100 |
+
|
| 101 |
+
if args.plot:
|
| 102 |
+
import matplotlib
|
| 103 |
+
|
| 104 |
+
matplotlib.use("Agg")
|
| 105 |
+
import matplotlib.pyplot as plt
|
| 106 |
+
|
| 107 |
+
plot_path = Path(args.plot).expanduser().resolve()
|
| 108 |
+
plot_path.parent.mkdir(parents=True, exist_ok=True)
|
| 109 |
+
plt.figure(figsize=(8, 6))
|
| 110 |
+
plt.plot(volumes, energies, marker="o", label="E-V Curve")
|
| 111 |
+
plt.xlabel("Volume (Angstrom^3)", fontsize=14)
|
| 112 |
+
plt.ylabel("Energy (eV)", fontsize=14)
|
| 113 |
+
plt.title(f"Energy-Volume Curve for Cubic {args.element}", fontsize=16)
|
| 114 |
+
plt.legend(fontsize=12)
|
| 115 |
+
plt.grid()
|
| 116 |
+
plt.tight_layout()
|
| 117 |
+
plt.savefig(plot_path, dpi=160)
|
| 118 |
+
plt.close()
|
| 119 |
+
result["plot"] = str(plot_path)
|
| 120 |
+
output_path.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
|
| 121 |
+
|
| 122 |
+
print(f"points: {len(points)}")
|
| 123 |
+
print(f"atoms per point: {result['num_atoms']}")
|
| 124 |
+
print(f"sampled minimum: {result['sampled_minimum']}")
|
| 125 |
+
print(f"result: {output_path}")
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
if __name__ == "__main__":
|
| 129 |
+
main()
|
model/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from ._version import __version__ # noqa: F401
|
| 2 |
+
|
| 3 |
+
import packaging.version
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
|
| 7 |
+
# Load all installed nequip extension packages
|
| 8 |
+
# This allows installed extensions to register themselves in
|
| 9 |
+
# the nequip infrastructure with calls like `register_fields`
|
| 10 |
+
|
| 11 |
+
# see https://packaging.python.org/en/guides/creating-and-discovering-plugins/#using-package-metadata
|
| 12 |
+
# we use "try ... except ..." to avoid importing sys.version_info
|
| 13 |
+
try:
|
| 14 |
+
# python >= 3.10
|
| 15 |
+
from importlib.metadata import entry_points
|
| 16 |
+
|
| 17 |
+
_DISCOVERED_NEQUIP_EXTENSION = entry_points(group="nequip.extension")
|
| 18 |
+
except (ImportError, TypeError):
|
| 19 |
+
# python < 3.10
|
| 20 |
+
from importlib_metadata import entry_points
|
| 21 |
+
|
| 22 |
+
_DISCOVERED_NEQUIP_EXTENSION = entry_points(group="nequip.extension")
|
| 23 |
+
|
| 24 |
+
from onescience.utils.nequip.internal.resolvers import _register_default_resolvers
|
| 25 |
+
from onescience.utils.nequip.internal.versions.version_utils import get_version_safe
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# torch version checks
|
| 29 |
+
torch_version = packaging.version.parse(get_version_safe(torch.__name__).split("+")[0])
|
| 30 |
+
|
| 31 |
+
# only allow 2.2.* or higher, required for `lightning` and `torchmetrics` compatibility
|
| 32 |
+
assert torch_version >= packaging.version.parse("2.2"), (
|
| 33 |
+
f"NequIP supports 2.2.* or later, but {torch_version} found"
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
for ep in _DISCOVERED_NEQUIP_EXTENSION:
|
| 37 |
+
if ep.name == "init_always":
|
| 38 |
+
ep.load()
|
| 39 |
+
|
| 40 |
+
# register OmegaConf resolvers
|
| 41 |
+
_register_default_resolvers()
|
model/_version.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# nequip package version file
|
| 2 |
+
# See Python packaging guide
|
| 3 |
+
# https://packaging.python.org/guides/single-sourcing-package-version/
|
| 4 |
+
|
| 5 |
+
__version__ = "0.19.0"
|
model/model/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is a part of the `nequip` package. Please see LICENSE and README at the root for information on using it.
|
| 2 |
+
from .utils import model_builder, override_model_compile_mode
|
| 3 |
+
from .modify_utils import modify
|
| 4 |
+
from .saved_models import (
|
| 5 |
+
ModelFromCheckpoint,
|
| 6 |
+
ModelFromPackage,
|
| 7 |
+
ModelTypeNamesFromPackage,
|
| 8 |
+
)
|
| 9 |
+
from .nequip_models import (
|
| 10 |
+
NequIPGNNModel,
|
| 11 |
+
PresetNequIPGNNModel,
|
| 12 |
+
FullNequIPGNNModel,
|
| 13 |
+
)
|
| 14 |
+
from .pair_potential import ZBLPairPotential
|
| 15 |
+
from .param_groups import MuonParamGroups
|
| 16 |
+
|
| 17 |
+
__all__ = [
|
| 18 |
+
"model_builder",
|
| 19 |
+
"override_model_compile_mode",
|
| 20 |
+
"modify",
|
| 21 |
+
"ModelFromCheckpoint",
|
| 22 |
+
"ModelFromPackage",
|
| 23 |
+
"ModelTypeNamesFromPackage",
|
| 24 |
+
"NequIPGNNModel",
|
| 25 |
+
"PresetNequIPGNNModel",
|
| 26 |
+
"FullNequIPGNNModel",
|
| 27 |
+
"ZBLPairPotential",
|
| 28 |
+
"MuonParamGroups",
|
| 29 |
+
]
|
model/model/energy_modules.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is a part of the `nequip` package. Please see LICENSE and README at the root for information on using it.
|
| 2 |
+
from typing import Dict, Optional, Sequence
|
| 3 |
+
|
| 4 |
+
from hydra.utils import instantiate
|
| 5 |
+
|
| 6 |
+
from onescience.datapipes.materials.nequip import AtomicDataDict
|
| 7 |
+
from onescience.models.nequip.nn import AtomwiseReduce, SequentialGraphNetwork
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _append_energy_modules(
|
| 11 |
+
model: SequentialGraphNetwork,
|
| 12 |
+
type_names: Sequence[str],
|
| 13 |
+
pair_potential: Optional[Dict] = None,
|
| 14 |
+
):
|
| 15 |
+
# === pair potentials ===
|
| 16 |
+
prev_irreps_out = model.irreps_out
|
| 17 |
+
if pair_potential is not None:
|
| 18 |
+
pair_potential = instantiate(
|
| 19 |
+
pair_potential,
|
| 20 |
+
type_names=type_names,
|
| 21 |
+
irreps_in=prev_irreps_out,
|
| 22 |
+
)
|
| 23 |
+
prev_irreps_out = pair_potential.irreps_out
|
| 24 |
+
model.append("pair_potential", pair_potential)
|
| 25 |
+
|
| 26 |
+
# === sum to total energy ===
|
| 27 |
+
# perform sum after applying `pair_potential`
|
| 28 |
+
total_energy_sum = AtomwiseReduce(
|
| 29 |
+
irreps_in=prev_irreps_out,
|
| 30 |
+
reduce="sum",
|
| 31 |
+
field=AtomicDataDict.PER_ATOM_ENERGY_KEY,
|
| 32 |
+
out_field=AtomicDataDict.TOTAL_ENERGY_KEY,
|
| 33 |
+
)
|
| 34 |
+
model.append("total_energy_sum", total_energy_sum)
|
| 35 |
+
return model
|
model/model/inference_models/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is a part of the `nequip` package. Please see LICENSE and README at the root for information on using it.
|
| 2 |
+
|
| 3 |
+
from .torchscript import load_torchscript_model
|
| 4 |
+
from .aotinductor import load_aotinductor_model
|
| 5 |
+
from .compiled import load_compiled_model
|
| 6 |
+
|
| 7 |
+
__all__ = ["load_torchscript_model", "load_aotinductor_model", "load_compiled_model"]
|
model/model/inference_models/aotinductor.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is a part of the `nequip` package. Please see LICENSE and README at the root for information on using it.
|
| 2 |
+
import torch
|
| 3 |
+
from typing import Union, Tuple, List, Optional
|
| 4 |
+
|
| 5 |
+
from onescience.models.nequip.nn import graph_model
|
| 6 |
+
from onescience.utils.nequip.internal.versions import check_pt2_compile_compatibility
|
| 7 |
+
from onescience.utils.nequip.internal.aoti_metadata import (
|
| 8 |
+
NEQUIP_AOTI_INPUTS_KEY,
|
| 9 |
+
NEQUIP_AOTI_OUTPUTS_KEY,
|
| 10 |
+
parse_aoti_keys,
|
| 11 |
+
import_custom_ops_libs,
|
| 12 |
+
)
|
| 13 |
+
from onescience.models.nequip.nn.compile import DictInputOutputWrapper
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _resolve_aot_keys(
|
| 17 |
+
provided_keys: Optional[List[str]],
|
| 18 |
+
metadata: dict,
|
| 19 |
+
metadata_key: str,
|
| 20 |
+
kind: str,
|
| 21 |
+
compile_path: str,
|
| 22 |
+
) -> List[str]:
|
| 23 |
+
"""
|
| 24 |
+
As of NequIP v0.17.0, we include the input and output fields in the AOTI artefact's metadata.
|
| 25 |
+
Previously, we always pass it from outside, but that's brittle.
|
| 26 |
+
In principle, now we can always use the metadata from the AOTI artefact to inform the input and output fields,
|
| 27 |
+
but there might be failure modes where a `--target batch` model is used for the ASE intergation or a `--target ase` model is used for the torchsim integration.
|
| 28 |
+
So it's safer for those integrations to also provide the input and output keys for what they expect.
|
| 29 |
+
This function will check for their consistency for safety.
|
| 30 |
+
"""
|
| 31 |
+
# for backwards compatibility since previous AOTI models don't store the key
|
| 32 |
+
metadata_entry = metadata.get(metadata_key, None)
|
| 33 |
+
|
| 34 |
+
if provided_keys is None:
|
| 35 |
+
if metadata_entry is None:
|
| 36 |
+
raise ValueError(
|
| 37 |
+
f"{kind}_keys are required for `{compile_path}` because this AOTI artifact does not store `{kind}` keys metadata. "
|
| 38 |
+
"Please pass them explicitly or recompile with a newer `nequip-compile`."
|
| 39 |
+
)
|
| 40 |
+
else:
|
| 41 |
+
return parse_aoti_keys(metadata_entry)
|
| 42 |
+
else:
|
| 43 |
+
provided_keys = list(provided_keys)
|
| 44 |
+
if metadata_entry is None:
|
| 45 |
+
return provided_keys
|
| 46 |
+
else:
|
| 47 |
+
# both probided, so we check their consistency
|
| 48 |
+
metadata_keys = parse_aoti_keys(metadata_entry)
|
| 49 |
+
if provided_keys != metadata_keys:
|
| 50 |
+
raise ValueError(
|
| 51 |
+
f"Provided {kind} keys do not match metadata for `{compile_path}`.\n"
|
| 52 |
+
f"provided={provided_keys}\n"
|
| 53 |
+
f"metadata={metadata_keys}"
|
| 54 |
+
)
|
| 55 |
+
return provided_keys
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def load_aotinductor_model(
|
| 59 |
+
compile_path: str,
|
| 60 |
+
device: Union[str, torch.device],
|
| 61 |
+
input_keys: Optional[List[str]] = None,
|
| 62 |
+
output_keys: Optional[List[str]] = None,
|
| 63 |
+
) -> Tuple[torch.nn.Module, dict]:
|
| 64 |
+
"""Load an AOTInductor model from a .nequip.pt2 file.
|
| 65 |
+
|
| 66 |
+
Args:
|
| 67 |
+
compile_path: path to compiled model file ending with .nequip.pt2
|
| 68 |
+
device: the device to use
|
| 69 |
+
input_keys: optional list of expected input field names for DictInputOutputWrapper
|
| 70 |
+
output_keys: optional list of expected output field names for DictInputOutputWrapper
|
| 71 |
+
|
| 72 |
+
Returns:
|
| 73 |
+
tuple of (wrapped_model, processed_metadata)
|
| 74 |
+
"""
|
| 75 |
+
# sanity checks
|
| 76 |
+
check_pt2_compile_compatibility()
|
| 77 |
+
|
| 78 |
+
# import any required custom ops libraries before the C++ loader runs
|
| 79 |
+
import_custom_ops_libs(compile_path)
|
| 80 |
+
|
| 81 |
+
# load compiled model
|
| 82 |
+
compiled_model = torch._inductor.aoti_load_package(compile_path)
|
| 83 |
+
|
| 84 |
+
# get and process metadata
|
| 85 |
+
metadata = compiled_model.get_metadata()
|
| 86 |
+
|
| 87 |
+
input_keys = _resolve_aot_keys(
|
| 88 |
+
provided_keys=input_keys,
|
| 89 |
+
metadata=metadata,
|
| 90 |
+
metadata_key=NEQUIP_AOTI_INPUTS_KEY,
|
| 91 |
+
kind="input",
|
| 92 |
+
compile_path=compile_path,
|
| 93 |
+
)
|
| 94 |
+
output_keys = _resolve_aot_keys(
|
| 95 |
+
provided_keys=output_keys,
|
| 96 |
+
metadata=metadata,
|
| 97 |
+
metadata_key=NEQUIP_AOTI_OUTPUTS_KEY,
|
| 98 |
+
kind="output",
|
| 99 |
+
compile_path=compile_path,
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
model = DictInputOutputWrapper(compiled_model, input_keys, output_keys)
|
| 103 |
+
|
| 104 |
+
# check device compatibility
|
| 105 |
+
compile_device = metadata["AOTI_DEVICE_KEY"]
|
| 106 |
+
if torch.device(compile_device) != torch.device(device):
|
| 107 |
+
raise RuntimeError(
|
| 108 |
+
f"`{compile_path}` was compiled for `{compile_device}` and won't work with device={device}, use device={compile_device} instead."
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
# process standard metadata
|
| 112 |
+
metadata[graph_model.R_MAX_KEY] = float(metadata[graph_model.R_MAX_KEY])
|
| 113 |
+
metadata[graph_model.TYPE_NAMES_KEY] = metadata[graph_model.TYPE_NAMES_KEY].split(
|
| 114 |
+
" "
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
# process per-edge-type cutoffs if present
|
| 118 |
+
if graph_model.PER_EDGE_TYPE_CUTOFF_KEY in metadata:
|
| 119 |
+
from onescience.models.nequip.nn.embedding.utils import cutoff_str_to_fulldict
|
| 120 |
+
|
| 121 |
+
cutoff_str = metadata[graph_model.PER_EDGE_TYPE_CUTOFF_KEY]
|
| 122 |
+
metadata[graph_model.PER_EDGE_TYPE_CUTOFF_KEY] = cutoff_str_to_fulldict(
|
| 123 |
+
cutoff_str, metadata[graph_model.TYPE_NAMES_KEY]
|
| 124 |
+
)
|
| 125 |
+
else:
|
| 126 |
+
metadata[graph_model.PER_EDGE_TYPE_CUTOFF_KEY] = None
|
| 127 |
+
|
| 128 |
+
return model, metadata
|
model/model/inference_models/compiled.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is a part of the `nequip` package. Please see LICENSE and README
|
| 2 |
+
# at the root for information on using it.
|
| 3 |
+
import torch
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Union, Tuple, List, Optional
|
| 7 |
+
|
| 8 |
+
from .torchscript import load_torchscript_model
|
| 9 |
+
from .aotinductor import load_aotinductor_model
|
| 10 |
+
from onescience.utils.nequip.internal.global_state import TF32_KEY, set_global_state
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def load_compiled_model(
|
| 14 |
+
compile_path: str,
|
| 15 |
+
device: Union[str, torch.device],
|
| 16 |
+
input_keys: Optional[List[str]] = None,
|
| 17 |
+
output_keys: Optional[List[str]] = None,
|
| 18 |
+
) -> Tuple[torch.nn.Module, dict]:
|
| 19 |
+
"""Load a compiled model from either TorchScript or AOTInductor format.
|
| 20 |
+
|
| 21 |
+
This function can load compiled models created with ``nequip-compile``:
|
| 22 |
+
|
| 23 |
+
- **TorchScript models** (``.nequip.pth``): legacy compiled format
|
| 24 |
+
- **AOT Inductor models** (``.nequip.pt2``): modern compiled format with better performance
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
compile_path: path to compiled model file (``.nequip.pth`` or ``.nequip.pt2``)
|
| 28 |
+
device: the device to use
|
| 29 |
+
input_keys: optional input field names for AOTInductor models (for ``.nequip.pt2``)
|
| 30 |
+
output_keys: optional output field names for AOTInductor models (for ``.nequip.pt2``)
|
| 31 |
+
|
| 32 |
+
Returns:
|
| 33 |
+
tuple: ``(model, metadata)`` with model prepared for inference
|
| 34 |
+
"""
|
| 35 |
+
compile_fname = Path(compile_path).name
|
| 36 |
+
|
| 37 |
+
if compile_fname.endswith(".nequip.pth"):
|
| 38 |
+
model, metadata = load_torchscript_model(compile_path, device)
|
| 39 |
+
elif compile_fname.endswith(".nequip.pt2"):
|
| 40 |
+
model, metadata = load_aotinductor_model(
|
| 41 |
+
compile_path, device, input_keys, output_keys
|
| 42 |
+
)
|
| 43 |
+
else:
|
| 44 |
+
raise ValueError(
|
| 45 |
+
f"Unknown file type: {compile_fname} "
|
| 46 |
+
f"(expected `*.nequip.pth` or `*.nequip.pt2`)"
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
# set global state from metadata
|
| 50 |
+
set_global_state(
|
| 51 |
+
**{
|
| 52 |
+
TF32_KEY: bool(int(metadata[TF32_KEY])),
|
| 53 |
+
}
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
# prepare model for inference
|
| 57 |
+
model = model.to(device)
|
| 58 |
+
model.eval()
|
| 59 |
+
|
| 60 |
+
return model, metadata
|
model/model/inference_models/torchscript.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is a part of the `nequip` package. Please see LICENSE and README
|
| 2 |
+
# at the root for information on using it.
|
| 3 |
+
import torch
|
| 4 |
+
from e3nn.util.jit import script
|
| 5 |
+
|
| 6 |
+
from onescience.models.nequip.nn import graph_model
|
| 7 |
+
from onescience.utils.nequip.internal.compile import prepare_model_for_compile
|
| 8 |
+
from onescience.utils.nequip.internal.global_state import TF32_KEY
|
| 9 |
+
|
| 10 |
+
from typing import Union, Tuple
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def load_torchscript_model(
|
| 14 |
+
compile_path: str,
|
| 15 |
+
device: Union[str, torch.device] = "cpu",
|
| 16 |
+
) -> Tuple[torch.nn.Module, dict]:
|
| 17 |
+
"""Load a torchscript model from a .nequip.pth file.
|
| 18 |
+
|
| 19 |
+
Args:
|
| 20 |
+
compile_path (str): path to compiled model file ending with .nequip.pth
|
| 21 |
+
device (Union[str, torch.device]): the device to use
|
| 22 |
+
"""
|
| 23 |
+
# load model with metadata
|
| 24 |
+
metadata = {
|
| 25 |
+
graph_model.R_MAX_KEY: None,
|
| 26 |
+
graph_model.TYPE_NAMES_KEY: None,
|
| 27 |
+
graph_model.PER_EDGE_TYPE_CUTOFF_KEY: None,
|
| 28 |
+
TF32_KEY: None,
|
| 29 |
+
}
|
| 30 |
+
model = torch.jit.load(compile_path, _extra_files=metadata, map_location=device)
|
| 31 |
+
model = torch.jit.freeze(model)
|
| 32 |
+
|
| 33 |
+
# process metadata
|
| 34 |
+
metadata[graph_model.R_MAX_KEY] = float(metadata[graph_model.R_MAX_KEY])
|
| 35 |
+
metadata[graph_model.TYPE_NAMES_KEY] = (
|
| 36 |
+
metadata[graph_model.TYPE_NAMES_KEY].decode("utf-8").split(" ")
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
# process per-edge-type cutoffs if present
|
| 40 |
+
if metadata[graph_model.PER_EDGE_TYPE_CUTOFF_KEY] is not None:
|
| 41 |
+
from onescience.models.nequip.nn.embedding.utils import cutoff_str_to_fulldict
|
| 42 |
+
|
| 43 |
+
cutoff_str = metadata[graph_model.PER_EDGE_TYPE_CUTOFF_KEY].decode("utf-8")
|
| 44 |
+
metadata[graph_model.PER_EDGE_TYPE_CUTOFF_KEY] = cutoff_str_to_fulldict(
|
| 45 |
+
cutoff_str, metadata[graph_model.TYPE_NAMES_KEY]
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
return model, metadata
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def save_torchscript_model(
|
| 52 |
+
model: torch.nn.Module,
|
| 53 |
+
metadata: dict,
|
| 54 |
+
output_path: str,
|
| 55 |
+
device: Union[str, torch.device],
|
| 56 |
+
) -> None:
|
| 57 |
+
"""Save a model as a torchscript .nequip.pth file.
|
| 58 |
+
|
| 59 |
+
Args:
|
| 60 |
+
model: model to save
|
| 61 |
+
metadata: metadata dictionary to save with the model
|
| 62 |
+
output_path: path to save the compiled model
|
| 63 |
+
device: device to prepare model on
|
| 64 |
+
"""
|
| 65 |
+
# encode metadata for torchscript
|
| 66 |
+
encoded_metadata = {k: str(v).encode("ascii") for k, v in metadata.items()}
|
| 67 |
+
|
| 68 |
+
# prepare and script model
|
| 69 |
+
model = prepare_model_for_compile(model, device)
|
| 70 |
+
script_model = script(model)
|
| 71 |
+
|
| 72 |
+
# save with metadata
|
| 73 |
+
torch.jit.save(script_model, output_path, _extra_files=encoded_metadata)
|
model/model/modify_utils.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is a part of the `nequip` package. Please see LICENSE and README at the root for information on using it.
|
| 2 |
+
import torch
|
| 3 |
+
|
| 4 |
+
from onescience.models.nequip.nn.model_modifier_utils import (
|
| 5 |
+
is_model_modifier,
|
| 6 |
+
is_persistent_model_modifier,
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
import inspect
|
| 10 |
+
import contextvars
|
| 11 |
+
import contextlib
|
| 12 |
+
from hydra.utils import get_method
|
| 13 |
+
from typing import Dict, List, Union, Any, Optional
|
| 14 |
+
|
| 15 |
+
_ONLY_APPLY_PERSISTENT = contextvars.ContextVar("_ONLY_APPLY_PERSISTENT", default=False)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@contextlib.contextmanager
|
| 19 |
+
def only_apply_persistent_modifiers(persistent_only: bool):
|
| 20 |
+
"""
|
| 21 |
+
Used during `nequip-package` to only apply persistent modifiers.
|
| 22 |
+
"""
|
| 23 |
+
global _ONLY_APPLY_PERSISTENT
|
| 24 |
+
init_state = _ONLY_APPLY_PERSISTENT.get()
|
| 25 |
+
assert not init_state, (
|
| 26 |
+
"this error implies that the `only_apply_persistent_modifiers` context manager is being nested, which is unexpected behavior"
|
| 27 |
+
)
|
| 28 |
+
_ONLY_APPLY_PERSISTENT.set(persistent_only)
|
| 29 |
+
try:
|
| 30 |
+
yield
|
| 31 |
+
finally:
|
| 32 |
+
_ONLY_APPLY_PERSISTENT.set(init_state)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def get_all_modifiers(
|
| 36 |
+
module: torch.nn.Module, _all_modifiers: Optional[Dict[str, callable]] = None
|
| 37 |
+
) -> Dict[str, callable]:
|
| 38 |
+
"""
|
| 39 |
+
Find all model modifiers available in a model.
|
| 40 |
+
|
| 41 |
+
Args:
|
| 42 |
+
module (torch.nn.Module): The model to collect modifiers from.
|
| 43 |
+
|
| 44 |
+
Returns:
|
| 45 |
+
Dict[str, callable]: A dictionary mapping modifier names to their functions.
|
| 46 |
+
"""
|
| 47 |
+
if _all_modifiers is None:
|
| 48 |
+
_all_modifiers = {}
|
| 49 |
+
|
| 50 |
+
for name, member in inspect.getmembers(module, predicate=inspect.ismethod):
|
| 51 |
+
if is_model_modifier(member):
|
| 52 |
+
if name in _all_modifiers:
|
| 53 |
+
# confirm (indirectly) that these are @classmethods (bound instance methods will not be equal)
|
| 54 |
+
# this ensures that having a globally unique name for each modifier does not hide differences between different copies of the same modifier hiding in a single module tree
|
| 55 |
+
assert _all_modifiers[name] == member, (
|
| 56 |
+
f"Found at least two non-unique modifiers with same name `{name}`: {_all_modifiers[name]!r} and {member!r}"
|
| 57 |
+
)
|
| 58 |
+
_all_modifiers[name] = member
|
| 59 |
+
|
| 60 |
+
for _, child in module.named_children():
|
| 61 |
+
get_all_modifiers(child, _all_modifiers=_all_modifiers)
|
| 62 |
+
|
| 63 |
+
return _all_modifiers
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def modify(
|
| 67 |
+
model: Union[Dict[str, torch.nn.Module], torch.nn.Module],
|
| 68 |
+
modifiers: Union[List[Dict[str, Any]], Dict[str, List[Dict[str, Any]]]],
|
| 69 |
+
) -> Union[Dict[str, torch.nn.Module], torch.nn.Module]:
|
| 70 |
+
"""Applies a sequence of model modifier functions to a model.
|
| 71 |
+
|
| 72 |
+
The modifiers will be applied in the specified order. Whether the order of modifiers matters depends on the specific modifiers used.
|
| 73 |
+
|
| 74 |
+
Args:
|
| 75 |
+
model (Union[Dict[str, torch.nn.Module], torch.nn.Module]): The model(s) to modify.
|
| 76 |
+
modifiers (Union[List[Dict[str, Any]], Dict[str, List[Dict[str, Any]]]]): A list of modifier configurations (if ``model`` is a single model) or a dictionary mapping model names to lists of modifier configurations (if ``model`` is a dictionary).
|
| 77 |
+
Each modifier configuration is a dictionary. The dictionary must contain a key "modifier" that specifies the name of the modifier function to apply as a string. All other keys in the dictionary are passed as keyword arguments to the modifier function.
|
| 78 |
+
|
| 79 |
+
Returns:
|
| 80 |
+
Union[Dict[str, torch.nn.Module], torch.nn.Module]: The modified model(s).
|
| 81 |
+
"""
|
| 82 |
+
# check persistence
|
| 83 |
+
global _ONLY_APPLY_PERSISTENT
|
| 84 |
+
persistent_only: bool = _ONLY_APPLY_PERSISTENT.get()
|
| 85 |
+
|
| 86 |
+
# build inner model if not already built
|
| 87 |
+
if not isinstance(model, torch.nn.Module):
|
| 88 |
+
# don't use `hydra.utils.instantiate` because it may lead to a hydra dependency during packaging
|
| 89 |
+
model = model.copy()
|
| 90 |
+
model_fn = get_method(model.pop("_target_"))
|
| 91 |
+
model = model_fn(**model)
|
| 92 |
+
|
| 93 |
+
def _apply_modifier(
|
| 94 |
+
avail_modifiers: Dict[str, callable],
|
| 95 |
+
modifier_cfg: Dict[str, Any],
|
| 96 |
+
this_model: torch.nn.Module,
|
| 97 |
+
) -> None:
|
| 98 |
+
modifier_cfg = modifier_cfg.copy()
|
| 99 |
+
modifier_name = modifier_cfg.pop("modifier")
|
| 100 |
+
if modifier_name not in avail_modifiers.keys():
|
| 101 |
+
avail_names = list(avail_modifiers.keys())
|
| 102 |
+
raise RuntimeError(
|
| 103 |
+
f"`{modifier_name}` is not a registered model modifier. The following are registered model modifiers: {avail_names}"
|
| 104 |
+
)
|
| 105 |
+
modifier_fn = avail_modifiers[modifier_name]
|
| 106 |
+
is_persistent = is_persistent_model_modifier(modifier_fn)
|
| 107 |
+
# only skip if doing `persistent_only` and modifier is non-persistent, otherwise always apply
|
| 108 |
+
if not (persistent_only and not is_persistent):
|
| 109 |
+
this_model = modifier_fn(this_model, **modifier_cfg)
|
| 110 |
+
|
| 111 |
+
if isinstance(model, torch.nn.ModuleDict):
|
| 112 |
+
# because `model` is actually a `ModuleDict`, we make the modifiers flexible while keeping a simple default for the more common single-model use case
|
| 113 |
+
# a single list of modifiers is given, we assume it'll be uniformly applied to everything
|
| 114 |
+
if isinstance(modifiers, list):
|
| 115 |
+
modifiers = {model_name: modifiers.copy() for model_name in model.keys()}
|
| 116 |
+
# ^ the above allows us to use a common loop over individual sub-models and apply the relevant model-specific modifiers
|
| 117 |
+
|
| 118 |
+
for model_name, submodel in model.items():
|
| 119 |
+
avail_modifiers: Dict[str, callable] = get_all_modifiers(submodel)
|
| 120 |
+
for modifier in modifiers[model_name]:
|
| 121 |
+
_apply_modifier(avail_modifiers, modifier, submodel)
|
| 122 |
+
|
| 123 |
+
elif isinstance(model, torch.nn.Module):
|
| 124 |
+
assert isinstance(modifiers, list)
|
| 125 |
+
avail_modifiers: Dict[str, callable] = get_all_modifiers(model)
|
| 126 |
+
for modifier in modifiers:
|
| 127 |
+
_apply_modifier(avail_modifiers, modifier, model)
|
| 128 |
+
else:
|
| 129 |
+
raise RuntimeError("Unrecognized model object found.")
|
| 130 |
+
|
| 131 |
+
return model
|
model/model/nequip_models.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is a part of the `nequip` package. Please see LICENSE and README at the root for information on using it.
|
| 2 |
+
import math
|
| 3 |
+
from e3nn import o3
|
| 4 |
+
|
| 5 |
+
from onescience.datapipes.materials.nequip import AtomicDataDict
|
| 6 |
+
|
| 7 |
+
from onescience.models.nequip.nn import (
|
| 8 |
+
GraphModel,
|
| 9 |
+
SequentialGraphNetwork,
|
| 10 |
+
ScalarMLP,
|
| 11 |
+
PerTypeScaleShift,
|
| 12 |
+
ConvNetLayer,
|
| 13 |
+
ForceStressOutput,
|
| 14 |
+
ApplyFactor,
|
| 15 |
+
)
|
| 16 |
+
from onescience.models.nequip.nn.embedding import (
|
| 17 |
+
NodeTypeEmbed,
|
| 18 |
+
PolynomialCutoff,
|
| 19 |
+
EdgeLengthNormalizer,
|
| 20 |
+
BesselEdgeLengthEncoding,
|
| 21 |
+
SphericalHarmonicEdgeAttrs,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
from .utils import model_builder
|
| 25 |
+
from .energy_modules import _append_energy_modules
|
| 26 |
+
import warnings
|
| 27 |
+
from typing import Sequence, Optional, List, Dict, Union, Callable
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
_NEQUIP_GNN_PRESETS = {
|
| 31 |
+
"S": {
|
| 32 |
+
"num_layers": 2,
|
| 33 |
+
"l_max": 1,
|
| 34 |
+
"num_features": [128, 64],
|
| 35 |
+
},
|
| 36 |
+
"M": {
|
| 37 |
+
"num_layers": 4,
|
| 38 |
+
"l_max": 2,
|
| 39 |
+
"num_features": [128, 64, 32],
|
| 40 |
+
},
|
| 41 |
+
"L": {
|
| 42 |
+
"num_layers": 6,
|
| 43 |
+
"l_max": 3,
|
| 44 |
+
"num_features": [128, 64, 32, 32],
|
| 45 |
+
},
|
| 46 |
+
"XL": {
|
| 47 |
+
"num_layers": 6,
|
| 48 |
+
"l_max": 4,
|
| 49 |
+
"num_features": [320, 96, 64, 32, 32],
|
| 50 |
+
},
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
_NEQUIP_GNN_STANDARD_PRESET = {
|
| 54 |
+
"parity": False,
|
| 55 |
+
"type_embed_num_features": 32,
|
| 56 |
+
"radial_mlp_depth": 1,
|
| 57 |
+
"radial_mlp_width": 128,
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _format_nequip_gnn_preset_docstring() -> str:
|
| 62 |
+
shared_defaults = "\n".join(
|
| 63 |
+
[
|
| 64 |
+
f" - ``{key}``: ``{value!r}``"
|
| 65 |
+
for key, value in _NEQUIP_GNN_STANDARD_PRESET.items()
|
| 66 |
+
]
|
| 67 |
+
)
|
| 68 |
+
preset_defaults = "\n".join(
|
| 69 |
+
[
|
| 70 |
+
f" - ``{preset}``: ``{defaults!r}``"
|
| 71 |
+
for preset, defaults in _NEQUIP_GNN_PRESETS.items()
|
| 72 |
+
]
|
| 73 |
+
)
|
| 74 |
+
return f"""Build :func:`NequIPGNNModel` from a named architecture preset.
|
| 75 |
+
|
| 76 |
+
This is a wrapper of :func:`NequIPGNNModel` that injects preset hyperparameters based on model sizes of the NequIP foundation potentials.
|
| 77 |
+
All arguments are the same as :func:`NequIPGNNModel`, except this builder also requires ``preset`` and applies preset defaults before ``**kwargs``.
|
| 78 |
+
For full argument documentation, see :func:`NequIPGNNModel`.
|
| 79 |
+
Users can override the preset defaults by providing arguments for the fields to be overriden.
|
| 80 |
+
|
| 81 |
+
Preset argument:
|
| 82 |
+
preset (str): one of {", ".join([f"``{name}``" for name in _NEQUIP_GNN_PRESETS.keys()])}
|
| 83 |
+
|
| 84 |
+
Override order:
|
| 85 |
+
1. shared defaults
|
| 86 |
+
2. per-preset defaults
|
| 87 |
+
3. explicit ``**kwargs`` (highest priority)
|
| 88 |
+
|
| 89 |
+
Shared defaults:
|
| 90 |
+
{shared_defaults}
|
| 91 |
+
|
| 92 |
+
Per-preset defaults:
|
| 93 |
+
{preset_defaults}
|
| 94 |
+
"""
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@model_builder
|
| 98 |
+
def PresetNequIPGNNModel(
|
| 99 |
+
preset: str,
|
| 100 |
+
**kwargs,
|
| 101 |
+
) -> GraphModel:
|
| 102 |
+
preset = preset.upper()
|
| 103 |
+
assert preset in _NEQUIP_GNN_PRESETS, (
|
| 104 |
+
f"`preset` must be one of {list(_NEQUIP_GNN_PRESETS.keys())}, but found `{preset}`"
|
| 105 |
+
)
|
| 106 |
+
model_kwargs = {
|
| 107 |
+
**_NEQUIP_GNN_STANDARD_PRESET,
|
| 108 |
+
**_NEQUIP_GNN_PRESETS[preset],
|
| 109 |
+
}
|
| 110 |
+
# explicit kwargs override standard and preset defaults
|
| 111 |
+
model_kwargs.update(kwargs)
|
| 112 |
+
|
| 113 |
+
return NequIPGNNModel(**model_kwargs)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@model_builder
|
| 117 |
+
def NequIPGNNModel(
|
| 118 |
+
num_layers: int = 4,
|
| 119 |
+
l_max: int = 1,
|
| 120 |
+
parity: bool = True,
|
| 121 |
+
num_features: Union[int, List[int]] = 32,
|
| 122 |
+
type_embed_num_features: Optional[int] = None,
|
| 123 |
+
radial_mlp_depth: int = 1,
|
| 124 |
+
radial_mlp_width: int = 128,
|
| 125 |
+
**kwargs,
|
| 126 |
+
) -> GraphModel:
|
| 127 |
+
"""NequIP GNN model that can predict energies only or energies with forces/stresses.
|
| 128 |
+
|
| 129 |
+
Args:
|
| 130 |
+
seed (int): seed for reproducibility
|
| 131 |
+
model_dtype (str): ``float32`` or ``float64``
|
| 132 |
+
r_max (float): cutoff radius
|
| 133 |
+
per_edge_type_cutoff (Dict): one can optionally specify cutoffs for each edge type [must be smaller than ``r_max``] (default ``None``)
|
| 134 |
+
type_names (Sequence[str]): list of atom type names
|
| 135 |
+
num_layers (int): number of interaction blocks, we find 3-5 to work best (default ``4``)
|
| 136 |
+
l_max (int): the maximum rotation order for the network's features, ``1`` is a good default, ``2`` is more accurate but slower (default ``1``)
|
| 137 |
+
parity (bool): whether to include features with odd mirror parity -- often turning parity off gives equally good results but faster networks, so it's worth testing (default ``True``)
|
| 138 |
+
num_features (int/List[int]): multiplicity of the features, smaller is faster (default ``32``); it is also possible to provide the multiplicity for each irrep, e.g. for ``l_max=2`` and ``parity=False``, ``num_features=[5, 2, 7]`` refers to ``5x0e``, ``2x1o`` and ``7x2e`` features
|
| 139 |
+
type_embed_num_features (int): number of features for the type embedding layer; if not provided, defaults to ``num_features[0]`` (default ``None``)
|
| 140 |
+
radial_mlp_depth (int): number of radial layers, usually 1-3 works best, smaller is faster (default ``1``)
|
| 141 |
+
radial_mlp_width (int): number of hidden neurons in radial function, smaller is faster (default ``128``)
|
| 142 |
+
readout_mlp_hidden_layers_depth (int): number of hidden layers in the readout MLP (default ``0``)
|
| 143 |
+
readout_mlp_hidden_layers_width (int): width of hidden layers in the readout MLP (default 0e contribution of ``num_features``)
|
| 144 |
+
readout_mlp_nonlinearity (str): ``silu``, ``mish``, ``gelu``, or ``None`` (default ``silu``)
|
| 145 |
+
num_bessels (int): number of Bessel basis functions (default ``8``)
|
| 146 |
+
bessel_trainable (bool): whether the Bessel roots are trainable (default ``False``)
|
| 147 |
+
polynomial_cutoff_p (int): p-exponent used in polynomial cutoff function, smaller p corresponds to stronger decay with distance (default ``6``)
|
| 148 |
+
avg_num_neighbors (float/Dict[str, float]): used to normalize edge sums for better numerics (default ``None``)
|
| 149 |
+
per_type_energy_scales (float/List[float]): per-atom energy scales, which could be derived from the force RMS of the data (default ``None``)
|
| 150 |
+
per_type_energy_shifts (float/List[float]): per-atom energy shifts, which should generally be isolated atom reference energies or estimated from average per-atom energies of the data (default ``None``)
|
| 151 |
+
per_type_energy_scales_trainable (bool): whether the per-atom energy scales are trainable (default ``False``)
|
| 152 |
+
per_type_energy_shifts_trainable (bool): whether the per-atom energy shifts are trainable (default ``False``)
|
| 153 |
+
pair_potential (torch.nn.Module): additional pair potential term, e.g. :class:`~nequip.nn.pair_potential.ZBL` (default ``None``)
|
| 154 |
+
do_derivatives (bool): whether to compute forces and stresses via autograd (default ``True``)
|
| 155 |
+
"""
|
| 156 |
+
# === sanity checks and warnings ===
|
| 157 |
+
assert num_layers > 0, (
|
| 158 |
+
f"at least one convnet layer required, but found `num_layers={num_layers}`"
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
# === spherical harmonics ===
|
| 162 |
+
irreps_edge_sh = repr(o3.Irreps.spherical_harmonics(lmax=l_max))
|
| 163 |
+
|
| 164 |
+
# === handle `num_features` ===
|
| 165 |
+
if isinstance(num_features, int):
|
| 166 |
+
num_features = [num_features] * (l_max + 1)
|
| 167 |
+
assert len(num_features) == l_max + 1, (
|
| 168 |
+
f"`num_features` should be of length `l_max + 1` ({l_max + 1}), but found `num_features={num_features}` with {len(num_features)} entries."
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
# === type embedding ===
|
| 172 |
+
type_embed_num_features = (
|
| 173 |
+
type_embed_num_features
|
| 174 |
+
if type_embed_num_features is not None
|
| 175 |
+
else num_features[0]
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
# === convnet ===
|
| 179 |
+
# convert a single set of parameters uniformly for every layer
|
| 180 |
+
feature_irreps_hidden = repr(
|
| 181 |
+
o3.Irreps(
|
| 182 |
+
[
|
| 183 |
+
(num_features[l], (l, p))
|
| 184 |
+
for l in range(l_max + 1)
|
| 185 |
+
for p in (
|
| 186 |
+
(1, -1) if parity else ((1,) if l % 2 == 0 else (-1,))
|
| 187 |
+
) # p = 1 for even l, -1 for odd l, with parity = False
|
| 188 |
+
]
|
| 189 |
+
)
|
| 190 |
+
)
|
| 191 |
+
feature_irreps_hidden_list = [feature_irreps_hidden] * (num_layers - 1)
|
| 192 |
+
radial_mlp_depth_list = [radial_mlp_depth] * num_layers
|
| 193 |
+
radial_mlp_width_list = [radial_mlp_width] * num_layers
|
| 194 |
+
|
| 195 |
+
# === post convnets ===
|
| 196 |
+
feature_irreps_hidden_list += [repr(o3.Irreps([(num_features[0], (0, 1))]))]
|
| 197 |
+
|
| 198 |
+
# === build model ===
|
| 199 |
+
model = FullNequIPGNNModel(
|
| 200 |
+
irreps_edge_sh=irreps_edge_sh,
|
| 201 |
+
type_embed_num_features=type_embed_num_features,
|
| 202 |
+
feature_irreps_hidden=feature_irreps_hidden_list,
|
| 203 |
+
radial_mlp_depth=radial_mlp_depth_list,
|
| 204 |
+
radial_mlp_width=radial_mlp_width_list,
|
| 205 |
+
**kwargs,
|
| 206 |
+
)
|
| 207 |
+
return model
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
PresetNequIPGNNModel.__doc__ = _format_nequip_gnn_preset_docstring()
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
@model_builder
|
| 214 |
+
def FullNequIPGNNModel(
|
| 215 |
+
r_max: float,
|
| 216 |
+
type_names: Sequence[str],
|
| 217 |
+
# convnet params
|
| 218 |
+
radial_mlp_depth: Sequence[int],
|
| 219 |
+
radial_mlp_width: Sequence[int],
|
| 220 |
+
feature_irreps_hidden: Sequence[Union[str, o3.Irreps]],
|
| 221 |
+
# irreps and dims
|
| 222 |
+
irreps_edge_sh: Union[int, str, o3.Irreps],
|
| 223 |
+
type_embed_num_features: int,
|
| 224 |
+
categorical_graph_field_embed: Optional[List[Dict[str, int]]] = None,
|
| 225 |
+
# readout
|
| 226 |
+
readout_mlp_hidden_layers_depth: int = 0,
|
| 227 |
+
readout_mlp_hidden_layers_width: Optional[int] = None,
|
| 228 |
+
readout_mlp_nonlinearity: Optional[str] = "silu",
|
| 229 |
+
# edge length encoding
|
| 230 |
+
per_edge_type_cutoff: Optional[Dict[str, Union[float, Dict[str, float]]]] = None,
|
| 231 |
+
num_bessels: int = 8,
|
| 232 |
+
bessel_trainable: bool = False,
|
| 233 |
+
polynomial_cutoff_p: int = 6,
|
| 234 |
+
# edge sum normalization
|
| 235 |
+
avg_num_neighbors: Optional[Union[float, Dict[str, float]]] = None,
|
| 236 |
+
# per atom energy params
|
| 237 |
+
per_type_energy_scales: Optional[Union[float, Sequence[float]]] = None,
|
| 238 |
+
per_type_energy_shifts: Optional[Union[float, Sequence[float]]] = None,
|
| 239 |
+
per_type_energy_scales_trainable: Optional[bool] = False,
|
| 240 |
+
per_type_energy_shifts_trainable: Optional[bool] = False,
|
| 241 |
+
pair_potential: Optional[Dict] = None,
|
| 242 |
+
# derivatives
|
| 243 |
+
do_derivatives: bool = True,
|
| 244 |
+
# developmental params
|
| 245 |
+
convnet_sc: bool = True,
|
| 246 |
+
learnable_shift: bool = False,
|
| 247 |
+
# == things that generally shouldn't be changed ==
|
| 248 |
+
# convnet
|
| 249 |
+
convnet_resnet: bool = False,
|
| 250 |
+
convnet_nonlinearity_type: str = "gate",
|
| 251 |
+
convnet_nonlinearity_scalars: Dict[int, Callable] = {"e": "silu", "o": "tanh"},
|
| 252 |
+
convnet_nonlinearity_gates: Dict[int, Callable] = {"e": "silu", "o": "tanh"},
|
| 253 |
+
) -> GraphModel:
|
| 254 |
+
"""NequIP GNN model that predicts energies based on a more extensive set of arguments."""
|
| 255 |
+
# === sanity checks and warnings ===
|
| 256 |
+
assert all(tn.isalnum() for tn in type_names), (
|
| 257 |
+
"`type_names` must contain only alphanumeric characters"
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
# learnable_shift requires skip connections to be enabled
|
| 261 |
+
assert not learnable_shift or (convnet_sc or convnet_resnet), (
|
| 262 |
+
"`learnable_shift=True` requires at least one of `convnet_sc` or `convnet_resnet` to be True"
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
# require every convnet layer to be specified explicitly in a list
|
| 266 |
+
# infer num_layers from the list size
|
| 267 |
+
assert (
|
| 268 |
+
len(radial_mlp_depth) == len(radial_mlp_width) == len(feature_irreps_hidden)
|
| 269 |
+
), (
|
| 270 |
+
f"radial_mlp_depth: {radial_mlp_depth}, radial_mlp_width: {radial_mlp_width}, feature_irreps_hidden: {feature_irreps_hidden} should all have the same length"
|
| 271 |
+
)
|
| 272 |
+
num_layers = len(radial_mlp_depth)
|
| 273 |
+
|
| 274 |
+
# assert that last convnet produces only scalars
|
| 275 |
+
assert all([l == 0 for l in o3.Irreps(feature_irreps_hidden[-1]).ls]), (
|
| 276 |
+
f"last convnet layer output must only contain scalars but found {feature_irreps_hidden[-1]}"
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
if per_type_energy_scales is None:
|
| 280 |
+
warnings.warn(
|
| 281 |
+
"Found `per_type_energy_scales=None` -- it is recommended to set `per_type_energy_scales` for better numerics during training."
|
| 282 |
+
)
|
| 283 |
+
if per_type_energy_shifts is None:
|
| 284 |
+
warnings.warn(
|
| 285 |
+
"Found `per_type_energy_shifts=None` -- it is HIGHLY recommended to set `per_type_energy_shifts` as it determines the per-atom energies approaching the isolated atom regime."
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
# === encode and embed features ===
|
| 289 |
+
# == node scalar embedding ==
|
| 290 |
+
# NOTE: node embed is done first in case we need to pass in categorical graph fields as inputs
|
| 291 |
+
# see how `irreps_in` is registered in the `NodeTypeEmbed` class
|
| 292 |
+
type_embed = NodeTypeEmbed(
|
| 293 |
+
type_names=type_names,
|
| 294 |
+
num_features=type_embed_num_features,
|
| 295 |
+
categorical_graph_field_embed=categorical_graph_field_embed,
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
# == edge tensor embedding ==
|
| 299 |
+
spharm = SphericalHarmonicEdgeAttrs(
|
| 300 |
+
irreps_edge_sh=irreps_edge_sh,
|
| 301 |
+
irreps_in=type_embed.irreps_out,
|
| 302 |
+
)
|
| 303 |
+
# == edge scalar embedding ==
|
| 304 |
+
edge_norm = EdgeLengthNormalizer(
|
| 305 |
+
r_max=r_max,
|
| 306 |
+
type_names=type_names,
|
| 307 |
+
per_edge_type_cutoff=per_edge_type_cutoff,
|
| 308 |
+
irreps_in=spharm.irreps_out,
|
| 309 |
+
)
|
| 310 |
+
bessel_encode = BesselEdgeLengthEncoding(
|
| 311 |
+
num_bessels=num_bessels,
|
| 312 |
+
trainable=bessel_trainable,
|
| 313 |
+
cutoff=PolynomialCutoff(polynomial_cutoff_p),
|
| 314 |
+
edge_invariant_field=AtomicDataDict.EDGE_EMBEDDING_KEY,
|
| 315 |
+
irreps_in=edge_norm.irreps_out,
|
| 316 |
+
)
|
| 317 |
+
# for backwards compatibility of NequIP's bessel encoding
|
| 318 |
+
factor = ApplyFactor(
|
| 319 |
+
in_field=AtomicDataDict.EDGE_EMBEDDING_KEY,
|
| 320 |
+
factor=(2 * math.pi) / (r_max * r_max),
|
| 321 |
+
irreps_in=bessel_encode.irreps_out,
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
modules = {
|
| 325 |
+
"type_embed": type_embed,
|
| 326 |
+
"spharm": spharm,
|
| 327 |
+
"edge_norm": edge_norm,
|
| 328 |
+
"bessel_encode": bessel_encode,
|
| 329 |
+
"factor": factor,
|
| 330 |
+
}
|
| 331 |
+
prev_irreps_out = factor.irreps_out
|
| 332 |
+
|
| 333 |
+
# === convnet layers ===
|
| 334 |
+
for layer_i in range(num_layers):
|
| 335 |
+
current_convnet = ConvNetLayer(
|
| 336 |
+
irreps_in=prev_irreps_out,
|
| 337 |
+
feature_irreps_hidden=feature_irreps_hidden[layer_i],
|
| 338 |
+
convolution_kwargs={
|
| 339 |
+
"radial_mlp_depth": radial_mlp_depth[layer_i],
|
| 340 |
+
"radial_mlp_width": radial_mlp_width[layer_i],
|
| 341 |
+
# to ensure isolated atom limit
|
| 342 |
+
"use_sc": convnet_sc
|
| 343 |
+
if learnable_shift
|
| 344 |
+
else (layer_i != 0) and convnet_sc,
|
| 345 |
+
"is_first_layer": layer_i == 0,
|
| 346 |
+
# normalization parameters
|
| 347 |
+
"avg_num_neighbors": avg_num_neighbors,
|
| 348 |
+
"type_names": type_names,
|
| 349 |
+
},
|
| 350 |
+
resnet=convnet_resnet
|
| 351 |
+
if learnable_shift
|
| 352 |
+
else (layer_i != 0) and convnet_resnet,
|
| 353 |
+
nonlinearity_type=convnet_nonlinearity_type,
|
| 354 |
+
nonlinearity_scalars=convnet_nonlinearity_scalars,
|
| 355 |
+
nonlinearity_gates=convnet_nonlinearity_gates,
|
| 356 |
+
)
|
| 357 |
+
prev_irreps_out = current_convnet.irreps_out
|
| 358 |
+
modules.update({f"layer{layer_i}_convnet": current_convnet})
|
| 359 |
+
|
| 360 |
+
# === readout ===
|
| 361 |
+
if readout_mlp_hidden_layers_width is None:
|
| 362 |
+
readout_mlp_hidden_layers_width = o3.Irreps(feature_irreps_hidden[-1]).dim
|
| 363 |
+
per_atom_energy_readout = ScalarMLP(
|
| 364 |
+
output_dim=1,
|
| 365 |
+
hidden_layers_depth=readout_mlp_hidden_layers_depth,
|
| 366 |
+
hidden_layers_width=readout_mlp_hidden_layers_width,
|
| 367 |
+
nonlinearity=readout_mlp_nonlinearity,
|
| 368 |
+
bias=False,
|
| 369 |
+
forward_weight_init=True,
|
| 370 |
+
field=AtomicDataDict.NODE_FEATURES_KEY,
|
| 371 |
+
out_field=AtomicDataDict.PER_ATOM_ENERGY_KEY,
|
| 372 |
+
irreps_in=prev_irreps_out,
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
per_type_energy_scale_shift = PerTypeScaleShift(
|
| 376 |
+
type_names=type_names,
|
| 377 |
+
field=AtomicDataDict.PER_ATOM_ENERGY_KEY,
|
| 378 |
+
out_field=AtomicDataDict.PER_ATOM_ENERGY_KEY,
|
| 379 |
+
scales=per_type_energy_scales,
|
| 380 |
+
shifts=per_type_energy_shifts,
|
| 381 |
+
scales_trainable=per_type_energy_scales_trainable,
|
| 382 |
+
shifts_trainable=per_type_energy_shifts_trainable,
|
| 383 |
+
irreps_in=per_atom_energy_readout.irreps_out,
|
| 384 |
+
)
|
| 385 |
+
|
| 386 |
+
modules.update(
|
| 387 |
+
{
|
| 388 |
+
"per_atom_energy_readout": per_atom_energy_readout,
|
| 389 |
+
"per_type_energy_scale_shift": per_type_energy_scale_shift,
|
| 390 |
+
}
|
| 391 |
+
)
|
| 392 |
+
|
| 393 |
+
energy_model = SequentialGraphNetwork(modules)
|
| 394 |
+
energy_model = _append_energy_modules(
|
| 395 |
+
model=energy_model,
|
| 396 |
+
type_names=type_names,
|
| 397 |
+
pair_potential=pair_potential,
|
| 398 |
+
)
|
| 399 |
+
return ForceStressOutput(energy_model, do_derivatives)
|
model/model/pair_potential.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is a part of the `nequip` package. Please see LICENSE and README at the root for information on using it.
|
| 2 |
+
from onescience.models.nequip.nn import SequentialGraphNetwork, AtomwiseReduce, ForceStressOutput
|
| 3 |
+
from onescience.models.nequip.nn.embedding import EdgeLengthNormalizer
|
| 4 |
+
from onescience.datapipes.materials.nequip import AtomicDataDict
|
| 5 |
+
from onescience.models.nequip.nn.pair_potential import ZBL
|
| 6 |
+
from .utils import model_builder
|
| 7 |
+
|
| 8 |
+
from typing import Optional, Dict, Union, Sequence
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@model_builder
|
| 12 |
+
def ZBLPairPotential(
|
| 13 |
+
r_max: float,
|
| 14 |
+
type_names: Sequence[str],
|
| 15 |
+
chemical_species: Sequence[str],
|
| 16 |
+
units: str,
|
| 17 |
+
polynomial_cutoff_p: int = 6,
|
| 18 |
+
per_edge_type_cutoff: Optional[Dict[str, Union[float, Dict[str, float]]]] = None,
|
| 19 |
+
):
|
| 20 |
+
"""
|
| 21 |
+
Model builder for a force field containing only a ZBL pair potential term, mainly for internal testing purposes.
|
| 22 |
+
"""
|
| 23 |
+
edge_norm = EdgeLengthNormalizer(
|
| 24 |
+
r_max=r_max,
|
| 25 |
+
type_names=type_names,
|
| 26 |
+
per_edge_type_cutoff=per_edge_type_cutoff,
|
| 27 |
+
)
|
| 28 |
+
zbl_module = ZBL(
|
| 29 |
+
type_names=type_names,
|
| 30 |
+
chemical_species=chemical_species,
|
| 31 |
+
units=units,
|
| 32 |
+
polynomial_cutoff_p=polynomial_cutoff_p,
|
| 33 |
+
irreps_in=edge_norm.irreps_out,
|
| 34 |
+
)
|
| 35 |
+
energy_sum = AtomwiseReduce(
|
| 36 |
+
reduce="sum",
|
| 37 |
+
field=AtomicDataDict.PER_ATOM_ENERGY_KEY,
|
| 38 |
+
out_field=AtomicDataDict.TOTAL_ENERGY_KEY,
|
| 39 |
+
irreps_in=zbl_module.irreps_out,
|
| 40 |
+
)
|
| 41 |
+
energy_model = SequentialGraphNetwork(
|
| 42 |
+
{
|
| 43 |
+
"edge_norm": edge_norm,
|
| 44 |
+
"pair_potential": zbl_module,
|
| 45 |
+
"total_energy_sum": energy_sum,
|
| 46 |
+
}
|
| 47 |
+
)
|
| 48 |
+
model = ForceStressOutput(func=energy_model)
|
| 49 |
+
|
| 50 |
+
return model
|
model/model/param_groups.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is a part of the `nequip` package. Please see LICENSE and README at the root for information on using it.
|
| 2 |
+
import torch
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def _normalize_weight_index_slices(weight_index_slices):
|
| 6 |
+
normalized = []
|
| 7 |
+
for entry in weight_index_slices:
|
| 8 |
+
index_slice = getattr(entry, "slice_1D", None)
|
| 9 |
+
shape_2d = getattr(entry, "shape_2D", None)
|
| 10 |
+
if index_slice is None or shape_2d is None:
|
| 11 |
+
index_slice, shape_2d = entry
|
| 12 |
+
if isinstance(index_slice, slice):
|
| 13 |
+
index_slice = (index_slice.start, index_slice.stop, index_slice.step)
|
| 14 |
+
else:
|
| 15 |
+
index_slice = tuple(index_slice)
|
| 16 |
+
assert len(index_slice) == 3
|
| 17 |
+
shape_2d = tuple(shape_2d)
|
| 18 |
+
assert len(shape_2d) == 2
|
| 19 |
+
normalized.append((index_slice, shape_2d))
|
| 20 |
+
return normalized
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def MuonParamGroups(
|
| 24 |
+
model: torch.nn.Module,
|
| 25 |
+
muon: dict,
|
| 26 |
+
adam: dict,
|
| 27 |
+
):
|
| 28 |
+
"""
|
| 29 |
+
Build optimizer parameter groups, splitting parameters between a Muon-based optimizer
|
| 30 |
+
and Adam (or Adam-like) optimizer.
|
| 31 |
+
|
| 32 |
+
Assigned to Adam group:
|
| 33 |
+
- Any parameter whose name does **not** contain the substring ``"layer"``.
|
| 34 |
+
- Any parameter not matching the Muon-specific rules below.
|
| 35 |
+
|
| 36 |
+
Assigned to Muon group:
|
| 37 |
+
- Edge MLP weights: parameters whose name contains ``"edge_mlp"`` and that are
|
| 38 |
+
2D tensors (i.e., matrix weights).
|
| 39 |
+
- e3nn convolution linear weights: parameters whose name contains ``"conv.linear"``.
|
| 40 |
+
|
| 41 |
+
For e3nn ``Linear`` layers, the returned Muon parameter group includes an
|
| 42 |
+
``e3nn_reshaping`` dictionary mapping the index of the parameter within the Muon
|
| 43 |
+
group to the module's ``weight_index_slices``. This metadata will be used by the
|
| 44 |
+
to reshape or operate on corresponding matrix weights.
|
| 45 |
+
|
| 46 |
+
Args:
|
| 47 |
+
model (torch.nn.Module): The model to optimize.
|
| 48 |
+
muon (dict): Muon config parameters.
|
| 49 |
+
adam (dict): Adam config parameters.
|
| 50 |
+
|
| 51 |
+
"""
|
| 52 |
+
muon_weights = []
|
| 53 |
+
adam_weights = []
|
| 54 |
+
|
| 55 |
+
e3nn_reshaping = {}
|
| 56 |
+
|
| 57 |
+
modules = dict(model.named_modules())
|
| 58 |
+
|
| 59 |
+
for name, param in model.named_parameters():
|
| 60 |
+
# Assumes all input and output layers are
|
| 61 |
+
# not called layers.
|
| 62 |
+
if "layer" not in name:
|
| 63 |
+
adam_weights.append(param)
|
| 64 |
+
continue
|
| 65 |
+
|
| 66 |
+
# First, all edge_mlps should be muon
|
| 67 |
+
if "edge_mlp" in name and param.ndim == 2:
|
| 68 |
+
muon_weights.append(param)
|
| 69 |
+
continue
|
| 70 |
+
|
| 71 |
+
if "conv.linear" in name:
|
| 72 |
+
# e3nn conv layers.
|
| 73 |
+
|
| 74 |
+
# Find the e3nn Linear module this represents
|
| 75 |
+
module_name, _, _ = name.rpartition(".")
|
| 76 |
+
module = modules[module_name]
|
| 77 |
+
|
| 78 |
+
# use Muon only when reshape metadata is available
|
| 79 |
+
weight_index_slices = getattr(module, "weight_index_slices", None)
|
| 80 |
+
if weight_index_slices is None:
|
| 81 |
+
adam_weights.append(param)
|
| 82 |
+
continue
|
| 83 |
+
|
| 84 |
+
# store plain tuples to keep optimizer state picklable
|
| 85 |
+
index = len(muon_weights)
|
| 86 |
+
e3nn_reshaping[index] = _normalize_weight_index_slices(weight_index_slices)
|
| 87 |
+
muon_weights.append(param)
|
| 88 |
+
continue
|
| 89 |
+
|
| 90 |
+
adam_weights.append(param)
|
| 91 |
+
|
| 92 |
+
param_groups = [
|
| 93 |
+
dict(params=muon_weights, use_muon=True, e3nn_reshaping=e3nn_reshaping, **muon),
|
| 94 |
+
dict(params=adam_weights, use_muon=False, **adam),
|
| 95 |
+
]
|
| 96 |
+
|
| 97 |
+
return param_groups
|
model/model/saved_models/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is a part of the `nequip` package. Please see LICENSE and README at the root for information on using it.
|
| 2 |
+
|
| 3 |
+
from .checkpoint import ModelFromCheckpoint
|
| 4 |
+
from .package import ModelFromPackage, ModelTypeNamesFromPackage
|
| 5 |
+
from .load_utils import load_saved_model
|
| 6 |
+
|
| 7 |
+
__all__ = [
|
| 8 |
+
"ModelFromCheckpoint",
|
| 9 |
+
"ModelFromPackage",
|
| 10 |
+
"ModelTypeNamesFromPackage",
|
| 11 |
+
"load_saved_model",
|
| 12 |
+
]
|
model/model/saved_models/_utils.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is a part of the `nequip` package. Please see LICENSE and README at the root for information on using it.
|
| 2 |
+
"""
|
| 3 |
+
Shared utilities for loading models from saved formats (checkpoints and packages).
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
from typing import List
|
| 8 |
+
|
| 9 |
+
from onescience.models.nequip.model.utils import _COMPILE_MODE_OPTIONS
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _check_compile_mode(compile_mode: str, client: str, exclude_keys: List[str] = []):
|
| 13 |
+
"""Helper function for checking input arguments."""
|
| 14 |
+
allowed_options = [
|
| 15 |
+
mode for mode in _COMPILE_MODE_OPTIONS if mode not in exclude_keys
|
| 16 |
+
]
|
| 17 |
+
assert compile_mode in allowed_options, (
|
| 18 |
+
f"`compile_mode={compile_mode}` is not recognized for `{client}`, only the following are supported: {allowed_options}"
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _check_file_exists(file_path: str, file_type: str):
|
| 23 |
+
"""Check if a checkpoint or package file exists."""
|
| 24 |
+
if not os.path.isfile(file_path):
|
| 25 |
+
assert file_type in ("checkpoint", "package")
|
| 26 |
+
client = (
|
| 27 |
+
"`ModelFromCheckpoint`"
|
| 28 |
+
if file_type == "checkpoint"
|
| 29 |
+
else "`ModelFromPackage`"
|
| 30 |
+
)
|
| 31 |
+
raise RuntimeError(
|
| 32 |
+
f"{file_type} file provided at `{file_path}` is not found. NOTE: Any process that loads a checkpoint produced from training runs based on {client} will look for the original {file_type} file at the location specified during training. It is also recommended to use full paths (instead or relative paths) to avoid potential errors."
|
| 33 |
+
)
|
model/model/saved_models/checkpoint.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is a part of the `nequip` package. Please see LICENSE and README at the root for information on using it.
|
| 2 |
+
"""
|
| 3 |
+
Functions for loading models from checkpoint files.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
import hydra
|
| 8 |
+
import warnings
|
| 9 |
+
|
| 10 |
+
from onescience.models.nequip.model.utils import (
|
| 11 |
+
override_model_compile_mode,
|
| 12 |
+
_EAGER_MODEL_KEY,
|
| 13 |
+
)
|
| 14 |
+
from onescience.datapipes.materials.nequip import AtomicDataDict
|
| 15 |
+
from onescience.datapipes.materials.nequip.transforms import NonPeriodicCellTransform
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
from onescience.utils.nequip.internal.global_dtype import _GLOBAL_DTYPE
|
| 19 |
+
from onescience.utils.nequip.internal.logger import RankedLogger
|
| 20 |
+
|
| 21 |
+
from ._utils import _check_compile_mode, _check_file_exists
|
| 22 |
+
|
| 23 |
+
# === setup logging ===
|
| 24 |
+
logger = RankedLogger(__name__, rank_zero_only=True)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def ModelFromCheckpoint(checkpoint_path: str, compile_mode: str = _EAGER_MODEL_KEY):
|
| 28 |
+
"""Builds model from a NequIP framework checkpoint file.
|
| 29 |
+
|
| 30 |
+
This function can be used in the config file as follows.
|
| 31 |
+
|
| 32 |
+
.. code-block:: yaml
|
| 33 |
+
|
| 34 |
+
model:
|
| 35 |
+
_target_: onescience.models.nequip.model.ModelFromCheckpoint
|
| 36 |
+
checkpoint_path: path/to/ckpt
|
| 37 |
+
compile_mode: eager/compile
|
| 38 |
+
|
| 39 |
+
.. warning::
|
| 40 |
+
DO NOT CHANGE the directory structure or location of the checkpoint file if this model loader is used for training. Any process that loads a checkpoint produced from training runs originating from a package file will look for the original package file at the location specified during training. It is also recommended to use full paths (instead or relative paths) to avoid potential errors.
|
| 41 |
+
|
| 42 |
+
Args:
|
| 43 |
+
checkpoint_path (str): path to a ``nequip`` framework checkpoint file
|
| 44 |
+
compile_mode (str): ``eager`` or ``compile`` allowed for training
|
| 45 |
+
"""
|
| 46 |
+
# === sanity checks ===
|
| 47 |
+
_check_file_exists(file_path=checkpoint_path, file_type="checkpoint")
|
| 48 |
+
_check_compile_mode(compile_mode, "ModelFromCheckpoint")
|
| 49 |
+
logger.info(f"Loading model from checkpoint file: {checkpoint_path} ...")
|
| 50 |
+
|
| 51 |
+
# === load checkpoint and extract info ===
|
| 52 |
+
checkpoint = torch.load(
|
| 53 |
+
checkpoint_path,
|
| 54 |
+
map_location="cpu",
|
| 55 |
+
weights_only=False,
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
# === versions ===
|
| 59 |
+
ckpt_versions = checkpoint["hyper_parameters"]["info_dict"]["versions"]
|
| 60 |
+
from onescience.utils.nequip.internal import get_current_code_versions
|
| 61 |
+
|
| 62 |
+
session_versions = get_current_code_versions(verbose=False)
|
| 63 |
+
|
| 64 |
+
for code, session_version in session_versions.items():
|
| 65 |
+
if code in ckpt_versions:
|
| 66 |
+
ckpt_version = ckpt_versions[code]
|
| 67 |
+
# sanity check that versions for current build matches versions from ckpt
|
| 68 |
+
if ckpt_version != session_version:
|
| 69 |
+
warnings.warn(
|
| 70 |
+
f"`{code}` versions differ between the checkpoint file ({ckpt_version}) and the current run ({session_version}) -- `ModelFromCheckpoint` will be built with the current run's versions, but please check that this decision is as intended."
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
# === load model via lightning module ===
|
| 74 |
+
# Rewrite legacy upstream ``nequip.`` targets to the OneScience namespace.
|
| 75 |
+
from onescience.utils.nequip.internal.compat import rewrite_nequip_targets
|
| 76 |
+
|
| 77 |
+
compatible_hyper_parameters = rewrite_nequip_targets(
|
| 78 |
+
checkpoint["hyper_parameters"]
|
| 79 |
+
)
|
| 80 |
+
info_dict = compatible_hyper_parameters["info_dict"]
|
| 81 |
+
training_module = hydra.utils.get_class(info_dict["training_module"]["_target_"])
|
| 82 |
+
# ensure that model is built with correct `compile_mode`
|
| 83 |
+
with override_model_compile_mode(compile_mode):
|
| 84 |
+
lightning_module = training_module.load_from_checkpoint(
|
| 85 |
+
checkpoint_path,
|
| 86 |
+
weights_only=False,
|
| 87 |
+
**compatible_hyper_parameters,
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
model = lightning_module.evaluation_model
|
| 91 |
+
return model
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def data_dict_from_checkpoint(ckpt_path: str) -> AtomicDataDict.Type:
|
| 95 |
+
from onescience.utils.nequip.internal.dtype import torch_default_dtype
|
| 96 |
+
|
| 97 |
+
with torch_default_dtype(_GLOBAL_DTYPE):
|
| 98 |
+
# === get data from checkpoint ===
|
| 99 |
+
checkpoint = torch.load(
|
| 100 |
+
ckpt_path,
|
| 101 |
+
map_location="cpu",
|
| 102 |
+
weights_only=False,
|
| 103 |
+
)
|
| 104 |
+
from onescience.utils.nequip.internal.compat import rewrite_nequip_targets
|
| 105 |
+
|
| 106 |
+
data_config = rewrite_nequip_targets(
|
| 107 |
+
checkpoint["hyper_parameters"]["info_dict"]["data"].copy()
|
| 108 |
+
)
|
| 109 |
+
if "train_dataloader" not in data_config:
|
| 110 |
+
data_config["train_dataloader"] = {
|
| 111 |
+
"_target_": "torch.utils.data.DataLoader"
|
| 112 |
+
}
|
| 113 |
+
data_config["train_dataloader"]["batch_size"] = 1
|
| 114 |
+
datamodule = hydra.utils.instantiate(data_config, _recursive_=False)
|
| 115 |
+
# TODO: better way of doing this?
|
| 116 |
+
# instantiate the datamodule, dataset, and get train dataloader
|
| 117 |
+
try:
|
| 118 |
+
datamodule.prepare_data()
|
| 119 |
+
# instantiate train dataset
|
| 120 |
+
datamodule.setup(stage="fit")
|
| 121 |
+
dloader = datamodule.train_dataloader()
|
| 122 |
+
for data in dloader:
|
| 123 |
+
if AtomicDataDict.num_nodes(data) > 3:
|
| 124 |
+
break
|
| 125 |
+
finally:
|
| 126 |
+
datamodule.teardown(stage="fit")
|
| 127 |
+
|
| 128 |
+
# === sanitize data ===
|
| 129 |
+
if AtomicDataDict.CELL_KEY not in data:
|
| 130 |
+
# try to construct sensible cell for nonperiodic system
|
| 131 |
+
transform = NonPeriodicCellTransform(padding=10.0, override_cell=False)
|
| 132 |
+
data = transform(data)
|
| 133 |
+
|
| 134 |
+
# if still no cell (transform was no-op), create a large cell
|
| 135 |
+
if AtomicDataDict.CELL_KEY not in data:
|
| 136 |
+
data[AtomicDataDict.CELL_KEY] = 1e5 * torch.eye(
|
| 137 |
+
3,
|
| 138 |
+
dtype=_GLOBAL_DTYPE,
|
| 139 |
+
device=data[AtomicDataDict.POSITIONS_KEY].device,
|
| 140 |
+
).unsqueeze(0)
|
| 141 |
+
|
| 142 |
+
data[AtomicDataDict.EDGE_CELL_SHIFT_KEY] = torch.zeros(
|
| 143 |
+
(AtomicDataDict.num_edges(data), 3),
|
| 144 |
+
dtype=_GLOBAL_DTYPE,
|
| 145 |
+
device=data[AtomicDataDict.POSITIONS_KEY].device,
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
return data
|
model/model/saved_models/load_utils.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is a part of the `nequip` package. Please see LICENSE and README at the root for information on using it.
|
| 2 |
+
|
| 3 |
+
import contextlib
|
| 4 |
+
import pathlib
|
| 5 |
+
import requests
|
| 6 |
+
from tqdm.auto import tqdm
|
| 7 |
+
|
| 8 |
+
from onescience.models.nequip.model.utils import _EAGER_MODEL_KEY
|
| 9 |
+
from onescience.models.nequip.model.saved_models import ModelFromPackage, ModelFromCheckpoint
|
| 10 |
+
from onescience.models.nequip.model.modify_utils import only_apply_persistent_modifiers
|
| 11 |
+
from onescience.utils.nequip.train.lightning import _SOLE_MODEL_KEY
|
| 12 |
+
from onescience.utils.nequip.internal import model_repository
|
| 13 |
+
from onescience.utils.nequip.internal.logger import RankedLogger
|
| 14 |
+
from onescience.utils.nequip.internal.model_cache import get_cached_model, cache_model
|
| 15 |
+
|
| 16 |
+
logger = RankedLogger(__name__, rank_zero_only=True)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@contextlib.contextmanager
|
| 20 |
+
def _get_model_file_path(input_path):
|
| 21 |
+
"""Context manager that provides a file path for both local and nequip.net models.
|
| 22 |
+
|
| 23 |
+
For local files: yields the input path directly
|
| 24 |
+
For nequip.net downloads: uses cache if available, otherwise downloads and caches
|
| 25 |
+
(default cache location: ``~/.nequip/model_cache``, configurable via ``NEQUIP_CACHE_DIR``)
|
| 26 |
+
|
| 27 |
+
Args:
|
| 28 |
+
input_path: path to the model checkpoint or package file, or nequip.net model ID
|
| 29 |
+
(format: ``nequip.net:group-name/model-name:version``)
|
| 30 |
+
|
| 31 |
+
Yields:
|
| 32 |
+
pathlib.Path: Path to the model file (either original or cached)
|
| 33 |
+
"""
|
| 34 |
+
is_nequip_net_download: bool = str(input_path).startswith("nequip.net:")
|
| 35 |
+
|
| 36 |
+
if is_nequip_net_download:
|
| 37 |
+
# get model ID
|
| 38 |
+
model_id = str(input_path)[len("nequip.net:") :]
|
| 39 |
+
logger.info(f"Fetching {model_id} from onescience.models.nequip.net...")
|
| 40 |
+
|
| 41 |
+
# get download URL
|
| 42 |
+
with model_repository.NequIPNetAPIClient() as client:
|
| 43 |
+
model_info = client.get_model_download_info(model_id)
|
| 44 |
+
|
| 45 |
+
if model_info.newer_version_id is not None:
|
| 46 |
+
logger.info(
|
| 47 |
+
f"Model {model_id} has a newer version available: {model_info.newer_version_id}"
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
download_url = model_info.artifact.download_url
|
| 51 |
+
|
| 52 |
+
# check cache first
|
| 53 |
+
cached_path = get_cached_model(model_id, download_url)
|
| 54 |
+
if cached_path is not None:
|
| 55 |
+
yield cached_path
|
| 56 |
+
return
|
| 57 |
+
|
| 58 |
+
# cache miss: download and cache
|
| 59 |
+
def download_fn(target_path: pathlib.Path):
|
| 60 |
+
response = requests.get(download_url, stream=True)
|
| 61 |
+
response.raise_for_status()
|
| 62 |
+
|
| 63 |
+
total_size = int(response.headers.get("content-length", 0))
|
| 64 |
+
|
| 65 |
+
with open(target_path, "wb") as f:
|
| 66 |
+
with tqdm(
|
| 67 |
+
total=total_size,
|
| 68 |
+
unit="B",
|
| 69 |
+
unit_scale=True,
|
| 70 |
+
desc=f"Downloading from {model_info.artifact.host_name}",
|
| 71 |
+
) as pbar:
|
| 72 |
+
for chunk in response.iter_content(chunk_size=65536):
|
| 73 |
+
if chunk:
|
| 74 |
+
f.write(chunk)
|
| 75 |
+
pbar.update(len(chunk))
|
| 76 |
+
|
| 77 |
+
# download and cache (cache_model will skip caching if NEQUIP_NO_CACHE is set)
|
| 78 |
+
cached_path = cache_model(model_id, download_url, download_fn)
|
| 79 |
+
logger.info("Download complete, loading model...")
|
| 80 |
+
yield cached_path
|
| 81 |
+
else:
|
| 82 |
+
logger.info(f"Loading model from {input_path} ...")
|
| 83 |
+
yield pathlib.Path(input_path)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def load_saved_model(
|
| 87 |
+
input_path,
|
| 88 |
+
compile_mode: str = _EAGER_MODEL_KEY,
|
| 89 |
+
model_key: str = _SOLE_MODEL_KEY,
|
| 90 |
+
return_data_dict: bool = False,
|
| 91 |
+
):
|
| 92 |
+
"""Load a saved model from checkpoint, package, or nequip.net.
|
| 93 |
+
|
| 94 |
+
This function can load models from:
|
| 95 |
+
|
| 96 |
+
- **Checkpoint files** (``.ckpt``): saved during training runs
|
| 97 |
+
- **Package files** (``.nequip.zip``): created with ``nequip-package``
|
| 98 |
+
- **nequip.net models**: using model ID format ``nequip.net:group-name/model-name:version`` from `nequip.net <https://www.nequip.net/>`__
|
| 99 |
+
|
| 100 |
+
Args:
|
| 101 |
+
input_path: path to the model checkpoint or package file, or nequip.net model ID
|
| 102 |
+
(format: ``nequip.net:group-name/model-name:version``)
|
| 103 |
+
compile_mode (str): compile mode for the model (default: ``"eager"``)
|
| 104 |
+
model_key (str): key to select the model from ModuleDict (default: ``"sole_model"``)
|
| 105 |
+
return_data_dict (bool): if ``True``, also return the data dict for compilation (default: ``False``)
|
| 106 |
+
|
| 107 |
+
Returns:
|
| 108 |
+
torch.nn.Module or tuple: the loaded model, or ``(model, data)`` tuple if ``return_data_dict=True``
|
| 109 |
+
"""
|
| 110 |
+
|
| 111 |
+
with _get_model_file_path(input_path) as actual_input_path:
|
| 112 |
+
# check if the resolved file exists
|
| 113 |
+
if not actual_input_path.exists():
|
| 114 |
+
raise ValueError(
|
| 115 |
+
f"Model file does not exist: {input_path} (resolved to: {actual_input_path})"
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
# use package load path if extension matches, otherwise assume checkpoint file
|
| 119 |
+
use_ckpt = not str(actual_input_path).endswith(".nequip.zip")
|
| 120 |
+
|
| 121 |
+
# load model
|
| 122 |
+
if use_ckpt:
|
| 123 |
+
# we only apply persistent modifiers when building from checkpoint
|
| 124 |
+
# i.e. acceleration modifiers won't be applied, and have to be specified during compile time
|
| 125 |
+
with only_apply_persistent_modifiers(persistent_only=True):
|
| 126 |
+
model = ModelFromCheckpoint(
|
| 127 |
+
actual_input_path, compile_mode=compile_mode
|
| 128 |
+
)
|
| 129 |
+
else:
|
| 130 |
+
# packaged models will never have non-persistent modifiers built in
|
| 131 |
+
model = ModelFromPackage(actual_input_path, compile_mode=compile_mode)
|
| 132 |
+
|
| 133 |
+
if model_key is not None:
|
| 134 |
+
model = model[model_key]
|
| 135 |
+
# ^ `ModuleDict` of `GraphModel` is loaded, we then select the desired `GraphModel` (`model_key` defaults to work for single model case)
|
| 136 |
+
# otherwise, return the `ModuleDict`
|
| 137 |
+
|
| 138 |
+
# load data dict if requested
|
| 139 |
+
if return_data_dict:
|
| 140 |
+
from onescience.models.nequip.model.saved_models.checkpoint import data_dict_from_checkpoint
|
| 141 |
+
from onescience.models.nequip.model.saved_models.package import data_dict_from_package
|
| 142 |
+
|
| 143 |
+
if use_ckpt:
|
| 144 |
+
data = data_dict_from_checkpoint(str(actual_input_path))
|
| 145 |
+
else:
|
| 146 |
+
data = data_dict_from_package(str(actual_input_path))
|
| 147 |
+
|
| 148 |
+
return model, data
|
| 149 |
+
else:
|
| 150 |
+
return model
|
model/model/saved_models/package.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is a part of the `nequip` package. Please see LICENSE and README at the root for information on using it.
|
| 2 |
+
"""
|
| 3 |
+
Functions for loading models from package files.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
import yaml
|
| 8 |
+
import warnings
|
| 9 |
+
import contextlib
|
| 10 |
+
import io
|
| 11 |
+
from typing import Dict, Any
|
| 12 |
+
|
| 13 |
+
from onescience.datapipes.materials.nequip import AtomicDataDict
|
| 14 |
+
from onescience.models.nequip.model.utils import (
|
| 15 |
+
get_current_compile_mode,
|
| 16 |
+
_EAGER_MODEL_KEY,
|
| 17 |
+
)
|
| 18 |
+
from onescience.utils.nequip.cli._workflow_utils import get_workflow_state
|
| 19 |
+
from onescience.utils.nequip.internal.logger import RankedLogger
|
| 20 |
+
|
| 21 |
+
from ._utils import _check_compile_mode, _check_file_exists
|
| 22 |
+
from onescience.utils.nequip.internal.asserts import assert_package_extension
|
| 23 |
+
|
| 24 |
+
# === setup logging ===
|
| 25 |
+
logger = RankedLogger(__name__, rank_zero_only=True)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@contextlib.contextmanager
|
| 29 |
+
def _cpu_deserialize_if_no_cuda():
|
| 30 |
+
"""Force CUDA-saved storages inside packaged models to load on CPU when CUDA is unavailable."""
|
| 31 |
+
if torch.cuda.is_available():
|
| 32 |
+
yield
|
| 33 |
+
return
|
| 34 |
+
|
| 35 |
+
orig = torch.storage._load_from_bytes
|
| 36 |
+
|
| 37 |
+
def _load_from_bytes_cpu(b):
|
| 38 |
+
return torch.load(io.BytesIO(b), map_location="cpu", weights_only=False)
|
| 39 |
+
|
| 40 |
+
torch.storage._load_from_bytes = _load_from_bytes_cpu
|
| 41 |
+
try:
|
| 42 |
+
yield
|
| 43 |
+
finally:
|
| 44 |
+
torch.storage._load_from_bytes = orig
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# === package importer utilities ===
|
| 48 |
+
# most of the complexity for `ModelFromPackage` is due to the need to keep track of the `Importer` if we ever repackage
|
| 49 |
+
# see `nequip/scripts/package.py` to get the full picture of how they interact
|
| 50 |
+
# we expect the following variable to only be used during `nequip-package`
|
| 51 |
+
|
| 52 |
+
_PACKAGE_TIME_SHARED_IMPORTER = None
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _get_shared_importer():
|
| 56 |
+
global _PACKAGE_TIME_SHARED_IMPORTER
|
| 57 |
+
return _PACKAGE_TIME_SHARED_IMPORTER
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _get_package_metadata(imp) -> Dict[str, Any]:
|
| 61 |
+
"""Load packaged model metadata from an existing PackageImporter."""
|
| 62 |
+
pkg_metadata: Dict[str, Any] = yaml.safe_load(
|
| 63 |
+
imp.load_text(package="model", resource="package_metadata.txt")
|
| 64 |
+
)
|
| 65 |
+
assert int(pkg_metadata["package_version_id"]) > 0
|
| 66 |
+
# ^ extra sanity check since saving metadata in txt files was implemented in packaging version 1
|
| 67 |
+
|
| 68 |
+
return pkg_metadata
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# === warning management ===
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@contextlib.contextmanager
|
| 75 |
+
def _suppress_package_importer_exporter_warnings():
|
| 76 |
+
# Ideally this ceases to exist or becomes a no-op in future versions of PyTorch
|
| 77 |
+
with warnings.catch_warnings():
|
| 78 |
+
# suppress torch.package TypedStorage warning
|
| 79 |
+
warnings.filterwarnings(
|
| 80 |
+
"ignore",
|
| 81 |
+
message="TypedStorage is deprecated.*",
|
| 82 |
+
category=UserWarning,
|
| 83 |
+
module=r"torch\.package\.(package_exporter|package_importer)",
|
| 84 |
+
)
|
| 85 |
+
yield
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# === loading models from package files ===
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def ModelFromPackage(package_path: str, compile_mode: str = _EAGER_MODEL_KEY):
|
| 92 |
+
"""Builds model from a NequIP framework packaged zip file constructed with ``nequip-package``.
|
| 93 |
+
|
| 94 |
+
This function can be used in the config file as follows.
|
| 95 |
+
|
| 96 |
+
.. code-block:: yaml
|
| 97 |
+
|
| 98 |
+
model:
|
| 99 |
+
_target_: onescience.models.nequip.model.ModelFromPackage
|
| 100 |
+
package_path: path/to/pkg
|
| 101 |
+
compile_mode: eager/compile
|
| 102 |
+
|
| 103 |
+
.. warning::
|
| 104 |
+
DO NOT CHANGE the directory structure or location of the package file if this model loader is used for training. Any process that loads a checkpoint produced from training runs originating from a package file will look for the original package file at the location specified during training. It is also recommended to use full paths (instead or relative paths) to avoid potential errors.
|
| 105 |
+
|
| 106 |
+
Args:
|
| 107 |
+
package_path (str): path to NequIP framework packaged model with the ``.nequip.zip`` extension (an error will be thrown if the file has a different extension)
|
| 108 |
+
compile_mode (str): ``eager`` or ``compile`` allowed for training
|
| 109 |
+
"""
|
| 110 |
+
# === sanity checks ===
|
| 111 |
+
_check_file_exists(file_path=package_path, file_type="package")
|
| 112 |
+
assert_package_extension(package_path)
|
| 113 |
+
|
| 114 |
+
# === account for checkpoint loading ===
|
| 115 |
+
# if `ModelFromPackage` is used by itself, `override=False` and the input `compile_mode` argument is used
|
| 116 |
+
# if this function is called at the end of checkpoint loading via `ModelFromCheckpoint`, `override=True` and the overriden `compile_mode` takes precedence
|
| 117 |
+
cm, override = get_current_compile_mode(return_override=True)
|
| 118 |
+
compile_mode = cm if override else compile_mode
|
| 119 |
+
|
| 120 |
+
# === sanity check compile modes ===
|
| 121 |
+
workflow_state = get_workflow_state()
|
| 122 |
+
_check_compile_mode(compile_mode, "ModelFromPackage")
|
| 123 |
+
|
| 124 |
+
# === load model ===
|
| 125 |
+
logger.info(f"Loading model from package file: {package_path} ...")
|
| 126 |
+
with _suppress_package_importer_exporter_warnings():
|
| 127 |
+
# during `nequip-package`, we need to use the same importer for all the models for successful repackaging
|
| 128 |
+
# see https://pytorch.org/docs/stable/package.html#re-export-an-imported-object
|
| 129 |
+
if workflow_state == "package":
|
| 130 |
+
global _PACKAGE_TIME_SHARED_IMPORTER
|
| 131 |
+
imp = _PACKAGE_TIME_SHARED_IMPORTER
|
| 132 |
+
# we load the importer from `package_path` for the first time
|
| 133 |
+
if imp is None:
|
| 134 |
+
imp = torch.package.PackageImporter(package_path)
|
| 135 |
+
_PACKAGE_TIME_SHARED_IMPORTER = imp
|
| 136 |
+
# if it's not `None`, it means we've previously loaded a model during `nequip-package` and should keep using the same importer
|
| 137 |
+
else:
|
| 138 |
+
# if not doing `nequip-package`, we just load a new importer every time `ModelFromPackage` is called
|
| 139 |
+
imp = torch.package.PackageImporter(package_path)
|
| 140 |
+
|
| 141 |
+
# do sanity checking with available models
|
| 142 |
+
pkg_metadata = _get_package_metadata(imp)
|
| 143 |
+
available_models = pkg_metadata["available_models"]
|
| 144 |
+
# throw warning if desired `compile_mode` is not available, and default to eager
|
| 145 |
+
if compile_mode not in available_models:
|
| 146 |
+
warnings.warn(
|
| 147 |
+
f"Requested `{compile_mode}` model is not present in the package file ({package_path}). `nequip-{workflow_state}` task will default to using the `{_EAGER_MODEL_KEY}` model."
|
| 148 |
+
)
|
| 149 |
+
compile_mode = _EAGER_MODEL_KEY
|
| 150 |
+
|
| 151 |
+
with _cpu_deserialize_if_no_cuda():
|
| 152 |
+
model = imp.load_pickle(
|
| 153 |
+
package="model",
|
| 154 |
+
resource=f"{compile_mode}_model.pkl",
|
| 155 |
+
map_location="cpu",
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
# NOTE: model returned is not a GraphModel object tied to the `nequip` in current Python env, but a GraphModel object from the packaged zip file
|
| 159 |
+
return model
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def data_dict_from_package(package_path: str) -> AtomicDataDict.Type:
|
| 163 |
+
"""Load example data from a .nequip.zip package file."""
|
| 164 |
+
with _suppress_package_importer_exporter_warnings():
|
| 165 |
+
imp = torch.package.PackageImporter(package_path)
|
| 166 |
+
with _cpu_deserialize_if_no_cuda():
|
| 167 |
+
data = imp.load_pickle(package="model", resource="example_data.pkl")
|
| 168 |
+
return data
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def ModelTypeNamesFromPackage(package_path: str):
|
| 172 |
+
"""Extract model type names from a packaged model file.
|
| 173 |
+
|
| 174 |
+
Useful for setting up type mappers when fine-tuning models or when you need to know what atom types a model was trained on.
|
| 175 |
+
|
| 176 |
+
Args:
|
| 177 |
+
package_path (str): path to packaged model file
|
| 178 |
+
"""
|
| 179 |
+
from typing import List
|
| 180 |
+
|
| 181 |
+
_check_file_exists(file_path=package_path, file_type="package")
|
| 182 |
+
|
| 183 |
+
with _suppress_package_importer_exporter_warnings():
|
| 184 |
+
imp = torch.package.PackageImporter(package_path)
|
| 185 |
+
pkg_metadata = _get_package_metadata(imp)
|
| 186 |
+
|
| 187 |
+
atom_types_dict = pkg_metadata["atom_types"]
|
| 188 |
+
# convert dict {idx: name} to list [name, ...]
|
| 189 |
+
type_names: List[str] = [atom_types_dict[i] for i in range(len(atom_types_dict))]
|
| 190 |
+
return type_names
|
model/model/utils.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is a part of the `nequip` package. Please see LICENSE and README at the root for information on using it.
|
| 2 |
+
import torch
|
| 3 |
+
from lightning.pytorch.utilities.seed import isolate_rng
|
| 4 |
+
|
| 5 |
+
from onescience.models.nequip.nn.graph_model import GraphModel
|
| 6 |
+
from onescience.models.nequip.nn.compile import CompileGraphModel
|
| 7 |
+
from onescience.utils.nequip.internal import (
|
| 8 |
+
dtype_from_name,
|
| 9 |
+
torch_default_dtype,
|
| 10 |
+
conditional_torchscript_mode,
|
| 11 |
+
)
|
| 12 |
+
from onescience.utils.nequip.internal.global_state import (
|
| 13 |
+
global_state_initialized,
|
| 14 |
+
get_latest_global_state,
|
| 15 |
+
TF32_KEY,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
import functools
|
| 19 |
+
import contextvars
|
| 20 |
+
import contextlib
|
| 21 |
+
|
| 22 |
+
from typing import Optional, Final
|
| 23 |
+
|
| 24 |
+
_IS_BUILDING_MODEL = contextvars.ContextVar("_IS_BUILDING_MODEL", default=False)
|
| 25 |
+
_CURRENT_MODEL_BUILDER_DEFAULTS = contextvars.ContextVar(
|
| 26 |
+
"_CURRENT_MODEL_BUILDER_DEFAULTS",
|
| 27 |
+
default=None,
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
# the following is the set of model build types for specific purposes
|
| 31 |
+
_EAGER_MODEL_KEY = "eager"
|
| 32 |
+
_TRAIN_TIME_COMPILE_KEY: Final[str] = "compile"
|
| 33 |
+
|
| 34 |
+
_COMPILE_MODE_OPTIONS = {
|
| 35 |
+
_EAGER_MODEL_KEY,
|
| 36 |
+
_TRAIN_TIME_COMPILE_KEY,
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
_OVERRIDE_COMPILE_MODE = contextvars.ContextVar("_OVERRIDE_COMPILE_MODE", default=False)
|
| 41 |
+
_CURRENT_COMPILE_MODE = contextvars.ContextVar(
|
| 42 |
+
"_CURRENT_COMPILE_MODE", default=_EAGER_MODEL_KEY
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@contextlib.contextmanager
|
| 47 |
+
def override_model_compile_mode(compile_mode: Optional[str]):
|
| 48 |
+
"""
|
| 49 |
+
Overrides the ``compile_mode`` for model building.
|
| 50 |
+
If several of these context managers are nested, the outermost one will be prioritized while the inner ones are ignored.
|
| 51 |
+
The intended client is `ModelFromCheckpoint`.
|
| 52 |
+
Anybody using this function should be warned that the behavior is designed for loading models from checkpoints and packages correctly.
|
| 53 |
+
"""
|
| 54 |
+
assert compile_mode in _COMPILE_MODE_OPTIONS
|
| 55 |
+
global _OVERRIDE_COMPILE_MODE
|
| 56 |
+
global _CURRENT_COMPILE_MODE
|
| 57 |
+
init_state = _OVERRIDE_COMPILE_MODE.get()
|
| 58 |
+
# in the case of nested overrides, we prioritize the outermost context manager
|
| 59 |
+
if init_state:
|
| 60 |
+
yield
|
| 61 |
+
else:
|
| 62 |
+
init_mode = _CURRENT_COMPILE_MODE.get()
|
| 63 |
+
_OVERRIDE_COMPILE_MODE.set(True)
|
| 64 |
+
_CURRENT_COMPILE_MODE.set(compile_mode)
|
| 65 |
+
try:
|
| 66 |
+
yield
|
| 67 |
+
finally:
|
| 68 |
+
_OVERRIDE_COMPILE_MODE.set(init_state)
|
| 69 |
+
_CURRENT_COMPILE_MODE.set(init_mode)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@contextlib.contextmanager
|
| 73 |
+
def fresh_model_builder_context():
|
| 74 |
+
"""Temporarily treat nested model-builder calls as fresh top-level builds.
|
| 75 |
+
|
| 76 |
+
This is an explicit escape hatch for composing models where an inner builder
|
| 77 |
+
should run with full `@model_builder` behavior (dtype/seed/wrapping),
|
| 78 |
+
instead of being returned as a raw nested module.
|
| 79 |
+
|
| 80 |
+
Required builder args (`seed`, `model_dtype`, `type_names`) are inherited
|
| 81 |
+
from the active outer model-builder context when not explicitly provided.
|
| 82 |
+
"""
|
| 83 |
+
# TODO: decide compile_mode semantics for fresh nested builds:
|
| 84 |
+
# should they inherit outer builder compile_mode or use current default/override?
|
| 85 |
+
global _IS_BUILDING_MODEL
|
| 86 |
+
init_state = _IS_BUILDING_MODEL.get()
|
| 87 |
+
_IS_BUILDING_MODEL.set(False)
|
| 88 |
+
try:
|
| 89 |
+
yield
|
| 90 |
+
finally:
|
| 91 |
+
_IS_BUILDING_MODEL.set(init_state)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def get_current_compile_mode(return_override: bool = False):
|
| 95 |
+
# returns tuple of (whether compile mode is overriden, compile mode)
|
| 96 |
+
global _CURRENT_COMPILE_MODE
|
| 97 |
+
if return_override:
|
| 98 |
+
global _OVERRIDE_COMPILE_MODE
|
| 99 |
+
return _CURRENT_COMPILE_MODE.get(), _OVERRIDE_COMPILE_MODE.get()
|
| 100 |
+
else:
|
| 101 |
+
return _CURRENT_COMPILE_MODE.get()
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def model_builder(func=None, *, wrapper_class=None, compile_wrapper_class=None):
|
| 105 |
+
"""Decorator for model builder functions in the ``nequip`` ecosystem.
|
| 106 |
+
|
| 107 |
+
Handles model building with proper seeding, floating point precision (``float32`` or ``float64``), and wraps the result with ``GraphModel``. Requires ``seed``, ``model_dtype``, and ``type_names`` arguments.
|
| 108 |
+
Supports ``eager`` and ``compile`` modes via ``compile_mode``.
|
| 109 |
+
|
| 110 |
+
The ``seed``, ``model_dtype``, and ``compile_mode`` arguments are consumed by the decorator and not passed to the decorated function.
|
| 111 |
+
|
| 112 |
+
Can be used in two ways:
|
| 113 |
+
- @model_builder (uses GraphModel wrapper, backward compatible)
|
| 114 |
+
- @model_builder(wrapper_class=CustomGraphModel) (uses custom wrapper)
|
| 115 |
+
|
| 116 |
+
Args:
|
| 117 |
+
func: The function to decorate (when used without parentheses)
|
| 118 |
+
wrapper_class: Custom GraphModel subclass to use for wrapping (default: GraphModel)
|
| 119 |
+
compile_wrapper_class: Custom wrapper for compile mode (default: CompileGraphModel)
|
| 120 |
+
"""
|
| 121 |
+
|
| 122 |
+
# default wrapper classes
|
| 123 |
+
if wrapper_class is None:
|
| 124 |
+
wrapper_class = GraphModel
|
| 125 |
+
if compile_wrapper_class is None:
|
| 126 |
+
compile_wrapper_class = CompileGraphModel
|
| 127 |
+
|
| 128 |
+
def decorator(f):
|
| 129 |
+
@functools.wraps(f)
|
| 130 |
+
def wrapper(*args, **kwargs):
|
| 131 |
+
# to handle nested model building
|
| 132 |
+
global _IS_BUILDING_MODEL
|
| 133 |
+
|
| 134 |
+
# to handle compile modes
|
| 135 |
+
global _OVERRIDE_COMPILE_MODE
|
| 136 |
+
global _CURRENT_COMPILE_MODE
|
| 137 |
+
|
| 138 |
+
# this means we're in an inner model, so we shouldn't apply the model builder operations, and just pass the function
|
| 139 |
+
if _IS_BUILDING_MODEL.get():
|
| 140 |
+
return f(*args, **kwargs)
|
| 141 |
+
|
| 142 |
+
# this means we're in the outer model, and have to apply the model builder operations
|
| 143 |
+
_IS_BUILDING_MODEL.set(True)
|
| 144 |
+
prev_builder_defaults = _CURRENT_MODEL_BUILDER_DEFAULTS.get()
|
| 145 |
+
try:
|
| 146 |
+
default_builder_kwargs = _CURRENT_MODEL_BUILDER_DEFAULTS.get()
|
| 147 |
+
if default_builder_kwargs is not None:
|
| 148 |
+
for key in ("seed", "model_dtype", "type_names"):
|
| 149 |
+
if key not in kwargs and key in default_builder_kwargs:
|
| 150 |
+
kwargs[key] = default_builder_kwargs[key]
|
| 151 |
+
|
| 152 |
+
model_cfg = kwargs.copy()
|
| 153 |
+
# === sanity checks ===
|
| 154 |
+
assert global_state_initialized(), (
|
| 155 |
+
"global state must be initialized before building models"
|
| 156 |
+
)
|
| 157 |
+
assert all(
|
| 158 |
+
key in kwargs for key in ["seed", "model_dtype", "type_names"]
|
| 159 |
+
), (
|
| 160 |
+
"`seed`, `model_dtype`, and `type_names` are mandatory model arguments."
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
if get_latest_global_state().get(TF32_KEY, False):
|
| 164 |
+
assert kwargs["model_dtype"] == "float32", (
|
| 165 |
+
"`allow_tf32=True` only works with `model_dtype=float32`"
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
# seed and model_dtype are removed from kwargs, so they will NOT get passed to inner models
|
| 169 |
+
seed = kwargs.pop("seed")
|
| 170 |
+
model_dtype = kwargs.pop("model_dtype")
|
| 171 |
+
dtype = dtype_from_name(model_dtype)
|
| 172 |
+
inherited_builder_defaults = {
|
| 173 |
+
"seed": seed,
|
| 174 |
+
"model_dtype": model_dtype,
|
| 175 |
+
"type_names": kwargs["type_names"],
|
| 176 |
+
}
|
| 177 |
+
_CURRENT_MODEL_BUILDER_DEFAULTS.set(inherited_builder_defaults)
|
| 178 |
+
|
| 179 |
+
# === compilation options ===
|
| 180 |
+
# `compile_mode` dictates the optimization path chosen
|
| 181 |
+
# users can set this with the `compile_mode` arg to the model builder
|
| 182 |
+
# devs can override it with `override_model_compile_mode`
|
| 183 |
+
|
| 184 |
+
# always pop because inner models won't need `compile_mode` arg
|
| 185 |
+
compile_mode = kwargs.pop("compile_mode", _CURRENT_COMPILE_MODE.get())
|
| 186 |
+
# compile mode overriding logic
|
| 187 |
+
if _OVERRIDE_COMPILE_MODE.get():
|
| 188 |
+
compile_mode = _CURRENT_COMPILE_MODE.get()
|
| 189 |
+
assert compile_mode in _COMPILE_MODE_OPTIONS, (
|
| 190 |
+
f"`compile_mode` can only be any of {_COMPILE_MODE_OPTIONS}, but `{compile_mode}` found"
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
# use custom wrapper class or default
|
| 194 |
+
if compile_mode == _TRAIN_TIME_COMPILE_KEY:
|
| 195 |
+
# === torch version check ===
|
| 196 |
+
from onescience.utils.nequip.internal.versions import check_pt2_compile_compatibility
|
| 197 |
+
|
| 198 |
+
check_pt2_compile_compatibility()
|
| 199 |
+
graph_model_module = compile_wrapper_class
|
| 200 |
+
else:
|
| 201 |
+
graph_model_module = wrapper_class
|
| 202 |
+
|
| 203 |
+
# never script
|
| 204 |
+
with conditional_torchscript_mode(False):
|
| 205 |
+
# set dtype and seed
|
| 206 |
+
with torch_default_dtype(dtype):
|
| 207 |
+
with isolate_rng():
|
| 208 |
+
torch.manual_seed(seed)
|
| 209 |
+
model = f(*args, **kwargs)
|
| 210 |
+
# wrap with GraphModel
|
| 211 |
+
graph_model = graph_model_module(
|
| 212 |
+
model=model,
|
| 213 |
+
model_config=model_cfg,
|
| 214 |
+
model_input_fields=model.irreps_in,
|
| 215 |
+
)
|
| 216 |
+
return graph_model
|
| 217 |
+
finally:
|
| 218 |
+
_CURRENT_MODEL_BUILDER_DEFAULTS.set(prev_builder_defaults)
|
| 219 |
+
# reset to default in case of failure
|
| 220 |
+
_IS_BUILDING_MODEL.set(False)
|
| 221 |
+
|
| 222 |
+
return wrapper
|
| 223 |
+
|
| 224 |
+
# handle both @model_builder and @model_builder(...)
|
| 225 |
+
if func is None:
|
| 226 |
+
# called with arguments: @model_builder(wrapper_class=X)
|
| 227 |
+
return decorator
|
| 228 |
+
else:
|
| 229 |
+
# called without arguments: @model_builder
|
| 230 |
+
return decorator(func)
|
model/nn/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is a part of the `nequip` package. Please see LICENSE and README at the root for information on using it.
|
| 2 |
+
from ._graph_mixin import GraphModuleMixin, SequentialGraphNetwork
|
| 3 |
+
from .graph_model import GraphModel
|
| 4 |
+
from .atomwise import (
|
| 5 |
+
AtomwiseOperation,
|
| 6 |
+
AtomwiseReduce,
|
| 7 |
+
AtomwiseLinear,
|
| 8 |
+
PerTypeScaleShift,
|
| 9 |
+
)
|
| 10 |
+
from .nonlinearities import ShiftedSoftplus
|
| 11 |
+
from .mlp import ScalarMLP, ScalarMLPFunction
|
| 12 |
+
from .interaction_block import InteractionBlock
|
| 13 |
+
from .convnetlayer import ConvNetLayer
|
| 14 |
+
from .grad_output import PartialForceOutput, ForceStressOutput
|
| 15 |
+
from .misc import Concat, ApplyFactor, SaveForOutput
|
| 16 |
+
from .utils import scatter, tp_path_exists, with_edge_vectors_, with_edge_type_
|
| 17 |
+
from .model_modifier_utils import model_modifier, replace_submodules
|
| 18 |
+
from .norm import AvgNumNeighborsNorm
|
| 19 |
+
|
| 20 |
+
__all__ = [
|
| 21 |
+
"GraphModel",
|
| 22 |
+
"GraphModuleMixin",
|
| 23 |
+
"SequentialGraphNetwork",
|
| 24 |
+
"AtomwiseOperation",
|
| 25 |
+
"AtomwiseReduce",
|
| 26 |
+
"AtomwiseLinear",
|
| 27 |
+
"PerTypeScaleShift",
|
| 28 |
+
"ShiftedSoftplus",
|
| 29 |
+
"ScalarMLP",
|
| 30 |
+
"ScalarMLPFunction",
|
| 31 |
+
"InteractionBlock",
|
| 32 |
+
"PartialForceOutput",
|
| 33 |
+
"ForceStressOutput",
|
| 34 |
+
"ConvNetLayer",
|
| 35 |
+
"Concat",
|
| 36 |
+
"ApplyFactor",
|
| 37 |
+
"SaveForOutput",
|
| 38 |
+
"scatter",
|
| 39 |
+
"tp_path_exists",
|
| 40 |
+
"with_edge_vectors_",
|
| 41 |
+
"with_edge_type_",
|
| 42 |
+
"model_modifier",
|
| 43 |
+
"replace_submodules",
|
| 44 |
+
"AvgNumNeighborsNorm",
|
| 45 |
+
]
|
model/nn/_ghost_exchange_base.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
|
| 3 |
+
from onescience.datapipes.materials.nequip import AtomicDataDict
|
| 4 |
+
from ._graph_mixin import GraphModuleMixin
|
| 5 |
+
from .model_modifier_utils import replace_submodules, model_modifier
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class GhostExchangeModule(GraphModuleMixin, torch.nn.Module):
|
| 9 |
+
"""Base class for ghost atom exchange modules."""
|
| 10 |
+
|
| 11 |
+
def __init__(
|
| 12 |
+
self,
|
| 13 |
+
field: str = AtomicDataDict.NODE_FEATURES_KEY,
|
| 14 |
+
irreps_in={},
|
| 15 |
+
):
|
| 16 |
+
super().__init__()
|
| 17 |
+
self.field = field
|
| 18 |
+
|
| 19 |
+
self._init_irreps(
|
| 20 |
+
irreps_in=irreps_in,
|
| 21 |
+
my_irreps_in={field: irreps_in[field]},
|
| 22 |
+
irreps_out={field: irreps_in[field]},
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
def forward(
|
| 26 |
+
self,
|
| 27 |
+
data: AtomicDataDict.Type,
|
| 28 |
+
ghost_included: bool,
|
| 29 |
+
) -> AtomicDataDict.Type:
|
| 30 |
+
raise NotImplementedError("Subclasses must implement forward method")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class NoOpGhostExchangeModule(GhostExchangeModule):
|
| 34 |
+
"""Base ghost exchange module that performs a no-op."""
|
| 35 |
+
|
| 36 |
+
def forward(
|
| 37 |
+
self,
|
| 38 |
+
data: AtomicDataDict.Type,
|
| 39 |
+
ghost_included: bool,
|
| 40 |
+
) -> AtomicDataDict.Type:
|
| 41 |
+
return data
|
| 42 |
+
|
| 43 |
+
@model_modifier(persistent=True, private=True)
|
| 44 |
+
@classmethod
|
| 45 |
+
def enable_LAMMPSMLIAPGhostExchange(cls, model):
|
| 46 |
+
"""Enable LAMMPS ML-IAP ghost exchange for inference in LAMMPS ML-IAP."""
|
| 47 |
+
|
| 48 |
+
from ._ghost_exchange_lmp_mliap import LAMMPSMLIAPGhostExchangeModule
|
| 49 |
+
|
| 50 |
+
def factory(old):
|
| 51 |
+
new = LAMMPSMLIAPGhostExchangeModule(
|
| 52 |
+
field=old.field,
|
| 53 |
+
irreps_in=old.irreps_in,
|
| 54 |
+
)
|
| 55 |
+
return new
|
| 56 |
+
|
| 57 |
+
return replace_submodules(model, cls, factory)
|
model/nn/_ghost_exchange_lmp_mliap.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
|
| 3 |
+
from onescience.datapipes.materials.nequip import AtomicDataDict
|
| 4 |
+
from ._ghost_exchange_base import GhostExchangeModule
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
# NOTE: can't use custom ops https://docs.pytorch.org/tutorials/advanced/python_custom_ops.html#python-custom-ops-tutorial
|
| 8 |
+
# because of complications with `lmp_data` type and PyTorch custom ops registration system
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class LAMMPSMLIAPGhostExchangeOp(torch.autograd.Function):
|
| 12 |
+
@staticmethod
|
| 13 |
+
def forward(ctx, *args):
|
| 14 |
+
node_features, lmp_data = args
|
| 15 |
+
original_shape = node_features.shape
|
| 16 |
+
node_features_flat = node_features.view(node_features.size(0), -1)
|
| 17 |
+
out_flat = torch.empty_like(node_features_flat)
|
| 18 |
+
lmp_data.forward_exchange(node_features_flat, out_flat, out_flat.size(-1))
|
| 19 |
+
|
| 20 |
+
# save for backward
|
| 21 |
+
ctx.original_shape = original_shape
|
| 22 |
+
ctx.lmp_data = lmp_data
|
| 23 |
+
|
| 24 |
+
return out_flat.view(original_shape)
|
| 25 |
+
|
| 26 |
+
@staticmethod
|
| 27 |
+
def backward(ctx, grad_output):
|
| 28 |
+
grad_output_flat = grad_output.view(grad_output.size(0), -1)
|
| 29 |
+
gout_flat = torch.empty_like(grad_output_flat)
|
| 30 |
+
ctx.lmp_data.reverse_exchange(grad_output_flat, gout_flat, gout_flat.size(-1))
|
| 31 |
+
return gout_flat.view(ctx.original_shape), None
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class LAMMPSMLIAPGhostExchangeModule(GhostExchangeModule):
|
| 35 |
+
"""LAMMPS ML-IAP ghost atom exchange module."""
|
| 36 |
+
|
| 37 |
+
def forward(
|
| 38 |
+
self, data: AtomicDataDict.Type, ghost_included=False
|
| 39 |
+
) -> AtomicDataDict.Type:
|
| 40 |
+
assert AtomicDataDict.LMP_MLIAP_DATA_KEY in data, (
|
| 41 |
+
"`LAMMPSMLIAPGhostExchangeModule` shouldn't be used if LAMMPS ML-IAP data is not provided as input."
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
node_features = data[self.field]
|
| 45 |
+
lmp_data = data[AtomicDataDict.LMP_MLIAP_DATA_KEY]
|
| 46 |
+
|
| 47 |
+
if ghost_included:
|
| 48 |
+
local_node_features = torch.narrow(node_features, 0, 0, lmp_data.nlocal)
|
| 49 |
+
else:
|
| 50 |
+
local_node_features = node_features
|
| 51 |
+
num_ghost_atoms = lmp_data.ntotal - lmp_data.nlocal
|
| 52 |
+
ghost_zeros = torch.zeros(
|
| 53 |
+
(num_ghost_atoms,) + node_features.shape[1:],
|
| 54 |
+
dtype=node_features.dtype,
|
| 55 |
+
device=node_features.device,
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
prepared_node_features = torch.cat((local_node_features, ghost_zeros), dim=0)
|
| 59 |
+
|
| 60 |
+
# perform LAMMPS exchange
|
| 61 |
+
data[self.field] = LAMMPSMLIAPGhostExchangeOp.apply(
|
| 62 |
+
prepared_node_features, lmp_data
|
| 63 |
+
)
|
| 64 |
+
return data
|
model/nn/_graph_mixin.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is a part of the `nequip` package. Please see LICENSE and README at the root for information on using it.
|
| 2 |
+
from typing import Dict, Any, Sequence, Union, Optional, Final
|
| 3 |
+
from collections import OrderedDict
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
|
| 7 |
+
from e3nn.o3._irreps import Irreps
|
| 8 |
+
|
| 9 |
+
from onescience.datapipes.materials.nequip import AtomicDataDict
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class GraphModuleMixin:
|
| 13 |
+
r"""Mixin parent class for ``torch.nn.Module``s that act on and return ``AtomicDataDict.Type`` graph data.
|
| 14 |
+
|
| 15 |
+
All such classes should call ``_init_irreps`` in their ``__init__`` functions with information on the data fields they expect, require, and produce, as well as their corresponding irreps.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
_is_graph_module_mixin: Final[bool] = True
|
| 19 |
+
# ^ to identify `GraphModuleMixin` types from `torch.package`d models (see https://pytorch.org/docs/stable/package.html#torch-package-sharp-edges)
|
| 20 |
+
|
| 21 |
+
def _init_irreps(
|
| 22 |
+
self,
|
| 23 |
+
irreps_in: Optional[Dict[str, Any]] = None,
|
| 24 |
+
my_irreps_in: Optional[Dict[str, Any]] = None,
|
| 25 |
+
required_irreps_in: Optional[Sequence[str]] = None,
|
| 26 |
+
irreps_out: Optional[Dict[str, Any]] = None,
|
| 27 |
+
):
|
| 28 |
+
"""Setup the expected data fields and their irreps for this graph module.
|
| 29 |
+
|
| 30 |
+
``None`` is a valid irreps in the context for anything that is invariant but not well described by an ``e3nn.o3.Irreps``. An example are edge indexes in a graph, which are invariant but are integers, not ``0e`` scalars.
|
| 31 |
+
|
| 32 |
+
Args:
|
| 33 |
+
irreps_in (dict): maps names of all input fields from previous modules or
|
| 34 |
+
data to their corresponding irreps
|
| 35 |
+
my_irreps_in (dict): maps names of fields to the irreps they must have for
|
| 36 |
+
this graph module. Will be checked for consistancy with ``irreps_in``
|
| 37 |
+
required_irreps_in: sequence of names of fields that must be present in
|
| 38 |
+
``irreps_in``, but that can have any irreps.
|
| 39 |
+
irreps_out (dict): mapping names of fields that are modified/output by
|
| 40 |
+
this graph module to their irreps.
|
| 41 |
+
"""
|
| 42 |
+
# pattern to handle mutable defaults
|
| 43 |
+
irreps_in = {} if irreps_in is None else irreps_in
|
| 44 |
+
my_irreps_in = {} if my_irreps_in is None else my_irreps_in
|
| 45 |
+
required_irreps_in = () if required_irreps_in is None else required_irreps_in
|
| 46 |
+
irreps_out = {} if irreps_out is None else irreps_out
|
| 47 |
+
|
| 48 |
+
irreps_in = AtomicDataDict._fix_irreps_dict(irreps_in)
|
| 49 |
+
|
| 50 |
+
# positions are *always* 1o, and always present
|
| 51 |
+
if AtomicDataDict.POSITIONS_KEY in irreps_in:
|
| 52 |
+
if irreps_in[AtomicDataDict.POSITIONS_KEY] != Irreps("1x1o"):
|
| 53 |
+
raise ValueError(
|
| 54 |
+
f"Positions must have irreps 1o, got instead `{irreps_in[AtomicDataDict.POSITIONS_KEY]}`"
|
| 55 |
+
)
|
| 56 |
+
irreps_in[AtomicDataDict.POSITIONS_KEY] = Irreps("1o")
|
| 57 |
+
|
| 58 |
+
# edges are also always present
|
| 59 |
+
if AtomicDataDict.EDGE_INDEX_KEY in irreps_in:
|
| 60 |
+
if irreps_in[AtomicDataDict.EDGE_INDEX_KEY] is not None:
|
| 61 |
+
raise ValueError(
|
| 62 |
+
f"Edge indexes must have irreps None, got instead `{irreps_in[AtomicDataDict.EDGE_INDEX_KEY]}`"
|
| 63 |
+
)
|
| 64 |
+
irreps_in[AtomicDataDict.EDGE_INDEX_KEY] = None
|
| 65 |
+
|
| 66 |
+
# atom types are also always present
|
| 67 |
+
if AtomicDataDict.ATOM_TYPE_KEY in irreps_in:
|
| 68 |
+
if irreps_in[AtomicDataDict.ATOM_TYPE_KEY] is not None:
|
| 69 |
+
raise ValueError(
|
| 70 |
+
f"atom types must have irreps None, got instead `{irreps_in[AtomicDataDict.ATOM_TYPE_KEY]}`"
|
| 71 |
+
)
|
| 72 |
+
irreps_in[AtomicDataDict.ATOM_TYPE_KEY] = None
|
| 73 |
+
|
| 74 |
+
my_irreps_in = AtomicDataDict._fix_irreps_dict(my_irreps_in)
|
| 75 |
+
|
| 76 |
+
irreps_out = AtomicDataDict._fix_irreps_dict(irreps_out)
|
| 77 |
+
# Confirm compatibility:
|
| 78 |
+
# with my_irreps_in
|
| 79 |
+
for k in my_irreps_in:
|
| 80 |
+
if k in irreps_in and irreps_in[k] != my_irreps_in[k]:
|
| 81 |
+
raise ValueError(
|
| 82 |
+
f"The given input irreps {irreps_in[k]} for field '{k}' is incompatible with this configuration {type(self)}; should have been {my_irreps_in[k]}"
|
| 83 |
+
)
|
| 84 |
+
# with required_irreps_in
|
| 85 |
+
for k in required_irreps_in:
|
| 86 |
+
if k not in irreps_in:
|
| 87 |
+
raise ValueError(
|
| 88 |
+
f"This {type(self)} requires field '{k}' to be in irreps_in"
|
| 89 |
+
)
|
| 90 |
+
# Save stuff
|
| 91 |
+
self.irreps_in = irreps_in
|
| 92 |
+
# The output irreps of any graph module are whatever inputs it has, overwritten with whatever outputs it has.
|
| 93 |
+
new_out = irreps_in.copy()
|
| 94 |
+
new_out.update(irreps_out)
|
| 95 |
+
self.irreps_out = new_out
|
| 96 |
+
|
| 97 |
+
def _add_independent_irreps(self, irreps: Dict[str, Any]):
|
| 98 |
+
"""
|
| 99 |
+
Insert some independent irreps that need to be exposed to the self.irreps_in and self.irreps_out.
|
| 100 |
+
The terms that have already appeared in the irreps_in will be removed.
|
| 101 |
+
|
| 102 |
+
Args:
|
| 103 |
+
irreps (dict): maps names of all new fields
|
| 104 |
+
"""
|
| 105 |
+
|
| 106 |
+
irreps = {
|
| 107 |
+
key: irrep for key, irrep in irreps.items() if key not in self.irreps_in
|
| 108 |
+
}
|
| 109 |
+
irreps_in = AtomicDataDict._fix_irreps_dict(irreps)
|
| 110 |
+
irreps_out = AtomicDataDict._fix_irreps_dict(
|
| 111 |
+
{key: irrep for key, irrep in irreps.items() if key not in self.irreps_out}
|
| 112 |
+
)
|
| 113 |
+
self.irreps_in.update(irreps_in)
|
| 114 |
+
self.irreps_out.update(irreps_out)
|
| 115 |
+
|
| 116 |
+
@torch.jit.unused
|
| 117 |
+
def _get_metadata_contributions(self) -> Dict[str, str]:
|
| 118 |
+
"""Override to provide dynamic metadata at compilation time.
|
| 119 |
+
|
| 120 |
+
Modules can override this to contribute metadata based on their current state (e.g., learned parameters).
|
| 121 |
+
Called by GraphModel during compilation.
|
| 122 |
+
All values must be strings.
|
| 123 |
+
|
| 124 |
+
Returns:
|
| 125 |
+
Dict[str, str]: Metadata key-value pairs. Can override static config values (e.g., per_edge_type_cutoff) or add new keys.
|
| 126 |
+
"""
|
| 127 |
+
return {}
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
class SequentialGraphNetwork(GraphModuleMixin, torch.nn.Sequential):
|
| 131 |
+
r"""A ``torch.nn.Sequential`` of ``GraphModuleMixin``s.
|
| 132 |
+
|
| 133 |
+
Args:
|
| 134 |
+
modules (list or dict of ``GraphModuleMixin``s): the sequence of graph modules. If a list, the modules will be named ``"module0", "module1", ...``.
|
| 135 |
+
"""
|
| 136 |
+
|
| 137 |
+
def __init__(
|
| 138 |
+
self,
|
| 139 |
+
modules: Union[Sequence[GraphModuleMixin], Dict[str, GraphModuleMixin]],
|
| 140 |
+
):
|
| 141 |
+
if isinstance(modules, dict):
|
| 142 |
+
module_list = list(modules.values())
|
| 143 |
+
else:
|
| 144 |
+
module_list = list(modules)
|
| 145 |
+
# check in/out irreps compatible
|
| 146 |
+
for m1, m2 in zip(module_list, module_list[1:]):
|
| 147 |
+
assert AtomicDataDict._irreps_compatible(m1.irreps_out, m2.irreps_in), (
|
| 148 |
+
f"Incompatible irreps_out from {type(m1).__name__} for input to {type(m2).__name__}: {m1.irreps_out} -> {m2.irreps_in}"
|
| 149 |
+
)
|
| 150 |
+
self._init_irreps(
|
| 151 |
+
irreps_in=module_list[0].irreps_in,
|
| 152 |
+
my_irreps_in=module_list[0].irreps_in,
|
| 153 |
+
irreps_out=module_list[-1].irreps_out,
|
| 154 |
+
)
|
| 155 |
+
# torch.nn.Sequential will name children correctly if passed an OrderedDict
|
| 156 |
+
if isinstance(modules, dict):
|
| 157 |
+
modules = OrderedDict(modules)
|
| 158 |
+
else:
|
| 159 |
+
modules = OrderedDict((f"module{i}", m) for i, m in enumerate(module_list))
|
| 160 |
+
super().__init__(modules)
|
| 161 |
+
|
| 162 |
+
@torch.jit.unused
|
| 163 |
+
def append(self, name: str, module: GraphModuleMixin) -> None:
|
| 164 |
+
r"""Append a module to the SequentialGraphNetwork.
|
| 165 |
+
|
| 166 |
+
Args:
|
| 167 |
+
name (str): the name for the module
|
| 168 |
+
module (GraphModuleMixin): the module to append
|
| 169 |
+
"""
|
| 170 |
+
assert AtomicDataDict._irreps_compatible(self.irreps_out, module.irreps_in)
|
| 171 |
+
self.add_module(name, module)
|
| 172 |
+
self.irreps_out = dict(module.irreps_out)
|
| 173 |
+
return
|
| 174 |
+
|
| 175 |
+
@torch.jit.unused
|
| 176 |
+
def insert(
|
| 177 |
+
self,
|
| 178 |
+
name: str,
|
| 179 |
+
module: GraphModuleMixin,
|
| 180 |
+
after: Optional[str] = None,
|
| 181 |
+
before: Optional[str] = None,
|
| 182 |
+
) -> None:
|
| 183 |
+
"""Insert a module after the module with name ``after``.
|
| 184 |
+
|
| 185 |
+
Args:
|
| 186 |
+
name: the name of the module to insert
|
| 187 |
+
module: the moldule to insert
|
| 188 |
+
after: the module to insert after
|
| 189 |
+
before: the module to insert before
|
| 190 |
+
"""
|
| 191 |
+
|
| 192 |
+
if (before is None) is (after is None):
|
| 193 |
+
raise ValueError("Only one of before or after argument needs to be defined")
|
| 194 |
+
elif before is None:
|
| 195 |
+
insert_location = after
|
| 196 |
+
else:
|
| 197 |
+
insert_location = before
|
| 198 |
+
|
| 199 |
+
# This checks names, etc.
|
| 200 |
+
self.add_module(name, module)
|
| 201 |
+
# Now insert in the right place by overwriting
|
| 202 |
+
names = list(self._modules.keys())
|
| 203 |
+
modules = list(self._modules.values())
|
| 204 |
+
idx = names.index(insert_location)
|
| 205 |
+
if before is None:
|
| 206 |
+
idx += 1
|
| 207 |
+
names.insert(idx, name)
|
| 208 |
+
modules.insert(idx, module)
|
| 209 |
+
|
| 210 |
+
self._modules = OrderedDict(zip(names, modules))
|
| 211 |
+
|
| 212 |
+
module_list = list(self._modules.values())
|
| 213 |
+
|
| 214 |
+
# sanity check the compatibility
|
| 215 |
+
if idx > 0:
|
| 216 |
+
assert AtomicDataDict._irreps_compatible(
|
| 217 |
+
module_list[idx - 1].irreps_out, module.irreps_in
|
| 218 |
+
)
|
| 219 |
+
if len(module_list) > idx:
|
| 220 |
+
assert AtomicDataDict._irreps_compatible(
|
| 221 |
+
module_list[idx + 1].irreps_in, module.irreps_out
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
# insert the new irreps_out to the later modules
|
| 225 |
+
for module_id, next_module in enumerate(module_list[idx + 1 :]):
|
| 226 |
+
next_module._add_independent_irreps(module.irreps_out)
|
| 227 |
+
|
| 228 |
+
# update the final wrapper irreps_out
|
| 229 |
+
self.irreps_out = dict(module_list[-1].irreps_out)
|
| 230 |
+
|
| 231 |
+
return
|
| 232 |
+
|
| 233 |
+
# Copied from https://pytorch.org/docs/stable/_modules/torch/nn/modules/container.html#Sequential
|
| 234 |
+
# with type annotations added
|
| 235 |
+
def forward(self, input: AtomicDataDict.Type) -> AtomicDataDict.Type:
|
| 236 |
+
for module in self:
|
| 237 |
+
input = module(input)
|
| 238 |
+
return input
|
model/nn/_tp_scatter_base.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is a part of the `nequip` package. Please see LICENSE and README at the root for information on using it.
|
| 2 |
+
|
| 3 |
+
import torch
|
| 4 |
+
from e3nn.o3._tensor_product._tensor_product import TensorProduct
|
| 5 |
+
from .utils import scatter
|
| 6 |
+
from .model_modifier_utils import replace_submodules, model_modifier
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class TensorProductScatter(torch.nn.Module):
|
| 10 |
+
def __init__(
|
| 11 |
+
self,
|
| 12 |
+
feature_irreps_in,
|
| 13 |
+
irreps_edge_attr,
|
| 14 |
+
irreps_mid,
|
| 15 |
+
instructions,
|
| 16 |
+
) -> None:
|
| 17 |
+
super().__init__()
|
| 18 |
+
|
| 19 |
+
self.feature_irreps_in = feature_irreps_in
|
| 20 |
+
self.irreps_edge_attr = irreps_edge_attr
|
| 21 |
+
self.irreps_mid = irreps_mid
|
| 22 |
+
self.instructions = instructions
|
| 23 |
+
|
| 24 |
+
self.tp = TensorProduct(
|
| 25 |
+
feature_irreps_in,
|
| 26 |
+
irreps_edge_attr,
|
| 27 |
+
irreps_mid,
|
| 28 |
+
instructions,
|
| 29 |
+
shared_weights=False,
|
| 30 |
+
internal_weights=False,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
self.model_dtype = torch.get_default_dtype()
|
| 34 |
+
|
| 35 |
+
def forward(self, x, edge_attr, edge_weight, edge_dst, edge_src):
|
| 36 |
+
edge_features = self.tp(x[edge_src], edge_attr, edge_weight)
|
| 37 |
+
x = scatter(edge_features, edge_dst, dim=0, dim_size=x.size(0))
|
| 38 |
+
return x
|
| 39 |
+
|
| 40 |
+
@model_modifier(
|
| 41 |
+
persistent=False,
|
| 42 |
+
private=False,
|
| 43 |
+
unsupported_devices=["cpu"],
|
| 44 |
+
supported_compile_modes=["torchscript", "aotinductor"],
|
| 45 |
+
)
|
| 46 |
+
@classmethod
|
| 47 |
+
def enable_OpenEquivariance(cls, model):
|
| 48 |
+
"""
|
| 49 |
+
Enable OpenEquivariance tensor product kernel for accelerated NequIP training and inference.
|
| 50 |
+
For usage instructions, see https://nequip.readthedocs.io/en/latest/guide/accelerations/openequivariance.html
|
| 51 |
+
"""
|
| 52 |
+
|
| 53 |
+
from ._tp_scatter_oeq import OpenEquivarianceTensorProductScatter
|
| 54 |
+
from onescience.utils.nequip.internal.dtype import torch_default_dtype
|
| 55 |
+
from onescience.utils.nequip.internal.versions.torch_versions import _TORCH_GE_2_7
|
| 56 |
+
|
| 57 |
+
if not _TORCH_GE_2_7:
|
| 58 |
+
raise RuntimeError("OpenEquivariance requires PyTorch >= 2.7.")
|
| 59 |
+
|
| 60 |
+
_TRAIN_TIME_COMPILE: bool = model.is_compile_graph_model
|
| 61 |
+
|
| 62 |
+
def factory(old):
|
| 63 |
+
with torch_default_dtype(old.model_dtype):
|
| 64 |
+
new = OpenEquivarianceTensorProductScatter(
|
| 65 |
+
feature_irreps_in=old.feature_irreps_in,
|
| 66 |
+
irreps_edge_attr=old.irreps_edge_attr,
|
| 67 |
+
irreps_mid=old.irreps_mid,
|
| 68 |
+
instructions=old.instructions,
|
| 69 |
+
use_opaque=_TRAIN_TIME_COMPILE,
|
| 70 |
+
)
|
| 71 |
+
# c.f. https://github.com/mir-group/nequip/issues/572
|
| 72 |
+
# reuse old.tp to preserve e3nn compiled buffers (_tensor_constant*)
|
| 73 |
+
# this ensures state dict compatibility whether the modifier is applied or notwa
|
| 74 |
+
new.tp = old.tp
|
| 75 |
+
return new
|
| 76 |
+
|
| 77 |
+
return replace_submodules(model, cls, factory)
|
| 78 |
+
|
| 79 |
+
@model_modifier(
|
| 80 |
+
persistent=False,
|
| 81 |
+
private=False,
|
| 82 |
+
unsupported_devices=["cpu"],
|
| 83 |
+
supported_compile_modes=["torchscript", "aotinductor"],
|
| 84 |
+
)
|
| 85 |
+
@classmethod
|
| 86 |
+
def enable_CuEquivariance(cls, model):
|
| 87 |
+
"""
|
| 88 |
+
[ALPHA SUPPORT] Enable CuEquivariance tensor product kernel for accelerated NequIP inference.
|
| 89 |
+
For usage instructions, see https://nequip.readthedocs.io/en/latest/guide/accelerations/cuequivariance.html
|
| 90 |
+
"""
|
| 91 |
+
|
| 92 |
+
from ._tp_scatter_cueq import CuEquivarianceTensorProductScatter
|
| 93 |
+
from onescience.utils.nequip.internal.dtype import torch_default_dtype
|
| 94 |
+
|
| 95 |
+
def factory(old):
|
| 96 |
+
with torch_default_dtype(old.model_dtype):
|
| 97 |
+
new = CuEquivarianceTensorProductScatter(
|
| 98 |
+
feature_irreps_in=old.feature_irreps_in,
|
| 99 |
+
irreps_edge_attr=old.irreps_edge_attr,
|
| 100 |
+
irreps_mid=old.irreps_mid,
|
| 101 |
+
instructions=old.instructions,
|
| 102 |
+
)
|
| 103 |
+
# c.f. https://github.com/mir-group/nequip/issues/572
|
| 104 |
+
# reuse old.tp to preserve e3nn compiled buffers (_tensor_constant*)
|
| 105 |
+
# this ensures state dict compatibility whether the modifier is applied or not
|
| 106 |
+
new.tp = old.tp
|
| 107 |
+
return new
|
| 108 |
+
|
| 109 |
+
return replace_submodules(model, cls, factory)
|
model/nn/_tp_scatter_cueq.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is a part of the `nequip` package. Please see LICENSE and README at the root for information on using it.
|
| 2 |
+
|
| 3 |
+
from ._tp_scatter_base import TensorProductScatter
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def nequip_tp_desc(
|
| 7 |
+
irreps1,
|
| 8 |
+
irreps2,
|
| 9 |
+
irreps3,
|
| 10 |
+
):
|
| 11 |
+
"""Construct the NequIP version of channelwise tensor product descriptor.
|
| 12 |
+
|
| 13 |
+
subscripts: ``weights[uv],lhs[iu],rhs[jv],output[ku]``
|
| 14 |
+
|
| 15 |
+
Args:
|
| 16 |
+
irreps1 (Irreps): Irreps of the first operand.
|
| 17 |
+
irreps2 (Irreps): Irreps of the second operand.
|
| 18 |
+
irreps3 (Irreps): Irreps of the output to consider.
|
| 19 |
+
"""
|
| 20 |
+
import cuequivariance as cue
|
| 21 |
+
from cuequivariance.group_theory.irreps_array.irrep_utils import into_list_of_irrep
|
| 22 |
+
import itertools
|
| 23 |
+
|
| 24 |
+
# modified from `channelwise_tensor_product`
|
| 25 |
+
# https://github.com/NVIDIA/cuEquivariance/blob/7236768147394a7da6abd7d5209d274704057eed/cuequivariance/cuequivariance/group_theory/descriptors/irreps_tp.py#L149
|
| 26 |
+
|
| 27 |
+
G = irreps1.irrep_class
|
| 28 |
+
irreps3_filter = into_list_of_irrep(G, irreps3)
|
| 29 |
+
|
| 30 |
+
d = cue.SegmentedTensorProduct.from_subscripts("uv,iu,jv,kuv+ijk")
|
| 31 |
+
|
| 32 |
+
for mul, ir in irreps1:
|
| 33 |
+
d.add_segment(1, (ir.dim, mul))
|
| 34 |
+
for mul, ir in irreps2:
|
| 35 |
+
d.add_segment(2, (ir.dim, mul))
|
| 36 |
+
|
| 37 |
+
irreps3 = []
|
| 38 |
+
for (i1, (mul1, ir1)), (i2, (mul2, ir2)) in itertools.product(
|
| 39 |
+
enumerate(irreps1), enumerate(irreps2)
|
| 40 |
+
):
|
| 41 |
+
for ir3 in ir1 * ir2:
|
| 42 |
+
if ir3 not in irreps3_filter:
|
| 43 |
+
continue
|
| 44 |
+
|
| 45 |
+
for cg in cue.clebsch_gordan(ir1, ir2, ir3):
|
| 46 |
+
d.add_path(None, i1, i2, None, c=cg, dims={"u": mul1, "v": mul2})
|
| 47 |
+
|
| 48 |
+
irreps3.append((mul1 * mul2, ir3))
|
| 49 |
+
|
| 50 |
+
irreps3 = cue.Irreps(G, irreps3)
|
| 51 |
+
irreps3, perm, inv = irreps3.sort()
|
| 52 |
+
d = d.permute_segments(3, inv)
|
| 53 |
+
d = d.normalize_paths_for_operand(-1)
|
| 54 |
+
|
| 55 |
+
return cue.EquivariantPolynomial(
|
| 56 |
+
[
|
| 57 |
+
cue.IrrepsAndLayout(irreps1.new_scalars(d.operands[0].size), cue.ir_mul),
|
| 58 |
+
cue.IrrepsAndLayout(irreps1, cue.ir_mul),
|
| 59 |
+
cue.IrrepsAndLayout(irreps2, cue.ir_mul),
|
| 60 |
+
],
|
| 61 |
+
[cue.IrrepsAndLayout(irreps3, cue.ir_mul)],
|
| 62 |
+
cue.SegmentedPolynomial.eval_last_operand(d),
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class CuEquivarianceTensorProductScatter(TensorProductScatter):
|
| 67 |
+
_nequip_custom_ops_libs = ("cuequivariance_torch",)
|
| 68 |
+
|
| 69 |
+
def __init__(
|
| 70 |
+
self,
|
| 71 |
+
feature_irreps_in,
|
| 72 |
+
irreps_edge_attr,
|
| 73 |
+
irreps_mid,
|
| 74 |
+
instructions,
|
| 75 |
+
) -> None:
|
| 76 |
+
super().__init__(
|
| 77 |
+
feature_irreps_in=feature_irreps_in,
|
| 78 |
+
irreps_edge_attr=irreps_edge_attr,
|
| 79 |
+
irreps_mid=irreps_mid,
|
| 80 |
+
instructions=instructions,
|
| 81 |
+
)
|
| 82 |
+
# ^ we ensure that the base class keeps around a `self.tp` that carries its own set of persistent buffers
|
| 83 |
+
# even though `self.tp` is not used, having its (persistent) buffers always around ensures state dict compatibility when adding on or removing this subclass module
|
| 84 |
+
|
| 85 |
+
# === CuEq ===
|
| 86 |
+
|
| 87 |
+
# we do lazy imports of cuequivariance to allow `nequip-package` to pick this file up even if cuequivariance is not installed
|
| 88 |
+
# since `nequip-package` ignores files if it errors on loading the file
|
| 89 |
+
|
| 90 |
+
import cuequivariance as cue
|
| 91 |
+
import cuequivariance_torch as cuet
|
| 92 |
+
from cuequivariance.group_theory.experimental.e3nn import O3_e3nn
|
| 93 |
+
|
| 94 |
+
self.tp_conv = cuet.SegmentedPolynomial(
|
| 95 |
+
nequip_tp_desc(
|
| 96 |
+
cue.Irreps(O3_e3nn, feature_irreps_in),
|
| 97 |
+
cue.Irreps(O3_e3nn, irreps_edge_attr),
|
| 98 |
+
cue.Irreps(O3_e3nn, irreps_mid),
|
| 99 |
+
)
|
| 100 |
+
.flatten_coefficient_modes()
|
| 101 |
+
.squeeze_modes()
|
| 102 |
+
.polynomial,
|
| 103 |
+
method="fused_tp",
|
| 104 |
+
math_dtype=self.model_dtype,
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
self.transpose_feat = cuet.TransposeIrrepsLayout(
|
| 108 |
+
feature_irreps_in, source=cue.mul_ir, target=cue.ir_mul
|
| 109 |
+
)
|
| 110 |
+
self.transpose_out = cuet.TransposeIrrepsLayout(
|
| 111 |
+
irreps_mid, source=cue.ir_mul, target=cue.mul_ir
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
def forward(self, x, edge_attr, edge_weight, edge_dst, edge_src):
|
| 115 |
+
return self.transpose_out(
|
| 116 |
+
self.tp_conv(
|
| 117 |
+
[edge_weight, self.transpose_feat(x), edge_attr],
|
| 118 |
+
{1: edge_src},
|
| 119 |
+
{0: x},
|
| 120 |
+
{0: edge_dst},
|
| 121 |
+
)[0]
|
| 122 |
+
)
|
model/nn/_tp_scatter_oeq.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from ._tp_scatter_base import TensorProductScatter
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class OpenEquivarianceTensorProductScatter(TensorProductScatter):
|
| 5 |
+
_nequip_custom_ops_libs = ("openequivariance",)
|
| 6 |
+
|
| 7 |
+
def __init__(
|
| 8 |
+
self,
|
| 9 |
+
feature_irreps_in,
|
| 10 |
+
irreps_edge_attr,
|
| 11 |
+
irreps_mid,
|
| 12 |
+
instructions,
|
| 13 |
+
use_opaque: bool,
|
| 14 |
+
) -> None:
|
| 15 |
+
super().__init__(
|
| 16 |
+
feature_irreps_in=feature_irreps_in,
|
| 17 |
+
irreps_edge_attr=irreps_edge_attr,
|
| 18 |
+
irreps_mid=irreps_mid,
|
| 19 |
+
instructions=instructions,
|
| 20 |
+
)
|
| 21 |
+
# ^ we ensure that the base class keeps around a `self.tp` that carries its own set of persistent buffers
|
| 22 |
+
# even though `self.tp` is not used, having its (persistent) buffers always around ensures state dict compatibility when adding on or removing this subclass module
|
| 23 |
+
|
| 24 |
+
# === OEQ ===
|
| 25 |
+
|
| 26 |
+
# we do lazy imports of oeq to allow `nequip-package` to pick this file up even if oeq is not installed
|
| 27 |
+
# since `nequip-package` ignores files if it errors on loading the file
|
| 28 |
+
|
| 29 |
+
from openequivariance import (
|
| 30 |
+
TensorProductConv,
|
| 31 |
+
TPProblem,
|
| 32 |
+
torch_to_oeq_dtype,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
tpp = TPProblem(
|
| 36 |
+
feature_irreps_in,
|
| 37 |
+
irreps_edge_attr,
|
| 38 |
+
irreps_mid,
|
| 39 |
+
instructions,
|
| 40 |
+
irrep_dtype=torch_to_oeq_dtype(self.model_dtype),
|
| 41 |
+
weight_dtype=torch_to_oeq_dtype(self.model_dtype),
|
| 42 |
+
shared_weights=False,
|
| 43 |
+
internal_weights=False,
|
| 44 |
+
)
|
| 45 |
+
self.tp_conv = TensorProductConv(
|
| 46 |
+
tpp, torch_op=True, deterministic=False, use_opaque=use_opaque
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
def forward(self, x, edge_attr, edge_weight, edge_dst, edge_src):
|
| 50 |
+
# explicit cast to account for AMP
|
| 51 |
+
return self.tp_conv(
|
| 52 |
+
x.to(self.model_dtype),
|
| 53 |
+
edge_attr.to(self.model_dtype),
|
| 54 |
+
edge_weight.to(self.model_dtype),
|
| 55 |
+
edge_dst,
|
| 56 |
+
edge_src,
|
| 57 |
+
)
|
model/nn/atomwise.py
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is a part of the `nequip` package. Please see LICENSE and README at the root for information on using it.
|
| 2 |
+
import torch
|
| 3 |
+
import torch.nn.functional
|
| 4 |
+
|
| 5 |
+
from e3nn.o3._linear import Linear
|
| 6 |
+
|
| 7 |
+
from onescience.datapipes.materials.nequip import AtomicDataDict
|
| 8 |
+
from onescience.datapipes.materials.nequip._key_registry import get_field_type
|
| 9 |
+
from ._graph_mixin import GraphModuleMixin
|
| 10 |
+
from .utils import scatter
|
| 11 |
+
from .model_modifier_utils import model_modifier, replace_submodules
|
| 12 |
+
from onescience.utils.nequip.internal.global_dtype import _GLOBAL_DTYPE
|
| 13 |
+
|
| 14 |
+
from typing import Optional, List, Dict, Union
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class AtomwiseOperation(GraphModuleMixin, torch.nn.Module):
|
| 18 |
+
def __init__(self, operation, field: str, irreps_in=None):
|
| 19 |
+
super().__init__()
|
| 20 |
+
self.operation = operation
|
| 21 |
+
self.field = field
|
| 22 |
+
self._init_irreps(
|
| 23 |
+
irreps_in=irreps_in,
|
| 24 |
+
my_irreps_in={field: operation.irreps_in},
|
| 25 |
+
irreps_out={field: operation.irreps_out},
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
def forward(self, data: AtomicDataDict.Type) -> AtomicDataDict.Type:
|
| 29 |
+
data[self.field] = self.operation(data[self.field])
|
| 30 |
+
return data
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class AtomwiseLinear(GraphModuleMixin, torch.nn.Module):
|
| 34 |
+
def __init__(
|
| 35 |
+
self,
|
| 36 |
+
field: str = AtomicDataDict.NODE_FEATURES_KEY,
|
| 37 |
+
out_field: Optional[str] = None,
|
| 38 |
+
irreps_in=None,
|
| 39 |
+
irreps_out=None,
|
| 40 |
+
):
|
| 41 |
+
super().__init__()
|
| 42 |
+
self.field = field
|
| 43 |
+
out_field = out_field if out_field is not None else field
|
| 44 |
+
self.out_field = out_field
|
| 45 |
+
if irreps_out is None:
|
| 46 |
+
irreps_out = irreps_in[field]
|
| 47 |
+
|
| 48 |
+
self._init_irreps(
|
| 49 |
+
irreps_in=irreps_in,
|
| 50 |
+
required_irreps_in=[field],
|
| 51 |
+
irreps_out={out_field: irreps_out},
|
| 52 |
+
)
|
| 53 |
+
self.linear = Linear(
|
| 54 |
+
irreps_in=self.irreps_in[field], irreps_out=self.irreps_out[out_field]
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
def forward(self, data: AtomicDataDict.Type) -> AtomicDataDict.Type:
|
| 58 |
+
data[self.out_field] = self.linear(data[self.field])
|
| 59 |
+
return data
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class AtomwiseReduce(GraphModuleMixin, torch.nn.Module):
|
| 63 |
+
constant: float
|
| 64 |
+
|
| 65 |
+
def __init__(
|
| 66 |
+
self,
|
| 67 |
+
field: str,
|
| 68 |
+
out_field: Optional[str] = None,
|
| 69 |
+
reduce="sum",
|
| 70 |
+
avg_num_atoms=None,
|
| 71 |
+
irreps_in={},
|
| 72 |
+
):
|
| 73 |
+
super().__init__()
|
| 74 |
+
assert reduce in ("sum", "mean", "normalized_sum")
|
| 75 |
+
self.constant = 1.0
|
| 76 |
+
if reduce == "normalized_sum":
|
| 77 |
+
assert avg_num_atoms is not None
|
| 78 |
+
self.constant = float(avg_num_atoms) ** -0.5
|
| 79 |
+
reduce = "sum"
|
| 80 |
+
self.reduce = reduce
|
| 81 |
+
self.field = field
|
| 82 |
+
self.out_field = f"{reduce}_{field}" if out_field is None else out_field
|
| 83 |
+
self._init_irreps(
|
| 84 |
+
irreps_in=irreps_in,
|
| 85 |
+
irreps_out=(
|
| 86 |
+
{self.out_field: irreps_in[self.field]}
|
| 87 |
+
if self.field in irreps_in
|
| 88 |
+
else {}
|
| 89 |
+
),
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
def forward(self, data: AtomicDataDict.Type) -> AtomicDataDict.Type:
|
| 93 |
+
field = data[self.field]
|
| 94 |
+
if AtomicDataDict.BATCH_KEY in data:
|
| 95 |
+
result = scatter(
|
| 96 |
+
field,
|
| 97 |
+
data[AtomicDataDict.BATCH_KEY],
|
| 98 |
+
dim=0,
|
| 99 |
+
dim_size=AtomicDataDict.num_frames(data),
|
| 100 |
+
reduce=self.reduce,
|
| 101 |
+
)
|
| 102 |
+
else:
|
| 103 |
+
# We can significantly simplify and avoid scatters
|
| 104 |
+
if self.reduce == "sum":
|
| 105 |
+
result = field.sum(dim=0, keepdim=True)
|
| 106 |
+
elif self.reduce == "mean":
|
| 107 |
+
result = field.mean(dim=0, keepdim=True)
|
| 108 |
+
else:
|
| 109 |
+
assert False
|
| 110 |
+
if self.constant != 1.0:
|
| 111 |
+
result = result * self.constant
|
| 112 |
+
data[self.out_field] = result
|
| 113 |
+
return data
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class PerTypeScaleShift(GraphModuleMixin, torch.nn.Module):
|
| 117 |
+
"""Scale and/or shift a predicted per-atom property based on (learnable) per-species/type parameters.
|
| 118 |
+
|
| 119 |
+
Note that scaling/shifting is always done casting into the global dtype (``float64``), even if ``model_dtype`` is a lower precision.
|
| 120 |
+
|
| 121 |
+
If a single scalar is provided for scales/shifts, a shortcut implementation is used. Otherwise, a more expensive implementation that assigns separate scales/shifts to each atom type is used.
|
| 122 |
+
|
| 123 |
+
If scales/shifts are trainable, the more expensive implementation that assigns separate scales/shifts to each atom type is used, even if a single scalar was provided for the initialization.
|
| 124 |
+
"""
|
| 125 |
+
|
| 126 |
+
field: str
|
| 127 |
+
out_field: str
|
| 128 |
+
has_scales: bool
|
| 129 |
+
has_shifts: bool
|
| 130 |
+
scales_trainble: bool
|
| 131 |
+
shifts_trainable: bool
|
| 132 |
+
|
| 133 |
+
def __init__(
|
| 134 |
+
self,
|
| 135 |
+
type_names: List[str],
|
| 136 |
+
field: str,
|
| 137 |
+
out_field: Optional[str] = None,
|
| 138 |
+
scales: Optional[Union[float, Dict[str, float]]] = None,
|
| 139 |
+
shifts: Optional[Union[float, Dict[str, float]]] = None,
|
| 140 |
+
scales_trainable: bool = False,
|
| 141 |
+
shifts_trainable: bool = False,
|
| 142 |
+
irreps_in={},
|
| 143 |
+
):
|
| 144 |
+
super().__init__()
|
| 145 |
+
self.type_names = type_names
|
| 146 |
+
self.num_types = len(type_names)
|
| 147 |
+
|
| 148 |
+
# === fields and irreps ===
|
| 149 |
+
self.field = field
|
| 150 |
+
self.out_field = field if out_field is None else out_field
|
| 151 |
+
assert get_field_type(self.field) == "node"
|
| 152 |
+
assert get_field_type(self.out_field) == "node"
|
| 153 |
+
|
| 154 |
+
self._init_irreps(
|
| 155 |
+
irreps_in=irreps_in,
|
| 156 |
+
my_irreps_in={self.field: "0e"}, # input to shift must be a single scalar
|
| 157 |
+
irreps_out={self.out_field: irreps_in[self.field]},
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
# === dtype ===
|
| 161 |
+
self.out_dtype = _GLOBAL_DTYPE
|
| 162 |
+
|
| 163 |
+
# === preprocess scales and shifts ===
|
| 164 |
+
# we only accept single values or dicts
|
| 165 |
+
# lists are no longer supported
|
| 166 |
+
if isinstance(scales, list) or isinstance(shifts, list):
|
| 167 |
+
raise ValueError(
|
| 168 |
+
"\n\nLists are no longer supported for per-type energy scales and shifts. Please use dicts that map from the model's `type_names` as keys to the relevant scale or shift values. For example, the following\n\n per_type_energy_shifts: [1, 2, 3]\n\nshould be changed to\n\n per_type_energy_shifts:\n C: 1\n H: 2\n O: 3\n\n"
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
# single valued case
|
| 172 |
+
if isinstance(scales, float) or isinstance(scales, int):
|
| 173 |
+
scales = [scales]
|
| 174 |
+
if isinstance(shifts, float) or isinstance(shifts, int):
|
| 175 |
+
shifts = [shifts]
|
| 176 |
+
|
| 177 |
+
# dict case
|
| 178 |
+
if isinstance(scales, dict):
|
| 179 |
+
assert set(self.type_names) == set(scales.keys())
|
| 180 |
+
scales = [scales[name] for name in self.type_names]
|
| 181 |
+
if isinstance(shifts, dict):
|
| 182 |
+
assert set(self.type_names) == set(shifts.keys())
|
| 183 |
+
shifts = [shifts[name] for name in self.type_names]
|
| 184 |
+
|
| 185 |
+
# we convert everything to lists at this point for conversion into `torch.Tensor`s
|
| 186 |
+
for sc_vars in (scales, shifts):
|
| 187 |
+
if sc_vars is not None:
|
| 188 |
+
assert isinstance(sc_vars, list)
|
| 189 |
+
|
| 190 |
+
# === scales ===
|
| 191 |
+
self.has_scales = scales is not None
|
| 192 |
+
self.scales_trainable = scales_trainable
|
| 193 |
+
if self.has_scales:
|
| 194 |
+
scales = torch.as_tensor(scales, dtype=self.out_dtype)
|
| 195 |
+
if self.scales_trainable and scales.numel() == 1:
|
| 196 |
+
# effective no-op if self.num_types == 1
|
| 197 |
+
scales = (
|
| 198 |
+
torch.ones(self.num_types, dtype=scales.dtype, device=scales.device)
|
| 199 |
+
* scales
|
| 200 |
+
)
|
| 201 |
+
assert scales.shape == (self.num_types,) or scales.numel() == 1, (
|
| 202 |
+
f"Scales expected to have shape ({self.num_types},), but found {scales.shape}"
|
| 203 |
+
)
|
| 204 |
+
scales = scales.reshape(-1, 1)
|
| 205 |
+
if self.scales_trainable:
|
| 206 |
+
self.scales = torch.nn.Parameter(scales)
|
| 207 |
+
else:
|
| 208 |
+
self.register_buffer("scales", scales)
|
| 209 |
+
else:
|
| 210 |
+
self.register_buffer("scales", torch.Tensor())
|
| 211 |
+
self.scales_shortcut = self.scales.numel() == 1
|
| 212 |
+
|
| 213 |
+
# === shifts ===
|
| 214 |
+
self.has_shifts = shifts is not None
|
| 215 |
+
self.shifts_trainable = shifts_trainable
|
| 216 |
+
if self.has_shifts:
|
| 217 |
+
shifts = torch.as_tensor(shifts, dtype=self.out_dtype)
|
| 218 |
+
if self.shifts_trainable and shifts.numel() == 1:
|
| 219 |
+
# effective no-op if self.num_types == 1
|
| 220 |
+
shifts = (
|
| 221 |
+
torch.ones(self.num_types, dtype=shifts.dtype, device=shifts.device)
|
| 222 |
+
* shifts
|
| 223 |
+
)
|
| 224 |
+
assert shifts.shape == (self.num_types,) or shifts.numel() == 1, (
|
| 225 |
+
f"Shifts expected to have shape ({self.num_types},), but found {shifts.shape}"
|
| 226 |
+
)
|
| 227 |
+
shifts = shifts.reshape(-1, 1)
|
| 228 |
+
if self.shifts_trainable:
|
| 229 |
+
self.shifts = torch.nn.Parameter(shifts)
|
| 230 |
+
else:
|
| 231 |
+
self.register_buffer("shifts", shifts)
|
| 232 |
+
else:
|
| 233 |
+
self.register_buffer("shifts", torch.Tensor())
|
| 234 |
+
self.shifts_shortcut = self.shifts.numel() == 1
|
| 235 |
+
|
| 236 |
+
def forward(self, data: AtomicDataDict.Type) -> AtomicDataDict.Type:
|
| 237 |
+
""""""
|
| 238 |
+
# shortcut if no scales or shifts found (only dtype promotion performed)
|
| 239 |
+
if not (self.has_scales or self.has_shifts):
|
| 240 |
+
data[self.out_field] = data[self.field].to(self.out_dtype)
|
| 241 |
+
return data
|
| 242 |
+
|
| 243 |
+
# === set up ===
|
| 244 |
+
in_field = data[self.field]
|
| 245 |
+
types = data[AtomicDataDict.ATOM_TYPE_KEY].view(-1)
|
| 246 |
+
# to account for local-ghost truncation in ML-IAP
|
| 247 |
+
types = types[: in_field.size(0)]
|
| 248 |
+
|
| 249 |
+
if self.has_scales:
|
| 250 |
+
if self.scales_shortcut:
|
| 251 |
+
scales = self.scales
|
| 252 |
+
else:
|
| 253 |
+
scales = torch.nn.functional.embedding(types, self.scales)
|
| 254 |
+
else:
|
| 255 |
+
scales = self.scales # dummy for torchscript
|
| 256 |
+
|
| 257 |
+
if self.has_shifts:
|
| 258 |
+
if self.shifts_shortcut:
|
| 259 |
+
shifts = self.shifts
|
| 260 |
+
else:
|
| 261 |
+
shifts = torch.nn.functional.embedding(types, self.shifts)
|
| 262 |
+
else:
|
| 263 |
+
shifts = self.shifts # dummy for torchscript
|
| 264 |
+
|
| 265 |
+
# === explicit cast ===
|
| 266 |
+
in_field = in_field.to(self.out_dtype)
|
| 267 |
+
|
| 268 |
+
# === scale/shift ===
|
| 269 |
+
if self.has_scales and self.has_shifts:
|
| 270 |
+
# we can used an FMA for performance
|
| 271 |
+
# addcmul computes
|
| 272 |
+
# input + tensor1 * tensor2 elementwise
|
| 273 |
+
# it will promote to widest dtype, which comes from shifts/scales
|
| 274 |
+
in_field = torch.addcmul(shifts, scales, in_field)
|
| 275 |
+
else:
|
| 276 |
+
# fallback path for mix of enabled shifts and scales
|
| 277 |
+
# multiplication / addition promotes dtypes already, so no cast is needed
|
| 278 |
+
if self.has_scales:
|
| 279 |
+
in_field = scales * in_field
|
| 280 |
+
if self.has_shifts:
|
| 281 |
+
in_field = shifts + in_field
|
| 282 |
+
|
| 283 |
+
data[self.out_field] = in_field
|
| 284 |
+
return data
|
| 285 |
+
|
| 286 |
+
@model_modifier(persistent=True, private=False)
|
| 287 |
+
@classmethod
|
| 288 |
+
def modify_PerTypeScaleShift(
|
| 289 |
+
cls,
|
| 290 |
+
model,
|
| 291 |
+
scales: Optional[Union[float, Dict[str, float]]] = None,
|
| 292 |
+
shifts: Optional[Union[float, Dict[str, float]]] = None,
|
| 293 |
+
scales_trainable: bool = False,
|
| 294 |
+
shifts_trainable: bool = False,
|
| 295 |
+
):
|
| 296 |
+
"""Modify per-type scales and shifts of a model.
|
| 297 |
+
|
| 298 |
+
The new ``scales`` and ``shifts`` should be provided as dicts.
|
| 299 |
+
The keys must correspond to the ``type_names`` registered in the model being modified, and may not include all the possible ``type_names`` of the original model.
|
| 300 |
+
For example, if one uses a pretrained model with 50 atom types, and seeks to only modify 3 per-atom shifts to be consistent with a fine-tuning dataset's DFT settings, one could use
|
| 301 |
+
|
| 302 |
+
.. code-block:: yaml
|
| 303 |
+
|
| 304 |
+
shifts:
|
| 305 |
+
C: 1.23
|
| 306 |
+
H: 0.12
|
| 307 |
+
O: 2.13
|
| 308 |
+
|
| 309 |
+
In this case, the per-type atomic energy shifts of the original model will be used for every other atom type, except for atom types with the new shifts specified.
|
| 310 |
+
|
| 311 |
+
For more details on fine-tuning, see https://nequip.readthedocs.io/en/latest/guide/training-techniques/fine_tuning.html
|
| 312 |
+
|
| 313 |
+
Args:
|
| 314 |
+
scales: the new per-type atomic energy scales
|
| 315 |
+
shifts: the new per-type atomic energy shifts (e.g. isolated atom energies of a dataset used for fine-tuning)
|
| 316 |
+
scales_trainable (bool): whether the new scales are trainable
|
| 317 |
+
shifts_trainable (bool): whether the new shifts are trainable
|
| 318 |
+
"""
|
| 319 |
+
|
| 320 |
+
def _helper(sc_var, vname, old):
|
| 321 |
+
# get original dict values
|
| 322 |
+
orig_sc_var = getattr(old, vname).detach().cpu().reshape(-1).tolist()
|
| 323 |
+
# handle special case of single-valued shortcut
|
| 324 |
+
if len(orig_sc_var) != len(old.type_names):
|
| 325 |
+
assert len(orig_sc_var) == 1
|
| 326 |
+
orig_sc_var = orig_sc_var * len(old.type_names)
|
| 327 |
+
new_sc_var = {name: val for name, val in zip(old.type_names, orig_sc_var)}
|
| 328 |
+
if sc_var is not None:
|
| 329 |
+
# preprocess to list if single number
|
| 330 |
+
if isinstance(sc_var, float) or isinstance(sc_var, int):
|
| 331 |
+
sc_var = {name: sc_var for name in old.type_names}
|
| 332 |
+
assert isinstance(sc_var, dict)
|
| 333 |
+
assert all(k in old.type_names for k in sc_var.keys()), (
|
| 334 |
+
f"Provided `{vname}` dict keys ({sc_var.keys()}) do not match the expected type names of the model ({old.type_names})."
|
| 335 |
+
)
|
| 336 |
+
# update original model's dict with new dict entries
|
| 337 |
+
new_sc_var.update(sc_var)
|
| 338 |
+
# if no new values provided, we default to the original model's dict entries
|
| 339 |
+
return new_sc_var
|
| 340 |
+
|
| 341 |
+
def factory(old):
|
| 342 |
+
return cls(
|
| 343 |
+
type_names=old.type_names,
|
| 344 |
+
field=old.field,
|
| 345 |
+
out_field=old.out_field,
|
| 346 |
+
scales=_helper(scales, "scales", old),
|
| 347 |
+
shifts=_helper(shifts, "shifts", old),
|
| 348 |
+
scales_trainable=scales_trainable,
|
| 349 |
+
shifts_trainable=shifts_trainable,
|
| 350 |
+
irreps_in=old.irreps_in,
|
| 351 |
+
)
|
| 352 |
+
|
| 353 |
+
return replace_submodules(model, cls, factory)
|
| 354 |
+
|
| 355 |
+
def __repr__(self) -> str:
|
| 356 |
+
return f"{self.__class__.__name__} \n scales: {_format_type_vals(self.scales.reshape(-1).tolist(), self.type_names)}\n shifts: {_format_type_vals(self.shifts.reshape(-1).tolist(), self.type_names)}"
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
def _format_type_vals(
|
| 360 |
+
vals: List[float], type_names: List[str], element_formatter: str = ".6f"
|
| 361 |
+
) -> str:
|
| 362 |
+
if vals is None or not vals:
|
| 363 |
+
return f"[{', '.join(type_names)}: None]"
|
| 364 |
+
|
| 365 |
+
if len(vals) == 1:
|
| 366 |
+
return (f"[{', '.join(type_names)}: {{:{element_formatter}}}]").format(vals[0])
|
| 367 |
+
elif len(vals) == len(type_names):
|
| 368 |
+
return (
|
| 369 |
+
"["
|
| 370 |
+
+ ", ".join(
|
| 371 |
+
f"{{{i}[0]}}: {{{i}[1]:{element_formatter}}}" for i in range(len(vals))
|
| 372 |
+
)
|
| 373 |
+
+ "]"
|
| 374 |
+
).format(*zip(type_names, vals))
|
| 375 |
+
else:
|
| 376 |
+
raise ValueError(
|
| 377 |
+
f"Don't know how to format vals=`{vals}` for types {type_names} with element_formatter=`{element_formatter}`"
|
| 378 |
+
)
|
model/nn/compile.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is a part of the `nequip` package. Please see LICENSE and README at the root for information on using it.
|
| 2 |
+
import torch
|
| 3 |
+
|
| 4 |
+
from onescience.datapipes.materials.nequip import AtomicDataDict
|
| 5 |
+
from .graph_model import GraphModel
|
| 6 |
+
from ._graph_mixin import GraphModuleMixin
|
| 7 |
+
from onescience.utils.nequip.internal.dtype import (
|
| 8 |
+
test_model_output_similarity_by_dtype,
|
| 9 |
+
_pt2_compile_error_message,
|
| 10 |
+
)
|
| 11 |
+
from onescience.utils.nequip.internal.fx import nequip_make_fx
|
| 12 |
+
from onescience.utils.nequip.internal.dtype import dtype_to_name
|
| 13 |
+
from typing import Dict, Sequence, List, Optional, Any, Final
|
| 14 |
+
from torch.func import functional_call
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _list_to_dict(
|
| 18 |
+
keys: Sequence[str], args: List[torch.Tensor]
|
| 19 |
+
) -> Dict[str, torch.Tensor]:
|
| 20 |
+
return {key: arg for key, arg in zip(keys, args)}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _list_from_dict(
|
| 24 |
+
keys: Sequence[str], data: Dict[str, torch.Tensor]
|
| 25 |
+
) -> List[torch.Tensor]:
|
| 26 |
+
return [data[key] for key in keys]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class ListInputOutputWrapper(torch.nn.Module):
|
| 30 |
+
"""
|
| 31 |
+
Wraps a ``torch.nn.Module`` that takes and returns ``Dict[str, torch.Tensor]`` to have it take and return ``Sequence[torch.Tensor]`` for specified input and output fields.
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
def __init__(
|
| 35 |
+
self,
|
| 36 |
+
model: torch.nn.Module,
|
| 37 |
+
input_keys: Sequence[str],
|
| 38 |
+
output_keys: Sequence[str],
|
| 39 |
+
):
|
| 40 |
+
super().__init__()
|
| 41 |
+
self.model = model
|
| 42 |
+
self.input_keys = list(input_keys)
|
| 43 |
+
self.output_keys = list(output_keys)
|
| 44 |
+
|
| 45 |
+
def forward(self, *args: torch.Tensor) -> List[torch.Tensor]:
|
| 46 |
+
inputs = _list_to_dict(self.input_keys, args)
|
| 47 |
+
outputs = self.model(inputs)
|
| 48 |
+
return _list_from_dict(self.output_keys, outputs)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class DictInputOutputWrapper(torch.nn.Module):
|
| 52 |
+
"""
|
| 53 |
+
Wraps a model that takes and returns ``Sequence[torch.Tensor]`` to have it take and return ``Dict[str, torch.Tensor]`` for specified input and output fields (i.e. the opposite of ``ListInputOutputWrapper``).
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
def __init__(self, model, input_keys: List[str], output_keys: List[str]):
|
| 57 |
+
super().__init__()
|
| 58 |
+
self.model = model
|
| 59 |
+
self.input_keys = input_keys
|
| 60 |
+
self.output_keys = output_keys
|
| 61 |
+
|
| 62 |
+
def forward(self, data: AtomicDataDict.Type) -> AtomicDataDict.Type:
|
| 63 |
+
inputs = _list_from_dict(self.input_keys, data)
|
| 64 |
+
with torch.inference_mode():
|
| 65 |
+
outputs = self.model(inputs)
|
| 66 |
+
return _list_to_dict(self.output_keys, outputs)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class ListInputOutputStateDictWrapper(ListInputOutputWrapper):
|
| 70 |
+
"""Like ``ListInputOutputWrapper``, but also updates the model with state dict entries before each ``forward`` using ``functional_call``."""
|
| 71 |
+
|
| 72 |
+
def __init__(
|
| 73 |
+
self,
|
| 74 |
+
model: torch.nn.Module,
|
| 75 |
+
input_keys: Sequence[str],
|
| 76 |
+
output_keys: Sequence[str],
|
| 77 |
+
state_dict_keys: Sequence[str],
|
| 78 |
+
):
|
| 79 |
+
super().__init__(model, input_keys, output_keys)
|
| 80 |
+
self.state_dict_keys = state_dict_keys
|
| 81 |
+
|
| 82 |
+
def forward(self, *args: torch.Tensor) -> List[torch.Tensor]:
|
| 83 |
+
# won't check that `args` is of the correct length
|
| 84 |
+
input_dict = _list_to_dict(self.input_keys, args[: len(self.input_keys)])
|
| 85 |
+
state_dict = _list_to_dict(self.state_dict_keys, args[len(self.input_keys) :])
|
| 86 |
+
# use functional_call to avoid in-place modification
|
| 87 |
+
output_dict = functional_call(self.model, state_dict, args=(input_dict,))
|
| 88 |
+
return _list_from_dict(self.output_keys, output_dict)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class CompileGraphModel(GraphModel):
|
| 92 |
+
"""Wrapper that uses ``torch.compile`` to optimize the wrapped module while allowing it to be trained.
|
| 93 |
+
|
| 94 |
+
The cache is keyed by input signature (input keys only).
|
| 95 |
+
For each input signature, the eager model is run to determine the output keys, and then a compiled model is created for that input/output combination.
|
| 96 |
+
The compiled model and output keys are stored together in the cache.
|
| 97 |
+
"""
|
| 98 |
+
|
| 99 |
+
is_compile_graph_model: Final[bool] = True
|
| 100 |
+
# ^ to identify `GraphModel` types from `nequip-package`d models (see https://pytorch.org/docs/stable/package.html#torch-package-sharp-edges)
|
| 101 |
+
|
| 102 |
+
def __init__(
|
| 103 |
+
self,
|
| 104 |
+
model: GraphModuleMixin,
|
| 105 |
+
model_config: Optional[Dict[str, str]] = None,
|
| 106 |
+
model_input_fields: Dict[str, Any] = {},
|
| 107 |
+
) -> None:
|
| 108 |
+
super().__init__(model, model_config, model_input_fields)
|
| 109 |
+
# cache for multiple compiled variants based on input key signatures
|
| 110 |
+
# cache structure: {input_signature: (compiled_model, output_fields)}
|
| 111 |
+
# NOTE: the cache dict is wrapped in a tuple so that it's not registered and saved in the state dict -- this is necessary to enable `GraphModel` to load `CompileGraphModel` state dicts
|
| 112 |
+
# see https://discuss.pytorch.org/t/saving-nn-module-to-parent-nn-module-without-registering-paremeters/132082/6
|
| 113 |
+
self._compiled_cache = ({},)
|
| 114 |
+
# weights and buffers should be done lazily because model modification can happen after instantiation
|
| 115 |
+
# such that parameters and buffers may change between class instantiation and the lazy compilation in the `forward`
|
| 116 |
+
self.weight_names = None
|
| 117 |
+
self.buffer_names = None
|
| 118 |
+
|
| 119 |
+
def _get_input_signature(self, data: AtomicDataDict.Type) -> tuple:
|
| 120 |
+
"""Compute a hashable signature for the input keys.
|
| 121 |
+
|
| 122 |
+
The unique set of input keys determines a unique set of output keys when run through the model,
|
| 123 |
+
so we only need the input keys for the cache lookup signature.
|
| 124 |
+
|
| 125 |
+
Uses intersection of data keys and GraphModel inputs, which assumes:
|
| 126 |
+
- correctness of irreps registration system
|
| 127 |
+
- this particular batch contains all necessary inputs for this variant
|
| 128 |
+
"""
|
| 129 |
+
input_keys = tuple(sorted(data.keys() & self.model_input_fields))
|
| 130 |
+
return input_keys
|
| 131 |
+
|
| 132 |
+
def forward(self, data: AtomicDataDict.Type) -> AtomicDataDict.Type:
|
| 133 |
+
# short-circuit if one of the batch dims is 1 (0 would be an error)
|
| 134 |
+
# this is related to the 0/1 specialization problem
|
| 135 |
+
# see https://docs.google.com/document/d/16VPOa3d-Liikf48teAOmxLc92rgvJdfosIy-yoT38Io/edit?fbclid=IwAR3HNwmmexcitV0pbZm_x1a4ykdXZ9th_eJWK-3hBtVgKnrkmemz6Pm5jRQ&tab=t.0#heading=h.ez923tomjvyk
|
| 136 |
+
# we just need something that doesn't have a batch dim of 1 to `make_fx` or else it'll shape specialize
|
| 137 |
+
# the models compiled for more batch_size > 1 data cannot be used for batch_size=1 data
|
| 138 |
+
# (under specific cases related to the `PerTypeScaleShift` module)
|
| 139 |
+
# for now we just make sure to always use the eager model when the data has any batch dims of 1
|
| 140 |
+
if (
|
| 141 |
+
AtomicDataDict.num_nodes(data) < 2
|
| 142 |
+
or AtomicDataDict.num_frames(data) < 2
|
| 143 |
+
or AtomicDataDict.num_edges(data) < 2
|
| 144 |
+
):
|
| 145 |
+
# use parent class's forward
|
| 146 |
+
return super().forward(data)
|
| 147 |
+
|
| 148 |
+
# === get or compile variant for this input signature ===
|
| 149 |
+
# compilation happens lazily when we encounter a new combination of input keys
|
| 150 |
+
input_signature = self._get_input_signature(data)
|
| 151 |
+
cache = self._compiled_cache[0]
|
| 152 |
+
|
| 153 |
+
if input_signature not in cache:
|
| 154 |
+
# get weight names and buffers (only once on first compilation)
|
| 155 |
+
if self.weight_names is None:
|
| 156 |
+
self.weight_names = [n for n, _ in self.model.named_parameters()]
|
| 157 |
+
self.buffer_names = [n for n, _ in self.model.named_buffers()]
|
| 158 |
+
|
| 159 |
+
# == get input fields for this variant ==
|
| 160 |
+
input_fields = list(input_signature)
|
| 161 |
+
|
| 162 |
+
# == run eager model to determine output fields ==
|
| 163 |
+
eager_output = super().forward(data.copy())
|
| 164 |
+
output_fields = tuple(sorted(eager_output.keys()))
|
| 165 |
+
del eager_output
|
| 166 |
+
|
| 167 |
+
# == preprocess model and make_fx ==
|
| 168 |
+
model_to_trace = ListInputOutputStateDictWrapper(
|
| 169 |
+
model=self.model,
|
| 170 |
+
input_keys=input_fields,
|
| 171 |
+
output_keys=output_fields,
|
| 172 |
+
state_dict_keys=self.weight_names + self.buffer_names,
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
weights, buffers = self._get_weights_buffers()
|
| 176 |
+
fx_model = nequip_make_fx(
|
| 177 |
+
model=model_to_trace,
|
| 178 |
+
data=data,
|
| 179 |
+
fields=input_fields,
|
| 180 |
+
extra_inputs=weights + buffers,
|
| 181 |
+
)
|
| 182 |
+
del weights, buffers
|
| 183 |
+
|
| 184 |
+
# == compile exported program ==
|
| 185 |
+
# see https://pytorch.org/tutorials/intermediate/torch_export_tutorial.html#running-the-exported-program
|
| 186 |
+
# TODO: compile options
|
| 187 |
+
compiled_model = torch.compile(
|
| 188 |
+
fx_model,
|
| 189 |
+
dynamic=True,
|
| 190 |
+
fullgraph=False,
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
# store in cache: (compiled_model, output_fields)
|
| 194 |
+
cache[input_signature] = (compiled_model, output_fields)
|
| 195 |
+
|
| 196 |
+
# run original model and compiled model with data to sanity check
|
| 197 |
+
def compiled_forward_for_test(data_test):
|
| 198 |
+
return self._compiled_forward(
|
| 199 |
+
data_test, compiled_model, input_fields, output_fields
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
# only test output fields that are present in data (i.e. labels are present)
|
| 203 |
+
test_fields = sorted(set(output_fields) & data.keys())
|
| 204 |
+
test_model_output_similarity_by_dtype(
|
| 205 |
+
compiled_forward_for_test,
|
| 206 |
+
self.model,
|
| 207 |
+
{k: data[k] for k in input_fields},
|
| 208 |
+
dtype_to_name(self.model_dtype),
|
| 209 |
+
fields=test_fields,
|
| 210 |
+
error_message=_pt2_compile_error_message,
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
# === run compiled model for this variant ===
|
| 214 |
+
compiled_model, output_fields = cache[input_signature]
|
| 215 |
+
out_dict = self._compiled_forward(
|
| 216 |
+
data, compiled_model, input_signature, output_fields
|
| 217 |
+
)
|
| 218 |
+
to_return = data.copy()
|
| 219 |
+
to_return.update(out_dict)
|
| 220 |
+
return to_return
|
| 221 |
+
|
| 222 |
+
def _compiled_forward(self, data, compiled_model, input_fields, output_fields):
|
| 223 |
+
# run compiled model with data
|
| 224 |
+
weights, buffers = self._get_weights_buffers()
|
| 225 |
+
data_list = _list_from_dict(input_fields, data)
|
| 226 |
+
out_list = compiled_model(*(data_list + weights + buffers))
|
| 227 |
+
out_dict = _list_to_dict(output_fields, out_list)
|
| 228 |
+
return out_dict
|
| 229 |
+
|
| 230 |
+
def _get_weights_buffers(self):
|
| 231 |
+
# get weights and buffers from trainable model
|
| 232 |
+
weight_dict = dict(self.model.named_parameters())
|
| 233 |
+
weights = [weight_dict[name] for name in self.weight_names]
|
| 234 |
+
buffer_dict = dict(self.model.named_buffers())
|
| 235 |
+
buffers = [buffer_dict[name] for name in self.buffer_names]
|
| 236 |
+
return weights, buffers
|
model/nn/convnetlayer.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is a part of the `nequip` package. Please see LICENSE and README at the root for information on using it.
|
| 2 |
+
import torch
|
| 3 |
+
|
| 4 |
+
from e3nn.o3._irreps import Irreps
|
| 5 |
+
from e3nn.nn._gate import Gate
|
| 6 |
+
from e3nn.nn._normact import NormActivation
|
| 7 |
+
|
| 8 |
+
from onescience.datapipes.materials.nequip import AtomicDataDict
|
| 9 |
+
from ._graph_mixin import GraphModuleMixin
|
| 10 |
+
from .interaction_block import InteractionBlock
|
| 11 |
+
from .nonlinearities import shifted_softplus
|
| 12 |
+
from .utils import tp_path_exists
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
from typing import Any, Dict, Optional, Callable
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
acts = {
|
| 19 |
+
"abs": torch.abs,
|
| 20 |
+
"tanh": torch.tanh,
|
| 21 |
+
"ssp": shifted_softplus,
|
| 22 |
+
"silu": torch.nn.functional.silu,
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class ConvNetLayer(GraphModuleMixin, torch.nn.Module):
|
| 27 |
+
"""
|
| 28 |
+
Args:
|
| 29 |
+
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
resnet: bool
|
| 33 |
+
|
| 34 |
+
def __init__(
|
| 35 |
+
self,
|
| 36 |
+
irreps_in,
|
| 37 |
+
feature_irreps_hidden,
|
| 38 |
+
convolution=InteractionBlock,
|
| 39 |
+
convolution_kwargs: Optional[Dict[str, Any]] = None,
|
| 40 |
+
resnet: bool = False,
|
| 41 |
+
nonlinearity_type: str = "gate",
|
| 42 |
+
nonlinearity_scalars: Dict[int, Callable] = {"e": "silu", "o": "tanh"},
|
| 43 |
+
nonlinearity_gates: Dict[int, Callable] = {"e": "silu", "o": "tanh"},
|
| 44 |
+
):
|
| 45 |
+
super().__init__()
|
| 46 |
+
# initialization
|
| 47 |
+
assert nonlinearity_type in ("gate", "norm")
|
| 48 |
+
# make the nonlin dicts from parity ints instead of convinience strs
|
| 49 |
+
nonlinearity_scalars = {
|
| 50 |
+
1: nonlinearity_scalars["e"],
|
| 51 |
+
-1: nonlinearity_scalars["o"],
|
| 52 |
+
}
|
| 53 |
+
nonlinearity_gates = {
|
| 54 |
+
1: nonlinearity_gates["e"],
|
| 55 |
+
-1: nonlinearity_gates["o"],
|
| 56 |
+
}
|
| 57 |
+
# normalize optional inputs to avoid shared mutable defaults
|
| 58 |
+
convolution_kwargs = (
|
| 59 |
+
{} if convolution_kwargs is None else dict(convolution_kwargs)
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
self.feature_irreps_hidden = Irreps(feature_irreps_hidden)
|
| 63 |
+
self.resnet = resnet
|
| 64 |
+
|
| 65 |
+
# We'll set irreps_out later when we know them
|
| 66 |
+
self._init_irreps(
|
| 67 |
+
irreps_in=irreps_in,
|
| 68 |
+
required_irreps_in=[AtomicDataDict.NODE_FEATURES_KEY],
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
edge_attr_irreps = self.irreps_in[AtomicDataDict.EDGE_ATTRS_KEY]
|
| 72 |
+
irreps_layer_out_prev = self.irreps_in[AtomicDataDict.NODE_FEATURES_KEY]
|
| 73 |
+
|
| 74 |
+
irreps_scalars = Irreps(
|
| 75 |
+
[
|
| 76 |
+
(mul, ir)
|
| 77 |
+
for mul, ir in self.feature_irreps_hidden
|
| 78 |
+
if ir.l == 0
|
| 79 |
+
and tp_path_exists(irreps_layer_out_prev, edge_attr_irreps, ir)
|
| 80 |
+
]
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
irreps_gated = Irreps(
|
| 84 |
+
[
|
| 85 |
+
(mul, ir)
|
| 86 |
+
for mul, ir in self.feature_irreps_hidden
|
| 87 |
+
if ir.l > 0
|
| 88 |
+
and tp_path_exists(irreps_layer_out_prev, edge_attr_irreps, ir)
|
| 89 |
+
]
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
irreps_layer_out = (irreps_scalars + irreps_gated).simplify()
|
| 93 |
+
|
| 94 |
+
if nonlinearity_type == "gate":
|
| 95 |
+
ir = (
|
| 96 |
+
"0e"
|
| 97 |
+
if tp_path_exists(irreps_layer_out_prev, edge_attr_irreps, "0e")
|
| 98 |
+
else "0o"
|
| 99 |
+
)
|
| 100 |
+
irreps_gates = Irreps([(mul, ir) for mul, _ in irreps_gated])
|
| 101 |
+
|
| 102 |
+
# TO DO, it's not that safe to directly use the
|
| 103 |
+
# dictionary
|
| 104 |
+
equivariant_nonlin = Gate(
|
| 105 |
+
irreps_scalars=irreps_scalars,
|
| 106 |
+
act_scalars=[
|
| 107 |
+
acts[nonlinearity_scalars[ir.p]] for _, ir in irreps_scalars
|
| 108 |
+
],
|
| 109 |
+
irreps_gates=irreps_gates,
|
| 110 |
+
act_gates=[acts[nonlinearity_gates[ir.p]] for _, ir in irreps_gates],
|
| 111 |
+
irreps_gated=irreps_gated,
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
conv_irreps_out = equivariant_nonlin.irreps_in.simplify()
|
| 115 |
+
|
| 116 |
+
else:
|
| 117 |
+
conv_irreps_out = irreps_layer_out.simplify()
|
| 118 |
+
|
| 119 |
+
equivariant_nonlin = NormActivation(
|
| 120 |
+
irreps_in=conv_irreps_out,
|
| 121 |
+
# norm is an even scalar, so use nonlinearity_scalars[1]
|
| 122 |
+
scalar_nonlinearity=acts[nonlinearity_scalars[1]],
|
| 123 |
+
normalize=True,
|
| 124 |
+
epsilon=1e-8,
|
| 125 |
+
bias=False,
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
self.equivariant_nonlin = equivariant_nonlin
|
| 129 |
+
|
| 130 |
+
# TODO: partial resnet?
|
| 131 |
+
if irreps_layer_out == irreps_layer_out_prev and resnet:
|
| 132 |
+
# We are doing resnet updates and can for this layer
|
| 133 |
+
self.resnet = True
|
| 134 |
+
else:
|
| 135 |
+
self.resnet = False
|
| 136 |
+
|
| 137 |
+
# TODO: last convolution should go to explicit irreps out
|
| 138 |
+
|
| 139 |
+
# override defaults for irreps:
|
| 140 |
+
convolution_kwargs.pop("irreps_in", None)
|
| 141 |
+
convolution_kwargs.pop("irreps_out", None)
|
| 142 |
+
self.conv = convolution(
|
| 143 |
+
irreps_in=self.irreps_in,
|
| 144 |
+
irreps_out=conv_irreps_out,
|
| 145 |
+
**convolution_kwargs,
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
# The output features are whatever we got in
|
| 149 |
+
# updated with whatever the convolution outputs (which is a full graph module)
|
| 150 |
+
self.irreps_out.update(self.conv.irreps_out)
|
| 151 |
+
# but with the features updated by the nonlinearity
|
| 152 |
+
self.irreps_out[AtomicDataDict.NODE_FEATURES_KEY] = (
|
| 153 |
+
self.equivariant_nonlin.irreps_out
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
def forward(self, data: AtomicDataDict.Type) -> AtomicDataDict.Type:
|
| 157 |
+
# save old features for resnet
|
| 158 |
+
old_x = data[AtomicDataDict.NODE_FEATURES_KEY]
|
| 159 |
+
# run convolution
|
| 160 |
+
data = self.conv(data)
|
| 161 |
+
# do nonlinearity
|
| 162 |
+
data[AtomicDataDict.NODE_FEATURES_KEY] = self.equivariant_nonlin(
|
| 163 |
+
data[AtomicDataDict.NODE_FEATURES_KEY]
|
| 164 |
+
)
|
| 165 |
+
# do resnet
|
| 166 |
+
if self.resnet:
|
| 167 |
+
data[AtomicDataDict.NODE_FEATURES_KEY] = (
|
| 168 |
+
old_x + data[AtomicDataDict.NODE_FEATURES_KEY]
|
| 169 |
+
)
|
| 170 |
+
return data
|
model/nn/embedding/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is a part of the `nequip` package. Please see LICENSE and README at the root for information on using it.
|
| 2 |
+
from .node import NodeTypeEmbed
|
| 3 |
+
from .node_tensor import AppendVectorFieldEmbed
|
| 4 |
+
from ._edge import (
|
| 5 |
+
EdgeLengthNormalizer,
|
| 6 |
+
BesselEdgeLengthEncoding,
|
| 7 |
+
SphericalHarmonicEdgeAttrs,
|
| 8 |
+
AddRadialCutoffToData,
|
| 9 |
+
)
|
| 10 |
+
from .cutoffs import PolynomialCutoff
|
| 11 |
+
|
| 12 |
+
__all__ = [
|
| 13 |
+
NodeTypeEmbed,
|
| 14 |
+
AppendVectorFieldEmbed,
|
| 15 |
+
EdgeLengthNormalizer,
|
| 16 |
+
BesselEdgeLengthEncoding,
|
| 17 |
+
SphericalHarmonicEdgeAttrs,
|
| 18 |
+
AddRadialCutoffToData,
|
| 19 |
+
PolynomialCutoff,
|
| 20 |
+
]
|
model/nn/embedding/_edge.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is a part of the `nequip` package. Please see LICENSE and README at the root for information on using it.
|
| 2 |
+
import torch
|
| 3 |
+
|
| 4 |
+
from e3nn.o3._irreps import Irreps
|
| 5 |
+
from e3nn.o3._spherical_harmonics import SphericalHarmonics
|
| 6 |
+
from e3nn.util.jit import compile_mode
|
| 7 |
+
|
| 8 |
+
from onescience.utils.nequip.internal.global_dtype import _GLOBAL_DTYPE
|
| 9 |
+
from onescience.utils.nequip.internal.compile import conditional_torchscript_jit
|
| 10 |
+
from onescience.datapipes.materials.nequip import AtomicDataDict
|
| 11 |
+
from .._graph_mixin import GraphModuleMixin
|
| 12 |
+
from ..utils import with_edge_vectors_, with_edge_type_
|
| 13 |
+
from .utils import cutoff_partialdict_to_tensor
|
| 14 |
+
|
| 15 |
+
from typing import Optional, List, Dict, Union
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@compile_mode("script")
|
| 19 |
+
class EdgeLengthNormalizer(GraphModuleMixin, torch.nn.Module):
|
| 20 |
+
num_types: int
|
| 21 |
+
r_max: float
|
| 22 |
+
_per_edge_type: bool
|
| 23 |
+
|
| 24 |
+
def __init__(
|
| 25 |
+
self,
|
| 26 |
+
r_max: float,
|
| 27 |
+
type_names: List[str],
|
| 28 |
+
per_edge_type_cutoff: Optional[
|
| 29 |
+
Dict[str, Union[float, Dict[str, float]]]
|
| 30 |
+
] = None,
|
| 31 |
+
# bookkeeping
|
| 32 |
+
edge_type_field: str = AtomicDataDict.EDGE_TYPE_KEY,
|
| 33 |
+
norm_length_field: str = AtomicDataDict.NORM_LENGTH_KEY,
|
| 34 |
+
irreps_in=None,
|
| 35 |
+
):
|
| 36 |
+
super().__init__()
|
| 37 |
+
|
| 38 |
+
self.r_max = float(r_max)
|
| 39 |
+
self.num_types = len(type_names)
|
| 40 |
+
self.edge_type_field = edge_type_field
|
| 41 |
+
self.norm_length_field = norm_length_field
|
| 42 |
+
|
| 43 |
+
self._per_edge_type = False
|
| 44 |
+
if per_edge_type_cutoff is not None:
|
| 45 |
+
# process per_edge_type_cutoff
|
| 46 |
+
self._per_edge_type = True
|
| 47 |
+
per_edge_type_cutoff = cutoff_partialdict_to_tensor(
|
| 48 |
+
per_edge_type_cutoff, type_names, self.r_max
|
| 49 |
+
)
|
| 50 |
+
# compute 1/rmax and flatten for how they're used in forward, i.e. (n_type, n_type) -> (n_type^2,)
|
| 51 |
+
rmax_recip = per_edge_type_cutoff.reciprocal().view(-1)
|
| 52 |
+
else:
|
| 53 |
+
rmax_recip = torch.as_tensor(1.0 / self.r_max, dtype=_GLOBAL_DTYPE)
|
| 54 |
+
self.register_buffer("_rmax_recip", rmax_recip)
|
| 55 |
+
|
| 56 |
+
irreps_out = {self.norm_length_field: Irreps([(1, (0, 1))])}
|
| 57 |
+
if self._per_edge_type:
|
| 58 |
+
irreps_out.update({self.edge_type_field: None})
|
| 59 |
+
|
| 60 |
+
self._init_irreps(
|
| 61 |
+
irreps_in=irreps_in,
|
| 62 |
+
irreps_out=irreps_out,
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
def forward(self, data: AtomicDataDict.Type) -> AtomicDataDict.Type:
|
| 66 |
+
# == get lengths with shape (num_edges, 1) ==
|
| 67 |
+
data = with_edge_vectors_(data, with_lengths=True)
|
| 68 |
+
r = data[AtomicDataDict.EDGE_LENGTH_KEY].view(-1, 1)
|
| 69 |
+
# == get norm ==
|
| 70 |
+
rmax_recip = self._rmax_recip
|
| 71 |
+
if self._per_edge_type:
|
| 72 |
+
# use helper to get edge types
|
| 73 |
+
data = with_edge_type_(data, self.edge_type_field)
|
| 74 |
+
edge_type = data[self.edge_type_field]
|
| 75 |
+
# convert to row-major NxN matrix index with shape (num_edges,)
|
| 76 |
+
edge_type_flat = edge_type[0] * self.num_types + edge_type[1]
|
| 77 |
+
# (num_type^2,), (num_edges,) -> (num_edges, 1)
|
| 78 |
+
rmax_recip = torch.index_select(rmax_recip, 0, edge_type_flat).unsqueeze(-1)
|
| 79 |
+
data[self.norm_length_field] = r * rmax_recip
|
| 80 |
+
return data
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@compile_mode("script")
|
| 84 |
+
class BesselEdgeLengthEncoding(GraphModuleMixin, torch.nn.Module):
|
| 85 |
+
r"""Bessel edge length encoding.
|
| 86 |
+
|
| 87 |
+
Args:
|
| 88 |
+
num_bessels (int): number of Bessel basis functions
|
| 89 |
+
trainable (bool): whether the :math:`n \pi` coefficients are trainable
|
| 90 |
+
cutoff (torch.nn.Module): ``torch.nn.Module`` to apply a cutoff function that smoothly goes to zero at the cutoff radius
|
| 91 |
+
"""
|
| 92 |
+
|
| 93 |
+
def __init__(
|
| 94 |
+
self,
|
| 95 |
+
cutoff: torch.nn.Module,
|
| 96 |
+
num_bessels: int = 8,
|
| 97 |
+
trainable: bool = False,
|
| 98 |
+
# bookkeeping
|
| 99 |
+
edge_invariant_field: str = AtomicDataDict.EDGE_EMBEDDING_KEY,
|
| 100 |
+
norm_length_field: str = AtomicDataDict.NORM_LENGTH_KEY,
|
| 101 |
+
irreps_in=None,
|
| 102 |
+
):
|
| 103 |
+
super().__init__()
|
| 104 |
+
# === process inputs ===
|
| 105 |
+
self.cutoff = conditional_torchscript_jit(cutoff)
|
| 106 |
+
self.num_bessels = num_bessels
|
| 107 |
+
self.trainable = trainable
|
| 108 |
+
self.edge_invariant_field = edge_invariant_field
|
| 109 |
+
self.norm_length_field = norm_length_field
|
| 110 |
+
|
| 111 |
+
# === bessel weights ===
|
| 112 |
+
bessel_weights = torch.linspace(
|
| 113 |
+
start=1.0,
|
| 114 |
+
end=self.num_bessels,
|
| 115 |
+
steps=self.num_bessels,
|
| 116 |
+
dtype=_GLOBAL_DTYPE,
|
| 117 |
+
).unsqueeze(0) # (1, num_bessel)
|
| 118 |
+
if self.trainable:
|
| 119 |
+
self.bessel_weights = torch.nn.Parameter(bessel_weights)
|
| 120 |
+
else:
|
| 121 |
+
self.register_buffer("bessel_weights", bessel_weights)
|
| 122 |
+
|
| 123 |
+
self._init_irreps(
|
| 124 |
+
irreps_in=irreps_in,
|
| 125 |
+
irreps_out={
|
| 126 |
+
self.edge_invariant_field: Irreps([(self.num_bessels, (0, 1))]),
|
| 127 |
+
AtomicDataDict.EDGE_CUTOFF_KEY: "0e",
|
| 128 |
+
},
|
| 129 |
+
)
|
| 130 |
+
# i.e. `model_dtype`
|
| 131 |
+
self._output_dtype = torch.get_default_dtype()
|
| 132 |
+
|
| 133 |
+
def extra_repr(self) -> str:
|
| 134 |
+
return f"num_bessels={self.num_bessels}"
|
| 135 |
+
|
| 136 |
+
def forward(self, data: AtomicDataDict.Type) -> AtomicDataDict.Type:
|
| 137 |
+
# == Bessel basis ==
|
| 138 |
+
x = data[self.norm_length_field] # (num_edges, 1)
|
| 139 |
+
# (num_edges, 1), (1, num_bessel) -> (num_edges, num_bessel)
|
| 140 |
+
bessel = (torch.sinc(x * self.bessel_weights) * self.bessel_weights).to(
|
| 141 |
+
self._output_dtype
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
# == polynomial cutoff ==
|
| 145 |
+
cutoff = self.cutoff(x).to(self._output_dtype)
|
| 146 |
+
data[AtomicDataDict.EDGE_CUTOFF_KEY] = cutoff
|
| 147 |
+
|
| 148 |
+
# == save product ==
|
| 149 |
+
data[self.edge_invariant_field] = bessel * cutoff
|
| 150 |
+
return data
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
@compile_mode("script")
|
| 154 |
+
class SphericalHarmonicEdgeAttrs(GraphModuleMixin, torch.nn.Module):
|
| 155 |
+
"""Construct edge attrs as spherical harmonic projections of edge vectors.
|
| 156 |
+
|
| 157 |
+
Parameters follow ``e3nn.o3.spherical_harmonics``.
|
| 158 |
+
|
| 159 |
+
Args:
|
| 160 |
+
irreps_edge_sh (int, str, or o3.Irreps): if int, will be treated as lmax for o3.Irreps.spherical_harmonics(lmax)
|
| 161 |
+
edge_sh_normalization (str): the normalization scheme to use
|
| 162 |
+
edge_sh_normalize (bool, default: True): whether to normalize the spherical harmonics
|
| 163 |
+
out_field (str, default: AtomicDataDict.EDGE_ATTRS_KEY: data/irreps field
|
| 164 |
+
"""
|
| 165 |
+
|
| 166 |
+
out_field: str
|
| 167 |
+
|
| 168 |
+
def __init__(
|
| 169 |
+
self,
|
| 170 |
+
irreps_edge_sh: Union[int, str, Irreps],
|
| 171 |
+
edge_sh_normalization: str = "component",
|
| 172 |
+
edge_sh_normalize: bool = True,
|
| 173 |
+
irreps_in=None,
|
| 174 |
+
out_field: str = AtomicDataDict.EDGE_ATTRS_KEY,
|
| 175 |
+
):
|
| 176 |
+
super().__init__()
|
| 177 |
+
self.out_field = out_field
|
| 178 |
+
|
| 179 |
+
if isinstance(irreps_edge_sh, int):
|
| 180 |
+
self.irreps_edge_sh = Irreps.spherical_harmonics(irreps_edge_sh)
|
| 181 |
+
else:
|
| 182 |
+
self.irreps_edge_sh = Irreps(irreps_edge_sh)
|
| 183 |
+
self._init_irreps(
|
| 184 |
+
irreps_in=irreps_in,
|
| 185 |
+
irreps_out={out_field: self.irreps_edge_sh},
|
| 186 |
+
)
|
| 187 |
+
self.sh = SphericalHarmonics(
|
| 188 |
+
self.irreps_edge_sh, edge_sh_normalize, edge_sh_normalization
|
| 189 |
+
)
|
| 190 |
+
# i.e. `model_dtype`
|
| 191 |
+
self._output_dtype = torch.get_default_dtype()
|
| 192 |
+
|
| 193 |
+
def forward(self, data: AtomicDataDict.Type) -> AtomicDataDict.Type:
|
| 194 |
+
data = with_edge_vectors_(data, with_lengths=False)
|
| 195 |
+
edge_vec = data[AtomicDataDict.EDGE_VECTORS_KEY]
|
| 196 |
+
edge_sh = self.sh(edge_vec)
|
| 197 |
+
data[self.out_field] = edge_sh.to(self._output_dtype)
|
| 198 |
+
return data
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
@compile_mode("script")
|
| 202 |
+
class AddRadialCutoffToData(GraphModuleMixin, torch.nn.Module):
|
| 203 |
+
def __init__(
|
| 204 |
+
self,
|
| 205 |
+
cutoff: torch.nn.Module,
|
| 206 |
+
norm_length_field: str = AtomicDataDict.NORM_LENGTH_KEY,
|
| 207 |
+
irreps_in=None,
|
| 208 |
+
):
|
| 209 |
+
super().__init__()
|
| 210 |
+
self.cutoff = conditional_torchscript_jit(cutoff)
|
| 211 |
+
self.norm_length_field = norm_length_field
|
| 212 |
+
self._init_irreps(
|
| 213 |
+
irreps_in=irreps_in, irreps_out={AtomicDataDict.EDGE_CUTOFF_KEY: "0e"}
|
| 214 |
+
)
|
| 215 |
+
# i.e. `model_dtype`
|
| 216 |
+
self._output_dtype = torch.get_default_dtype()
|
| 217 |
+
|
| 218 |
+
def forward(self, data: AtomicDataDict.Type) -> AtomicDataDict.Type:
|
| 219 |
+
if AtomicDataDict.EDGE_CUTOFF_KEY not in data:
|
| 220 |
+
x = data[self.norm_length_field]
|
| 221 |
+
cutoff = self.cutoff(x).to(self._output_dtype)
|
| 222 |
+
data[AtomicDataDict.EDGE_CUTOFF_KEY] = cutoff
|
| 223 |
+
return data
|
model/nn/embedding/cutoffs.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is a part of the `nequip` package. Please see LICENSE and README at the root for information on using it.
|
| 2 |
+
import torch
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class PolynomialCutoff(torch.nn.Module):
|
| 6 |
+
def __init__(self, p: float = 6):
|
| 7 |
+
r"""Polynomial cutoff, as proposed in DimeNet: https://arxiv.org/abs/2003.03123
|
| 8 |
+
|
| 9 |
+
Args:
|
| 10 |
+
r_max (float): cutoff radius
|
| 11 |
+
p (int) : power used in envelope function
|
| 12 |
+
"""
|
| 13 |
+
super().__init__()
|
| 14 |
+
assert p >= 2.0
|
| 15 |
+
self.p = float(p)
|
| 16 |
+
|
| 17 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 18 |
+
"""Evaluate cutoff function.
|
| 19 |
+
|
| 20 |
+
Args:
|
| 21 |
+
x (torch.Tensor): input distance
|
| 22 |
+
"""
|
| 23 |
+
out = 1.0
|
| 24 |
+
out = out - (((self.p + 1.0) * (self.p + 2.0) / 2.0) * torch.pow(x, self.p))
|
| 25 |
+
out = out + (self.p * (self.p + 2.0) * torch.pow(x, self.p + 1.0))
|
| 26 |
+
out = out - ((self.p * (self.p + 1.0) / 2) * torch.pow(x, self.p + 2.0))
|
| 27 |
+
return out * (x < 1.0)
|
model/nn/embedding/node.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is a part of the `nequip` package. Please see LICENSE and README at the root for information on using it.
|
| 2 |
+
from dataclasses import dataclass
|
| 3 |
+
from math import sqrt
|
| 4 |
+
import torch
|
| 5 |
+
|
| 6 |
+
from e3nn.o3._irreps import Irreps
|
| 7 |
+
|
| 8 |
+
from onescience.datapipes.materials.nequip import AtomicDataDict
|
| 9 |
+
from onescience.datapipes.materials.nequip._key_registry import _GRAPH_FIELDS
|
| 10 |
+
from .._graph_mixin import GraphModuleMixin
|
| 11 |
+
|
| 12 |
+
from typing import Optional, Final, List, Dict, Any
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass(frozen=True)
|
| 16 |
+
class CategoricalGraphFieldEmbedSpec:
|
| 17 |
+
field: str
|
| 18 |
+
num_features: int
|
| 19 |
+
min: int
|
| 20 |
+
max: int
|
| 21 |
+
init: Optional[str] = None
|
| 22 |
+
|
| 23 |
+
@classmethod
|
| 24 |
+
def from_dict(cls, field_embed: Dict[str, Any]) -> "CategoricalGraphFieldEmbedSpec":
|
| 25 |
+
required_keys: Final[List[str]] = ["field", "num_features", "min", "max"]
|
| 26 |
+
missing_keys = [key for key in required_keys if key not in field_embed]
|
| 27 |
+
assert len(missing_keys) == 0, (
|
| 28 |
+
f"missing keys {missing_keys} in `categorical_graph_field_embed` entry; required keys are {required_keys}."
|
| 29 |
+
)
|
| 30 |
+
return cls(
|
| 31 |
+
field=str(field_embed["field"]),
|
| 32 |
+
num_features=int(field_embed["num_features"]),
|
| 33 |
+
min=int(field_embed["min"]),
|
| 34 |
+
max=int(field_embed["max"]),
|
| 35 |
+
init=field_embed.get("init", None),
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class NodeTypeEmbed(GraphModuleMixin, torch.nn.Module):
|
| 40 |
+
"""Generates node type embeddings.
|
| 41 |
+
|
| 42 |
+
Args:
|
| 43 |
+
type_names (List[str]): list of type names
|
| 44 |
+
num_features (int): embedding dimension
|
| 45 |
+
type_embed_init (str): embedding initialization mode for atom type embeddings.
|
| 46 |
+
One of ``"uniform"``, ``"zero"``, ``"near_zero"``, or ``None`` (default, keep PyTorch behavior).
|
| 47 |
+
set_features (bool): ``node_features`` will be set in addition to ``node_attrs`` if ``True`` (default)
|
| 48 |
+
categorical_graph_field_embed: list of dicts, each dict having keys ``field``, ``num_features``, ``min``, ``max``, and optional ``init``.
|
| 49 |
+
``field`` must correspond to a registered graph data field.
|
| 50 |
+
The data dict for the field must be populated by an integer quantity that lies between ``min`` and ``max``.
|
| 51 |
+
"""
|
| 52 |
+
|
| 53 |
+
num_types: int
|
| 54 |
+
set_features: bool
|
| 55 |
+
type_embed_init: Optional[str]
|
| 56 |
+
|
| 57 |
+
def __init__(
|
| 58 |
+
self,
|
| 59 |
+
type_names: List[str],
|
| 60 |
+
num_features: int,
|
| 61 |
+
type_embed_init: Optional[str] = None,
|
| 62 |
+
set_features: bool = True,
|
| 63 |
+
categorical_graph_field_embed: Optional[List[Dict[str, Any]]] = None,
|
| 64 |
+
irreps_in: Optional[Dict[str, Any]] = None,
|
| 65 |
+
):
|
| 66 |
+
super().__init__()
|
| 67 |
+
# normalize optional inputs to avoid shared mutable defaults
|
| 68 |
+
irreps_in = {} if irreps_in is None else dict(irreps_in)
|
| 69 |
+
# === bookkeeping ===
|
| 70 |
+
self.num_types = len(type_names)
|
| 71 |
+
self.set_features = set_features
|
| 72 |
+
self.type_embed_init = type_embed_init
|
| 73 |
+
|
| 74 |
+
# === type embedding module ===
|
| 75 |
+
self.embed_module = torch.nn.Embedding(
|
| 76 |
+
num_embeddings=self.num_types,
|
| 77 |
+
embedding_dim=num_features,
|
| 78 |
+
)
|
| 79 |
+
self._init_embedding(self.embed_module, init=self.type_embed_init)
|
| 80 |
+
|
| 81 |
+
# === categorical graph field embedding ===
|
| 82 |
+
total_features = num_features
|
| 83 |
+
self.categorical_graph_field_embed_modules = torch.nn.ModuleDict()
|
| 84 |
+
self.categorical_graph_field_embed_shifts = {}
|
| 85 |
+
self.do_categorical_graph_field_embed = False
|
| 86 |
+
if categorical_graph_field_embed is not None:
|
| 87 |
+
self.do_categorical_graph_field_embed = True
|
| 88 |
+
for field_embed_dict in categorical_graph_field_embed:
|
| 89 |
+
field_embed = CategoricalGraphFieldEmbedSpec.from_dict(field_embed_dict)
|
| 90 |
+
assert field_embed.field in _GRAPH_FIELDS, (
|
| 91 |
+
f"`{field_embed.field}` is not a graph field, only graph fields should be provided to `categorical_graph_field_embed`."
|
| 92 |
+
)
|
| 93 |
+
assert field_embed.max >= field_embed.min, (
|
| 94 |
+
f"`max` must be >= `min` for field `{field_embed.field}`."
|
| 95 |
+
)
|
| 96 |
+
field_init = field_embed.init
|
| 97 |
+
|
| 98 |
+
# == important inits ==
|
| 99 |
+
embed_module = torch.nn.Embedding(
|
| 100 |
+
num_embeddings=field_embed.max - field_embed.min + 1,
|
| 101 |
+
embedding_dim=field_embed.num_features,
|
| 102 |
+
)
|
| 103 |
+
self._init_embedding(embed_module, init=field_init)
|
| 104 |
+
self.categorical_graph_field_embed_modules.update(
|
| 105 |
+
{field_embed.field: embed_module}
|
| 106 |
+
)
|
| 107 |
+
self.categorical_graph_field_embed_shifts.update(
|
| 108 |
+
{field_embed.field: field_embed.min}
|
| 109 |
+
)
|
| 110 |
+
# ^ we subtract this quantity to make sure the smallest index is 0
|
| 111 |
+
|
| 112 |
+
# == bookkeeping ==
|
| 113 |
+
total_features += field_embed.num_features
|
| 114 |
+
|
| 115 |
+
# register `irreps_in` if not already done
|
| 116 |
+
# needed to ensure that the field is propagated into the model
|
| 117 |
+
if field_embed.field not in irreps_in:
|
| 118 |
+
# categorical, so no irreps
|
| 119 |
+
irreps_in[field_embed.field] = None
|
| 120 |
+
|
| 121 |
+
irreps_out = {AtomicDataDict.NODE_ATTRS_KEY: Irreps([(total_features, (0, 1))])}
|
| 122 |
+
if self.set_features:
|
| 123 |
+
irreps_out[AtomicDataDict.NODE_FEATURES_KEY] = irreps_out[
|
| 124 |
+
AtomicDataDict.NODE_ATTRS_KEY
|
| 125 |
+
]
|
| 126 |
+
self._init_irreps(irreps_in=irreps_in, irreps_out=irreps_out)
|
| 127 |
+
|
| 128 |
+
@staticmethod
|
| 129 |
+
def _init_embedding(
|
| 130 |
+
module: torch.nn.Embedding,
|
| 131 |
+
init: Optional[str],
|
| 132 |
+
) -> None:
|
| 133 |
+
if init is None:
|
| 134 |
+
return
|
| 135 |
+
if init == "uniform":
|
| 136 |
+
torch.nn.init.uniform_(module.weight, -sqrt(3.0), sqrt(3.0))
|
| 137 |
+
elif init == "zero":
|
| 138 |
+
torch.nn.init.zeros_(module.weight)
|
| 139 |
+
elif init == "near_zero":
|
| 140 |
+
torch.nn.init.normal_(module.weight, mean=0.0, std=1e-5)
|
| 141 |
+
else:
|
| 142 |
+
raise ValueError(
|
| 143 |
+
f"unsupported embedding init mode `{init}`. supported modes: ('uniform', 'zero', 'near_zero') or None"
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
def forward(self, data: AtomicDataDict.Type) -> AtomicDataDict.Type:
|
| 147 |
+
# (num_atoms, 1) -> (num_atoms, num_type_features)
|
| 148 |
+
atom_types = data[AtomicDataDict.ATOM_TYPE_KEY].view(-1)
|
| 149 |
+
embedding = self.embed_module(atom_types)
|
| 150 |
+
|
| 151 |
+
# handle categorical graph field embeddings
|
| 152 |
+
if self.do_categorical_graph_field_embed:
|
| 153 |
+
embeddings = [embedding]
|
| 154 |
+
for field, module in self.categorical_graph_field_embed_modules.items():
|
| 155 |
+
# (num_graph, 1) -> (num_atoms, 1)
|
| 156 |
+
if AtomicDataDict.BATCH_KEY in data:
|
| 157 |
+
categorical_graph_field = torch.index_select(
|
| 158 |
+
data[field].view(-1), 0, data[AtomicDataDict.BATCH_KEY].view(-1)
|
| 159 |
+
)
|
| 160 |
+
else:
|
| 161 |
+
categorical_graph_field = (
|
| 162 |
+
data[field].view(-1).expand((atom_types.size(0),))
|
| 163 |
+
)
|
| 164 |
+
# (num_atoms,) -> (num_atoms, num_extra_features)
|
| 165 |
+
categorical_graph_field_embedding = module(
|
| 166 |
+
categorical_graph_field
|
| 167 |
+
- self.categorical_graph_field_embed_shifts[field]
|
| 168 |
+
)
|
| 169 |
+
embeddings.append(categorical_graph_field_embedding)
|
| 170 |
+
embedding = torch.cat(embeddings, dim=1)
|
| 171 |
+
|
| 172 |
+
data[AtomicDataDict.NODE_ATTRS_KEY] = embedding
|
| 173 |
+
if self.set_features:
|
| 174 |
+
data[AtomicDataDict.NODE_FEATURES_KEY] = embedding
|
| 175 |
+
return data
|
model/nn/embedding/node_tensor.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is a part of the `nequip` package. Please see LICENSE and README at the root for information on using it.
|
| 2 |
+
from typing import Any, Dict, List, Optional
|
| 3 |
+
|
| 4 |
+
import torch
|
| 5 |
+
|
| 6 |
+
from e3nn.o3._irreps import Irreps
|
| 7 |
+
from e3nn.o3._spherical_harmonics import SphericalHarmonics
|
| 8 |
+
|
| 9 |
+
from onescience.datapipes.materials.nequip import AtomicDataDict
|
| 10 |
+
from onescience.datapipes.materials.nequip._key_registry import get_field_type
|
| 11 |
+
from .._graph_mixin import GraphModuleMixin
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class AppendVectorFieldEmbed(GraphModuleMixin, torch.nn.Module):
|
| 15 |
+
"""Append embedded node or graph vector fields to node features.
|
| 16 |
+
|
| 17 |
+
Each field is embedded via solid harmonics up to ``l_max``.
|
| 18 |
+
The parity of the input vector must be specified per field: ``+1`` for axial vectors
|
| 19 |
+
(pseudovectors, e.g. spin, magnetic field) and ``-1`` for polar vectors (e.g. electric field).
|
| 20 |
+
|
| 21 |
+
Args:
|
| 22 |
+
vector_fields: dict mapping field name to its vector parity (+1 or -1).
|
| 23 |
+
l_max: maximum l for the solid harmonic embedding of each field.
|
| 24 |
+
append_to_node_attrs: if True, keep ``node_attrs`` equal to appended ``node_features``.
|
| 25 |
+
irreps_in: input irreps dictionary passed to ``GraphModuleMixin``.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
def __init__(
|
| 29 |
+
self,
|
| 30 |
+
vector_fields: Dict[str, int],
|
| 31 |
+
l_max: int,
|
| 32 |
+
append_to_node_attrs: bool = True,
|
| 33 |
+
irreps_in: Optional[Dict[str, Any]] = None,
|
| 34 |
+
):
|
| 35 |
+
super().__init__()
|
| 36 |
+
|
| 37 |
+
irreps_in = {} if irreps_in is None else dict(irreps_in)
|
| 38 |
+
self.append_to_node_attrs = append_to_node_attrs
|
| 39 |
+
|
| 40 |
+
assert AtomicDataDict.NODE_FEATURES_KEY in irreps_in, (
|
| 41 |
+
f"`{AtomicDataDict.NODE_FEATURES_KEY}` must be present in `irreps_in`"
|
| 42 |
+
)
|
| 43 |
+
if self.append_to_node_attrs:
|
| 44 |
+
assert AtomicDataDict.NODE_ATTRS_KEY in irreps_in, (
|
| 45 |
+
f"`{AtomicDataDict.NODE_ATTRS_KEY}` must be present in `irreps_in` when `append_to_node_attrs=True`"
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
assert len(vector_fields) > 0, "`vector_fields` cannot be empty"
|
| 49 |
+
assert all(p in (1, -1) for p in vector_fields.values()), (
|
| 50 |
+
"all parity values in `vector_fields` must be +1 (axial) or -1 (polar)"
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
# preserve insertion order for consistent forward indexing
|
| 54 |
+
self.vector_fields: List[str] = list(vector_fields.keys())
|
| 55 |
+
self.field_kinds: Dict[str, str] = self._validate_fields(self.vector_fields)
|
| 56 |
+
|
| 57 |
+
# per-field SH modules; e3nn infers irreps_in ("1e" or "1o") from the output irreps
|
| 58 |
+
sh_modules = []
|
| 59 |
+
extra_irreps = Irreps()
|
| 60 |
+
for field, parity in vector_fields.items():
|
| 61 |
+
required_irreps = Irreps("1e" if parity == 1 else "1o")
|
| 62 |
+
if field in irreps_in:
|
| 63 |
+
assert irreps_in[field] == required_irreps, (
|
| 64 |
+
f"`{field}` must have irreps {required_irreps} for parity {parity:+d}, "
|
| 65 |
+
f"but got {irreps_in[field]}"
|
| 66 |
+
)
|
| 67 |
+
else:
|
| 68 |
+
irreps_in[field] = required_irreps
|
| 69 |
+
|
| 70 |
+
# degree-l SH of a parity-p vector transforms as (l, p**l):
|
| 71 |
+
# axial (p=+1): all even — 0e, 1e, 2e, ...
|
| 72 |
+
# polar (p=-1): alternating — 0e, 1o, 2e, ...
|
| 73 |
+
# e3nn validates this and auto-infers irreps_in from these labels
|
| 74 |
+
field_sh_irreps = Irreps([(1, (l, parity**l)) for l in range(l_max + 1)])
|
| 75 |
+
# don't normalize SH for field vectors; this gives solid harmonics
|
| 76 |
+
sh_modules.append(
|
| 77 |
+
SphericalHarmonics(
|
| 78 |
+
field_sh_irreps, normalize=False, normalization="component"
|
| 79 |
+
)
|
| 80 |
+
)
|
| 81 |
+
extra_irreps += field_sh_irreps
|
| 82 |
+
|
| 83 |
+
self.sh_modules = torch.nn.ModuleList(sh_modules)
|
| 84 |
+
|
| 85 |
+
irreps_out = {
|
| 86 |
+
AtomicDataDict.NODE_FEATURES_KEY: (
|
| 87 |
+
irreps_in[AtomicDataDict.NODE_FEATURES_KEY] + extra_irreps
|
| 88 |
+
)
|
| 89 |
+
}
|
| 90 |
+
if self.append_to_node_attrs:
|
| 91 |
+
irreps_out[AtomicDataDict.NODE_ATTRS_KEY] = (
|
| 92 |
+
irreps_in[AtomicDataDict.NODE_ATTRS_KEY] + extra_irreps
|
| 93 |
+
)
|
| 94 |
+
required_irreps_in = [AtomicDataDict.NODE_FEATURES_KEY]
|
| 95 |
+
if self.append_to_node_attrs:
|
| 96 |
+
required_irreps_in.append(AtomicDataDict.NODE_ATTRS_KEY)
|
| 97 |
+
required_irreps_in.extend(self.vector_fields)
|
| 98 |
+
|
| 99 |
+
self._init_irreps(
|
| 100 |
+
irreps_in=irreps_in,
|
| 101 |
+
required_irreps_in=required_irreps_in,
|
| 102 |
+
irreps_out=irreps_out,
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
self.model_dtype = torch.get_default_dtype()
|
| 106 |
+
|
| 107 |
+
def __repr__(self) -> str:
|
| 108 |
+
lines = [f"{self.__class__.__name__}("]
|
| 109 |
+
for field, sh in zip(self.vector_fields, self.sh_modules):
|
| 110 |
+
lines.append(f" {field}: {sh.irreps_in} -> {sh.irreps_out},")
|
| 111 |
+
lines.append(
|
| 112 |
+
f" node_features: {self.irreps_in[AtomicDataDict.NODE_FEATURES_KEY]}"
|
| 113 |
+
f" -> {self.irreps_out[AtomicDataDict.NODE_FEATURES_KEY]}"
|
| 114 |
+
)
|
| 115 |
+
lines.append(")")
|
| 116 |
+
return "\n".join(lines)
|
| 117 |
+
|
| 118 |
+
@staticmethod
|
| 119 |
+
def _validate_fields(vector_fields: List[str]) -> Dict[str, str]:
|
| 120 |
+
assert len(vector_fields) > 0, "`vector_fields` cannot be empty"
|
| 121 |
+
field_kinds = {}
|
| 122 |
+
for field in vector_fields:
|
| 123 |
+
field_kind = get_field_type(field, error_on_unregistered=True)
|
| 124 |
+
assert field_kind in ("graph", "node"), (
|
| 125 |
+
f"`{field}` has field type `{field_kind}` but only graph/node fields can be appended"
|
| 126 |
+
)
|
| 127 |
+
field_kinds[field] = field_kind
|
| 128 |
+
return field_kinds
|
| 129 |
+
|
| 130 |
+
def _field_to_per_node(
|
| 131 |
+
self,
|
| 132 |
+
data: AtomicDataDict.Type,
|
| 133 |
+
field: str,
|
| 134 |
+
num_nodes: int,
|
| 135 |
+
) -> torch.Tensor:
|
| 136 |
+
value = data[field].view(-1, 3)
|
| 137 |
+
field_kind = self.field_kinds[field]
|
| 138 |
+
# short-circuit of node case
|
| 139 |
+
if field_kind == "node":
|
| 140 |
+
return value
|
| 141 |
+
|
| 142 |
+
# (num_graph, 3) -> (num_nodes, 3)
|
| 143 |
+
if AtomicDataDict.BATCH_KEY in data:
|
| 144 |
+
batch = data[AtomicDataDict.BATCH_KEY].view(-1)
|
| 145 |
+
return torch.index_select(value, 0, batch)
|
| 146 |
+
# unbatched case -> all nodes get same value
|
| 147 |
+
return value.expand(num_nodes, 3)
|
| 148 |
+
|
| 149 |
+
def forward(self, data: AtomicDataDict.Type) -> AtomicDataDict.Type:
|
| 150 |
+
node_features = data[AtomicDataDict.NODE_FEATURES_KEY]
|
| 151 |
+
|
| 152 |
+
embedded_fields = []
|
| 153 |
+
for i, sh in enumerate(self.sh_modules):
|
| 154 |
+
per_node_vector = self._field_to_per_node(
|
| 155 |
+
data=data,
|
| 156 |
+
field=self.vector_fields[i],
|
| 157 |
+
num_nodes=node_features.size(0),
|
| 158 |
+
)
|
| 159 |
+
embedded_fields.append(sh(per_node_vector).to(dtype=self.model_dtype))
|
| 160 |
+
|
| 161 |
+
# build the concatenation input list explicitly to satisfy TorchScript
|
| 162 |
+
cat_inputs = [node_features]
|
| 163 |
+
for embedded in embedded_fields:
|
| 164 |
+
cat_inputs.append(embedded)
|
| 165 |
+
node_features = torch.cat(cat_inputs, dim=1)
|
| 166 |
+
data[AtomicDataDict.NODE_FEATURES_KEY] = node_features
|
| 167 |
+
|
| 168 |
+
if self.append_to_node_attrs:
|
| 169 |
+
data[AtomicDataDict.NODE_ATTRS_KEY] = node_features
|
| 170 |
+
|
| 171 |
+
return data
|
model/nn/embedding/utils.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is a part of the `nequip` package. Please see LICENSE and README at the root for information on using it.
|
| 2 |
+
|
| 3 |
+
from typing import List, Dict, Union
|
| 4 |
+
import torch
|
| 5 |
+
|
| 6 |
+
from onescience.utils.nequip.internal.global_dtype import _GLOBAL_DTYPE
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
# conversion flow: partial_dict -> full_dict -> tensor -> str
|
| 10 |
+
# |
|
| 11 |
+
# v
|
| 12 |
+
# full_dict
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def cutoff_partialdict_to_fulldict(
|
| 16 |
+
partial_dict: Dict[str, Union[float, Dict[str, float]]],
|
| 17 |
+
type_names: List[str],
|
| 18 |
+
r_max: float,
|
| 19 |
+
) -> Dict[str, Dict[str, float]]:
|
| 20 |
+
"""Convert partial cutoff dict to full dict with all entries.
|
| 21 |
+
|
| 22 |
+
Fills missing entries with ``r_max``.
|
| 23 |
+
|
| 24 |
+
Args:
|
| 25 |
+
partial_dict: partial specification from config,
|
| 26 |
+
e.g. ``{"H": 2.0, "C": {"H": 4.0, "C": 3.5}}``
|
| 27 |
+
type_names: list of atom type names
|
| 28 |
+
r_max: global cutoff radius (default for missing entries)
|
| 29 |
+
|
| 30 |
+
Returns:
|
| 31 |
+
full dict with all source -> target pairs specified,
|
| 32 |
+
e.g. ``{"H": {"H": 2.0, "C": 2.0}, "C": {"H": 4.0, "C": 3.5}}``
|
| 33 |
+
"""
|
| 34 |
+
full_dict = {}
|
| 35 |
+
for source_type in type_names:
|
| 36 |
+
full_dict[source_type] = {}
|
| 37 |
+
if source_type in partial_dict:
|
| 38 |
+
entry = partial_dict[source_type]
|
| 39 |
+
if isinstance(entry, float):
|
| 40 |
+
# uniform cutoff for this source type
|
| 41 |
+
for target_type in type_names:
|
| 42 |
+
full_dict[source_type][target_type] = entry
|
| 43 |
+
else:
|
| 44 |
+
# per-target specification
|
| 45 |
+
for target_type in type_names:
|
| 46 |
+
if target_type in entry:
|
| 47 |
+
full_dict[source_type][target_type] = entry[target_type]
|
| 48 |
+
else:
|
| 49 |
+
# missing target defaults to r_max
|
| 50 |
+
full_dict[source_type][target_type] = r_max
|
| 51 |
+
else:
|
| 52 |
+
# missing source defaults to r_max for all targets
|
| 53 |
+
for target_type in type_names:
|
| 54 |
+
full_dict[source_type][target_type] = r_max
|
| 55 |
+
|
| 56 |
+
return full_dict
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def cutoff_fulldict_to_tensor(
|
| 60 |
+
full_dict: Dict[str, Dict[str, float]],
|
| 61 |
+
type_names: List[str],
|
| 62 |
+
) -> torch.Tensor:
|
| 63 |
+
"""Convert full cutoff dict to tensor.
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
full_dict: full specification with all source -> target pairs
|
| 67 |
+
type_names: list of atom type names
|
| 68 |
+
|
| 69 |
+
Returns:
|
| 70 |
+
tensor of shape ``(num_types, num_types)`` with per-edge-type cutoffs
|
| 71 |
+
"""
|
| 72 |
+
num_types = len(type_names)
|
| 73 |
+
cutoff_list = []
|
| 74 |
+
for source_type in type_names:
|
| 75 |
+
row = []
|
| 76 |
+
for target_type in type_names:
|
| 77 |
+
row.append(full_dict[source_type][target_type])
|
| 78 |
+
cutoff_list.append(row)
|
| 79 |
+
|
| 80 |
+
cutoff_tensor = torch.as_tensor(cutoff_list, dtype=_GLOBAL_DTYPE).contiguous()
|
| 81 |
+
assert cutoff_tensor.shape == (num_types, num_types)
|
| 82 |
+
assert torch.all(cutoff_tensor > 0)
|
| 83 |
+
return cutoff_tensor
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def cutoff_tensor_to_str(cutoff_tensor: torch.Tensor) -> str:
|
| 87 |
+
"""Convert tensor to metadata string format.
|
| 88 |
+
|
| 89 |
+
Args:
|
| 90 |
+
cutoff_tensor: cutoff values as tensor (any shape, will be flattened)
|
| 91 |
+
|
| 92 |
+
Returns:
|
| 93 |
+
space-separated string of cutoff values in row-major order
|
| 94 |
+
"""
|
| 95 |
+
return " ".join(str(r.item()) for r in cutoff_tensor.reshape(-1))
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def cutoff_str_to_fulldict(
|
| 99 |
+
cutoff_str: str,
|
| 100 |
+
type_names: List[str],
|
| 101 |
+
) -> Dict[str, Dict[str, float]]:
|
| 102 |
+
"""Convert metadata string to full dict format.
|
| 103 |
+
|
| 104 |
+
Args:
|
| 105 |
+
cutoff_str: space-separated string of cutoff values
|
| 106 |
+
type_names: list of atom type names
|
| 107 |
+
|
| 108 |
+
Returns:
|
| 109 |
+
full dict with all source -> target pairs specified
|
| 110 |
+
"""
|
| 111 |
+
if cutoff_str in ("", None):
|
| 112 |
+
return None
|
| 113 |
+
|
| 114 |
+
cutoff_values = [float(x) for x in cutoff_str.split()]
|
| 115 |
+
num_types = len(type_names)
|
| 116 |
+
|
| 117 |
+
assert len(cutoff_values) == num_types * num_types, (
|
| 118 |
+
f"Expected {num_types * num_types} cutoff values, got {len(cutoff_values)}"
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
full_dict = {}
|
| 122 |
+
for i, source_type in enumerate(type_names):
|
| 123 |
+
full_dict[source_type] = {}
|
| 124 |
+
for j, target_type in enumerate(type_names):
|
| 125 |
+
full_dict[source_type][target_type] = cutoff_values[i * num_types + j]
|
| 126 |
+
|
| 127 |
+
return full_dict
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def cutoff_partialdict_to_tensor(
|
| 131 |
+
partial_dict: Dict[str, Union[float, Dict[str, float]]],
|
| 132 |
+
type_names: List[str],
|
| 133 |
+
r_max: float,
|
| 134 |
+
) -> torch.Tensor:
|
| 135 |
+
"""Composes ``cutoff_partialdict_to_fulldict`` and ``cutoff_fulldict_to_tensor``."""
|
| 136 |
+
full_dict = cutoff_partialdict_to_fulldict(partial_dict, type_names, r_max)
|
| 137 |
+
cutoff_tensor = cutoff_fulldict_to_tensor(full_dict, type_names)
|
| 138 |
+
assert torch.all(cutoff_tensor <= r_max)
|
| 139 |
+
return cutoff_tensor
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def cutoff_partialdict_to_str(
|
| 143 |
+
partial_dict: Dict[str, Union[float, Dict[str, float]]],
|
| 144 |
+
type_names: List[str],
|
| 145 |
+
r_max: float,
|
| 146 |
+
) -> str:
|
| 147 |
+
"""Composes ``cutoff_partialdict_to_fulldict``, ``cutoff_fulldict_to_tensor``, and ``cutoff_tensor_to_str``."""
|
| 148 |
+
full_dict = cutoff_partialdict_to_fulldict(partial_dict, type_names, r_max)
|
| 149 |
+
tensor = cutoff_fulldict_to_tensor(full_dict, type_names)
|
| 150 |
+
return cutoff_tensor_to_str(tensor)
|
model/nn/grad_output.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is a part of the `nequip` package. Please see LICENSE and README at the root for information on using it.
|
| 2 |
+
|
| 3 |
+
import torch
|
| 4 |
+
|
| 5 |
+
from e3nn.o3._irreps import Irreps
|
| 6 |
+
from e3nn.util.jit import compile_mode
|
| 7 |
+
|
| 8 |
+
from onescience.datapipes.materials.nequip import AtomicDataDict
|
| 9 |
+
from ._graph_mixin import GraphModuleMixin
|
| 10 |
+
from .model_modifier_utils import model_modifier, replace_submodules
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@compile_mode("unsupported")
|
| 14 |
+
class PartialForceOutput(GraphModuleMixin, torch.nn.Module):
|
| 15 |
+
r"""Generate partial and total forces from an energy model.
|
| 16 |
+
|
| 17 |
+
Args:
|
| 18 |
+
func: the energy model
|
| 19 |
+
vectorize: the vectorize option to ``torch.autograd.functional.jacobian``,
|
| 20 |
+
false by default since it doesn't work well.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
vectorize: bool
|
| 24 |
+
|
| 25 |
+
def __init__(
|
| 26 |
+
self,
|
| 27 |
+
func: GraphModuleMixin,
|
| 28 |
+
vectorize: bool = False,
|
| 29 |
+
vectorize_warnings: bool = False,
|
| 30 |
+
):
|
| 31 |
+
super().__init__()
|
| 32 |
+
self.func = func
|
| 33 |
+
self.vectorize = vectorize
|
| 34 |
+
if vectorize_warnings:
|
| 35 |
+
# See https://pytorch.org/docs/stable/generated/torch.autograd.functional.jacobian.html
|
| 36 |
+
torch._C._debug_only_display_vmap_fallback_warnings(True)
|
| 37 |
+
|
| 38 |
+
# check and init irreps
|
| 39 |
+
self._init_irreps(
|
| 40 |
+
irreps_in=func.irreps_in,
|
| 41 |
+
my_irreps_in={AtomicDataDict.PER_ATOM_ENERGY_KEY: Irreps("0e")},
|
| 42 |
+
irreps_out=func.irreps_out,
|
| 43 |
+
)
|
| 44 |
+
self.irreps_out[AtomicDataDict.PARTIAL_FORCE_KEY] = Irreps("1o")
|
| 45 |
+
self.irreps_out[AtomicDataDict.FORCE_KEY] = Irreps("1o")
|
| 46 |
+
|
| 47 |
+
def forward(self, data: AtomicDataDict.Type) -> AtomicDataDict.Type:
|
| 48 |
+
data = data.copy()
|
| 49 |
+
out_data = {}
|
| 50 |
+
|
| 51 |
+
def wrapper(pos: torch.Tensor) -> torch.Tensor:
|
| 52 |
+
"""Wrapper from pos to atomic energy"""
|
| 53 |
+
nonlocal data, out_data
|
| 54 |
+
data[AtomicDataDict.POSITIONS_KEY] = pos
|
| 55 |
+
out_data = self.func(data)
|
| 56 |
+
return out_data[AtomicDataDict.PER_ATOM_ENERGY_KEY].squeeze(-1)
|
| 57 |
+
|
| 58 |
+
pos = data[AtomicDataDict.POSITIONS_KEY]
|
| 59 |
+
|
| 60 |
+
partial_forces = torch.autograd.functional.jacobian(
|
| 61 |
+
func=wrapper,
|
| 62 |
+
inputs=pos,
|
| 63 |
+
create_graph=self.training, # needed to allow gradients of this output during training
|
| 64 |
+
vectorize=self.vectorize,
|
| 65 |
+
)
|
| 66 |
+
partial_forces = partial_forces.negative()
|
| 67 |
+
# output is [n_at, n_at, 3]
|
| 68 |
+
|
| 69 |
+
out_data[AtomicDataDict.PARTIAL_FORCE_KEY] = partial_forces
|
| 70 |
+
out_data[AtomicDataDict.FORCE_KEY] = partial_forces.sum(dim=0)
|
| 71 |
+
|
| 72 |
+
return out_data
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@compile_mode("script")
|
| 76 |
+
class ForceStressOutput(GraphModuleMixin, torch.nn.Module):
|
| 77 |
+
r"""Compute forces (and stress if cell is provided) using autograd of an energy model.
|
| 78 |
+
|
| 79 |
+
See:
|
| 80 |
+
Knuth et. al. Comput. Phys. Commun 190, 33-50, 2015
|
| 81 |
+
https://pure.mpg.de/rest/items/item_2085135_9/component/file_2156800/content
|
| 82 |
+
|
| 83 |
+
Args:
|
| 84 |
+
func: the energy model to wrap
|
| 85 |
+
"""
|
| 86 |
+
|
| 87 |
+
do_derivatives: bool
|
| 88 |
+
|
| 89 |
+
def __init__(self, func: GraphModuleMixin, do_derivatives: bool = True):
|
| 90 |
+
super().__init__()
|
| 91 |
+
self.func = func
|
| 92 |
+
self.do_derivatives = do_derivatives
|
| 93 |
+
|
| 94 |
+
# check and init irreps
|
| 95 |
+
self._init_irreps(
|
| 96 |
+
irreps_in=self.func.irreps_in.copy(),
|
| 97 |
+
irreps_out=self.func.irreps_out.copy(),
|
| 98 |
+
)
|
| 99 |
+
self.irreps_out[AtomicDataDict.FORCE_KEY] = "1o"
|
| 100 |
+
self.irreps_out[AtomicDataDict.STRESS_KEY] = "1o"
|
| 101 |
+
self.irreps_out[AtomicDataDict.VIRIAL_KEY] = "1o"
|
| 102 |
+
self.irreps_out[AtomicDataDict.EDGE_FORCE_KEY] = "1o"
|
| 103 |
+
|
| 104 |
+
# for torchscript compat
|
| 105 |
+
self.register_buffer("_empty", torch.Tensor())
|
| 106 |
+
|
| 107 |
+
def forward(self, data: AtomicDataDict.Type) -> AtomicDataDict.Type:
|
| 108 |
+
# short-circuit
|
| 109 |
+
if not self.do_derivatives:
|
| 110 |
+
return self.func(data)
|
| 111 |
+
|
| 112 |
+
# === LOGIC BRANCHING NOTES ===
|
| 113 |
+
# if edge vectors not present, we assume that positions are present
|
| 114 |
+
# and proceed with the usual procedure to compute forces, virials, stress
|
| 115 |
+
# else, we compute edge forces
|
| 116 |
+
|
| 117 |
+
# NOTE: if edge vectors are not present, we assume that it is for non-batched inference with no cell
|
| 118 |
+
# at the point of making this change, it is specifically for LAMMPS-MLIAP compatibility
|
| 119 |
+
if AtomicDataDict.EDGE_VECTORS_KEY not in data:
|
| 120 |
+
if AtomicDataDict.BATCH_KEY in data:
|
| 121 |
+
batch = data[AtomicDataDict.BATCH_KEY]
|
| 122 |
+
num_batch: int = AtomicDataDict.num_frames(data)
|
| 123 |
+
else:
|
| 124 |
+
# Special case for efficiency
|
| 125 |
+
batch = self._empty
|
| 126 |
+
num_batch: int = 1
|
| 127 |
+
|
| 128 |
+
pos = data[AtomicDataDict.POSITIONS_KEY]
|
| 129 |
+
has_cell: bool = AtomicDataDict.CELL_KEY in data
|
| 130 |
+
|
| 131 |
+
if has_cell:
|
| 132 |
+
orig_cell = data[AtomicDataDict.CELL_KEY]
|
| 133 |
+
# Make the cell per-batch
|
| 134 |
+
cell = orig_cell.view(-1, 3, 3).expand(num_batch, 3, 3)
|
| 135 |
+
data[AtomicDataDict.CELL_KEY] = cell
|
| 136 |
+
else:
|
| 137 |
+
# torchscript
|
| 138 |
+
orig_cell = self._empty
|
| 139 |
+
cell = self._empty
|
| 140 |
+
# Add the displacements
|
| 141 |
+
# the GradientOutput will make them require grad
|
| 142 |
+
# See SchNetPack code:
|
| 143 |
+
# https://github.com/atomistic-machine-learning/schnetpack/blob/master/src/schnetpack/atomistic/model.py#L45
|
| 144 |
+
# SchNetPack issue:
|
| 145 |
+
# https://github.com/atomistic-machine-learning/schnetpack/issues/165
|
| 146 |
+
# Paper they worked from:
|
| 147 |
+
# Knuth et. al. Comput. Phys. Commun 190, 33-50, 2015
|
| 148 |
+
# https://pure.mpg.de/rest/items/item_2085135_9/component/file_2156800/content
|
| 149 |
+
|
| 150 |
+
if num_batch > 1:
|
| 151 |
+
displacement = torch.zeros(
|
| 152 |
+
(num_batch, 3, 3),
|
| 153 |
+
dtype=pos.dtype,
|
| 154 |
+
device=pos.device,
|
| 155 |
+
)
|
| 156 |
+
else:
|
| 157 |
+
displacement = torch.zeros(
|
| 158 |
+
(3, 3),
|
| 159 |
+
dtype=pos.dtype,
|
| 160 |
+
device=pos.device,
|
| 161 |
+
)
|
| 162 |
+
displacement.requires_grad_(True)
|
| 163 |
+
data["_displacement"] = displacement
|
| 164 |
+
# in the above paper, the infinitesimal distortion is *symmetric*
|
| 165 |
+
# so we symmetrize the displacement before applying it to
|
| 166 |
+
# the positions/cell
|
| 167 |
+
# This is not strictly necessary (reasoning thanks to Mario):
|
| 168 |
+
# the displacement's asymmetric 1o term corresponds to an
|
| 169 |
+
# infinitesimal rotation, which should not affect the final
|
| 170 |
+
# output (invariance).
|
| 171 |
+
# That said, due to numerical error, this will never be
|
| 172 |
+
# exactly true. So, we symmetrize the deformation to
|
| 173 |
+
# take advantage of this understanding and not rely on
|
| 174 |
+
# the invariance here:
|
| 175 |
+
symmetric_displacement = 0.5 * (
|
| 176 |
+
displacement + displacement.transpose(-1, -2)
|
| 177 |
+
)
|
| 178 |
+
did_pos_req_grad: bool = pos.requires_grad
|
| 179 |
+
pos.requires_grad_(True)
|
| 180 |
+
if num_batch > 1:
|
| 181 |
+
# bmm is natom in batch
|
| 182 |
+
# batched [natom, 1, 3] @ [natom, 3, 3] -> [natom, 1, 3] -> [natom, 3]
|
| 183 |
+
data[AtomicDataDict.POSITIONS_KEY] = pos + torch.bmm(
|
| 184 |
+
pos.unsqueeze(-2),
|
| 185 |
+
torch.index_select(symmetric_displacement, 0, batch),
|
| 186 |
+
).squeeze(-2)
|
| 187 |
+
else:
|
| 188 |
+
# (num_atoms, 3), (3, 3) -> (num_atoms, 3)
|
| 189 |
+
data[AtomicDataDict.POSITIONS_KEY] = pos + torch.sum(
|
| 190 |
+
pos.view(-1, 3, 1) * symmetric_displacement, 1
|
| 191 |
+
)
|
| 192 |
+
# assert torch.equal(pos, data[AtomicDataDict.POSITIONS_KEY])
|
| 193 |
+
# we only displace the cell if we have one:
|
| 194 |
+
if has_cell:
|
| 195 |
+
# bmm is num_batch in batch
|
| 196 |
+
# here we apply the distortion to the cell as well
|
| 197 |
+
# this is critical also for the correctness
|
| 198 |
+
# if we didn't symmetrize the distortion, since without this
|
| 199 |
+
# there would then be an infinitesimal rotation of the positions
|
| 200 |
+
# but not cell, and it thus wouldn't be global and have
|
| 201 |
+
# no effect due to equivariance/invariance.
|
| 202 |
+
if num_batch > 1:
|
| 203 |
+
# [n_batch, 3, 3] @ [n_batch, 3, 3]
|
| 204 |
+
data[AtomicDataDict.CELL_KEY] = cell + torch.bmm(
|
| 205 |
+
cell, symmetric_displacement
|
| 206 |
+
)
|
| 207 |
+
else:
|
| 208 |
+
# [3, 3] @ [3, 3] --- enforced to these shapes
|
| 209 |
+
data[AtomicDataDict.CELL_KEY] = (
|
| 210 |
+
cell.view(3, 3)
|
| 211 |
+
+ torch.sum(cell.view(3, 3, 1) * symmetric_displacement, 1)
|
| 212 |
+
).view(1, 3, 3)
|
| 213 |
+
|
| 214 |
+
# Call model and get gradients
|
| 215 |
+
data = self.func(data)
|
| 216 |
+
|
| 217 |
+
grads = torch.autograd.grad(
|
| 218 |
+
[data[AtomicDataDict.TOTAL_ENERGY_KEY].sum()],
|
| 219 |
+
[pos, data["_displacement"]],
|
| 220 |
+
create_graph=self.training, # needed to allow gradients of this output during training
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
# Put negative sign on forces
|
| 224 |
+
forces = grads[0]
|
| 225 |
+
if forces is None:
|
| 226 |
+
# condition needed to unwrap optional for torchscript
|
| 227 |
+
assert False, "failed to compute forces autograd"
|
| 228 |
+
forces = torch.neg(forces)
|
| 229 |
+
data[AtomicDataDict.FORCE_KEY] = forces
|
| 230 |
+
|
| 231 |
+
# Store virial
|
| 232 |
+
virial = grads[1]
|
| 233 |
+
if virial is None:
|
| 234 |
+
# condition needed to unwrap optional for torchscript
|
| 235 |
+
assert False, "failed to compute virial autograd"
|
| 236 |
+
virial = virial.view(num_batch, 3, 3)
|
| 237 |
+
|
| 238 |
+
# we only compute the stress (1/V * virial) if we have a cell whose volume we can compute
|
| 239 |
+
if has_cell:
|
| 240 |
+
# ^ can only scale by cell volume if we have one...:
|
| 241 |
+
# Rescale stress tensor
|
| 242 |
+
# See https://github.com/atomistic-machine-learning/schnetpack/blob/master/src/schnetpack/atomistic/output_modules.py#L180
|
| 243 |
+
# See also https://en.wikipedia.org/wiki/Triple_product
|
| 244 |
+
# See also https://gitlab.com/ase/ase/-/blob/master/ase/cell.py,
|
| 245 |
+
# which uses np.abs(np.linalg.det(cell))
|
| 246 |
+
# First dim is batch, second is vec, third is xyz
|
| 247 |
+
# Note the .abs(), since volume should always be positive
|
| 248 |
+
# det is equal to a dot (b cross c)
|
| 249 |
+
volume = torch.linalg.det(cell).abs().unsqueeze(-1)
|
| 250 |
+
|
| 251 |
+
# NOTE: to support batching periodic and non-periodic structures together,
|
| 252 |
+
# the data processing stage is responsible for ensuring that:
|
| 253 |
+
# 1. non-periodic systems have a finite dummy cell to prevent infs in the division below
|
| 254 |
+
# 2. stress labels for non-periodic systems are NaN and handled with `ignore_nan` in loss and metrics
|
| 255 |
+
|
| 256 |
+
stress = virial / volume.view(num_batch, 1, 1)
|
| 257 |
+
data[AtomicDataDict.CELL_KEY] = orig_cell
|
| 258 |
+
else:
|
| 259 |
+
stress = self._empty # torchscript
|
| 260 |
+
data[AtomicDataDict.STRESS_KEY] = stress
|
| 261 |
+
|
| 262 |
+
# see discussion in https://github.com/libAtoms/QUIP/issues/227 about sign convention
|
| 263 |
+
# (and conventions docs page)
|
| 264 |
+
# they say the standard convention is virial = -stress x volume
|
| 265 |
+
# looking above this means that we need to pick up another negative sign for the virial
|
| 266 |
+
# to fit this equation with the stress computed above
|
| 267 |
+
virial = torch.neg(virial)
|
| 268 |
+
data[AtomicDataDict.VIRIAL_KEY] = virial
|
| 269 |
+
|
| 270 |
+
# Remove helper
|
| 271 |
+
del data["_displacement"]
|
| 272 |
+
if not did_pos_req_grad:
|
| 273 |
+
# don't give later modules one that does
|
| 274 |
+
pos.requires_grad_(False)
|
| 275 |
+
|
| 276 |
+
else:
|
| 277 |
+
# we differentiate wrt EDGE_VECTORS_KEY directly in this branch
|
| 278 |
+
# NOTE: we only consider the case of non-batched inference, without a cell
|
| 279 |
+
# so no batching, no training considerations, no cell
|
| 280 |
+
|
| 281 |
+
# make `edge_vectors` requires grad
|
| 282 |
+
edge_vectors = data[AtomicDataDict.EDGE_VECTORS_KEY]
|
| 283 |
+
edge_vectors.requires_grad_(True)
|
| 284 |
+
data[AtomicDataDict.EDGE_VECTORS_KEY] = edge_vectors
|
| 285 |
+
|
| 286 |
+
# do energy model forward and backward
|
| 287 |
+
data = self.func(data)
|
| 288 |
+
edge_forces = torch.autograd.grad(
|
| 289 |
+
[data[AtomicDataDict.TOTAL_ENERGY_KEY].sum()],
|
| 290 |
+
[edge_vectors],
|
| 291 |
+
# no training arg because we only consider inference
|
| 292 |
+
)[0]
|
| 293 |
+
# assert needed for TorchScript
|
| 294 |
+
assert edge_forces is not None
|
| 295 |
+
# NOTE: there shouldn't be a sign flip to match LAMMPS convention
|
| 296 |
+
data[AtomicDataDict.EDGE_FORCE_KEY] = edge_forces
|
| 297 |
+
|
| 298 |
+
return data
|
| 299 |
+
|
| 300 |
+
@model_modifier(persistent=True, private=False)
|
| 301 |
+
@classmethod
|
| 302 |
+
def enable_ForceStressOutput(cls, model):
|
| 303 |
+
"""Enable force and stress computation."""
|
| 304 |
+
|
| 305 |
+
def factory(old):
|
| 306 |
+
new = cls(func=old.func, do_derivatives=True)
|
| 307 |
+
return new
|
| 308 |
+
|
| 309 |
+
return replace_submodules(model, cls, factory)
|
| 310 |
+
|
| 311 |
+
@model_modifier(persistent=True, private=False)
|
| 312 |
+
@classmethod
|
| 313 |
+
def disable_ForceStressOutput(cls, model):
|
| 314 |
+
"""Disable force and stress computation."""
|
| 315 |
+
|
| 316 |
+
def factory(old):
|
| 317 |
+
new = cls(func=old.func, do_derivatives=False)
|
| 318 |
+
return new
|
| 319 |
+
|
| 320 |
+
return replace_submodules(model, cls, factory)
|