Skip to content

Commit d32b1b6

Browse files
committed
fix(dpa4): align pt_expt training and native-spin fine-tuning
1 parent bc902da commit d32b1b6

34 files changed

Lines changed: 1510 additions & 352 deletions

deepmd/dpmodel/atomic_model/base_atomic_model.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -863,6 +863,10 @@ def _get_forward_wrapper_func(self) -> Callable[..., dict[str, np.ndarray]]:
863863
:meth:`get_sel` otherwise. Sizing a dense list from ``get_sel`` is not
864864
merely wasteful for a graph-native model -- such a model reports no
865865
finite capacity, so the allocation is unbounded.
866+
867+
A native-spin model conditions on a per-atom magnetic moment, which the
868+
wrapper forwards on the graph route alone: that scheme implements only
869+
the graph lower, so the dense route never carries a moment.
866870
"""
867871
import array_api_compat
868872

@@ -880,6 +884,7 @@ def model_forward(
880884
fparam: np.ndarray | None = None,
881885
aparam: np.ndarray | None = None,
882886
charge_spin: np.ndarray | None = None,
887+
spin: np.ndarray | None = None,
883888
) -> dict[str, np.ndarray]:
884889
# Get reference array to determine the target array type and device
885890
# Use out_bias as reference since it's always present
@@ -901,6 +906,8 @@ def model_forward(
901906
aparam = xp.asarray(aparam, device=device)
902907
if charge_spin is not None:
903908
charge_spin = xp.asarray(charge_spin, device=device)
909+
if spin is not None:
910+
spin = xp.asarray(spin, device=device)
904911

905912
if self.uses_graph_lower():
906913
nframes, nloc = atype.shape
@@ -927,6 +934,7 @@ def model_forward(
927934
else None
928935
),
929936
charge_spin=charge_spin,
937+
spin=None if spin is None else xp.reshape(spin, (-1, 3)),
930938
)
931939
# The graph route works on a flat node axis; restore the
932940
# per-frame layout the dense route returns.

deepmd/dpmodel/descriptor/dpa4.py

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -595,7 +595,7 @@ class DescrptDPA4(NativeOP, BaseDescriptor):
595595
"""
596596

597597
_ENV_DIM: int = 1 # Use se_r style (radial only) for EnvMatStatSe compatibility
598-
LATEST_VERSION: float = 1.1
598+
LATEST_VERSION: float = 1.2
599599

600600
def __init__(
601601
self,
@@ -2687,6 +2687,68 @@ def load(module: Any, prefix: str) -> Any:
26872687
# === Output FFN ===
26882688
self.output_ffn._load_variables(take_prefix("output_ffn."))
26892689

2690+
def _migrate_variables(
2691+
self,
2692+
variables: dict[str, Any],
2693+
version: float,
2694+
prefix: str = "",
2695+
) -> float:
2696+
"""Rewrite stored variables whose meaning changed since ``version``.
2697+
2698+
Operates on the flat mapping keyed by ``state_dict`` names, BEFORE
2699+
anything is assigned to a module: ``load_state_dict`` restores a
2700+
module's own buffers before descending into its children, so a
2701+
migration applied to live attributes would rewrite values the child
2702+
load is about to overwrite. Only representations are upgraded here;
2703+
a difference no rewrite can absorb stays a forward-time branch on
2704+
:attr:`version`, so a migrated descriptor never changes its own math.
2705+
2706+
Version 1.2 moved the env-seed spin gate from the spin coordinate to
2707+
the resulting environment quadratic form. For an active-spin model,
2708+
squaring the stored amplitude preserves the represented function.
2709+
Legacy native-spin models with no magnetic types instead carry
2710+
dormant, unconstrained spin-route values; those output-controlling
2711+
values are canonicalized to the zero function before the routes can
2712+
be activated by fine-tuning. Versions below 1.1 predate the
2713+
native-spin route and retain their original forward semantics.
2714+
2715+
Parameters
2716+
----------
2717+
variables
2718+
Stored variables keyed by ``state_dict`` name, mutated in place.
2719+
version
2720+
Version the variables were written at.
2721+
prefix
2722+
Key prefix of this descriptor within ``variables``.
2723+
2724+
Returns
2725+
-------
2726+
float
2727+
Version the variables express after migration.
2728+
"""
2729+
if not 1.1 <= version < 1.2:
2730+
return version
2731+
2732+
gate_key = prefix + "env_seed_embedding.spin_scale"
2733+
if self.use_spin is not None and not any(self.use_spin):
2734+
# dpmodel serialization names NativeLayer weights ``matrix``;
2735+
# pt_expt state dictionaries expose the wrapped attribute as ``w``.
2736+
dormant_keys = (
2737+
"spin_embedding.mag_layer2.matrix",
2738+
"spin_embedding.mag_layer2.w",
2739+
"spin_embedding.adam_spin_vec_weight",
2740+
"spin_embedding.adam_spin_nbr_weight",
2741+
"env_seed_embedding.spin_scale",
2742+
)
2743+
for name in dormant_keys:
2744+
key = prefix + name
2745+
if key in variables:
2746+
xp = array_api_compat.array_namespace(variables[key])
2747+
variables[key] = xp.zeros_like(variables[key])
2748+
elif gate_key in variables:
2749+
variables[gate_key] = variables[gate_key] ** 2
2750+
return 1.2
2751+
26902752
def serialize(self) -> dict[str, Any]:
26912753
return {
26922754
"@class": "Descriptor",
@@ -2776,7 +2838,7 @@ def deserialize(cls, data: dict[str, Any]) -> DescrptDPA4:
27762838
data.pop("env_mat", None)
27772839
config.pop("s2_grid_resolution", None)
27782840
obj = cls(**config)
2779-
obj.version = version
2841+
obj.version = obj._migrate_variables(variables, version)
27802842
obj._load_variables(variables)
27812843
return obj
27822844

deepmd/dpmodel/descriptor/dpa4_nn/embedding.py

Lines changed: 49 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,8 @@ class EnvironmentInitialEmbedding(NativeOP):
445445
Random seed for reproducibility.
446446
"""
447447

448+
CONFIG_DERIVED_ARRAYS = ("spin_mask",)
449+
448450
def __init__(
449451
self,
450452
*,
@@ -488,7 +490,10 @@ def __init__(
488490
# plus, for the native spin scheme, the 3 envelope-gated neighbor-spin
489491
# components, so the inner product ``D = M^T M`` yields the neighbor
490492
# spin-spin invariants alongside the geometric ones.
491-
self.coord_dim = 4 + (3 if self.spin_flags is not None else 0)
493+
self.geometry_coord_dim = 4
494+
self.coord_dim = self.geometry_coord_dim + (
495+
3 if self.spin_flags is not None else 0
496+
)
492497

493498
# === RBF projection: n_radial -> rbf_out_dim (two-layer MLP) ===
494499
# rbf_out_dim = max(32, embed_dim - 2*type_dim) to align G-network width to embed_dim
@@ -566,19 +571,18 @@ def __init__(
566571
dtype=PRECISION_DICT[self.precision.lower()],
567572
)
568573

569-
# === Native spin: per-type mask and isotropic channel scale ===
574+
# === Native spin: per-type mask and post-quadratic activation gate ===
570575
# The mask gates the neighbor-spin channel by source type, so a
571576
# non-magnetic neighbor contributes zero and (critically) carries zero
572-
# magnetic force ``-dE/ds``. The single scalar scale (shared across
573-
# x/y/z) keeps the spin coordinates transforming with the geometry, so
574-
# the env-matrix invariant stays SO(3)-invariant; ``output_proj`` is
575-
# zero-initialized, so the spin contribution starts neutral regardless.
577+
# magnetic force ``-dE/ds``. ``spin_scale`` multiplies the spin-only
578+
# contribution after the environment quadratic form, providing a
579+
# linear gate that can start from exactly zero.
576580
if self.spin_flags is not None:
577581
self.spin_mask = np.array(
578582
[1.0 if flag else 0.0 for flag in self.spin_flags],
579583
dtype=PRECISION_DICT[self.precision.lower()],
580584
)
581-
self.spin_scale = np.ones(
585+
self.spin_scale = np.zeros(
582586
(1,), dtype=PRECISION_DICT[self.precision.lower()]
583587
)
584588

@@ -648,11 +652,7 @@ def call(
648652
xp.take(xp.astype(atype_flat, xp.int64), src_i, axis=0),
649653
axis=0,
650654
)[:, None] # (E, 1)
651-
spin_scale = xp.astype(
652-
xp_asarray_nodetach(xp, self.spin_scale[...], device=device),
653-
r_tilde.dtype,
654-
)
655-
spin_chan = edge_env * spin_scale * spin_src * mask # (E, 3)
655+
spin_chan = edge_env * spin_src * mask # (E, 3)
656656
else:
657657
spin_chan = xp.zeros(
658658
(r_tilde.shape[0], 3), dtype=r_tilde.dtype, device=device
@@ -720,9 +720,26 @@ def call(
720720
# Summing over the coordinate axis makes D invariant to a joint rotation
721721
# of the geometry and the spin channels; with the spin channels present,
722722
# D additionally carries the neighbor spin-spin invariants.
723-
env_agg_t = xp.permute_dims(env_agg, (0, 2, 1)) # (N, embed_dim, coord_dim)
724-
env_agg_axis = env_agg[:, :, : self.axis_dim] # (N, coord_dim, axis_dim)
725-
D = xp.matmul(env_agg_t, env_agg_axis) # (N, embed_dim, axis_dim)
723+
if self.spin_flags is None:
724+
env_agg_t = xp.permute_dims(env_agg, (0, 2, 1))
725+
env_agg_axis = env_agg[:, :, : self.axis_dim]
726+
D = xp.matmul(env_agg_t, env_agg_axis)
727+
else:
728+
geometry_agg = env_agg[:, : self.geometry_coord_dim, :]
729+
spin_agg = env_agg[:, self.geometry_coord_dim :, :]
730+
D_geometry = xp.matmul(
731+
xp.permute_dims(geometry_agg, (0, 2, 1)),
732+
geometry_agg[:, :, : self.axis_dim],
733+
)
734+
D_spin = xp.matmul(
735+
xp.permute_dims(spin_agg, (0, 2, 1)),
736+
spin_agg[:, :, : self.axis_dim],
737+
)
738+
spin_scale = xp.astype(
739+
xp_asarray_nodetach(xp, self.spin_scale[...], device=device),
740+
D_spin.dtype,
741+
)
742+
D = D_geometry + spin_scale * D_spin
726743

727744
# === Step 6. Output projection for FiLM logits ===
728745
D_flat = xp.reshape(
@@ -994,6 +1011,8 @@ class SpinEmbedding(NativeOP):
9941011
Whether parameters are trainable.
9951012
"""
9961013

1014+
CONFIG_DERIVED_ARRAYS = ("spin_mask",)
1015+
9971016
def __init__(
9981017
self,
9991018
*,
@@ -1020,8 +1039,9 @@ def __init__(
10201039
self.spin_flags = [bool(flag) for flag in use_spin]
10211040

10221041
# === Per-type spin gate ===
1023-
# Non-persistent: rebuilt from config on construction and moved with the
1024-
# module, so the deterministic mask never enters the serialized state.
1042+
# Configuration-derived (hence ``CONFIG_DERIVED_ARRAYS``): rebuilt on
1043+
# construction and moved with the module, so the deterministic mask
1044+
# never enters the serialized state.
10251045
self.spin_mask = np.array(
10261046
[1.0 if bool(flag) else 0.0 for flag in use_spin], dtype=prec
10271047
)
@@ -1053,23 +1073,26 @@ def __init__(
10531073
seed=child_seed(seed_scalar, 1),
10541074
trainable=self.trainable,
10551075
)
1076+
self.mag_layer2.w = np.zeros(
1077+
(self.channels, self.channels),
1078+
dtype=prec,
1079+
)
10561080

10571081
# === l=1 per-type per-channel weight ===
10581082
# ``adam_`` prefix routes the table to Adam in HybridMuon, matching the
10591083
# type-embedding treatment for per-type lookup parameters.
1060-
init_std = 1.0 / math.sqrt(float(self.ntypes + self.channels))
1061-
rng_vec = np.random.default_rng(child_seed(seed, 1))
1062-
self.adam_spin_vec_weight = rng_vec.normal(
1063-
0.0, init_std, size=(self.ntypes, self.channels)
1064-
).astype(prec)
1084+
self.adam_spin_vec_weight = np.zeros(
1085+
(self.ntypes, self.channels),
1086+
dtype=prec,
1087+
)
10651088

10661089
# === l=1 per-source-type per-channel weight for neighbor aggregation ===
10671090
# Separate from the on-site weight: this scales the neighbor's spin
10681091
# direction before it is aggregated into the center node's l=1 seed.
1069-
rng_nbr = np.random.default_rng(child_seed(seed, 2))
1070-
self.adam_spin_nbr_weight = rng_nbr.normal(
1071-
0.0, init_std, size=(self.ntypes, self.channels)
1072-
).astype(prec)
1092+
self.adam_spin_nbr_weight = np.zeros(
1093+
(self.ntypes, self.channels),
1094+
dtype=prec,
1095+
)
10731096

10741097
def call(self, spin: Any, atype: Any) -> tuple[Any, Any]:
10751098
"""

deepmd/dpmodel/descriptor/dpa4_nn/norm.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -360,18 +360,25 @@ def __init__(
360360
self.trainable = bool(trainable)
361361
prec = PRECISION_DICT[self.precision.lower()]
362362

363-
self.degree_index_m = np.asarray(degree_index_m, dtype=np.int64)
363+
# A backend wrapping this module holds array attributes as framework
364+
# tensors, possibly on an accelerator, and the caller's index table is
365+
# one of them. Normalize it to NumPy once, then drive the setup below
366+
# from that local binding rather than from the stored attribute: the
367+
# numpy-only surface it relies on (``.size``, boolean-mask assignment)
368+
# does not survive the backend's conversion either.
369+
degree_index_m = to_numpy_array(degree_index_m).astype(np.int64, copy=False)
370+
self.degree_index_m = degree_index_m
364371

365372
# Pre-fuse degree balancing and channel averaging into a single weight:
366373
# w_d = 1 / (n_coeff_l * (lmax+1) * C)
367374
# where n_coeff_l is the number of retained coefficients for degree l in
368375
# the reduced layout.
369-
weights = np.zeros(self.degree_index_m.size, dtype=prec)
376+
weights = np.zeros(degree_index_m.size, dtype=prec)
370377
scale = 1.0 / ((self.lmax + 1) * self.channels)
371378
for l in range(self.lmax + 1):
372379
n_coeff_l = 2 * min(l, self.mmax) + 1
373380
w_l = scale / float(n_coeff_l)
374-
weights[self.degree_index_m == l] = w_l
381+
weights[degree_index_m == l] = w_l
375382
if np.any(weights == 0):
376383
raise ValueError(
377384
"ReducedEquivariantRMSNorm: balance_weight has zeros; "
@@ -411,15 +418,15 @@ def call(self, x: Any) -> Any:
411418
# === Step 2. Compute a shared degree-balanced RMS ===
412419
balance_weight = xp_asarray_nodetach(xp, self.balance_weight, device=device)
413420
mean_variance = xp.sum(x0 * x0, axis=(2, 3)) * balance_weight[0]
414-
if self.degree_index_m.size > 1:
421+
if xt.shape[2] > 0:
415422
mean_variance = mean_variance + xp.sum(
416423
(xt * xt) * balance_weight[1:][None, None, :, None], axis=(2, 3)
417424
)
418425
inv_rms = 1.0 / xp.sqrt(mean_variance + self.eps)
419426
inv_rms = inv_rms[:, :, None, None] # (F, E, 1, 1)
420427

421428
x0 = x0 * inv_rms
422-
if self.degree_index_m.size > 1:
429+
if xt.shape[2] > 0:
423430
xt = xt * inv_rms
424431

425432
# === Step 3. Apply per-degree affine parameters ===
@@ -428,7 +435,7 @@ def call(self, x: Any) -> Any:
428435
expanded_scale = xp.take(adam_scale, degree_index_m, axis=1)
429436
expanded_scale = expanded_scale[:, None, ...] # (F, 1, D_m_trunc, C)
430437
x0 = x0 * expanded_scale[:, :, :1, :]
431-
if self.degree_index_m.size > 1:
438+
if xt.shape[2] > 0:
432439
xt = xt * expanded_scale[:, :, 1:, :]
433440

434441
# === Step 4. Add scalar bias and restore layout ===
@@ -438,7 +445,7 @@ def call(self, x: Any) -> Any:
438445
) # (F, 1, 1, C)
439446
x0 = x0 + bias0
440447

441-
out = x0 if self.degree_index_m.size == 1 else xp.concat([x0, xt], axis=2)
448+
out = x0 if xt.shape[2] == 0 else xp.concat([x0, xt], axis=2)
442449
out = xp.astype(out, in_dtype)
443450
return out
444451

deepmd/dpmodel/model/model.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,6 @@ def get_native_spin_model(data: dict) -> NativeSpinEnergyModel:
156156
use_spin = normalize_spin_use_spin(spin_cfg["use_spin"], data["type_map"])
157157
spin = Spin(
158158
use_spin=use_spin,
159-
virtual_scale=spin_cfg.get("virtual_scale", 1.0),
160159
allow_missing_label=spin_cfg.get("allow_missing_label", False),
161160
)
162161
data.setdefault("descriptor", {})

deepmd/dpmodel/model/native_spin_model.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,14 @@ def make_native_spin_model(T_Model: type) -> type:
7474
class NSM(T_Model, NativeSpinModelKind):
7575
"""Native-spin variant of ``T_Model`` (see ``make_native_spin_model``)."""
7676

77+
CONFIG_DERIVED_ARRAYS = ("spin_mask",)
78+
7779
def __init__(self, *args: Any, spin: Spin, **kwargs: Any) -> None:
7880
super().__init__(*args, **kwargs)
7981
self.spin = spin
8082
self.ntypes_real = self.spin.ntypes_real
81-
# Per-real-type 0/1 spin gate.
83+
# Per-real-type 0/1 spin gate, derived from ``use_spin`` and hence
84+
# rebuilt here rather than adopted from a checkpoint.
8285
self.spin_mask = self.spin.get_spin_mask()
8386

8487
@staticmethod

deepmd/dpmodel/model/spin_model.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ class SpinModel(NativeOP):
5555
\boldsymbol{\tau}_i = \mathbf{F}_i^{\mathrm{virtual}} \times \boldsymbol{\sigma}_i.
5656
"""
5757

58+
CONFIG_DERIVED_ARRAYS = ("spin_mask", "virtual_scale_mask")
59+
5860
def __init__(
5961
self,
6062
backbone_model: DPAtomicModel,
@@ -76,6 +78,8 @@ def __init__(
7678
# concrete default).
7779
descriptor.disable_graph_lower()
7880
self.ntypes_real = self.spin.ntypes_real
81+
# Both per-type tables follow from ``use_spin`` and ``virtual_scale``,
82+
# so they are rebuilt here rather than adopted from a checkpoint.
7983
self.virtual_scale_mask = self.spin.get_virtual_scale_mask()
8084
self.spin_mask = self.spin.get_spin_mask()
8185

deepmd/dpmodel/utils/stat.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,9 +208,20 @@ def _compute_model_predict(
208208
fparam = to_numpy_array(system.get("fparam", None))
209209
aparam = to_numpy_array(system.get("aparam", None))
210210
charge_spin = to_numpy_array(system.get("charge_spin", None))
211+
# A native-spin model conditions on the per-atom moment, so the bias it
212+
# predicts here is only the bias it will predict during training if the
213+
# moment travels with the sample. The virtual-atom scheme never reaches
214+
# this key: it expands the moment into virtual atoms before sampling.
215+
spin = to_numpy_array(system.get("spin", None))
211216

212217
sample_predict = model_forward(
213-
coord, atype, box, fparam=fparam, aparam=aparam, charge_spin=charge_spin
218+
coord,
219+
atype,
220+
box,
221+
fparam=fparam,
222+
aparam=aparam,
223+
charge_spin=charge_spin,
224+
spin=spin,
214225
)
215226
for kk in keys:
216227
model_predict[kk].append(

0 commit comments

Comments
 (0)