dadadaxi commited on
Commit
3e02ab8
·
verified ·
1 Parent(s): 71b7534

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +23 -9
  2. LICENSE +203 -0
  3. README.md +221 -0
  4. demo/_parse_config.py +115 -0
  5. demo/configs/oam_l_finetune.yaml +104 -0
  6. demo/configs/oam_l_finetune_smoke.yaml +95 -0
  7. demo/configs/tutorial_fcu.yaml +130 -0
  8. demo/configs/tutorial_fcu_8dcu.yaml +140 -0
  9. demo/configs/tutorial_fcu_full.yaml +131 -0
  10. demo/configs/tutorial_smoke.yaml +146 -0
  11. demo/configs/tutorial_smoke_8dcu.yaml +133 -0
  12. demo/download_tutorial_data.py +64 -0
  13. demo/prepare_smoke_data.py +35 -0
  14. demo/run.sh +162 -0
  15. energy_volume.py +129 -0
  16. model/__init__.py +41 -0
  17. model/_version.py +5 -0
  18. model/model/__init__.py +29 -0
  19. model/model/energy_modules.py +35 -0
  20. model/model/inference_models/__init__.py +7 -0
  21. model/model/inference_models/aotinductor.py +128 -0
  22. model/model/inference_models/compiled.py +60 -0
  23. model/model/inference_models/torchscript.py +73 -0
  24. model/model/modify_utils.py +131 -0
  25. model/model/nequip_models.py +399 -0
  26. model/model/pair_potential.py +50 -0
  27. model/model/param_groups.py +97 -0
  28. model/model/saved_models/__init__.py +12 -0
  29. model/model/saved_models/_utils.py +33 -0
  30. model/model/saved_models/checkpoint.py +148 -0
  31. model/model/saved_models/load_utils.py +150 -0
  32. model/model/saved_models/package.py +190 -0
  33. model/model/utils.py +230 -0
  34. model/nn/__init__.py +45 -0
  35. model/nn/_ghost_exchange_base.py +57 -0
  36. model/nn/_ghost_exchange_lmp_mliap.py +64 -0
  37. model/nn/_graph_mixin.py +238 -0
  38. model/nn/_tp_scatter_base.py +109 -0
  39. model/nn/_tp_scatter_cueq.py +122 -0
  40. model/nn/_tp_scatter_oeq.py +57 -0
  41. model/nn/atomwise.py +378 -0
  42. model/nn/compile.py +236 -0
  43. model/nn/convnetlayer.py +170 -0
  44. model/nn/embedding/__init__.py +20 -0
  45. model/nn/embedding/_edge.py +223 -0
  46. model/nn/embedding/cutoffs.py +27 -0
  47. model/nn/embedding/node.py +175 -0
  48. model/nn/embedding/node_tensor.py +171 -0
  49. model/nn/embedding/utils.py +150 -0
  50. 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)