Skip to content
74 changes: 53 additions & 21 deletions deepmd/infer/deep_eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,48 @@
import ase.neighborlist


def _standardize_fparam_aparam(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deepmd/pt_expt/infer/deep_eval.py has the same defect and is not wired to this helper: raw fparam/aparam go into _eval_func / AutoBatchSize, and _eval_model / _eval_model_lower do bare fparam.reshape(nframes, dim_fparam) and aparam.reshape(nframes, natoms, dim_aparam) with no shorthand handling. It is also absent from the io test loop, so nothing would catch it.

Its own eval docstring documents only the full nframes x ... forms, which quietly narrows the base DeepEvalBackend.eval contract - that has listed all three shorthand forms since #3213 and is the contract this PR is enforcing everywhere else.

Given pt_expt is the actively developed backend and carries no back-compat constraints, adding the same one-line call here seems worth doing in this PR rather than leaving a fifth adapter behind.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9867233. pt_expt now calls the shared helper before auto batching, its eval docstring documents all supported shorthand forms, and a direct adapter regression verifies the normalized arguments reaching _eval_model.

Coding agent: Codex
Codex version: codex-cli 0.144.6
Model: gpt-5.6-sol
Reasoning effort: xhigh

fparam: np.ndarray | list | None,
aparam: np.ndarray | list | None,
nframes: int,
natoms: int,
dim_fparam: int,
dim_aparam: int,
) -> tuple[np.ndarray | None, np.ndarray | None]:
"""Normalize documented parameter shorthand to frame-major arrays.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring explains the rationale well but skips the numpydoc Parameters/Returns sections that the neighbouring functions in this file use - eval, eval_descriptor, eval_fitting_last_layer, eval_embedding, and even the private single-argument _check_mixed_types.

With six parameters and a two-element tuple return, and given this is now the single definition of the shorthand contract for four backends, it is worth documenting each argument and the returned shapes explicitly - in particular that aparam comes back 3-D (nframes, natoms, dim_aparam) while _standard_input re-flattens it to 2-D for the public path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9867233. The shared helper now has complete numpydoc Parameters and Returns sections, including the canonical 2-D fparam and 3-D aparam return shapes and the public wrapper flattening note.

Coding agent: Codex
Codex version: codex-cli 0.144.6
Model: gpt-5.6-sol
Reasoning effort: xhigh


This normalization must happen before automatic batching. In particular,
an ``(natoms, dim_aparam)`` shared atomic parameter has an atom axis first;
a batcher would otherwise mistake that axis for frames and slice it.
Comment on lines +43 to +45

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified across the public wrapper and the dpmodel, JAX, Paddle, PyTorch, pt_expt, TensorFlow v1, and TF2 adapters: normalizing to a frame-major representation at this boundary prevents AutoBatchSize from slicing a shared per-atom array as frames, while the later wrapper flattening preserves the historical backend ABI. The full, shared-frame, shared-per-atom, singleton, mixed-type, and zero-dimensional parameter cases remain consistent.

"""
if fparam is not None:
fparam = np.asarray(fparam)
if fparam.size == nframes * dim_fparam:
fparam = fparam.reshape(nframes, dim_fparam)
elif fparam.size == dim_fparam:
fparam = np.tile(fparam.reshape(1, dim_fparam), (nframes, 1))
else:
raise RuntimeError(
"got wrong size of frame param, should be either "
f"{nframes} x {dim_fparam} or {dim_fparam}"
)
if aparam is not None:
aparam = np.asarray(aparam)
if aparam.size == nframes * natoms * dim_aparam:
aparam = aparam.reshape(nframes, natoms, dim_aparam)
elif aparam.size == natoms * dim_aparam:
aparam = np.tile(aparam.reshape(1, natoms, dim_aparam), (nframes, 1, 1))
elif aparam.size == dim_aparam:
aparam = np.tile(aparam.reshape(1, 1, dim_aparam), (nframes, natoms, 1))
else:
raise RuntimeError(
"got wrong size of atomic param, should be either "
f"{nframes} x {natoms} x {dim_aparam} or "
f"{natoms} x {dim_aparam} or {dim_aparam}"
)
return fparam, aparam


class DeepEvalBackend(ABC):
"""Low-level Deep Evaluator interface.

Expand Down Expand Up @@ -948,28 +990,18 @@ def _standard_input(
coords = coords.reshape(nframes, natoms, 3)
if cells is not None:
cells = cells.reshape(nframes, 3, 3)
if fparam is not None:
fdim = self.get_dim_fparam()
if fparam.size == nframes * fdim:
fparam = np.reshape(fparam, [nframes, fdim])
elif fparam.size == fdim:
fparam = np.tile(fparam.reshape([-1]), [nframes, 1])
else:
raise RuntimeError(
f"got wrong size of frame param, should be either {nframes} x {fdim} or {fdim}"
)
fparam, aparam = _standardize_fparam_aparam(
fparam,
aparam,
nframes,
natoms,
self.get_dim_fparam(),
self.get_dim_aparam(),
)
if aparam is not None:
fdim = self.get_dim_aparam()
if aparam.size == nframes * natoms * fdim:
aparam = np.reshape(aparam, [nframes, natoms * fdim])
elif aparam.size == natoms * fdim:
aparam = np.tile(aparam.reshape([-1]), [nframes, 1])
elif aparam.size == fdim:
aparam = np.tile(aparam.reshape([-1]), [nframes, natoms])
else:
raise RuntimeError(
f"got wrong size of frame param, should be either {nframes} x {natoms} x {fdim} or {natoms} x {fdim} or {fdim}"
)
# Preserve the historical flattened backend ABI used by the public
# wrapper; backend adapters normalize it back to frame-major 3-D.
aparam = aparam.reshape(nframes, natoms * self.get_dim_aparam())
return coords, cells, atom_types, fparam, aparam, nframes, natoms

def get_sel_type(self) -> list[int]:
Expand Down
9 changes: 9 additions & 0 deletions deepmd/jax/infer/deep_eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from deepmd.infer.deep_eval import DeepEval as DeepEvalWrapper
from deepmd.infer.deep_eval import (
DeepEvalBackend,
_standardize_fparam_aparam,
)
from deepmd.infer.deep_polar import (
DeepPolar,
Expand Down Expand Up @@ -278,6 +279,14 @@ def eval(
natoms, numb_test = self._get_natoms_and_nframes(
coords, atom_types, len(atom_types.shape) > 1
)
fparam, aparam = _standardize_fparam_aparam(
fparam,
aparam,
numb_test,
natoms,
self.get_dim_fparam(),
self.get_dim_aparam(),
)
request_defs = self._get_request_defs(atomic)
out = self._eval_func(self._eval_model, numb_test, natoms)(
coords, cells, atom_types, fparam, aparam, charge_spin, request_defs
Expand Down
9 changes: 9 additions & 0 deletions deepmd/pd/infer/deep_eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from deepmd.infer.deep_eval import DeepEval as DeepEvalWrapper
from deepmd.infer.deep_eval import (
DeepEvalBackend,
_standardize_fparam_aparam,
)
from deepmd.infer.deep_polar import (
DeepGlobalPolar,
Expand Down Expand Up @@ -376,6 +377,14 @@ def eval(
natoms, numb_test = self._get_natoms_and_nframes(
coords, atom_types, len(atom_types.shape) > 1
)
fparam, aparam = _standardize_fparam_aparam(
fparam,
aparam,
numb_test,
natoms,
self.get_dim_fparam(),
self.get_dim_aparam(),
)
request_defs = self._get_request_defs(atomic)
if "spin" not in kwargs or kwargs["spin"] is None:
out = self._eval_func(self._eval_model, numb_test, natoms)(
Expand Down
20 changes: 20 additions & 0 deletions deepmd/pt/infer/deep_eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from deepmd.infer.deep_eval import DeepEval as DeepEvalWrapper
from deepmd.infer.deep_eval import (
DeepEvalBackend,
_standardize_fparam_aparam,
)
from deepmd.infer.deep_polar import (
DeepGlobalPolar,
Expand Down Expand Up @@ -544,6 +545,14 @@ def eval(
natoms, numb_test = self._get_natoms_and_nframes(
coords, atom_types, len(atom_types.shape) > 1
)
fparam, aparam = _standardize_fparam_aparam(
fparam,
aparam,
numb_test,
natoms,
self.get_dim_fparam(),
self.get_dim_aparam(),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
request_defs = self._get_request_defs(atomic)
if "spin" not in kwargs or kwargs["spin"] is None:
out = self._eval_func(self._eval_model, numb_test, natoms)(
Expand Down Expand Up @@ -1312,6 +1321,17 @@ def eval_embedding(
natoms, numb_test = self._get_natoms_and_nframes(
coords, atom_types, len(atom_types.shape) > 1
)
# Normalize shared parameter shorthand before auto batching. Otherwise
# a one-dimensional fparam/aparam is passed unchanged to every split,
# and _eval_embedding cannot reshape it to the split frame count.
fparam, aparam = _standardize_fparam_aparam(
fparam,
aparam,
numb_test,
natoms,
self.get_dim_fparam(),
self.get_dim_aparam(),
)
return self._eval_func(self._eval_embedding, numb_test, natoms)(
coords, cells, atom_types, fparam, aparam, charge_spin, dtype
)
Expand Down
9 changes: 9 additions & 0 deletions deepmd/tf2/infer/deep_eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from deepmd.infer.deep_eval import DeepEval as DeepEvalWrapper
from deepmd.infer.deep_eval import (
DeepEvalBackend,
_standardize_fparam_aparam,
)
from deepmd.infer.deep_polar import (
DeepPolar,
Expand Down Expand Up @@ -292,6 +293,14 @@ def eval(
natoms, numb_test = self._get_natoms_and_nframes(
coords, atom_types, len(atom_types.shape) > 1
)
fparam, aparam = _standardize_fparam_aparam(
fparam,
aparam,
numb_test,
natoms,
self.get_dim_fparam(),
self.get_dim_aparam(),
)
request_defs = self._get_request_defs(atomic)
out = self._eval_func(self._eval_model, numb_test, natoms)(
coords, cells, atom_types, fparam, aparam, request_defs
Expand Down
100 changes: 100 additions & 0 deletions source/tests/common/test_deep_eval_parameter_shorthand.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# SPDX-License-Identifier: LGPL-3.0-or-later
"""Tests for backend-level DeepEval parameter normalization."""

import numpy as np
import pytest

from deepmd.infer.deep_eval import (
_standardize_fparam_aparam,
)

NFRAMES = 3
NATOMS = 4
DIM_FPARAM = 2
DIM_APARAM = 2
FPARAM = np.array([0.25, -0.5], dtype=np.float64)
APARAM_PER_ATOM = np.arange(NATOMS * DIM_APARAM, dtype=np.float64).reshape(
NATOMS, DIM_APARAM
)
APARAM_ALL_ATOMS = np.array([0.3, -0.2], dtype=np.float64)


@pytest.mark.parametrize(
("fparam", "expected"),
[
(FPARAM.tolist(), np.tile(FPARAM, (NFRAMES, 1))),
(
np.arange(NFRAMES * DIM_FPARAM).reshape(NFRAMES, DIM_FPARAM),
np.arange(NFRAMES * DIM_FPARAM).reshape(NFRAMES, DIM_FPARAM),
),
],
ids=("shared", "per-frame"),
)
def test_standardize_fparam(fparam, expected) -> None:
"""Frame parameters become a canonical frame-major matrix."""
actual, _ = _standardize_fparam_aparam(
fparam,
None,
NFRAMES,
NATOMS,
DIM_FPARAM,
DIM_APARAM,
)

np.testing.assert_array_equal(actual, expected)


@pytest.mark.parametrize(
("aparam", "expected"),
[
(
APARAM_PER_ATOM,
np.tile(APARAM_PER_ATOM, (NFRAMES, 1, 1)),
),
(
APARAM_ALL_ATOMS.tolist(),
np.tile(APARAM_ALL_ATOMS, (NFRAMES, NATOMS, 1)),
),
(
np.arange(NFRAMES * NATOMS * DIM_APARAM).reshape(
NFRAMES, NATOMS, DIM_APARAM
),
np.arange(NFRAMES * NATOMS * DIM_APARAM).reshape(
NFRAMES, NATOMS, DIM_APARAM
),
),
],
ids=("shared-per-atom", "shared-all-atoms", "per-frame"),
)
def test_standardize_aparam(aparam, expected) -> None:
"""Atomic shorthand is expanded before a batcher can slice its atom axis."""
_, actual = _standardize_fparam_aparam(
None,
aparam,
NFRAMES,
NATOMS,
DIM_FPARAM,
DIM_APARAM,
)

np.testing.assert_array_equal(actual, expected)


@pytest.mark.parametrize(
("fparam", "aparam", "message"),
[
(np.zeros(3), None, "wrong size of frame param"),
(None, np.zeros(3), "wrong size of atomic param"),
],
)
def test_invalid_parameter_size_is_rejected(fparam, aparam, message) -> None:
"""Report the documented contract instead of a backend reshape failure."""
with pytest.raises(RuntimeError, match=message):
_standardize_fparam_aparam(
fparam,
aparam,
NFRAMES,
NATOMS,
DIM_FPARAM,
DIM_APARAM,
)
62 changes: 62 additions & 0 deletions source/tests/consistent/io/test_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,14 @@ def test_deep_eval(self) -> None:
aparam = np.ones((nframes, natoms, deep_eval.get_dim_aparam()))
else:
aparam = None
# Paddle is absent from the loop above: deserialize_to_file only
# writes .json, serialize_from_file is not implemented, and the
# .json reader rejects fparam/aparam. Its normalization is covered

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment claims more than the referenced file delivers. source/tests/common/test_deep_eval_parameter_shorthand.py imports _standardize_fparam_aparam from deepmd/infer/deep_eval.py and calls it directly - it never imports or executes a single line of deepmd/pd/infer/deep_eval.py. So it shows the helper is correct, but not that the pd adapter calls it, which is the part this PR added.

After 8d80b8579 dropped the paddle round trip, the pd hunk has no test that executes it at all. The PR body also still says "Paddle is now included in the same consistency path when its dependency is available", which is no longer true.

Either restore some executing coverage for pd, or reword both the comment and the PR body to say plainly that the pd change is untested and why.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9867233. Added a dedicated Paddle adapter test that constructs the backend, calls its eval method, and verifies the normalized frame-major arrays passed to _eval_model. I also corrected the PR body: Paddle is not installed locally, so this test will execute in the Paddle test environment rather than the local consistency loop.

Coding agent: Codex
Codex version: codex-cli 0.144.6
Model: gpt-5.6-sol
Reasoning effort: xhigh

# by source/tests/common/test_deep_eval_parameter_shorthand.py.
if backend_name in {"pytorch", "jax", "tf2"} and (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"dpmodel" is in the backend loop just above but is excluded here, and unlike paddle that exclusion carries no comment. The reason is that dpmodel was not fixed: deepmd/dpmodel/infer/deep_eval.py still passes raw fparam/aparam into _eval_func / AutoBatchSize and then does a bare reshape(nframes, natoms, dim) - the same defect this PR removes from the other four adapters. "tensorflow" is in the loop and excluded for the same unstated reason.

The consequence is worth deciding deliberately, because #5853 fixes exactly that file with its own inline copy of this ladder. The two PRs touch disjoint files and will merge cleanly, but the result is a shared _standardize_fparam_aparam plus a hand-inlined duplicate of it in dpmodel - the duplication this PR exists to remove.

Cleanest resolutions are to wire dpmodel into the helper here and close #5853, or rebase #5853 onto the helper. Either way, please make the allowlist say why each backend is out, so the gap is a recorded decision rather than an invisible one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9867233. dpmodel now uses the shared helper before auto batching, and the consistency allowlist now exercises every backend present in the loop. I also wired the legacy TensorFlow adapter because it had the same normalization-after-batching ordering.

Coding agent: Codex
Codex version: codex-cli 0.144.6
Model: gpt-5.6-sol
Reasoning effort: xhigh

deep_eval.get_dim_fparam() > 0 and deep_eval.get_dim_aparam() > 0
):
self._assert_backend_parameter_shorthand(model_file, deep_eval)
ret = deep_eval.eval(
self.coords,
self.box,
Expand Down Expand Up @@ -238,6 +246,60 @@ def test_deep_eval(self) -> None:
err_msg=f"backend {idx + 1} for rets_idx {rets_idx}",
)

def _assert_backend_parameter_shorthand(
self, model_file: str, deep_eval: DeepEval
) -> None:
"""Compare backend-direct shorthand with explicit frame-major inputs.

Calling ``deep_eval.deep_eval`` deliberately bypasses the public
``_standard_input`` normalization. A one-frame auto-batch size also
proves that shared per-atom parameters are expanded before the batcher
can mistake their atom axis for a frame axis.
"""
natoms = self.atype.shape[1]
nframes = 2
coords = np.repeat(self.coords, nframes, axis=0)
boxes = np.repeat(self.box, nframes, axis=0)
atom_types = self.atype.reshape(-1)
fparam_shared = np.ones(deep_eval.get_dim_fparam())
aparam_per_atom = np.ones((natoms, deep_eval.get_dim_aparam()))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every value in this test is 1.0, and coords/boxes above are np.repeat of a single frame, so the frames are byte-identical too. That means the comparison can only catch a crash or a shape error - an implementation that tiled along the wrong axis, permuted frame and atom axes, or reused frame 0's parameters for every frame would still produce identical outputs and pass.

That matters because frame-major and atom-major correctness under batching is the whole point of the change.

The sibling test in source/tests/pt/model/test_embedding.py already does this right - fparam = [0.25, -0.5] and aparam = np.linspace(0.1, 0.7, natoms) - so the fix is just to use the same idea here: distinct values per atom for aparam_per_atom, and distinct rows per frame for the full reference, so a mis-tiling shows up as a wrong number rather than a coincidence.

I raised the same point on #5853 and CodeRabbit flagged this exact block on this PR; it is still unaddressed at head, so flagging once more.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9867233. The consistency regression now uses distinct coordinates, boxes, per-atom values, and distinct full frame-major parameter rows. It checks shared-per-atom, shared-all-atoms, and full-frame-major cases, so axis swaps and frame reuse change the numerical result.

Coding agent: Codex
Codex version: codex-cli 0.144.6
Model: gpt-5.6-sol
Reasoning effort: xhigh

fparam_full = np.tile(fparam_shared, (nframes, 1))
aparam_full = np.tile(aparam_per_atom, (nframes, 1, 1))
backend = DeepEval(model_file, auto_batch_size=natoms).deep_eval

expected = backend.eval(
coords,
boxes,
atom_types,
fparam=fparam_full,
aparam=aparam_full,
)
shorthand_cases = (
(fparam_shared.tolist(), aparam_per_atom),
(
fparam_shared,
np.ones(deep_eval.get_dim_aparam()),
),
)
for fparam, aparam in shorthand_cases:
actual = backend.eval(
coords,
boxes,
atom_types,
fparam=fparam,
aparam=aparam,
)
self.assertEqual(actual.keys(), expected.keys())
for name in actual:
np.testing.assert_allclose(
actual[name],
expected[name],
rtol=1e-12,
atol=1e-12,
equal_nan=True,
err_msg=f"backend-direct shorthand output {name}",
)


class TestDeepPot(unittest.TestCase, IOTest):
def setUp(self) -> None:
Expand Down
Loading
Loading