Skip to content

Commit a7c5aaa

Browse files
author
Han Wang
committed
feat(dpmodel): graph_angle_cos with dpa3 dense-parity
1 parent 27ec5cb commit a7c5aaa

3 files changed

Lines changed: 345 additions & 0 deletions

File tree

deepmd/dpmodel/utils/neighbor_graph/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
angle_to_node_sum,
1515
attach_angles,
1616
build_angle_index,
17+
graph_angle_cos,
1718
)
1819
from .ase_builder import (
1920
build_neighbor_graph_ase,
@@ -63,6 +64,7 @@
6364
"edge_force_virial",
6465
"frame_id_from_n_node",
6566
"from_dense_quartet",
67+
"graph_angle_cos",
6668
"neighbor_graph_from_ijs",
6769
"node_validity_mask",
6870
"pad_and_guard_angles",

deepmd/dpmodel/utils/neighbor_graph/angles.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,35 @@ def attach_angles(
141141
return dataclasses.replace(graph, angle_index=ai, angle_mask=am)
142142

143143

144+
def graph_angle_cos(angle_index: Array, edge_vec: Array, eps: float = 1e-6) -> Array:
145+
"""Per-angle cosine, mirroring dpa3 ``cosine_ij`` (repflows.py:632-644).
146+
147+
Parameters
148+
----------
149+
angle_index : Array
150+
Shape (2, A) index pairs into edge list. ``angle_index[0, a]`` is
151+
edge_a and ``angle_index[1, a]`` is edge_b for angle ``a``.
152+
edge_vec : Array
153+
Shape (E, 3) edge vectors (r_src - r_dst, i.e. neighbor - center).
154+
eps : float, optional
155+
Numerical stabiliser: norm denominators use ``||v|| + eps`` and the
156+
dot product is scaled by ``(1 - eps)``. Mirrors the dpa3 dense
157+
channel exactly (repflows.py:643-649).
158+
159+
Returns
160+
-------
161+
Array
162+
Shape (A,) cosine values, one per angle slot (valid and padding).
163+
Padding slots carry arbitrary values; mask with angle_mask before use.
164+
"""
165+
xp = array_api_compat.array_namespace(edge_vec)
166+
va = xp.take(edge_vec, angle_index[0, :], axis=0) # (A, 3)
167+
vb = xp.take(edge_vec, angle_index[1, :], axis=0) # (A, 3)
168+
na = va / (xp.linalg.vector_norm(va, axis=-1, keepdims=True) + eps)
169+
nb = vb / (xp.linalg.vector_norm(vb, axis=-1, keepdims=True) + eps)
170+
return xp.sum(na * nb, axis=-1) * (1.0 - eps)
171+
172+
144173
def angle_to_edge_sum(data: Array, angle_index: Array, num_edges: int) -> Array:
145174
"""Aggregate per-angle data to the angle's query edge (edge_a).
146175
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
# SPDX-License-Identifier: LGPL-3.0-or-later
2+
"""Parity tests for graph_angle_cos vs dpa3 dense cosine_ij and se_t dot form."""
3+
4+
from __future__ import annotations
5+
6+
import numpy as np
7+
8+
from deepmd.dpmodel.utils.neighbor_graph import (
9+
attach_angles,
10+
build_neighbor_graph,
11+
graph_angle_cos,
12+
)
13+
14+
15+
# ---------------------------------------------------------------------------
16+
# Step 1/4: explicit geometry sanity
17+
# ---------------------------------------------------------------------------
18+
19+
20+
def test_graph_angle_cos_matches_normalized_dot():
21+
"""Center at origin, neighbors along x and y: cos=0 (perpendicular).
22+
23+
Use rcut=1.1 so neighbors (distance 1.0 from center) are included but
24+
the neighbor-to-neighbor distance (sqrt(2)~1.41) is NOT, making atom 0
25+
the ONLY center with multiple neighbors => exactly ONE unordered angle.
26+
"""
27+
coord = np.array([[[0.0, 0, 0], [1.0, 0, 0], [0.0, 1.0, 0]]])
28+
atype = np.array([[0, 0, 0]])
29+
ng = attach_angles(build_neighbor_graph(coord, atype, None, 1.1), a_rcut=1.1)
30+
cos = graph_angle_cos(ng.angle_index, ng.edge_vec)
31+
real = np.asarray(ng.angle_mask)
32+
vals = np.asarray(cos)[real]
33+
# only atom 0 has 2 neighbors within rcut => exactly ONE unordered angle
34+
assert int(real.sum()) == 1
35+
# perpendicular vectors: dot = 0, (1-eps) scaling => val ~0.0 to atol 1e-6
36+
np.testing.assert_allclose(vals, [0.0], atol=1e-6)
37+
38+
39+
def test_graph_angle_cos_parallel():
40+
"""Two neighbors in the SAME direction as seen from center: cos ~(1-eps).
41+
42+
Use rcut=1.5 so only center→neighbor1 (dist=1) and center→neighbor2 (dist=2)
43+
but atom1→atom2 (dist=1) is NOT seen from atom2 as a second neighbor.
44+
Actually place center at 1.0, so neighbors at 0.0 and 2.0 are collinear.
45+
rcut=1.1 => center (at 1.0) sees atoms at 0.0 (dist=1.0) and 2.0 (dist=1.0),
46+
but those two atoms don't see each other (dist=2.0 > 1.1).
47+
"""
48+
coord = np.array([[[1.0, 0, 0], [0.0, 0, 0], [2.0, 0, 0]]])
49+
atype = np.array([[0, 0, 0]])
50+
ng = attach_angles(build_neighbor_graph(coord, atype, None, 1.1), a_rcut=1.1)
51+
cos = graph_angle_cos(ng.angle_index, ng.edge_vec)
52+
real = np.asarray(ng.angle_mask)
53+
vals = np.asarray(cos)[real]
54+
assert int(real.sum()) == 1
55+
# edge_vec = neighbor - center; both neighbors are in opposite x-directions
56+
# from the center: edge to atom1=(0,0,0) is (-1,0,0); edge to atom2=(2,0,0) is (+1,0,0)
57+
# For unit-norm vectors (norm=1.0):
58+
# na = va / (1 + eps), nb = vb / (1 + eps)
59+
# dot(na, nb) = -1 / (1+eps)^2
60+
# cos = -1 / (1+eps)^2 * (1 - eps)
61+
eps = 1e-6
62+
expected = -1.0 / (1 + eps) ** 2 * (1 - eps)
63+
np.testing.assert_allclose(vals, [expected], rtol=1e-6)
64+
65+
66+
def test_graph_angle_cos_no_self_angles():
67+
"""No graph angle should have both edge slots pointing to the same edge."""
68+
coord = np.array([[[0.0, 0, 0], [1.0, 0, 0], [0.0, 1.0, 0], [0.0, 0.0, 1.0]]])
69+
atype = np.array([[0, 0, 0, 0]])
70+
ng = attach_angles(build_neighbor_graph(coord, atype, None, 3.0), a_rcut=3.0)
71+
ai = np.asarray(ng.angle_index)
72+
am = np.asarray(ng.angle_mask)
73+
# For every VALID angle, the two edge indices must differ
74+
real_angles = [(int(ai[0, p]), int(ai[1, p])) for p in range(am.shape[0]) if am[p]]
75+
for a, b in real_angles:
76+
assert a != b, f"Self-angle found: edge {a}"
77+
78+
79+
# ---------------------------------------------------------------------------
80+
# dpa3 dense parity
81+
# ---------------------------------------------------------------------------
82+
83+
84+
def _dense_cosine_ij(coord_ext, nlist, a_rcut, a_sel):
85+
"""Faithful numpy transcription of repflows.py:597-649.
86+
87+
Returns cosine_ij of shape (nf, nloc, a_sel, a_sel).
88+
a_diff convention: coord_r - coord_l = neighbor - center.
89+
"""
90+
nf, nloc, nnei = nlist.shape
91+
# coord_ext: (nf, nall, 3)
92+
# a_nlist: truncate to a_sel and mask beyond a_rcut
93+
diff_full = np.zeros((nf, nloc, nnei, 3), dtype=np.float64)
94+
for f in range(nf):
95+
for i in range(nloc):
96+
for k in range(nnei):
97+
j = nlist[f, i, k]
98+
if j >= 0:
99+
diff_full[f, i, k] = coord_ext[f, j] - coord_ext[f, i]
100+
# a_rcut gate — clip a_sel to actual nnei columns available
101+
nnei = nlist.shape[2]
102+
eff_a_sel = min(a_sel, nnei)
103+
a_dist_mask = (np.linalg.norm(diff_full, axis=-1) < a_rcut)[:, :, :eff_a_sel]
104+
a_nlist = nlist[:, :, :eff_a_sel].copy()
105+
a_nlist = np.where(a_dist_mask, a_nlist, np.full_like(a_nlist, -1))
106+
# a_diff: shape (nf, nloc, eff_a_sel, 3)
107+
a_diff = np.zeros((nf, nloc, eff_a_sel, 3), dtype=np.float64)
108+
for f in range(nf):
109+
for i in range(nloc):
110+
for k in range(eff_a_sel):
111+
j = a_nlist[f, i, k]
112+
if j >= 0:
113+
a_diff[f, i, k] = coord_ext[f, j] - coord_ext[f, i]
114+
# normalized_diff_i: (nf, nloc, eff_a_sel, 3)
115+
norm = np.linalg.norm(a_diff, axis=-1, keepdims=True)
116+
normalized_diff_i = a_diff / (norm + 1e-6)
117+
# cosine_ij: (nf, nloc, eff_a_sel, eff_a_sel)
118+
cosine_ij = np.matmul(normalized_diff_i, np.swapaxes(normalized_diff_i, -2, -1))
119+
cosine_ij = cosine_ij * (1 - 1e-6)
120+
return cosine_ij, a_nlist
121+
122+
123+
def test_graph_angle_cos_parity_vs_dpa3_dense():
124+
"""Graph unordered/no-self cos values must match dense OFF-DIAGONAL cosine_ij.
125+
126+
Uses a small 4-atom system with a large a_sel (non-binding) so that the
127+
graph and dense see the same neighbor set.
128+
129+
- Tolerance: rtol=1e-12, atol=1e-12 (CPU fp64 same-math, identical eps).
130+
- The graph is UNORDERED (no duplicates) and NO-SELF.
131+
- Dense diagonal (j==k, cos≈1) must NOT appear in graph angle set.
132+
- Dense off-diagonal (j!=k) are collected as unordered {j,k} pairs and
133+
matched against graph angles by (center_node, unordered edge-src pair).
134+
"""
135+
rng = np.random.default_rng(42)
136+
# 4 atoms in a box; no PBC => set box to None
137+
# Use a single frame, 4 atoms
138+
nf, nloc = 1, 4
139+
coord = rng.uniform(-1, 1, (nf, nloc, 3))
140+
atype = np.zeros((nf, nloc), dtype=np.int32)
141+
rcut = 3.0
142+
a_rcut = 2.5
143+
# Choose a_sel equal to nloc-1 (max neighbors) => non-binding
144+
a_sel = nloc - 1 # =3; each atom has at most 3 neighbors => non-binding
145+
146+
# --- graph side ---
147+
ng = attach_angles(build_neighbor_graph(coord, atype, None, rcut), a_rcut=a_rcut)
148+
cos_graph = np.asarray(graph_angle_cos(ng.angle_index, ng.edge_vec))
149+
am = np.asarray(ng.angle_mask)
150+
ai = np.asarray(ng.angle_index)
151+
ei = np.asarray(ng.edge_index) # (2, E): [src, dst]
152+
ev = np.asarray(ng.edge_vec)
153+
154+
# No self-angles
155+
for p in range(am.shape[0]):
156+
if am[p]:
157+
assert int(ai[0, p]) != int(ai[1, p]), "Self-angle found in graph"
158+
159+
# --- dense side ---
160+
# Build a dense nlist from the same coord (no PBC, full nlist)
161+
# We construct nlist by brute force
162+
coord3 = coord[0] # (nloc, 3)
163+
# For the dense side, coord_ext = coord (nloc=nall, no ghosts)
164+
coord_ext = coord # (1, nloc, 3)
165+
# Build dense nlist: shape (1, nloc, nnei_max)
166+
# For 4 atoms, each atom has at most nloc-1=3 neighbors
167+
nnei = nloc - 1 # max possible neighbors (no self)
168+
dense_nlist = np.full((nf, nloc, nnei), -1, dtype=np.int64)
169+
for i in range(nloc):
170+
k = 0
171+
for j in range(nloc):
172+
d = np.linalg.norm(coord3[j] - coord3[i])
173+
if d < rcut and i != j:
174+
dense_nlist[0, i, k] = j
175+
k += 1
176+
177+
cosine_ij, a_nlist = _dense_cosine_ij(coord_ext, dense_nlist, a_rcut, a_sel)
178+
# cosine_ij: (1, nloc, a_sel, a_sel)
179+
180+
# --- match graph angles to dense off-diagonal ---
181+
# graph edge_index: src=edge_index[0], dst=edge_index[1] (center=dst)
182+
# For each valid graph angle p: edge_a=ai[0,p], edge_b=ai[1,p]
183+
# center = ei[1, edge_a] = ei[1, edge_b] (shared center)
184+
# neighbor_a = ei[0, edge_a], neighbor_b = ei[0, edge_b]
185+
186+
# Build dense lookup: (center, na, nb) -> cos for off-diagonal
187+
dense_cos_lookup = {} # (center, unordered frozenset(na, nb)) -> cos
188+
for i in range(nloc):
189+
for j_idx in range(a_sel):
190+
na = int(a_nlist[0, i, j_idx])
191+
if na < 0:
192+
continue
193+
for k_idx in range(a_sel):
194+
nb = int(a_nlist[0, i, k_idx])
195+
if nb < 0:
196+
continue
197+
if j_idx == k_idx: # skip diagonal (self-angles = edge channel)
198+
continue
199+
cos_val = float(cosine_ij[0, i, j_idx, k_idx])
200+
key = (i, frozenset([na, nb]))
201+
# unordered: both (j,k) and (k,j) map to same pair
202+
# store the value for frozenset key (they should be equal by symmetry
203+
# of cos, but we verify)
204+
if key not in dense_cos_lookup:
205+
dense_cos_lookup[key] = cos_val
206+
else:
207+
# cosine is symmetric: both directions should be equal
208+
np.testing.assert_allclose(
209+
dense_cos_lookup[key],
210+
cos_val,
211+
atol=1e-14,
212+
err_msg=f"Dense cosine not symmetric for ({i},{na},{nb})",
213+
)
214+
215+
# Now compare each valid graph angle
216+
for p in range(am.shape[0]):
217+
if not am[p]:
218+
continue
219+
ea = int(ai[0, p])
220+
eb = int(ai[1, p])
221+
center = int(ei[1, ea])
222+
assert center == int(ei[1, eb]), "Angle edges don't share center"
223+
na = int(ei[0, ea])
224+
nb = int(ei[0, eb])
225+
key = (center, frozenset([na, nb]))
226+
assert key in dense_cos_lookup, (
227+
f"Graph angle (center={center}, na={na}, nb={nb}) not in dense"
228+
)
229+
cos_g = float(cos_graph[p])
230+
cos_d = dense_cos_lookup[key]
231+
np.testing.assert_allclose(
232+
cos_g,
233+
cos_d,
234+
rtol=1e-12,
235+
atol=1e-12,
236+
err_msg=f"cos mismatch at (center={center}, na={na}, nb={nb})",
237+
)
238+
239+
240+
# ---------------------------------------------------------------------------
241+
# se_t dot-product cross-check
242+
# ---------------------------------------------------------------------------
243+
244+
245+
def test_matches_se_t_dot_form():
246+
"""Cos * |va| * |vb| ≈ va·vb (the se_t unnormalized inner product).
247+
248+
se_t.py:428-437 uses `rr_i·rr_j` (unnormalized 3D vectors = edge_vec
249+
without smoothing weights). graph_angle_cos(eps=1e-6) satisfies:
250+
251+
cos ≈ (va/‖va‖) · (vb/‖vb‖) * (1-eps)
252+
253+
So:
254+
va·vb ≈ cos * (‖va‖ + eps) * (‖vb‖ + eps) / (1 - eps)
255+
256+
We verify this identity up to the eps rounding (atol~1e-6 * ‖va‖*‖vb‖).
257+
"""
258+
coord = np.array([[[0.0, 0, 0], [1.0, 0.5, 0.3], [0.2, 1.0, 0.8]]])
259+
atype = np.array([[0, 0, 0]])
260+
ng = attach_angles(build_neighbor_graph(coord, atype, None, 5.0), a_rcut=5.0)
261+
ai = np.asarray(ng.angle_index)
262+
am = np.asarray(ng.angle_mask)
263+
ev = np.asarray(ng.edge_vec)
264+
cos = np.asarray(graph_angle_cos(ng.angle_index, ng.edge_vec))
265+
266+
eps = 1e-6
267+
for p in range(am.shape[0]):
268+
if not am[p]:
269+
continue
270+
ea = int(ai[0, p])
271+
eb = int(ai[1, p])
272+
va = ev[ea]
273+
vb = ev[eb]
274+
# unnormalized dot product (se_t convention)
275+
dot_se_t = float(np.dot(va, vb))
276+
# reconstruct from graph cos
277+
na_norm = np.linalg.norm(va)
278+
nb_norm = np.linalg.norm(vb)
279+
dot_reconstructed = (
280+
float(cos[p]) * (na_norm + eps) * (nb_norm + eps) / (1 - eps)
281+
)
282+
# tolerance: eps * ‖va‖*‖vb‖ dominates
283+
tol = eps * max(na_norm * nb_norm, 1e-12)
284+
np.testing.assert_allclose(
285+
dot_reconstructed,
286+
dot_se_t,
287+
atol=tol,
288+
err_msg=f"se_t dot mismatch for angle {p}: va={va}, vb={vb}",
289+
)
290+
291+
292+
# ---------------------------------------------------------------------------
293+
# torch namespace smoke test (TID253)
294+
# ---------------------------------------------------------------------------
295+
296+
297+
def test_graph_angle_cos_torch_matches_numpy():
298+
"""graph_angle_cos on torch tensors matches numpy output (array-API compat)."""
299+
import torch
300+
301+
coord = np.array([[[0.0, 0, 0], [1.0, 0, 0], [0.0, 1.0, 0], [1.0, 1.0, 0]]])
302+
atype = np.array([[0, 0, 0, 0]])
303+
ng = attach_angles(build_neighbor_graph(coord, atype, None, 3.0), a_rcut=3.0)
304+
angle_index_np = np.asarray(ng.angle_index)
305+
edge_vec_np = np.asarray(ng.edge_vec)
306+
307+
cos_np = np.asarray(graph_angle_cos(angle_index_np, edge_vec_np))
308+
309+
angle_index_t = torch.from_numpy(angle_index_np)
310+
edge_vec_t = torch.from_numpy(edge_vec_np)
311+
cos_t = graph_angle_cos(angle_index_t, edge_vec_t)
312+
cos_t_np = cos_t.numpy()
313+
314+
np.testing.assert_allclose(cos_t_np, cos_np, rtol=1e-14, atol=1e-14)

0 commit comments

Comments
 (0)