Skip to content

Commit d92b1ac

Browse files
OutisLiclaude
andcommitted
feat(dpa4): split edge_norm into per-site [radial, film, focus] switches
The edge_norm option now accepts, besides the original bool, a list of three bools [radial, film, focus] that gates the radial-MLP hidden RMSNorms, the environment-seed FiLM scale/shift norms and the cross-focus competition norms individually; the post-SO(2) residual scaling is bound to the radial entry (1e-5 if on, unit floor if off), so all-true and all-false lists reproduce the bool behaviour bit for bit. Serialization writes the canonical three-bool list; configs and serialized data from models that predate the option (no key) or store a bool keep loading unchanged. The argument doc records the recommended setting [false, true, true]: the radial-site norms amplify noise where the radial features vanish at the cutoff and produce a spurious long-range force step, while the FiLM and focus norms are safe to keep. Unit tests cover the per-site gating, the SO(2) eps coupling, the canonical serialization, legacy no-key and bool deserialization, and pt/dpmodel parity for bool, list and legacy forms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 801ddfc commit d92b1ac

5 files changed

Lines changed: 194 additions & 50 deletions

File tree

deepmd/dpmodel/descriptor/dpa4.py

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -321,14 +321,16 @@ class DescrptDPA4(NativeOP, BaseDescriptor):
321321
Hidden layer sizes for radial networks. An output layer of size
322322
`(l_schedule[0]+extra_node_l+1)*channels` will be automatically appended.
323323
edge_norm
324-
Whether to apply channel RMSNorm on the descriptor's cutoff-vanishing
325-
branches: the radial network hidden layers, the environment-seed FiLM
326-
scale/shift logits, the cross-focus competition scalars, and the
327-
post-SO(2) residual messages. ``False`` replaces the first three norms
328-
with identity and changes only the post-SO(2) norm to unit-floor residual
329-
scaling. The unit floor uses ``sqrt(1 + variance)`` so small messages
330-
retain their cutoff envelope instead of receiving the standard
331-
``1/sqrt(eps)`` small-signal gain.
324+
Channel RMSNorm on the descriptor's cutoff-vanishing branches: the
325+
radial network hidden layers, the environment-seed FiLM scale/shift
326+
logits, and the cross-focus competition scalars. A bool switches all
327+
three together; a list of three bools ``[radial, film, focus]``
328+
switches them individually. Disabled norms are identity
329+
pass-throughs. The post-SO(2) residual scaling follows the ``radial``
330+
entry: with it disabled the norm uses the unit floor
331+
``sqrt(1 + variance)``, so small messages retain their cutoff
332+
envelope instead of receiving the standard ``1/sqrt(eps)``
333+
small-signal gain.
332334
use_env_seed
333335
If True, seed the initial node state with local-environment information:
334336
apply environment matrix FiLM conditioning on l=0 features using 4D
@@ -610,7 +612,7 @@ def __init__(
610612
basis_type: str = "bessel",
611613
n_radial: int = 16,
612614
radial_mlp: list[int] | None = None,
613-
edge_norm: bool = True,
615+
edge_norm: bool | list[bool] = True,
614616
use_env_seed: bool = True,
615617
random_gamma: bool = True,
616618
edge_cartesian: bool = False,
@@ -706,7 +708,22 @@ def __init__(
706708
if radial_mlp is None:
707709
radial_mlp = [0]
708710
self.radial_mlp = [self.channels if x == 0 else int(x) for x in radial_mlp]
709-
self.edge_norm = bool(edge_norm)
711+
if isinstance(edge_norm, bool):
712+
self.radial_norm = edge_norm
713+
self.film_norm = edge_norm
714+
self.focus_norm = edge_norm
715+
elif (
716+
isinstance(edge_norm, (list, tuple))
717+
and len(edge_norm) == 3
718+
and all(isinstance(v, bool) for v in edge_norm)
719+
):
720+
self.radial_norm = bool(edge_norm[0])
721+
self.film_norm = bool(edge_norm[1])
722+
self.focus_norm = bool(edge_norm[2])
723+
else:
724+
raise ValueError(
725+
"edge_norm must be a bool or a list[bool] of length 3: [radial, film, focus]"
726+
)
710727
if sandwich_norm is None:
711728
sandwich_norm = [False, True, True, False]
712729
if not isinstance(sandwich_norm, (list, tuple)) or len(sandwich_norm) != 4:
@@ -1009,7 +1026,7 @@ def __init__(
10091026
# vanishes at rcut; normalizing them shares the radial network's
10101027
# cutoff-smoothness issue, so ``edge_norm=False`` also drops these
10111028
# norms (identity pass-through) to keep the FiLM scale/shift smooth.
1012-
if self.edge_norm:
1029+
if self.film_norm:
10131030
self.film_scale_norm = ScalarRMSNorm(
10141031
channels=self.channels,
10151032
n_focus=1,
@@ -1066,7 +1083,7 @@ def __init__(
10661083
activation_function=self.activation_function,
10671084
precision=self.compute_precision, # force fp32+
10681085
trainable=self.trainable,
1069-
radial_norm=self.edge_norm,
1086+
radial_norm=self.radial_norm,
10701087
seed=seed_radial_embedding,
10711088
)
10721089

@@ -1129,7 +1146,7 @@ def __init__(
11291146
channels=self.channels,
11301147
n_focus=self.n_focus,
11311148
focus_dim=self.focus_dim,
1132-
focus_norm=self.edge_norm,
1149+
focus_norm=self.focus_norm,
11331150
so2_norm=self.so2_norm,
11341151
mixing_layers=self.mixing_layers,
11351152
so2_attn_res=self.so2_attn_res_mode,
@@ -1164,7 +1181,7 @@ def __init__(
11641181
atten_o_proj=self.use_atten_o_proj,
11651182
so2_pre_norm=self.so2_pre_norm,
11661183
so2_post_norm=self.so2_post_norm,
1167-
so2_post_norm_eps=1.0e-5 if self.edge_norm else 1.0,
1184+
so2_post_norm_eps=1.0e-5 if self.radial_norm else 1.0,
11681185
so2_activation_function=self.so2_activation_function,
11691186
ffn_pre_norm=self.ffn_pre_norm,
11701187
ffn_post_norm=self.ffn_post_norm,
@@ -1618,10 +1635,10 @@ def _run_graph(
16181635
scale_logits = film[:, : self.channels] # (N, C)
16191636
shift_logits = film[:, self.channels :] # (N, C)
16201637
scale_hat = (
1621-
self.film_scale_norm(scale_logits) if self.edge_norm else scale_logits
1638+
self.film_scale_norm(scale_logits) if self.film_norm else scale_logits
16221639
) # (N, C)
16231640
shift_hat = (
1624-
self.film_shift_norm(shift_logits) if self.edge_norm else shift_logits
1641+
self.film_shift_norm(shift_logits) if self.film_norm else shift_logits
16251642
) # (N, C)
16261643
scale_strength = xp.exp(
16271644
xp_asarray_nodetach(
@@ -2605,7 +2622,7 @@ def _variables(self) -> dict[str, np.ndarray]:
26052622
if self.use_env_seed:
26062623
for key, value in self.env_seed_embedding.serialize()["@variables"].items():
26072624
variables[f"env_seed_embedding.{key}"] = value
2608-
if self.edge_norm:
2625+
if self.film_norm:
26092626
for key, value in self.film_scale_norm.serialize()[
26102627
"@variables"
26112628
].items():
@@ -2712,7 +2729,7 @@ def load(module: Any, prefix: str) -> Any:
27122729
self.env_seed_embedding = load(
27132730
self.env_seed_embedding, "env_seed_embedding."
27142731
)
2715-
if self.edge_norm:
2732+
if self.film_norm:
27162733
self.film_scale_norm = load(self.film_scale_norm, "film_scale_norm.")
27172734
self.film_shift_norm = load(self.film_shift_norm, "film_shift_norm.")
27182735
self.film_scale_strength_log = np.asarray(
@@ -2826,7 +2843,11 @@ def serialize(self) -> dict[str, Any]:
28262843
"basis_type": self.basis_type,
28272844
"n_radial": self.n_radial,
28282845
"radial_mlp": self.radial_mlp,
2829-
"edge_norm": self.edge_norm,
2846+
"edge_norm": [
2847+
self.radial_norm,
2848+
self.film_norm,
2849+
self.focus_norm,
2850+
],
28302851
"use_env_seed": self.use_env_seed,
28312852
"random_gamma": self.random_gamma,
28322853
"edge_cartesian": self.edge_cartesian,

deepmd/pt/model/descriptor/sezm.py

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -162,14 +162,16 @@ class DescrptSeZM(BaseDescriptor, nn.Module):
162162
Hidden layer sizes for radial networks. An output layer of size
163163
`(l_schedule[0]+extra_node_l+1)*channels` will be automatically appended.
164164
edge_norm
165-
Whether to apply channel RMSNorm on the descriptor's cutoff-vanishing
166-
branches: the radial network hidden layers, the environment-seed FiLM
167-
scale/shift logits, the cross-focus competition scalars, and the
168-
post-SO(2) residual messages. ``False`` replaces the first three norms
169-
with identity and changes only the post-SO(2) norm to unit-floor residual
170-
scaling. The unit floor uses ``sqrt(1 + variance)`` so small messages
171-
retain their cutoff envelope instead of receiving the standard
172-
``1/sqrt(eps)`` small-signal gain.
165+
Channel RMSNorm on the descriptor's cutoff-vanishing branches: the
166+
radial network hidden layers, the environment-seed FiLM scale/shift
167+
logits, and the cross-focus competition scalars. A bool switches all
168+
three together; a list of three bools ``[radial, film, focus]``
169+
switches them individually. Disabled norms are identity
170+
pass-throughs. The post-SO(2) residual scaling follows the ``radial``
171+
entry: with it disabled the norm uses the unit floor
172+
``sqrt(1 + variance)``, so small messages retain their cutoff
173+
envelope instead of receiving the standard ``1/sqrt(eps)``
174+
small-signal gain.
173175
use_env_seed
174176
If True, seed the initial node state with local-environment information:
175177
apply environment matrix FiLM conditioning on l=0 features using 4D
@@ -452,7 +454,7 @@ def __init__(
452454
basis_type: str = "bessel",
453455
n_radial: int = 16,
454456
radial_mlp: list[int] | None = None,
455-
edge_norm: bool = True,
457+
edge_norm: bool | list[bool] = True,
456458
use_env_seed: bool = True,
457459
random_gamma: bool = True,
458460
edge_cartesian: bool = False,
@@ -554,7 +556,22 @@ def __init__(
554556
if radial_mlp is None:
555557
radial_mlp = [0]
556558
self.radial_mlp = [self.channels if x == 0 else int(x) for x in radial_mlp]
557-
self.edge_norm = bool(edge_norm)
559+
if isinstance(edge_norm, bool):
560+
self.radial_norm = edge_norm
561+
self.film_norm = edge_norm
562+
self.focus_norm = edge_norm
563+
elif (
564+
isinstance(edge_norm, (list, tuple))
565+
and len(edge_norm) == 3
566+
and all(isinstance(v, bool) for v in edge_norm)
567+
):
568+
self.radial_norm = bool(edge_norm[0])
569+
self.film_norm = bool(edge_norm[1])
570+
self.focus_norm = bool(edge_norm[2])
571+
else:
572+
raise ValueError(
573+
"edge_norm must be a bool or a list[bool] of length 3: [radial, film, focus]"
574+
)
558575
if sandwich_norm is None:
559576
sandwich_norm = [False, True, True, False]
560577
if not isinstance(sandwich_norm, (list, tuple)) or len(sandwich_norm) != 4:
@@ -862,7 +879,7 @@ def __init__(
862879
# vanishes at rcut; normalizing them shares the radial network's
863880
# cutoff-smoothness issue, so ``edge_norm=False`` also drops these
864881
# norms (identity pass-through) to keep the FiLM scale/shift smooth.
865-
if self.edge_norm:
882+
if self.film_norm:
866883
self.film_scale_norm: nn.Module = ScalarRMSNorm(
867884
channels=self.channels,
868885
n_focus=1,
@@ -928,7 +945,7 @@ def __init__(
928945
activation_function=self.activation_function,
929946
dtype=self.compute_dtype, # force fp32+
930947
trainable=self.trainable,
931-
radial_norm=self.edge_norm,
948+
radial_norm=self.radial_norm,
932949
seed=seed_radial_embedding,
933950
)
934951

@@ -991,7 +1008,7 @@ def __init__(
9911008
channels=self.channels,
9921009
n_focus=self.n_focus,
9931010
focus_dim=self.focus_dim,
994-
focus_norm=self.edge_norm,
1011+
focus_norm=self.focus_norm,
9951012
so2_norm=self.so2_norm,
9961013
mixing_layers=self.mixing_layers,
9971014
so2_attn_res=self.so2_attn_res_mode,
@@ -1026,7 +1043,7 @@ def __init__(
10261043
atten_o_proj=self.use_atten_o_proj,
10271044
so2_pre_norm=self.so2_pre_norm,
10281045
so2_post_norm=self.so2_post_norm,
1029-
so2_post_norm_eps=1.0e-5 if self.edge_norm else 1.0,
1046+
so2_post_norm_eps=1.0e-5 if self.radial_norm else 1.0,
10301047
so2_activation_function=self.so2_activation_function,
10311048
ffn_pre_norm=self.ffn_pre_norm,
10321049
ffn_post_norm=self.ffn_post_norm,
@@ -2617,7 +2634,11 @@ def serialize(self) -> dict[str, Any]:
26172634
"basis_type": self.basis_type,
26182635
"n_radial": self.n_radial,
26192636
"radial_mlp": self.radial_mlp,
2620-
"edge_norm": self.edge_norm,
2637+
"edge_norm": [
2638+
self.radial_norm,
2639+
self.film_norm,
2640+
self.focus_norm,
2641+
],
26212642
"use_env_seed": self.use_env_seed,
26222643
"random_gamma": self.random_gamma,
26232644
"edge_cartesian": self.edge_cartesian,

deepmd/utils/argcheck.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -617,7 +617,7 @@ def descrpt_se_zm_args() -> list[Argument]:
617617
doc_basis_type = "Radial basis type. Supported values are `bessel` and `gaussian`."
618618
doc_n_radial = "Number of radial basis functions."
619619
doc_radial_mlp = "Hidden layer sizes for radial networks. An output layer of size (l_schedule[0]+extra_node_l+1)*channels will be automatically appended. Use 0 as a placeholder to be replaced by channels."
620-
doc_edge_norm = "Whether to apply standard channel RMSNorm on cutoff-vanishing feature branches. Setting to `false` removes RMSNorm from the radial network, environment-seed FiLM, and cross-focus competition, and uses unit-floor residual scaling for post-SO(2) messages. Setting to `false` is recommended."
620+
doc_edge_norm = "Channel RMSNorm on the cutoff-vanishing feature branches. A bool switches every site together: `false` removes the RMSNorm from the radial-network hidden layers, the environment-seed FiLM scale/shift logits and the cross-focus competition scalars, and uses unit-floor residual scaling for post-SO(2) messages. A list of three bools `[radial, film, focus]` switches the sites individually; the post-SO(2) treatment follows the first (radial) entry. Recommended: `[false, true, false]` — the radial-site norms amplify noise where the radial features vanish at the cutoff and produce a spurious long-range force step, while the FiLM and focus norms are safe to keep."
621621
doc_use_env_seed = (
622622
"If True, seed the initial node state with local-environment information: "
623623
"apply environment matrix FiLM conditioning on l=0 features using 4D "
@@ -938,7 +938,7 @@ def descrpt_se_zm_args() -> list[Argument]:
938938
),
939939
Argument(
940940
"edge_norm",
941-
bool,
941+
[bool, list],
942942
optional=True,
943943
default=True,
944944
doc=doc_edge_norm,

source/tests/pt/model/test_descriptor_sezm.py

Lines changed: 78 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2346,8 +2346,19 @@ def test_radial_norm_structure_and_serialization(self) -> None:
23462346
torch.testing.assert_close(mlp(x), restored(x))
23472347

23482348
def test_edge_norm_gates_all_cutoff_vanishing_norms(self) -> None:
2349-
"""``edge_norm`` controls every cutoff-vanishing normalization path."""
2350-
for edge_norm in (True, False):
2349+
"""``edge_norm`` controls every cutoff-vanishing normalization path.
2350+
2351+
A bool switches the radial, FiLM and focus norms together; a list of
2352+
three bools ``[radial, film, focus]`` switches them individually. The
2353+
post-SO(2) residual scaling follows the radial switch.
2354+
"""
2355+
cases = [
2356+
(True, (True, True, True)),
2357+
(False, (False, False, False)),
2358+
([False, True, False], (False, True, False)),
2359+
([True, False, True], (True, False, True)),
2360+
]
2361+
for edge_norm, (radial_on, film_on, focus_on) in cases:
23512362
with self.subTest(edge_norm=edge_norm):
23522363
desc = DescrptSeZM(
23532364
**_descriptor_kwargs(
@@ -2362,26 +2373,84 @@ def test_edge_norm_gates_all_cutoff_vanishing_norms(self) -> None:
23622373
radial_has_norm = any(
23632374
type(m).__name__ == "RMSNorm" for m in desc.radial_embedding.net
23642375
)
2365-
self.assertEqual(radial_has_norm, edge_norm)
2376+
self.assertEqual(radial_has_norm, radial_on)
23662377
# env-seed FiLM scale/shift norms
23672378
self.assertEqual(
2368-
type(desc.film_scale_norm).__name__ == "ScalarRMSNorm", edge_norm
2379+
type(desc.film_scale_norm).__name__ == "ScalarRMSNorm", film_on
23692380
)
23702381
self.assertEqual(
2371-
type(desc.film_shift_norm).__name__ == "ScalarRMSNorm", edge_norm
2382+
type(desc.film_shift_norm).__name__ == "ScalarRMSNorm", film_on
23722383
)
23732384
# cross-focus competition norm (n_focus>1 -> competition active)
23742385
focus_norm_mod = desc.blocks[0].so2_conv.focus_compete_norm
23752386
self.assertEqual(
2376-
type(focus_norm_mod).__name__ == "ScalarRMSNorm", edge_norm
2387+
type(focus_norm_mod).__name__ == "ScalarRMSNorm", focus_on
23772388
)
2378-
# Only the post-SO(2) residual branch uses unit-floor scaling.
2379-
expected_eps = 1.0e-5 if edge_norm else 1.0
2389+
# Only the post-SO(2) residual branch uses unit-floor scaling,
2390+
# bound to the radial switch.
2391+
expected_eps = 1.0e-5 if radial_on else 1.0
23802392
self.assertEqual(desc.blocks[0].post_so2_norm.eps, expected_eps)
23812393
self.assertEqual(desc.blocks[0].pre_so2_norm.eps, 1.0e-5)
23822394
self.assertEqual(desc.blocks[0].pre_ffn_norms[0].eps, 1.0e-5)
23832395
self.assertEqual(desc.blocks[0].post_ffn_norms[0].eps, 1.0e-5)
2384-
self.assertEqual(desc.serialize()["config"]["edge_norm"], edge_norm)
2396+
canonical = [radial_on, film_on, focus_on]
2397+
self.assertEqual(desc.serialize()["config"]["edge_norm"], canonical)
2398+
restored = DescrptSeZM.deserialize(desc.serialize())
2399+
self.assertEqual(
2400+
[
2401+
restored.radial_norm,
2402+
restored.film_norm,
2403+
restored.focus_norm,
2404+
],
2405+
canonical,
2406+
)
2407+
2408+
def test_edge_norm_legacy_checkpoint_formats(self) -> None:
2409+
"""Serialized data from older checkpoints loads unchanged.
2410+
2411+
The oldest checkpoints predate the ``edge_norm`` option and carry no
2412+
such config key (the norms were always built, matching the default
2413+
``True``); intermediate checkpoints store a plain bool. Both must
2414+
deserialize into the same module structure and reproduce the source
2415+
model's output exactly.
2416+
"""
2417+
dtype = PRECISION_DICT["float64"]
2418+
cases = [
2419+
("missing_key", True, None, (True, True, True)),
2420+
("bool_true", True, True, (True, True, True)),
2421+
("bool_false", False, False, (False, False, False)),
2422+
]
2423+
for case_name, build_edge_norm, stored_edge_norm, expected in cases:
2424+
with self.subTest(case=case_name):
2425+
model = DescrptSeZM(
2426+
**_descriptor_kwargs(
2427+
edge_norm=build_edge_norm,
2428+
use_env_seed=True,
2429+
n_focus=2,
2430+
precision="float64",
2431+
)
2432+
)
2433+
data = model.serialize()
2434+
if stored_edge_norm is None:
2435+
data["config"].pop("edge_norm")
2436+
else:
2437+
data["config"]["edge_norm"] = stored_edge_norm
2438+
restored = DescrptSeZM.deserialize(data)
2439+
self.assertEqual(
2440+
(
2441+
restored.radial_norm,
2442+
restored.film_norm,
2443+
restored.focus_norm,
2444+
),
2445+
expected,
2446+
)
2447+
coord, atype, nlist = _tiny_two_atom_system(self.device, dtype=dtype)
2448+
extended_coord = coord.reshape(1, -1)
2449+
desc1, _, _, _, sw1 = model(extended_coord, atype, nlist)
2450+
desc2, _, _, _, sw2 = restored(extended_coord, atype, nlist)
2451+
atol, rtol = _forward_tols(dtype)
2452+
torch.testing.assert_close(desc1, desc2, atol=atol, rtol=rtol)
2453+
torch.testing.assert_close(sw1, sw2, atol=atol, rtol=rtol)
23852454

23862455

23872456
class TestDescriptorEnergyCurveSmoothness(_SeZMTestCase):

0 commit comments

Comments
 (0)