Skip to content

Commit d40d008

Browse files
authored
Merge pull request #52 from zhaiwenxi/fix/dpa-adapt-review-followups
Fix dpa-adapt review follow-ups
2 parents f29e7d8 + 6de5846 commit d40d008

12 files changed

Lines changed: 308 additions & 23 deletions

File tree

dpa_adapt/cli.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,17 @@ def _maybe_split_list(val: str | Sequence[str] | None) -> list[str] | None:
9696
]
9797

9898

99+
def _parse_batch_size(val: str) -> str | int:
100+
"""Parse DeePMD batch-size specs, preserving strings like ``auto:512``."""
101+
text = val.strip()
102+
if not text:
103+
raise argparse.ArgumentTypeError("batch size must not be empty")
104+
try:
105+
return int(text)
106+
except ValueError:
107+
return text
108+
109+
99110
class _RawTextArgDefaultsHelpFormatter(
100111
argparse.RawTextHelpFormatter, argparse.ArgumentDefaultsHelpFormatter
101112
):
@@ -304,6 +315,9 @@ def _cmd_data_convert(args: argparse.Namespace) -> int:
304315
elif result["method"] == "batch_dpdata":
305316
_LOG.info("Output dirs : %s", len(result["output_dirs"]))
306317
_LOG.info("Manifest : %s", result["manifest"])
318+
elif result["method"] == "formula":
319+
_LOG.info("Output systems: %s", len(result["output_systems"]))
320+
_LOG.info("Wrote deepmd/npy → %s", result["output_dir"])
307321
else:
308322
_LOG.info("Wrote deepmd/npy → %s", result["output_dir"])
309323
return 0
@@ -485,7 +499,7 @@ def get_parser() -> argparse.ArgumentParser:
485499
parser_fit.add_argument("--max-steps", type=int, default=100_000)
486500
parser_fit.add_argument("--learning-rate", type=float, default=1e-3)
487501
parser_fit.add_argument("--stop-lr", type=float, default=1e-5)
488-
parser_fit.add_argument("--batch-size", default="auto:512")
502+
parser_fit.add_argument("--batch-size", type=_parse_batch_size, default="auto:512")
489503
parser_fit.add_argument("--seed", type=int, default=42)
490504
parser_fit.add_argument("--output-dir", default="./dpa_output")
491505
parser_fit.add_argument("--save-freq", type=int, default=10_000)
@@ -523,11 +537,14 @@ def get_parser() -> argparse.ArgumentParser:
523537
help="(mft) Downstream head type.",
524538
)
525539
parser_fit.add_argument(
526-
"--aux-batch-size", default=None, help="(mft) Batch size for aux branch."
540+
"--aux-batch-size",
541+
type=_parse_batch_size,
542+
default=None,
543+
help="(mft) Batch size for aux branch.",
527544
)
528545
parser_fit.add_argument(
529546
"--downstream-batch-size",
530-
type=int,
547+
type=_parse_batch_size,
531548
default=None,
532549
help="(mft) Batch size for downstream.",
533550
)

dpa_adapt/data/convert.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,11 @@ def convert(
191191
)
192192
if verbose:
193193
_LOG.info("Formula conversion: %s systems written.", len(out))
194-
return {"method": "formula", "output_systems": out}
194+
return {
195+
"method": "formula",
196+
"output_dir": str(Path(output_dir).resolve()),
197+
"output_systems": out,
198+
}
195199

196200
# --- structure glob → batch dpdata ---
197201
input_str = str(input_path)
@@ -681,4 +685,6 @@ def attach_labels(
681685
)
682686

683687
for sys_dir, sub_vals in zip(sys_dirs, values_arr, strict=True):
688+
if np.isscalar(sub_vals):
689+
sub_vals = np.asarray([sub_vals])
684690
_attach_single(sys_dir, head, sub_vals)

dpa_adapt/data/desc_cache.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,8 @@ def _system_fingerprint(system: dpdata.System) -> str:
8989

9090

9191
def _data_fingerprint(systems: list) -> str:
92-
"""Aggregate fingerprint for a list of systems (order-independent)."""
93-
fps = sorted(_system_fingerprint(s) for s in systems)
92+
"""Aggregate fingerprint for a list of systems in request order."""
93+
fps = [_system_fingerprint(s) for s in systems]
9494
h = hashlib.sha1()
9595
for fp in fps:
9696
h.update(fp.encode())

dpa_adapt/finetuner.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -841,10 +841,10 @@ class DPAFineTuner:
841841
Auto-detected from the checkpoint if not provided.
842842
downstream_task_type : str
843843
(MFT only) Task type of the downstream head (``"property"`` etc.).
844-
aux_batch_size : str or None
844+
aux_batch_size : str or int or None
845845
(MFT only) Batch-size spec for the auxiliary head.
846-
downstream_batch_size : int or None
847-
(MFT only) Batch size for the downstream head.
846+
downstream_batch_size : str or int or None
847+
(MFT only) Batch-size spec for the downstream head.
848848
"""
849849

850850
_VALID_POOLING: ClassVar[set[str]] = {"mean", "sum", "mean+std", "mean+std+max+min"}
@@ -886,8 +886,8 @@ def __init__(
886886
aux_prob: float = 0.5,
887887
type_map: list[str] | None = None,
888888
downstream_task_type: str = "property",
889-
aux_batch_size: str | None = None,
890-
downstream_batch_size: int | None = None,
889+
aux_batch_size: str | int | None = None,
890+
downstream_batch_size: str | int | None = None,
891891
) -> None:
892892
if pooling not in self._VALID_POOLING:
893893
raise ValueError(
@@ -1041,7 +1041,7 @@ def _extract_features_cached(self, systems: list[dpdata.System]) -> np.ndarray:
10411041
except Exception:
10421042
# Cache read failed (e.g. corrupted file, permissions) —
10431043
# fall through and recompute features from scratch.
1044-
pass
1044+
_LOG.debug("Descriptor cache read failed, recomputing.", exc_info=True)
10451045

10461046
features = self._extract_features(systems)
10471047
try:
@@ -1050,7 +1050,7 @@ def _extract_features_cached(self, systems: list[dpdata.System]) -> np.ndarray:
10501050
except Exception:
10511051
# Cache write is best-effort — silently skip on permission errors
10521052
# or disk-full conditions; the features are already in memory.
1053-
pass
1053+
_LOG.debug("Descriptor cache write failed.", exc_info=True)
10541054
return features
10551055

10561056
def _extract_features(self, systems: list[dpdata.System]) -> np.ndarray:
@@ -1099,9 +1099,10 @@ def _resolve_type_maps(self, train_data: str | list[str]) -> list[str]:
10991099

11001100
try:
11011101
elements = read_data_type_map_union(systems)
1102-
validate_type_map_subset(elements, tm, label="train data")
11031102
except ValueError:
11041103
pass # no atom_names — deepmd uses raw atom indices
1104+
else:
1105+
validate_type_map_subset(elements, tm, label="train data")
11051106

11061107
return tm
11071108

@@ -1372,6 +1373,10 @@ def fit(
13721373
"strategy='mft' requires aux_data. "
13731374
"Provide auxiliary system directories for the force-field head."
13741375
)
1376+
if type_map is not None:
1377+
self.type_map = type_map
1378+
if self._mft is not None:
1379+
self._mft.type_map = type_map
13751380
return self._fit_mft(train_data, aux_data, valid_data)
13761381

13771382
# ---- single-task training paradigms ----
@@ -1719,6 +1724,5 @@ def freeze(self, output_path: str = "frozen_model.pth") -> str:
17191724
import torch
17201725

17211726
torch.save(bundle, output_path)
1722-
_LOG = logging.getLogger("dpa_adapt")
17231727
_LOG.info("Frozen model saved to: %s", output_path)
17241728
return output_path

dpa_adapt/mft.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,8 @@ def __init__(
122122
warmup_steps: int = 0,
123123
max_steps: int = 50000,
124124
batch_size: str | int = "auto:32",
125-
aux_batch_size: str | None = None,
126-
downstream_batch_size: int | None = None,
125+
aux_batch_size: str | int | None = None,
126+
downstream_batch_size: str | int | None = None,
127127
seed: int = 42,
128128
fparam_dim: int = 0,
129129
output_dir: str = "./mft_output",

dpa_adapt/predictor.py

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,38 @@ def _is_mlp(est: Any) -> bool:
5353
return isinstance(est, MLPRegressor)
5454

5555

56+
def _rf_tree_predictions(est: Any, features: np.ndarray) -> np.ndarray:
57+
"""Return RF per-tree predictions with shape ``(n_trees, n_frames, dim)``."""
58+
from sklearn.ensemble import (
59+
RandomForestRegressor,
60+
)
61+
from sklearn.multioutput import (
62+
MultiOutputRegressor,
63+
)
64+
65+
if isinstance(est, MultiOutputRegressor):
66+
per_output = []
67+
for rf in est.estimators_:
68+
if not isinstance(rf, RandomForestRegressor):
69+
raise TypeError(
70+
"Expected MultiOutputRegressor(RandomForestRegressor), "
71+
f"got wrapped estimator {type(rf).__name__!r}."
72+
)
73+
per_output.append(
74+
np.array([tree.predict(features) for tree in rf.estimators_])
75+
)
76+
return np.stack(per_output, axis=-1)
77+
78+
if isinstance(est, RandomForestRegressor):
79+
tree_preds = np.array([tree.predict(features) for tree in est.estimators_])
80+
return tree_preds.reshape(len(est.estimators_), -1, 1)
81+
82+
raise TypeError(
83+
"RF uncertainty requires RandomForestRegressor or "
84+
f"MultiOutputRegressor(RandomForestRegressor), got {type(est).__name__!r}."
85+
)
86+
87+
5688
class DPAPredictor:
5789
"""
5890
Read-only inference wrapper for a frozen DPA+sklearn bundle.
@@ -278,12 +310,8 @@ def _predict_with_uncertainty(self, features: np.ndarray) -> DotDict:
278310
for _, step in self._predictor.steps[:-1]:
279311
X_t = step.transform(X_t)
280312
rf = self._predictor.steps[-1][1]
281-
tree_preds = np.array([t.predict(X_t) for t in rf.estimators_])
282-
tree_preds = tree_preds.reshape(
283-
len(rf.estimators_),
284-
-1,
285-
self._task_dim,
286-
)
313+
tree_preds = _rf_tree_predictions(rf, X_t)
314+
tree_preds = tree_preds.reshape(tree_preds.shape[0], -1, self._task_dim)
287315
return DotDict(
288316
{
289317
"predictions": np.mean(tree_preds, axis=0),

source/tests/dpa_adapt/test_cache.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,13 @@ def test_different_data_different_fp(self, tmp_path):
6767
fp2 = _data_fingerprint([s2])
6868
assert fp1 != fp2
6969

70+
def test_system_order_changes_fp(self, tmp_path):
71+
s1 = _make_system(tmp_path, "s1", nframes=3)
72+
s2 = _make_system(tmp_path, "s2", nframes=5)
73+
fp1 = _data_fingerprint([s1, s2])
74+
fp2 = _data_fingerprint([s2, s1])
75+
assert fp1 != fp2
76+
7077

7178
class TestCacheKey:
7279
def test_same_inputs_same_key(self, tmp_path):
@@ -111,6 +118,15 @@ def test_different_type_map_different_key(self, tmp_path):
111118
k2 = _cache_key([s], str(ckpt), None, "mean", type_map=("O", "H"))
112119
assert k1 != k2
113120

121+
def test_different_system_order_different_key(self, tmp_path):
122+
s1 = _make_system(tmp_path, "s1", nframes=3)
123+
s2 = _make_system(tmp_path, "s2", nframes=5)
124+
ckpt = tmp_path / "dummy.pt"
125+
ckpt.write_text("dummy")
126+
k1 = _cache_key([s1, s2], str(ckpt), None, "mean")
127+
k2 = _cache_key([s2, s1], str(ckpt), None, "mean")
128+
assert k1 != k2
129+
114130

115131
class TestCacheDir:
116132
def test_respects_xdg(self, monkeypatch, tmp_path):

source/tests/dpa_adapt/test_cli_smoke.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,106 @@ def test_maybe_split_list_accepts_string_sequences(self):
162162
assert _maybe_split_list("H,C, O") == ["H", "C", "O"]
163163
assert _maybe_split_list(None) is None
164164

165+
def test_batch_size_parser_preserves_deepmd_specs(self):
166+
from dpa_adapt.cli import (
167+
_parse_batch_size,
168+
)
169+
170+
assert _parse_batch_size("128") == 128
171+
assert _parse_batch_size("auto:512") == "auto:512"
172+
173+
def test_fit_accepts_downstream_auto_batch_size(self):
174+
from dpa_adapt.cli import (
175+
get_parser,
176+
)
177+
178+
args = get_parser().parse_args(
179+
[
180+
"fit",
181+
"--train-data",
182+
"train",
183+
"--strategy",
184+
"mft",
185+
"--downstream-batch-size",
186+
"auto:512",
187+
]
188+
)
189+
190+
assert args.downstream_batch_size == "auto:512"
191+
192+
def test_fit_batch_size_numbers_parse_to_int(self):
193+
from dpa_adapt.cli import (
194+
get_parser,
195+
)
196+
197+
args = get_parser().parse_args(
198+
[
199+
"fit",
200+
"--train-data",
201+
"train",
202+
"--batch-size",
203+
"64",
204+
"--aux-batch-size",
205+
"128",
206+
"--downstream-batch-size",
207+
"256",
208+
]
209+
)
210+
211+
assert args.batch_size == 64
212+
assert args.aux_batch_size == 128
213+
assert args.downstream_batch_size == 256
214+
215+
216+
class TestDpaDataConvertDispatch:
217+
"""Verify data convert handles method-specific return payloads."""
218+
219+
def test_formula_result_exits_cleanly(self, monkeypatch, tmp_path):
220+
from argparse import (
221+
Namespace,
222+
)
223+
224+
import dpa_adapt
225+
from dpa_adapt.cli import (
226+
_cmd_data_convert,
227+
)
228+
229+
out = tmp_path / "npy"
230+
231+
def _fake_convert(**kwargs):
232+
return {
233+
"method": "formula",
234+
"output_dir": str(out),
235+
"output_systems": [str(out / "sys_0000")],
236+
}
237+
238+
monkeypatch.setattr(dpa_adapt, "convert", _fake_convert)
239+
240+
args = Namespace(
241+
input=str(tmp_path / "formula.csv"),
242+
output=str(out),
243+
fmt="formula",
244+
type_map=None,
245+
property_name=None,
246+
property_col="energy",
247+
train_ratio=0.9,
248+
smiles_col="SMILES",
249+
mol_dir=None,
250+
mol_template="id{row}.mol",
251+
split_seed=None,
252+
conformer_seed=None,
253+
poscar=str(tmp_path / "POSCAR"),
254+
formula_col="formula",
255+
base_element=None,
256+
sets=1,
257+
seed=42,
258+
overwrite=False,
259+
validate=True,
260+
strict=False,
261+
)
262+
263+
assert _cmd_data_convert(args) == 0
264+
165265

166266
class TestInitAllExports:
167267
"""Verify __all__ covers the key public names."""

source/tests/dpa_adapt/test_convert.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,7 @@ def _fake_formula_to_npy(**kwargs):
364364
)
365365

366366
assert result["method"] == "formula"
367+
assert result["output_dir"] == str(out.resolve())
367368
assert result["output_systems"] == [fake_sys_dir]
368369

369370
def test_formula_fmt_base_element_passed_through(self, tmp_path, monkeypatch):

source/tests/dpa_adapt/test_loader.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,17 @@ def test_multi_system_all_written(self, tmp_path):
316316
written = np.load(parent / f"sys_{i:04d}" / "set.000" / "bandgap.npy")
317317
np.testing.assert_array_equal(written, values[i])
318318

319+
def test_multi_system_1d_values_written_as_one_frame_labels(self, tmp_path):
320+
parent = tmp_path / "multi"
321+
parent.mkdir()
322+
for i in range(3):
323+
_make_system_path(parent, name=f"sys_{i:04d}", n_frames=1)
324+
values = np.array([1.0, 3.0, 5.0])
325+
attach_labels(parent, head="bandgap", values=values)
326+
for i in range(3):
327+
written = np.load(parent / f"sys_{i:04d}" / "set.000" / "bandgap.npy")
328+
np.testing.assert_array_equal(written, [values[i]])
329+
319330
def test_multi_system_values_mismatch_raises(self, tmp_path):
320331
parent = tmp_path / "multi"
321332
parent.mkdir()

0 commit comments

Comments
 (0)