Skip to content

Commit e809468

Browse files
committed
Proof of concept for hooks
1 parent 12da07e commit e809468

4 files changed

Lines changed: 237 additions & 2 deletions

File tree

src/metatrain/experimental/mace/documentation.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@
121121

122122
from metatrain.composition.documentation import FixedCompositionWeights
123123
from metatrain.utils.finetuning import FullFinetuneHypers
124+
from metatrain.utils.hooks import PostHooksHypers
124125
from metatrain.utils.loss import LossSpecification
125126
from metatrain.utils.scaler import FixedScalerWeights
126127

@@ -295,6 +296,9 @@ class ModelHypers(TypedDict):
295296
use_agnostic_product: bool = False
296297
"""Use element agnostic product"""
297298

299+
post_hooks: PostHooksHypers = {}
300+
"""Post-processing hooks to apply to the outputs of the model."""
301+
298302

299303
class TrainerHypers(TypedDict):
300304
# Optimizer hypers (directly using MACE's scripts)

src/metatrain/experimental/mace/model.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
sparsify_atomic_basis_target,
3030
)
3131
from metatrain.utils.dtype import dtype_to_str
32+
from metatrain.utils.hooks import setup_post_hooks
3233
from metatrain.utils.metadata import merge_metadata
3334
from metatrain.utils.scaler import Scaler
3435
from metatrain.utils.sum_over_atoms import sum_over_atoms
@@ -263,10 +264,15 @@ def __init__(self, hypers: ModelHypers, dataset_info: DatasetInfo) -> None:
263264
# the model during training.
264265
train_dataset_info = self._train_dataset_info(dataset_info)
265266

267+
post_hooks, model_outs = setup_post_hooks(
268+
self.hypers["post_hooks"], train_dataset_info
269+
)
270+
self.post_hooks = torch.nn.ModuleList(post_hooks)
271+
266272
# Create heads for each target, store the layout for each of them.
267273
self.heads = torch.nn.ModuleDict()
268274
self.layouts: Dict[str, TensorMap] = {}
269-
for target_name, target_info in train_dataset_info.targets.items():
275+
for target_name, target_info in model_outs.items():
270276
self._add_output(target_name, target_info)
271277

272278
self.layouts["mtt::aux::mace_features"] = get_e3nn_mts_layout(
@@ -279,13 +285,14 @@ def __init__(self, hypers: ModelHypers, dataset_info: DatasetInfo) -> None:
279285
)
280286

281287
targets = dataset_info.targets
288+
all_names = set([*train_dataset_info.targets, *model_outs, *self.layouts])
282289
self.outputs = {
283290
k: ModelOutput(
284291
quantity=targets[k].quantity if k in targets else "",
285292
unit=targets[k].unit if k in targets else "",
286293
sample_kind="atom",
287294
)
288-
for k in self.layouts
295+
for k in all_names
289296
}
290297

291298
# ---------------------------
@@ -358,6 +365,14 @@ def forward(
358365
selected_atoms: Optional[Labels] = None,
359366
) -> Dict[str, TensorMap]:
360367

368+
# ----------------------------
369+
# Add outputs needed by hooks
370+
# ----------------------------
371+
# TODO: In reality, we would have to check if the hook's output is requested
372+
for hook in self.post_hooks:
373+
requested_inputs = hook.requested_inputs()
374+
outputs.update(requested_inputs)
375+
361376
# --------------------------
362377
# Moving to device and dtype
363378
# --------------------------
@@ -460,6 +475,13 @@ def forward(
460475
else sum_over_atoms(per_atom_output)
461476
)
462477

478+
# -----------------------------------
479+
# Apply hooks
480+
# -----------------------------------
481+
482+
for hook in self.post_hooks:
483+
return_dict.update(hook(systems, return_dict))
484+
463485
# -----------------------------------------
464486
# Undo data preprocessing (eval only)
465487
# -----------------------------------------
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import torch
2+
3+
from metatrain.utils.data import DatasetInfo, TargetInfo
4+
5+
from .global_multipole import GlobalMultipole
6+
7+
8+
KNOWN_POST_HOOKS = {
9+
"global_multipoles": GlobalMultipole,
10+
}
11+
12+
13+
# For documentation purposes
14+
PostHooksHypers = dict[str, dict[str, str] | str]
15+
16+
17+
# To help models work with hooks.
18+
def setup_post_hooks(
19+
post_hooks_hypers: PostHooksHypers, dataset_info: DatasetInfo
20+
) -> tuple[
21+
list[torch.nn.Module],
22+
dict[str, TargetInfo],
23+
]:
24+
"""
25+
Setup the post-processing hooks to apply to the outputs of the model.
26+
27+
:param post_hooks_hypers: The hyperparameters for the post-processing hooks.
28+
:param dataset_info: The dataset information.
29+
:return: A tuple containing the list of post-processing hooks, and a
30+
dictionary with the outputs that the model should produce before the
31+
hooks are applied.
32+
"""
33+
model_outputs = dataset_info.targets.copy()
34+
post_hooks = []
35+
for hook_name, hook_hypers in post_hooks_hypers.items():
36+
if hook_name not in KNOWN_POST_HOOKS:
37+
raise ValueError(f"Unknown post-processing hook: {hook_name}")
38+
39+
# Get outputs of the hook and remove them from the model outputs.
40+
if isinstance(hook_hypers, str):
41+
san_hook_hypers: dict[str, dict[str, str] | str] = {
42+
"outputs": hook_hypers,
43+
"inputs": {},
44+
}
45+
else:
46+
san_hook_hypers = hook_hypers
47+
48+
hook_outputs: str | dict = san_hook_hypers["outputs"]
49+
if isinstance(hook_outputs, str):
50+
hook_output_names = [hook_outputs]
51+
else:
52+
hook_output_names = list(hook_outputs.values())
53+
54+
for output_name in hook_output_names:
55+
model_outputs.pop(output_name, None)
56+
57+
# Get hook and add it to the list of hooks.
58+
hook_class = KNOWN_POST_HOOKS[hook_name]
59+
hook = hook_class(san_hook_hypers, dataset_info)
60+
post_hooks.append(hook)
61+
62+
# Add the inputs that the hook requests to the model outputs.
63+
# TODO: Here we should not add the inputs that will be provided
64+
# as extra_data.
65+
requested = hook.requested_target_infos()
66+
model_outputs.update(requested)
67+
68+
return post_hooks, model_outputs
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import torch
2+
from metatensor.torch import TensorBlock, TensorMap
3+
from metatomic.torch import ModelOutput, System
4+
from typing_extensions import TypedDict
5+
6+
from metatrain.utils.data import DatasetInfo, TargetInfo
7+
from metatrain.utils.data.target_info import get_generic_target_info
8+
from metatrain.utils.sum_over_atoms import sum_over_atoms
9+
10+
11+
class HookHypers(TypedDict):
12+
"""
13+
Hyperparameters for the global multipole hook.
14+
"""
15+
16+
inputs: str
17+
18+
outputs: str
19+
20+
21+
class GlobalMultipole(torch.nn.Module):
22+
"""
23+
Computes a global multipole from local predictions.
24+
"""
25+
26+
def __init__(self, hypers: HookHypers, dataset_info: DatasetInfo):
27+
super().__init__()
28+
29+
self.hypers = hypers
30+
31+
# Get the information about the output target from the dataset info
32+
self.out_name = hypers["outputs"]
33+
self.out_target = dataset_info.targets[self.out_name]
34+
35+
self.degrees = self.out_target.layout.keys["o3_lambda"]
36+
self.max_degree = self.degrees.max().item()
37+
38+
if self.max_degree > 1:
39+
raise ValueError(
40+
f"Global multipoles hook only supports multipoles up "
41+
f"to l=1 for now, but {self.out_name} has max degree "
42+
f"{self.max_degree}"
43+
)
44+
45+
# Build the input target that we will request from the model,
46+
# which is the local multipoles
47+
self._input_name = "mtt::aux::local_multipoles"
48+
49+
self._input_target_info = get_generic_target_info(
50+
self._input_name,
51+
{
52+
"quantity": "",
53+
"unit": "",
54+
"type": {
55+
"spherical": {
56+
"irreps": [
57+
{"o3_lambda": i, "o3_sigma": 1}
58+
for i in range(self.max_degree + 1)
59+
]
60+
}
61+
},
62+
"num_subtargets": 1,
63+
"sample_kind": "atom",
64+
},
65+
)
66+
67+
def requested_target_infos(self) -> dict[str, TargetInfo]:
68+
"""
69+
Returns the list of requested target infos for the hook.
70+
71+
:return: A list of requested target names.
72+
"""
73+
return {self._input_name: self._input_target_info}
74+
75+
def requested_inputs(self) -> dict[str, ModelOutput]:
76+
"""
77+
Returns the list of requested inputs for the hook.
78+
79+
:return: A list of requested input names.
80+
"""
81+
return {
82+
self._input_name: ModelOutput(
83+
quantity="",
84+
unit="",
85+
sample_kind="atom",
86+
)
87+
}
88+
89+
def forward(
90+
self, systems: list[System], inputs: dict[str, TensorMap]
91+
) -> dict[str, TensorMap]:
92+
"""
93+
Computes the global multipole from the local predictions.
94+
"""
95+
# Get the concatenated positions of all atoms in the systems,
96+
# and reorder the axes to match the spherical harmonics convention
97+
# (x, y, z) -> (y, z, x)
98+
positions = torch.cat([s.positions for s in systems], dim=0)
99+
positions = positions[:, [1, 2, 0]]
100+
101+
# Get the local predictions for each degree in the multipole expansion
102+
input_tmap = inputs[self._input_name]
103+
local_values = [
104+
input_tmap.block(dict(o3_lambda=ell, o3_sigma=1)).values
105+
for ell in range(self.max_degree + 1)
106+
]
107+
108+
# Compute the local contributions to the global multipole for each degree
109+
local_contribs = []
110+
for ell in range(self.max_degree + 1):
111+
if ell == 0:
112+
local_contribs.append(local_values[ell])
113+
elif ell == 1:
114+
local_contribs.append(
115+
torch.einsum(
116+
"sp, sx -> sxp", local_contribs[ell - 1].squeeze(1), positions
117+
)
118+
+ local_values[ell]
119+
)
120+
else:
121+
raise ValueError(
122+
"Global multipoles hook only supports multipoles up to l=1 for now"
123+
f", but {self.out_name} has degree {ell}"
124+
)
125+
126+
# Build a tensor map with the requested multipole degrees.
127+
local_tmap = TensorMap(
128+
keys=self.out_target.layout.keys,
129+
blocks=[
130+
TensorBlock(
131+
values=local_contribs[degree],
132+
samples=input_tmap.block(0).samples,
133+
components=self.out_target.component_labels[i],
134+
properties=self.out_target.property_labels[i],
135+
)
136+
for i, degree in enumerate(self.degrees)
137+
],
138+
)
139+
140+
# Return the global multipole by summing over the atoms in the system
141+
return {self.out_name: sum_over_atoms(local_tmap)}

0 commit comments

Comments
 (0)