Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions deepmd/dpmodel/atomic_model/base_atomic_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -863,6 +863,10 @@ def _get_forward_wrapper_func(self) -> Callable[..., dict[str, np.ndarray]]:
:meth:`get_sel` otherwise. Sizing a dense list from ``get_sel`` is not
merely wasteful for a graph-native model -- such a model reports no
finite capacity, so the allocation is unbounded.

A native-spin model conditions on a per-atom magnetic moment, which the
wrapper forwards on the graph route alone: that scheme implements only
the graph lower, so the dense route never carries a moment.
"""
import array_api_compat

Expand All @@ -880,6 +884,7 @@ def model_forward(
fparam: np.ndarray | None = None,
aparam: np.ndarray | None = None,
charge_spin: np.ndarray | None = None,
spin: np.ndarray | None = None,
) -> dict[str, np.ndarray]:
# Get reference array to determine the target array type and device
# Use out_bias as reference since it's always present
Expand All @@ -901,6 +906,8 @@ def model_forward(
aparam = xp.asarray(aparam, device=device)
if charge_spin is not None:
charge_spin = xp.asarray(charge_spin, device=device)
if spin is not None:
spin = xp.asarray(spin, device=device)

if self.uses_graph_lower():
nframes, nloc = atype.shape
Expand All @@ -927,6 +934,7 @@ def model_forward(
else None
),
charge_spin=charge_spin,
spin=None if spin is None else xp.reshape(spin, (-1, 3)),
)
# The graph route works on a flat node axis; restore the
# per-frame layout the dense route returns.
Expand All @@ -935,6 +943,11 @@ def model_forward(
for kk, vv in atomic_ret.items()
}
else:
if spin is not None:
raise NotImplementedError(
"native-spin output-bias calibration requires the "
"NeighborGraph lower"
)
(
extended_coord,
extended_atype,
Expand Down
66 changes: 64 additions & 2 deletions deepmd/dpmodel/descriptor/dpa4.py
Original file line number Diff line number Diff line change
Expand Up @@ -595,7 +595,7 @@ class DescrptDPA4(NativeOP, BaseDescriptor):
"""

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

def __init__(
self,
Expand Down Expand Up @@ -2687,6 +2687,68 @@ def load(module: Any, prefix: str) -> Any:
# === Output FFN ===
self.output_ffn._load_variables(take_prefix("output_ffn."))

def _migrate_variables(
self,
variables: dict[str, Any],
version: float,
prefix: str = "",
) -> float:
"""Rewrite stored variables whose meaning changed since ``version``.

Operates on the flat mapping keyed by ``state_dict`` names, BEFORE
anything is assigned to a module: ``load_state_dict`` restores a
module's own buffers before descending into its children, so a
migration applied to live attributes would rewrite values the child
load is about to overwrite. Only representations are upgraded here;
a difference no rewrite can absorb stays a forward-time branch on
:attr:`version`, so a migrated descriptor never changes its own math.

Version 1.2 moved the env-seed spin gate from the spin coordinate to
the resulting environment quadratic form. For an active-spin model,
squaring the stored amplitude preserves the represented function.
Legacy native-spin models with no magnetic types instead carry
dormant, unconstrained spin-route values; those output-controlling
values are canonicalized to the zero function before the routes can
be activated by fine-tuning. Versions below 1.1 predate the
native-spin route and retain their original forward semantics.

Parameters
----------
variables
Stored variables keyed by ``state_dict`` name, mutated in place.
version
Version the variables were written at.
prefix
Key prefix of this descriptor within ``variables``.

Returns
-------
float
Version the variables express after migration.
"""
if not 1.1 <= version < 1.2:
return version

gate_key = prefix + "env_seed_embedding.spin_scale"
if self.use_spin is not None and not any(self.use_spin):
# dpmodel serialization names NativeLayer weights ``matrix``;
# pt_expt state dictionaries expose the wrapped attribute as ``w``.
dormant_keys = (
"spin_embedding.mag_layer2.matrix",
"spin_embedding.mag_layer2.w",
"spin_embedding.adam_spin_vec_weight",
"spin_embedding.adam_spin_nbr_weight",
"env_seed_embedding.spin_scale",
)
for name in dormant_keys:
key = prefix + name
if key in variables:
xp = array_api_compat.array_namespace(variables[key])
variables[key] = xp.zeros_like(variables[key])
elif gate_key in variables:
variables[gate_key] = variables[gate_key] ** 2
return 1.2

def serialize(self) -> dict[str, Any]:
return {
"@class": "Descriptor",
Expand Down Expand Up @@ -2776,7 +2838,7 @@ def deserialize(cls, data: dict[str, Any]) -> DescrptDPA4:
data.pop("env_mat", None)
config.pop("s2_grid_resolution", None)
obj = cls(**config)
obj.version = version
obj.version = obj._migrate_variables(variables, version)
obj._load_variables(variables)
return obj

Expand Down
75 changes: 49 additions & 26 deletions deepmd/dpmodel/descriptor/dpa4_nn/embedding.py
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,8 @@ class EnvironmentInitialEmbedding(NativeOP):
Random seed for reproducibility.
"""

CONFIG_DERIVED_ARRAYS = ("spin_mask",)

def __init__(
self,
*,
Expand Down Expand Up @@ -488,7 +490,10 @@ def __init__(
# plus, for the native spin scheme, the 3 envelope-gated neighbor-spin
# components, so the inner product ``D = M^T M`` yields the neighbor
# spin-spin invariants alongside the geometric ones.
self.coord_dim = 4 + (3 if self.spin_flags is not None else 0)
self.geometry_coord_dim = 4
self.coord_dim = self.geometry_coord_dim + (
3 if self.spin_flags is not None else 0
)

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

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

Expand Down Expand Up @@ -648,11 +652,7 @@ def call(
xp.take(xp.astype(atype_flat, xp.int64), src_i, axis=0),
axis=0,
)[:, None] # (E, 1)
spin_scale = xp.astype(
xp_asarray_nodetach(xp, self.spin_scale[...], device=device),
r_tilde.dtype,
)
spin_chan = edge_env * spin_scale * spin_src * mask # (E, 3)
spin_chan = edge_env * spin_src * mask # (E, 3)
else:
spin_chan = xp.zeros(
(r_tilde.shape[0], 3), dtype=r_tilde.dtype, device=device
Expand Down Expand Up @@ -720,9 +720,26 @@ def call(
# Summing over the coordinate axis makes D invariant to a joint rotation
# of the geometry and the spin channels; with the spin channels present,
# D additionally carries the neighbor spin-spin invariants.
env_agg_t = xp.permute_dims(env_agg, (0, 2, 1)) # (N, embed_dim, coord_dim)
env_agg_axis = env_agg[:, :, : self.axis_dim] # (N, coord_dim, axis_dim)
D = xp.matmul(env_agg_t, env_agg_axis) # (N, embed_dim, axis_dim)
if self.spin_flags is None:
env_agg_t = xp.permute_dims(env_agg, (0, 2, 1))
env_agg_axis = env_agg[:, :, : self.axis_dim]
D = xp.matmul(env_agg_t, env_agg_axis)
else:
geometry_agg = env_agg[:, : self.geometry_coord_dim, :]
spin_agg = env_agg[:, self.geometry_coord_dim :, :]
D_geometry = xp.matmul(
xp.permute_dims(geometry_agg, (0, 2, 1)),
geometry_agg[:, :, : self.axis_dim],
)
D_spin = xp.matmul(
xp.permute_dims(spin_agg, (0, 2, 1)),
spin_agg[:, :, : self.axis_dim],
)
spin_scale = xp.astype(
xp_asarray_nodetach(xp, self.spin_scale[...], device=device),
D_spin.dtype,
)
D = D_geometry + spin_scale * D_spin

# === Step 6. Output projection for FiLM logits ===
D_flat = xp.reshape(
Expand Down Expand Up @@ -994,6 +1011,8 @@ class SpinEmbedding(NativeOP):
Whether parameters are trainable.
"""

CONFIG_DERIVED_ARRAYS = ("spin_mask",)

def __init__(
self,
*,
Expand All @@ -1020,8 +1039,9 @@ def __init__(
self.spin_flags = [bool(flag) for flag in use_spin]

# === Per-type spin gate ===
# Non-persistent: rebuilt from config on construction and moved with the
# module, so the deterministic mask never enters the serialized state.
# Configuration-derived (hence ``CONFIG_DERIVED_ARRAYS``): rebuilt on
# construction and moved with the module, so the deterministic mask
# never enters the serialized state.
self.spin_mask = np.array(
[1.0 if bool(flag) else 0.0 for flag in use_spin], dtype=prec
)
Expand Down Expand Up @@ -1053,23 +1073,26 @@ def __init__(
seed=child_seed(seed_scalar, 1),
trainable=self.trainable,
)
self.mag_layer2.w = np.zeros(
(self.channels, self.channels),
dtype=prec,
)

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

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

def call(self, spin: Any, atype: Any) -> tuple[Any, Any]:
"""
Expand Down
21 changes: 14 additions & 7 deletions deepmd/dpmodel/descriptor/dpa4_nn/norm.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,18 +360,25 @@ def __init__(
self.trainable = bool(trainable)
prec = PRECISION_DICT[self.precision.lower()]

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

# Pre-fuse degree balancing and channel averaging into a single weight:
# w_d = 1 / (n_coeff_l * (lmax+1) * C)
# where n_coeff_l is the number of retained coefficients for degree l in
# the reduced layout.
weights = np.zeros(self.degree_index_m.size, dtype=prec)
weights = np.zeros(degree_index_m.size, dtype=prec)
scale = 1.0 / ((self.lmax + 1) * self.channels)
for l in range(self.lmax + 1):
n_coeff_l = 2 * min(l, self.mmax) + 1
w_l = scale / float(n_coeff_l)
weights[self.degree_index_m == l] = w_l
weights[degree_index_m == l] = w_l
if np.any(weights == 0):
raise ValueError(
"ReducedEquivariantRMSNorm: balance_weight has zeros; "
Expand Down Expand Up @@ -411,15 +418,15 @@ def call(self, x: Any) -> Any:
# === Step 2. Compute a shared degree-balanced RMS ===
balance_weight = xp_asarray_nodetach(xp, self.balance_weight, device=device)
mean_variance = xp.sum(x0 * x0, axis=(2, 3)) * balance_weight[0]
if self.degree_index_m.size > 1:
if xt.shape[2] > 0:
mean_variance = mean_variance + xp.sum(
(xt * xt) * balance_weight[1:][None, None, :, None], axis=(2, 3)
)
inv_rms = 1.0 / xp.sqrt(mean_variance + self.eps)
inv_rms = inv_rms[:, :, None, None] # (F, E, 1, 1)

x0 = x0 * inv_rms
if self.degree_index_m.size > 1:
if xt.shape[2] > 0:
xt = xt * inv_rms

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

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

out = x0 if self.degree_index_m.size == 1 else xp.concat([x0, xt], axis=2)
out = x0 if xt.shape[2] == 0 else xp.concat([x0, xt], axis=2)
out = xp.astype(out, in_dtype)
return out

Expand Down
1 change: 0 additions & 1 deletion deepmd/dpmodel/model/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,6 @@ def get_native_spin_model(data: dict) -> NativeSpinEnergyModel:
use_spin = normalize_spin_use_spin(spin_cfg["use_spin"], data["type_map"])
spin = Spin(
use_spin=use_spin,
virtual_scale=spin_cfg.get("virtual_scale", 1.0),
allow_missing_label=spin_cfg.get("allow_missing_label", False),
)
data.setdefault("descriptor", {})
Expand Down
5 changes: 4 additions & 1 deletion deepmd/dpmodel/model/native_spin_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,14 @@ def make_native_spin_model(T_Model: type) -> type:
class NSM(T_Model, NativeSpinModelKind):
"""Native-spin variant of ``T_Model`` (see ``make_native_spin_model``)."""

CONFIG_DERIVED_ARRAYS = ("spin_mask",)

def __init__(self, *args: Any, spin: Spin, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.spin = spin
self.ntypes_real = self.spin.ntypes_real
# Per-real-type 0/1 spin gate.
# Per-real-type 0/1 spin gate, derived from ``use_spin`` and hence
# rebuilt here rather than adopted from a checkpoint.
self.spin_mask = self.spin.get_spin_mask()

@staticmethod
Expand Down
4 changes: 4 additions & 0 deletions deepmd/dpmodel/model/spin_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ class SpinModel(NativeOP):
\boldsymbol{\tau}_i = \mathbf{F}_i^{\mathrm{virtual}} \times \boldsymbol{\sigma}_i.
"""

CONFIG_DERIVED_ARRAYS = ("spin_mask", "virtual_scale_mask")

def __init__(
self,
backbone_model: DPAtomicModel,
Expand All @@ -76,6 +78,8 @@ def __init__(
# concrete default).
descriptor.disable_graph_lower()
self.ntypes_real = self.spin.ntypes_real
# Both per-type tables follow from ``use_spin`` and ``virtual_scale``,
# so they are rebuilt here rather than adopted from a checkpoint.
self.virtual_scale_mask = self.spin.get_virtual_scale_mask()
self.spin_mask = self.spin.get_spin_mask()

Expand Down
Loading
Loading