Skip to content

Commit ab455ea

Browse files
committed
Add tensor basis hook
1 parent 243d253 commit ab455ea

2 files changed

Lines changed: 302 additions & 0 deletions

File tree

src/metatrain/utils/hooks/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66

77
from .global_multipole import GlobalMultipole
88
from .minmax_gap import MinMaxGap
9+
from .tensor_basis import TensorBasis
910

1011

1112
KNOWN_POST_HOOKS = {
1213
"global_multipoles": GlobalMultipole,
1314
"minmax_gap": MinMaxGap,
15+
"tensor_basis": TensorBasis,
1416
}
1517

1618

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
import torch
2+
from metatensor.torch import TensorBlock, TensorMap, Labels
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+
from metatrain.utils.hypers import init_with_defaults
10+
11+
from metatrain.soap_bpnn.modules.tensor_basis import TensorBasis as TensorBasisModule
12+
from metatrain.soap_bpnn.documentation import SOAPConfig
13+
14+
15+
class HookHypers(TypedDict):
16+
"""
17+
Hyperparameters for the tensor basis hook.
18+
"""
19+
20+
soap: SOAPConfig = init_with_defaults(SOAPConfig)
21+
22+
inputs: str
23+
24+
outputs: str | list
25+
"""
26+
Name or names of the targets to predict through a tensor basis.
27+
28+
A separate tensor basis will be built for each target.
29+
"""
30+
31+
def concatenate_structures(
32+
systems: list[System],
33+
) -> tuple[
34+
torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor
35+
]:
36+
"""
37+
Concatenate a list of systems into a single batch.
38+
39+
:param systems: List of systems to concatenate.
40+
:param neighbor_list_options: Options for the neighbor list.
41+
:return: A tuple containing the concatenated positions, centers, neighbors,
42+
species, cells, and cell shifts.
43+
"""
44+
positions = []
45+
centers = []
46+
neighbors = []
47+
species = []
48+
cell_shifts = []
49+
cells = []
50+
node_counter = 0
51+
52+
for system in systems:
53+
positions.append(system.positions)
54+
species.append(system.types)
55+
56+
neighbor_list = system.get_neighbor_list(system.known_neighbor_lists()[0])
57+
nl_values = neighbor_list.samples.values
58+
59+
centers.append(nl_values[:, 0] + node_counter)
60+
neighbors.append(nl_values[:, 1] + node_counter)
61+
cell_shifts.append(nl_values[:, 2:])
62+
63+
cells.append(system.cell)
64+
65+
node_counter += len(system.positions)
66+
67+
positions = torch.cat(positions)
68+
centers = torch.cat(centers)
69+
neighbors = torch.cat(neighbors)
70+
species = torch.cat(species)
71+
cells = torch.stack(cells)
72+
cell_shifts = torch.cat(cell_shifts)
73+
74+
return (
75+
positions,
76+
centers,
77+
neighbors,
78+
species,
79+
cells,
80+
cell_shifts,
81+
)
82+
83+
84+
class TensorBasis(torch.nn.Module):
85+
"""
86+
Provides a tensor basis in which to predict spherical tensor targets.
87+
"""
88+
89+
def __init__(self, hypers: HookHypers, dataset_info: DatasetInfo):
90+
super().__init__()
91+
92+
self.hypers = hypers
93+
94+
# Helper to map from atomic number to the index of that atomic
95+
# number in the list of atomic types.
96+
species_to_species_index = torch.empty(
97+
max(dataset_info.atomic_types) + 1, dtype=torch.long
98+
)
99+
species_to_species_index[dataset_info.atomic_types] = torch.arange(
100+
len(dataset_info.atomic_types)
101+
)
102+
self.register_buffer("species_to_species_index", species_to_species_index)
103+
104+
# Get the information about the output targets from the dataset info
105+
outputs = hypers["outputs"]
106+
if isinstance(outputs, str):
107+
outputs = [outputs]
108+
self.out_targets = {
109+
name: dataset_info.targets[name] for name in outputs
110+
}
111+
112+
# Names for the inputs that we will request from the model
113+
self._input_names = [
114+
f"mtt::aux::scalars::{name.replace('mtt::', '')}" for name in self.out_targets
115+
]
116+
117+
# Build the basis calculators for each target,
118+
# and the output that we have to request from the model
119+
soap_hypers = hypers.get("soap", init_with_defaults(SOAPConfig))
120+
self.basis_calculators = torch.nn.ModuleDict({})
121+
self._input_target_infos = {}
122+
for input_name, target_name in zip(self._input_names, self.out_targets):
123+
124+
target = self.out_targets[target_name]
125+
# Get one basis calculator for each block of the target, since each block
126+
# has different o3_lambda and o3_sigma values.
127+
self.basis_calculators[target_name] = torch.nn.ModuleList([
128+
TensorBasisModule(
129+
dataset_info.atomic_types,
130+
soap_hypers,
131+
o3_lambda=key["o3_lambda"],
132+
o3_sigma=key["o3_sigma"],
133+
add_lambda_basis=True,
134+
legacy=False,
135+
) for key in target.layout.keys
136+
])
137+
138+
# Build the input that we will request from the model.
139+
# We will ask for invariant coefficients. For each block we ask for 2l+1 coefficients
140+
# for each property, since the basis will have 2l+1 tensors.
141+
# We ask for all the coefficients in a single block, we will untangle
142+
# them in the forward pass.
143+
num_properties = sum(block.values.shape[1] * block.values.shape[2] for block in target.layout.blocks())
144+
self._input_target_infos[target_name] = get_generic_target_info(
145+
input_name,
146+
{
147+
"quantity": "_",
148+
"unit": "",
149+
"type": {"spherical": {
150+
"irreps": [
151+
{"o3_lambda": 0, "o3_sigma": 1}
152+
]
153+
}},
154+
"num_subtargets": num_properties,
155+
"sample_kind": "atom",
156+
},
157+
)
158+
159+
def requested_target_infos(self) -> dict[str, TargetInfo]:
160+
"""
161+
Returns the list of requested target infos for the hook.
162+
163+
:return: A list of requested target names.
164+
"""
165+
return self._input_target_infos
166+
167+
def requested_inputs(self) -> dict[str, ModelOutput]:
168+
"""
169+
Returns the list of requested inputs for the hook.
170+
171+
:return: A list of requested input names.
172+
"""
173+
return {
174+
name: ModelOutput(
175+
quantity="",
176+
unit="",
177+
sample_kind="atom",
178+
)
179+
for name in self._input_target_infos
180+
}
181+
182+
def forward(
183+
self, systems: list[System], inputs: dict[str, TensorMap]
184+
) -> dict[str, TensorMap]:
185+
"""
186+
Computes spherical targets using the tensor basis.
187+
"""
188+
device = systems[0].positions.device
189+
190+
# -------------------------------
191+
# Get structure information
192+
# -------------------------------
193+
194+
system_sizes = [len(system) for system in systems]
195+
system_sizes_tensor = torch.tensor(system_sizes, device=device)
196+
system_indices = torch.repeat_interleave(
197+
torch.arange(len(systems), device=device), system_sizes_tensor
198+
)
199+
atom_indices = torch.cat(
200+
[torch.arange(size, device=device) for size in system_sizes]
201+
)
202+
sample_values = torch.stack([system_indices, atom_indices], dim=1)
203+
204+
(
205+
positions,
206+
centers,
207+
neighbors,
208+
species,
209+
cells,
210+
cell_shifts,
211+
) = concatenate_structures(systems)
212+
species = self.species_to_species_index[species]
213+
214+
# somehow the backward of this operation is very slow at evaluation,
215+
# where there is only one cell, therefore we simplify the calculation
216+
# for that case
217+
if len(cells) == 1:
218+
cell_contributions = cell_shifts.to(cells.dtype) @ cells[0]
219+
else:
220+
cell_contributions = torch.einsum(
221+
"ab, abc -> ac",
222+
cell_shifts.to(cells.dtype),
223+
cells[system_indices][centers],
224+
)
225+
226+
interatomic_vectors = (
227+
positions[neighbors] - positions[centers] + cell_contributions
228+
)
229+
230+
# ------------------------------------
231+
# Build the values for each target
232+
# ------------------------------------
233+
234+
return_dict: dict[str, TensorMap] = {}
235+
for target_name, basis_calculators in self.basis_calculators.items():
236+
target_info = self.out_targets[target_name]
237+
target_invariant_coefficients = inputs[target_name].block().values
238+
239+
offset = 0
240+
blocks: list[TensorBlock] = []
241+
for i, basis_calculator in enumerate(basis_calculators):
242+
243+
layout_block = target_info.layout.block(i)
244+
245+
# Get shapes of the invariant coefficients to retrieve
246+
# for this block.
247+
n_properties = layout_block.properties.values.shape[0]
248+
n_basis = layout_block.values.shape[1]
249+
count = n_properties * n_basis
250+
251+
# Get those invariant coefficients
252+
invariant_coefficients = target_invariant_coefficients[
253+
:, 0, offset : offset + count
254+
].reshape(
255+
-1, n_properties, n_basis
256+
)
257+
# Update counter for the next block
258+
offset += count
259+
260+
# Now get the tensor basis.
261+
tensor_basis = basis_calculator(
262+
interatomic_vectors,
263+
centers,
264+
neighbors,
265+
species,
266+
sample_values,
267+
selected_atoms=None,
268+
)
269+
270+
# Multiply the invariant coefficients by the tensor basis
271+
# to get the final values for each atom.
272+
atomic_property_tensor = torch.einsum(
273+
"spb, scb -> scp",
274+
invariant_coefficients,
275+
tensor_basis,
276+
)
277+
278+
# Build the tensor block.
279+
blocks.append(
280+
TensorBlock(
281+
values=atomic_property_tensor,
282+
samples=Labels(
283+
names=["system", "atom"],
284+
values=sample_values
285+
),
286+
components=layout_block.components,
287+
properties=layout_block.properties,
288+
)
289+
)
290+
291+
tmap = TensorMap(
292+
keys=target_info.layout.keys,
293+
blocks=blocks,
294+
)
295+
296+
if target_info.sample_kind == "system":
297+
tmap = sum_over_atoms(tmap)
298+
return_dict[target_name] = tmap
299+
300+
return return_dict

0 commit comments

Comments
 (0)