Skip to content

Commit afd4211

Browse files
committed
Clean up dpa_tools property workflow
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d39d659 commit afd4211

13 files changed

Lines changed: 86 additions & 176 deletions

deepmd/dpa_tools/README.md

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
# dpa_tools
22

3-
Fine-tuning, descriptor extraction, cross-validation, and data utilities for
4-
DPA-3 pretrained models. Lives as a self-contained subpackage of `deepmd-kit`
5-
at `deepmd.dpa_tools`.
3+
Property-prediction tools built on top of DPA-3 pretrained models. `dpa_tools`
4+
turns molecular or atomistic structure data into `deepmd/npy` datasets, extracts
5+
DPA descriptors, and trains lightweight or fine-tuned property predictors for
6+
small- to medium-sized datasets. It lives as a self-contained subpackage of
7+
`deepmd-kit` at `deepmd.dpa_tools`.
68

79
## Relationship with deepmd-kit
810

@@ -16,7 +18,7 @@ at `deepmd.dpa_tools`.
1618
`dp --pt test`, auto-generating `input.json` config files.
1719
- **Inference**: deepmd-kit's built-in `DeepProperty` handles neural-network
1820
models; dpa_tools adds a lightweight frozen-descriptor + sklearn-head path.
19-
- **SMILES pipeline**: `data/smiles.py` converts CSV (SMILES or MOL files) +
21+
- **SMILES pipeline**: `data/smiles.py` converts CSV with SMILES columns +
2022
property labels into `deepmd/npy` format via RDKit 3D conformer generation.
2123
- **CLI**: registered as `dp dpa` subcommand group via `deepmd/main.py`.
2224
Torch and all DPA dependencies are loaded lazily — only when a `dp dpa ...`
@@ -30,15 +32,16 @@ at `deepmd.dpa_tools`.
3032
pip install deepmd-kit[dpa-tools]
3133
```
3234

33-
The `dpa-tools` extra brings in `scikit-learn`. `torch` and `dpdata` are
34-
already provided by deepmd-kit's core dependencies. For SMILES→3D conversion
35-
install RDKit (`conda install -c conda-forge rdkit`).
35+
The `dpa-tools` extra installs the Python dependencies used by this package,
36+
including `scikit-learn`, `dpdata`, `torch`, `rdkit`, and `e3nn`. For
37+
CUDA/GPU-specific PyTorch builds, install the desired PyTorch variant first or
38+
follow the PyTorch installation instructions for your platform.
3639

3740
## Python API
3841

3942
```python
4043
from deepmd.dpa_tools import (
41-
DPAFineTuner, # train (all strategies: frozen_sklearn, linear_probe, finetune, mft, scratch)
44+
DPAFineTuner, # train (strategies: frozen_sklearn, linear_probe, finetune, mft)
4245
DPAPredictor, # read-only inference from frozen bundles
4346
extract_descriptors, # standalone descriptor extraction
4447
cross_validate, # leak-proof cross-validation
@@ -56,15 +59,14 @@ from deepmd.dpa_tools import (
5659

5760
### DPAFineTuner
5861

59-
Four training strategies:
62+
Training strategies:
6063

6164
| Strategy | Description | Best for |
6265
|----------|------------|----------|
6366
| `frozen_sklearn` | Freeze descriptor, extract once, fit sklearn head (RF/Ridge/MLP) | Small data (<1k samples), CPU inference |
6467
| `linear_probe` | Freeze backbone, train property fitting net only | Medium data, GPU |
6568
| `finetune` | Full-network fine-tuning | Larger data, GPU |
6669
| `mft` | Multi-task: property head + force-field head | Prevents representation collapse |
67-
| `scratch` | Train from random init (experimental) | Large-scale data only |
6870

6971
```python
7072
model = DPAFineTuner(
@@ -120,7 +122,14 @@ from deepmd.dpa_tools import auto_convert
120122

121123
# CSV with SMILES → auto-detected, RDKit generates 3D coords
122124
result = auto_convert("data.csv", "./npy", property_name="homo", property_col="HOMO")
123-
# → {"method": "smiles", "train_systems": [...], "valid_systems": [...], ...}
125+
# prints: RDKit converted samples: ... / RDKit failed rows : ...
126+
# → {"method": "smiles", "train_systems": [...], "valid_systems": [...],
127+
# "samples_used": ..., "failed_rows": [...], "skipped_zero": ...,
128+
# "skipped_overlap": ...}
129+
130+
# To force the SMILES pipeline, pass fmt="smiles"; the value is case-insensitive
131+
# ("SMILES" and "Smiles" also work).
132+
result = auto_convert("data.csv", "./npy", fmt="SMILES", property_name="homo", property_col="HOMO")
124133

125134
# Structure file → auto-detected by dpdata
126135
result = auto_convert("POSCAR", "./npy")
@@ -130,8 +139,6 @@ result = auto_convert("POSCAR", "./npy")
130139
Supports `.csv`, `.xlsx`, `.xls` for SMILES inputs and any format dpdata
131140
recognises for structure files (POSCAR, extxyz, cif, OUTCAR, …).
132141

133-
A demo CSV and MOL files are included in `demo/`.
134-
135142
### Cross-validation
136143

137144
Formula-grouped to prevent same-molecule leakage:
@@ -163,7 +170,7 @@ All commands live under `dp dpa` with two-level nesting:
163170
dp dpa
164171
extract-descriptors extract pooled DPA descriptors to .npy
165172
fit train a model (any strategy)
166-
--strategy {frozen-sklearn|linear-probe|finetune|mft|scratch}
173+
--strategy {frozen_sklearn|linear_probe|finetune|mft}
167174
cv cross-validate (metric estimation, no model output)
168175
predict predict with a frozen .pth bundle
169176
evaluate evaluate a frozen .pth against stored labels
@@ -214,7 +221,6 @@ deepmd/dpa_tools/
214221
├── trainer.py # DPATrainer (dp --pt train subprocess wrapper)
215222
├── cv.py # cross-validation + data splitting
216223
├── conditions.py # scalar condition manager (T, P)
217-
├── demo/ # demo CSV + MOL files for the SMILES pipeline
218224
├── config/
219225
│ └── manager.py # MFT input.json generation
220226
├── data/

deepmd/dpa_tools/cli.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,14 +191,16 @@ def _cmd_data_convert(args: argparse.Namespace) -> int:
191191
overwrite=args.overwrite,
192192
validate=args.validate,
193193
strict=args.strict,
194+
verbose=False,
194195
)
195196
if result["method"] == "smiles":
196197
print(f"Train systems: {len(result['train_systems'])}")
197198
print(f"Valid systems: {len(result['valid_systems'])}")
198199
print(f"Type map : {result['type_map']}")
199200
print(f"Samples used : {result['samples_used']}")
200-
if result["failed_rows"]:
201-
print(f"Failed rows : {len(result['failed_rows'])}")
201+
print(f"Failed rows : {len(result['failed_rows'])}")
202+
print(f"Skipped zero : {result['skipped_zero']}")
203+
print(f"Skipped overlap: {result['skipped_overlap']}")
202204
else:
203205
_LOG.info("Wrote deepmd/npy → %s", result["output_dir"])
204206
return 0

deepmd/dpa_tools/cv.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ def cross_validate(
287287
extracted **once** and a cheap sklearn head is trained per fold — even
288288
``cv=5`` completes in seconds.
289289
290-
Training paradigms (``linear_probe`` / ``finetune`` / ``scratch`` / ``mft``)
290+
Training paradigms (``linear_probe`` / ``finetune`` / ``mft``)
291291
are expensive: each fold re-trains a full DeepMD model. To prevent
292292
accidental hour-long runs, *allow_expensive_cv* must be explicitly set
293293
to ``True`` for those strategies when *cv* is an integer >= 2. Otherwise
@@ -500,7 +500,7 @@ def cross_validate(
500500
# Phase 2 will wire this to DPATrainer / MFTFineTuner.
501501
raise NotImplementedError(
502502
"cross_validate for training paradigms "
503-
"(linear_probe / finetune / scratch / mft) is not yet "
503+
"(linear_probe / finetune / mft) is not yet "
504504
"implemented. Use frozen_sklearn for now."
505505
)
506506

@@ -548,7 +548,6 @@ def _estimate_runtime(strategy: str, n_splits: int) -> str:
548548
per_run = {
549549
"linear_probe": "~5-15 min/run",
550550
"finetune": "~10-30 min/run",
551-
"scratch": "~20-60 min/run",
552551
"mft": "~20-60 min/run",
553552
}.get(strategy, "unknown")
554553
return f"{n_splits} × {per_run}"

deepmd/dpa_tools/data/convert.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,28 @@
2626
_SMILES_COLUMNS = frozenset({"smiles", "smi", "mol"})
2727

2828

29-
def _sniff_csv(path: str) -> set[str]:
29+
def _sniff_csv(path: str) -> set[str] | None:
3030
"""Return the set of column names from a CSV file, or ``None`` if
3131
the file does not look like a table."""
3232
try:
3333
with open(path, newline="", encoding="utf-8") as fh:
3434
reader = csv.DictReader(fh)
3535
if reader.fieldnames is None:
3636
return None
37-
return {h.lower() for h in reader.fieldnames}
37+
38+
columns = []
39+
for header in reader.fieldnames:
40+
if header is None:
41+
return None
42+
header = header.strip()
43+
if not header:
44+
return None
45+
# Reject binary/malformed files that csv.DictReader otherwise
46+
# treats as a one-column header, e.g. b"\x00\x01\x02".
47+
if any(ord(ch) < 32 for ch in header):
48+
return None
49+
columns.append(header.lower())
50+
return set(columns)
3851
except Exception:
3952
return None
4053

@@ -87,6 +100,7 @@ def auto_convert(
87100
overwrite: bool = False,
88101
validate: bool = True,
89102
strict: bool = False,
103+
verbose: bool = True,
90104
) -> dict:
91105
"""Convert any supported input to ``deepmd/npy``, auto-detecting the format.
92106
@@ -103,7 +117,8 @@ def auto_convert(
103117
any additional metadata the chosen backend provides.
104118
"""
105119
# --- explicit SMILES hint, or auto-sniff ---
106-
if fmt == "smiles" or (fmt is None and _is_smiles_input(input_path)):
120+
is_smiles_fmt = isinstance(fmt, str) and fmt.lower() == "smiles"
121+
if is_smiles_fmt or (fmt is None and _is_smiles_input(input_path)):
107122
from deepmd.dpa_tools.data.smiles import smiles_to_npy
108123

109124
result = smiles_to_npy(
@@ -116,14 +131,20 @@ def auto_convert(
116131
seed=seed,
117132
overwrite=overwrite,
118133
)
119-
return {
134+
converted = {
120135
"method": "smiles",
121136
"train_systems": result.train_systems,
122137
"valid_systems": result.valid_systems,
123138
"type_map": result.type_map,
124139
"samples_used": result.samples_used,
125140
"failed_rows": result.failed_rows,
141+
"skipped_zero": result.skipped_zero,
142+
"skipped_overlap": result.skipped_overlap,
126143
}
144+
if verbose:
145+
print(f"RDKit converted samples: {converted['samples_used']}")
146+
print(f"RDKit failed rows : {len(converted['failed_rows'])}")
147+
return converted
127148

128149
# --- structure file → dpdata ---
129150
out = convert(

deepmd/dpa_tools/finetuner.py

Lines changed: 6 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -490,22 +490,8 @@ class DPAFineTuner:
490490
fitting net via ``dp --pt train --finetune``.
491491
``finetune`` Load the pretrained backbone and fine-tune the full
492492
network (descriptor + fitting net).
493-
``scratch`` (known limitation) Random-initialize and train from
494-
scratch — type_map is auto-inferred correctly but
495-
``dp --pt train`` exits before writing train.log;
496-
descriptor config likely missing required fields.
497-
Not recommended for small-data regimes.
498493
================== ======================================================
499494
500-
.. note::
501-
502-
``strategy="scratch"`` is a known limitation as of Phase 2 closeout.
503-
The entry point and auto-type_map logic are retained, but the emitted
504-
``input.json`` does not yet produce a successful ``dp --pt train`` run
505-
(exit 1 before train.log). Scratch training on 19-formula small data
506-
has negligible practical value; completing it is deferred to a future
507-
phase when larger datasets make random-init training meaningful.
508-
509495
Refactored: descriptor-loading, feature-extraction, and sklearn-fitting
510496
logic extracted into ``_FrozenSklearnPipeline``. DPAFineTuner is now a
511497
thin dispatcher that delegates to the pipeline for ``frozen_sklearn``
@@ -514,8 +500,7 @@ class DPAFineTuner:
514500
Parameters
515501
----------
516502
pretrained : str
517-
Path to the pretrained DPA checkpoint (.pt). Set to ``None`` for
518-
``scratch`` strategy.
503+
Path to the pretrained DPA checkpoint (.pt).
519504
model_branch : str, optional
520505
Branch name for multi-task checkpoints (e.g. ``"Omat24"``). Used
521506
by ``frozen_sklearn`` for descriptor extraction.
@@ -529,7 +514,7 @@ class DPAFineTuner:
529514
Random seed for the sklearn predictor or training.
530515
strategy : str
531516
``"frozen_sklearn"`` (default), ``"linear_probe"``, ``"finetune"``,
532-
or ``"scratch"``.
517+
or ``"mft"``.
533518
property_name : str
534519
Property label filename under ``set.*/`` (training paradigms).
535520
task_dim : int
@@ -554,7 +539,7 @@ class DPAFineTuner:
554539

555540
_VALID_POOLING = {"mean", "sum", "mean+std", "mean+std+max+min"}
556541
_VALID_STRATEGIES = {
557-
"frozen_sklearn", "linear_probe", "finetune", "mft", "scratch",
542+
"frozen_sklearn", "linear_probe", "finetune", "mft",
558543
}
559544

560545
def __init__(
@@ -600,9 +585,6 @@ def __init__(
600585
)
601586

602587
self.strategy = strategy
603-
# Scratch forces pretrained=None (random init, no ckpt).
604-
if strategy == "scratch":
605-
pretrained = None
606588

607589
self.pretrained = pretrained
608590
self.model_branch = model_branch
@@ -737,8 +719,7 @@ def _resolve_type_maps(self, train_data) -> list[str]:
737719
*train_data* element set is a subset.
738720
739721
Returns the checkpoint's type_map (e.g. 118-element full periodic
740-
table for DPA-3.1-3M). For scratch (``pretrained=None``) there is no
741-
checkpoint — the type_map is the union of data ``atom_names``.
722+
table for DPA-3.1-3M).
742723
"""
743724
from deepmd.dpa_tools.data.type_map import (
744725
read_checkpoint_type_map,
@@ -750,27 +731,10 @@ def _resolve_type_maps(self, train_data) -> list[str]:
750731
systems = load_data(train_data)
751732
except DPADataError:
752733
# Data paths may not exist during testing; fall back gracefully.
753-
if self.pretrained is None:
754-
raise ValueError(
755-
"strategy='scratch' requires valid data paths or "
756-
"pass type_map=[...] explicitly."
757-
)
758734
return read_checkpoint_type_map(
759735
self.pretrained, branch=self.init_branch,
760736
)
761737

762-
if self.pretrained is None:
763-
try:
764-
tm = read_data_type_map_union(systems)
765-
except ValueError:
766-
raise ValueError(
767-
"strategy='scratch' requires atom_names in data "
768-
"systems, or pass type_map=[...] explicitly. "
769-
"Without a checkpoint, the global type_map cannot be "
770-
"auto-inferred."
771-
)
772-
return tm
773-
774738
tm = read_checkpoint_type_map(
775739
self.pretrained, branch=self.init_branch,
776740
)
@@ -784,7 +748,7 @@ def _resolve_type_maps(self, train_data) -> list[str]:
784748
return tm
785749

786750
# -------------------------------------------------------------------
787-
# Training-paradigm fit (linear_probe / finetune / scratch)
751+
# Training-paradigm fit (linear_probe / finetune)
788752
# -------------------------------------------------------------------
789753

790754
def _fit_training(self, train_data, valid_data, type_map):
@@ -834,7 +798,7 @@ def fit(
834798
"""Train the model.
835799
836800
*frozen_sklearn* (default): extract descriptors, fit sklearn head.
837-
*linear_probe* / *finetune* / *scratch*: run ``dp --pt train``.
801+
*linear_probe* / *finetune*: run ``dp --pt train``.
838802
*mft*: multi-task fine-tuning (property head + force-field head).
839803
840804
Parameters

deepmd/dpa_tools/trainer.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -210,8 +210,6 @@ def __init__(
210210
# ----- mode label (debugging convenience) -----
211211
@property
212212
def mode(self) -> str:
213-
if self.pretrained is None:
214-
return "Scratch"
215213
return "LP" if self.freeze_backbone else "FT"
216214

217215
# ----- descriptor sourcing -----
@@ -238,9 +236,7 @@ def _get_descriptor(self) -> dict:
238236
else:
239237
descriptor = copy.deepcopy(DPA3_DESCRIPTOR_DEFAULT)
240238
# Paper alignment (qm9_gap input.json): silut:3.0 activation (alias of
241-
# the ckpt's custom_silu:3.0) + explicit fix_stat_std=0.3. Enforced on
242-
# both the ckpt-read and scratch paths so the emitted JSON matches the
243-
# paper repo verbatim.
239+
# the ckpt's custom_silu:3.0) + explicit fix_stat_std=0.3.
244240
descriptor["activation_function"] = "silut:3.0"
245241
descriptor["repflow"]["fix_stat_std"] = 0.3
246242
# LP: freeze the descriptor by setting trainable=False on the descriptor
@@ -410,8 +406,8 @@ def fit(self) -> str:
410406
Idempotency: training is skipped if a checkpoint at step
411407
``>= max_steps`` exists in ``output_dir``. If ``max_steps`` is
412408
increased between runs (i.e. only a shorter checkpoint exists),
413-
training is restarted from scratch (or from ``pretrained``) —
414-
checkpoint resumption is not supported.
409+
training is restarted from ``pretrained`` — checkpoint resumption is
410+
not supported.
415411
"""
416412
os.makedirs(self.output_dir, exist_ok=True)
417413

deepmd/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1027,7 +1027,7 @@ def main_parser() -> argparse.ArgumentParser:
10271027
help="Path to DPA checkpoint (.pt).")
10281028
parser_dpa_fit.add_argument("--model-branch", default=None)
10291029
parser_dpa_fit.add_argument("--strategy", default="frozen_sklearn",
1030-
choices=["frozen_sklearn", "linear_probe", "finetune", "mft", "scratch"])
1030+
choices=["frozen_sklearn", "linear_probe", "finetune", "mft"])
10311031
parser_dpa_fit.add_argument("--predictor", default="rf",
10321032
choices=["rf", "linear", "ridge", "mlp"])
10331033
parser_dpa_fit.add_argument("--pooling", default="mean",

pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,13 @@ test = [
9494
# to support Array API 2024.12
9595
'array-api-strict>=2.2;python_version>="3.9"',
9696
]
97+
dpa-tools = [
98+
"scikit-learn",
99+
"dpdata",
100+
"torch",
101+
"rdkit",
102+
"e3nn",
103+
]
97104
docs = [
98105
"sphinx>=3.1.1",
99106
"sphinx-book-theme",

0 commit comments

Comments
 (0)