File size: 13,604 Bytes
3e02ab8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
# This file is a part of the `nequip` package. Please see LICENSE and README at the root for information on using it.

import torch

from e3nn.o3._irreps import Irreps
from e3nn.util.jit import compile_mode

from onescience.datapipes.materials.nequip import AtomicDataDict
from ._graph_mixin import GraphModuleMixin
from .model_modifier_utils import model_modifier, replace_submodules


@compile_mode("unsupported")
class PartialForceOutput(GraphModuleMixin, torch.nn.Module):
    r"""Generate partial and total forces from an energy model.

    Args:
        func: the energy model
        vectorize: the vectorize option to ``torch.autograd.functional.jacobian``,
            false by default since it doesn't work well.
    """

    vectorize: bool

    def __init__(
        self,
        func: GraphModuleMixin,
        vectorize: bool = False,
        vectorize_warnings: bool = False,
    ):
        super().__init__()
        self.func = func
        self.vectorize = vectorize
        if vectorize_warnings:
            # See https://pytorch.org/docs/stable/generated/torch.autograd.functional.jacobian.html
            torch._C._debug_only_display_vmap_fallback_warnings(True)

        # check and init irreps
        self._init_irreps(
            irreps_in=func.irreps_in,
            my_irreps_in={AtomicDataDict.PER_ATOM_ENERGY_KEY: Irreps("0e")},
            irreps_out=func.irreps_out,
        )
        self.irreps_out[AtomicDataDict.PARTIAL_FORCE_KEY] = Irreps("1o")
        self.irreps_out[AtomicDataDict.FORCE_KEY] = Irreps("1o")

    def forward(self, data: AtomicDataDict.Type) -> AtomicDataDict.Type:
        data = data.copy()
        out_data = {}

        def wrapper(pos: torch.Tensor) -> torch.Tensor:
            """Wrapper from pos to atomic energy"""
            nonlocal data, out_data
            data[AtomicDataDict.POSITIONS_KEY] = pos
            out_data = self.func(data)
            return out_data[AtomicDataDict.PER_ATOM_ENERGY_KEY].squeeze(-1)

        pos = data[AtomicDataDict.POSITIONS_KEY]

        partial_forces = torch.autograd.functional.jacobian(
            func=wrapper,
            inputs=pos,
            create_graph=self.training,  # needed to allow gradients of this output during training
            vectorize=self.vectorize,
        )
        partial_forces = partial_forces.negative()
        # output is [n_at, n_at, 3]

        out_data[AtomicDataDict.PARTIAL_FORCE_KEY] = partial_forces
        out_data[AtomicDataDict.FORCE_KEY] = partial_forces.sum(dim=0)

        return out_data


@compile_mode("script")
class ForceStressOutput(GraphModuleMixin, torch.nn.Module):
    r"""Compute forces (and stress if cell is provided) using autograd of an energy model.

    See:
        Knuth et. al. Comput. Phys. Commun 190, 33-50, 2015
        https://pure.mpg.de/rest/items/item_2085135_9/component/file_2156800/content

    Args:
        func: the energy model to wrap
    """

    do_derivatives: bool

    def __init__(self, func: GraphModuleMixin, do_derivatives: bool = True):
        super().__init__()
        self.func = func
        self.do_derivatives = do_derivatives

        # check and init irreps
        self._init_irreps(
            irreps_in=self.func.irreps_in.copy(),
            irreps_out=self.func.irreps_out.copy(),
        )
        self.irreps_out[AtomicDataDict.FORCE_KEY] = "1o"
        self.irreps_out[AtomicDataDict.STRESS_KEY] = "1o"
        self.irreps_out[AtomicDataDict.VIRIAL_KEY] = "1o"
        self.irreps_out[AtomicDataDict.EDGE_FORCE_KEY] = "1o"

        # for torchscript compat
        self.register_buffer("_empty", torch.Tensor())

    def forward(self, data: AtomicDataDict.Type) -> AtomicDataDict.Type:
        # short-circuit
        if not self.do_derivatives:
            return self.func(data)

        # === LOGIC BRANCHING NOTES ===
        # if edge vectors not present, we assume that positions are present
        # and proceed with the usual procedure to compute forces, virials, stress
        # else, we compute edge forces

        # NOTE: if edge vectors are not present, we assume that it is for non-batched inference with no cell
        # at the point of making this change, it is specifically for LAMMPS-MLIAP compatibility
        if AtomicDataDict.EDGE_VECTORS_KEY not in data:
            if AtomicDataDict.BATCH_KEY in data:
                batch = data[AtomicDataDict.BATCH_KEY]
                num_batch: int = AtomicDataDict.num_frames(data)
            else:
                # Special case for efficiency
                batch = self._empty
                num_batch: int = 1

            pos = data[AtomicDataDict.POSITIONS_KEY]
            has_cell: bool = AtomicDataDict.CELL_KEY in data

            if has_cell:
                orig_cell = data[AtomicDataDict.CELL_KEY]
                # Make the cell per-batch
                cell = orig_cell.view(-1, 3, 3).expand(num_batch, 3, 3)
                data[AtomicDataDict.CELL_KEY] = cell
            else:
                # torchscript
                orig_cell = self._empty
                cell = self._empty
            # Add the displacements
            # the GradientOutput will make them require grad
            # See SchNetPack code:
            # https://github.com/atomistic-machine-learning/schnetpack/blob/master/src/schnetpack/atomistic/model.py#L45
            # SchNetPack issue:
            # https://github.com/atomistic-machine-learning/schnetpack/issues/165
            # Paper they worked from:
            # Knuth et. al. Comput. Phys. Commun 190, 33-50, 2015
            # https://pure.mpg.de/rest/items/item_2085135_9/component/file_2156800/content

            if num_batch > 1:
                displacement = torch.zeros(
                    (num_batch, 3, 3),
                    dtype=pos.dtype,
                    device=pos.device,
                )
            else:
                displacement = torch.zeros(
                    (3, 3),
                    dtype=pos.dtype,
                    device=pos.device,
                )
            displacement.requires_grad_(True)
            data["_displacement"] = displacement
            # in the above paper, the infinitesimal distortion is *symmetric*
            # so we symmetrize the displacement before applying it to
            # the positions/cell
            # This is not strictly necessary (reasoning thanks to Mario):
            # the displacement's asymmetric 1o term corresponds to an
            # infinitesimal rotation, which should not affect the final
            # output (invariance).
            # That said, due to numerical error, this will never be
            # exactly true. So, we symmetrize the deformation to
            # take advantage of this understanding and not rely on
            # the invariance here:
            symmetric_displacement = 0.5 * (
                displacement + displacement.transpose(-1, -2)
            )
            did_pos_req_grad: bool = pos.requires_grad
            pos.requires_grad_(True)
            if num_batch > 1:
                # bmm is natom in batch
                # batched [natom, 1, 3] @ [natom, 3, 3] -> [natom, 1, 3] -> [natom, 3]
                data[AtomicDataDict.POSITIONS_KEY] = pos + torch.bmm(
                    pos.unsqueeze(-2),
                    torch.index_select(symmetric_displacement, 0, batch),
                ).squeeze(-2)
            else:
                # (num_atoms, 3), (3, 3) -> (num_atoms, 3)
                data[AtomicDataDict.POSITIONS_KEY] = pos + torch.sum(
                    pos.view(-1, 3, 1) * symmetric_displacement, 1
                )
            # assert torch.equal(pos, data[AtomicDataDict.POSITIONS_KEY])
            # we only displace the cell if we have one:
            if has_cell:
                # bmm is num_batch in batch
                # here we apply the distortion to the cell as well
                # this is critical also for the correctness
                # if we didn't symmetrize the distortion, since without this
                # there would then be an infinitesimal rotation of the positions
                # but not cell, and it thus wouldn't be global and have
                # no effect due to equivariance/invariance.
                if num_batch > 1:
                    # [n_batch, 3, 3] @ [n_batch, 3, 3]
                    data[AtomicDataDict.CELL_KEY] = cell + torch.bmm(
                        cell, symmetric_displacement
                    )
                else:
                    # [3, 3] @ [3, 3] --- enforced to these shapes
                    data[AtomicDataDict.CELL_KEY] = (
                        cell.view(3, 3)
                        + torch.sum(cell.view(3, 3, 1) * symmetric_displacement, 1)
                    ).view(1, 3, 3)

            # Call model and get gradients
            data = self.func(data)

            grads = torch.autograd.grad(
                [data[AtomicDataDict.TOTAL_ENERGY_KEY].sum()],
                [pos, data["_displacement"]],
                create_graph=self.training,  # needed to allow gradients of this output during training
            )

            # Put negative sign on forces
            forces = grads[0]
            if forces is None:
                # condition needed to unwrap optional for torchscript
                assert False, "failed to compute forces autograd"
            forces = torch.neg(forces)
            data[AtomicDataDict.FORCE_KEY] = forces

            # Store virial
            virial = grads[1]
            if virial is None:
                # condition needed to unwrap optional for torchscript
                assert False, "failed to compute virial autograd"
            virial = virial.view(num_batch, 3, 3)

            # we only compute the stress (1/V * virial) if we have a cell whose volume we can compute
            if has_cell:
                # ^ can only scale by cell volume if we have one...:
                # Rescale stress tensor
                # See https://github.com/atomistic-machine-learning/schnetpack/blob/master/src/schnetpack/atomistic/output_modules.py#L180
                # See also https://en.wikipedia.org/wiki/Triple_product
                # See also https://gitlab.com/ase/ase/-/blob/master/ase/cell.py,
                #          which uses np.abs(np.linalg.det(cell))
                # First dim is batch, second is vec, third is xyz
                # Note the .abs(), since volume should always be positive
                # det is equal to a dot (b cross c)
                volume = torch.linalg.det(cell).abs().unsqueeze(-1)

                # NOTE: to support batching periodic and non-periodic structures together,
                # the data processing stage is responsible for ensuring that:
                # 1. non-periodic systems have a finite dummy cell to prevent infs in the division below
                # 2. stress labels for non-periodic systems are NaN and handled with `ignore_nan` in loss and metrics

                stress = virial / volume.view(num_batch, 1, 1)
                data[AtomicDataDict.CELL_KEY] = orig_cell
            else:
                stress = self._empty  # torchscript
            data[AtomicDataDict.STRESS_KEY] = stress

            # see discussion in https://github.com/libAtoms/QUIP/issues/227 about sign convention
            # (and conventions docs page)
            # they say the standard convention is virial = -stress x volume
            # looking above this means that we need to pick up another negative sign for the virial
            # to fit this equation with the stress computed above
            virial = torch.neg(virial)
            data[AtomicDataDict.VIRIAL_KEY] = virial

            # Remove helper
            del data["_displacement"]
            if not did_pos_req_grad:
                # don't give later modules one that does
                pos.requires_grad_(False)

        else:
            # we differentiate wrt EDGE_VECTORS_KEY directly in this branch
            # NOTE: we only consider the case of non-batched inference, without a cell
            # so no batching, no training considerations, no cell

            # make `edge_vectors` requires grad
            edge_vectors = data[AtomicDataDict.EDGE_VECTORS_KEY]
            edge_vectors.requires_grad_(True)
            data[AtomicDataDict.EDGE_VECTORS_KEY] = edge_vectors

            # do energy model forward and backward
            data = self.func(data)
            edge_forces = torch.autograd.grad(
                [data[AtomicDataDict.TOTAL_ENERGY_KEY].sum()],
                [edge_vectors],
                # no training arg because we only consider inference
            )[0]
            # assert needed for TorchScript
            assert edge_forces is not None
            # NOTE: there shouldn't be a sign flip to match LAMMPS convention
            data[AtomicDataDict.EDGE_FORCE_KEY] = edge_forces

        return data

    @model_modifier(persistent=True, private=False)
    @classmethod
    def enable_ForceStressOutput(cls, model):
        """Enable force and stress computation."""

        def factory(old):
            new = cls(func=old.func, do_derivatives=True)
            return new

        return replace_submodules(model, cls, factory)

    @model_modifier(persistent=True, private=False)
    @classmethod
    def disable_ForceStressOutput(cls, model):
        """Disable force and stress computation."""

        def factory(old):
            new = cls(func=old.func, do_derivatives=False)
            return new

        return replace_submodules(model, cls, factory)