| """Fine-tune an eSEN checkpoint on an ASE database. |
| |
| The input database must contain ASE calculator results (energy, forces, and |
| optionally stress). The output checkpoint keeps the native OneScience model |
| configuration and can be loaded by ``eSENCalculator.from_checkpoint``. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import copy |
| import json |
| import os |
| from dataclasses import dataclass |
| from pathlib import Path |
|
|
| os.environ.setdefault( |
| "ONESCIENCE_ESEN_JD_PATH", |
| os.path.join(os.path.dirname(__file__), "weight", "Jd.pt"), |
| ) |
|
|
| import torch |
| import yaml |
| from torch.nn.parallel import DistributedDataParallel |
| from torch.utils.data import DataLoader, Subset |
| from torch.utils.data.distributed import DistributedSampler |
|
|
| from onescience.datapipes.materials.custom_stack import data_list_collater |
| from onescience.datapipes.materials.custom_stack.storage.ase_datasets import AseDBDataset |
| from onescience.utils.esen.checkpoint import ESENCheckpointTransforms |
| from onescience.utils.uma.normalization.element_references import ( |
| fit_linear_references, |
| ) |
| from onescience.utils.uma.common.utils import load_model_and_weights_from_checkpoint |
|
|
|
|
| @dataclass(frozen=True) |
| class DistributedContext: |
| """Runtime information for a normal Python process or a torchrun worker.""" |
|
|
| rank: int = 0 |
| world_size: int = 1 |
| local_rank: int = 0 |
|
|
| @property |
| def enabled(self) -> bool: |
| return self.world_size > 1 |
|
|
| @property |
| def is_main(self) -> bool: |
| return self.rank == 0 |
|
|
|
|
| def _init_distributed(device_name: str, backend: str) -> DistributedContext: |
| world_size = int(os.environ.get("WORLD_SIZE", "1")) |
| if world_size == 1: |
| if device_name.startswith("cuda") and torch.cuda.is_available(): |
| torch.cuda.set_device(0) |
| return DistributedContext() |
|
|
| if not torch.distributed.is_available(): |
| raise RuntimeError("torch.distributed is required for multi-device fine-tuning.") |
| rank = int(os.environ["RANK"]) |
| local_rank = int(os.environ.get("LOCAL_RANK", rank)) |
| if device_name.startswith("cuda"): |
| if not torch.cuda.is_available(): |
| raise RuntimeError("torchrun requested multiple CUDA/DCU devices, but CUDA is unavailable.") |
| torch.cuda.set_device(local_rank) |
| torch.distributed.init_process_group(backend=backend, rank=rank, world_size=world_size) |
| return DistributedContext(rank=rank, world_size=world_size, local_rank=local_rank) |
|
|
|
|
| def _close_distributed(context: DistributedContext) -> None: |
| if context.enabled and torch.distributed.is_initialized(): |
| torch.distributed.barrier() |
| torch.distributed.destroy_process_group() |
|
|
|
|
| def _loader( |
| path: str | list[str], |
| batch_size: int, |
| workers: int, |
| max_samples: int | None = None, |
| context: DistributedContext | None = None, |
| train: bool = False, |
| seed: int = 0, |
| ) -> DataLoader: |
| dataset = AseDBDataset( |
| { |
| "src": path, |
| "a2g_args": { |
| "r_edges": False, |
| "r_energy": True, |
| "r_forces": True, |
| "r_stress": True, |
| }, |
| } |
| ) |
| if max_samples is not None: |
| sample_count = min(max_samples, len(dataset)) |
| generator = torch.Generator().manual_seed(seed) |
| indices = torch.randperm(len(dataset), generator=generator)[:sample_count].tolist() |
| dataset = Subset(dataset, indices) |
| context = context or DistributedContext() |
| sampler = None |
| if context.enabled: |
| sampler = DistributedSampler( |
| dataset, |
| num_replicas=context.world_size, |
| rank=context.rank, |
| shuffle=train, |
| drop_last=False, |
| ) |
| return DataLoader( |
| dataset, |
| batch_size=batch_size, |
| shuffle=sampler is None and train, |
| sampler=sampler, |
| num_workers=workers, |
| collate_fn=lambda items: data_list_collater(items, otf_graph=True), |
| ) |
|
|
|
|
| def _loss( |
| pred, |
| batch, |
| energy_weight: float, |
| force_weight: float, |
| stress_weight: float, |
| transforms: ESENCheckpointTransforms, |
| ): |
| losses = {} |
| if energy_weight: |
| energy_target = transforms.normalize_target( |
| "energy", batch.energy, pred["energy"], batch |
| ) |
| energy_error = pred["energy"] - energy_target |
| natoms_shape = (-1,) + (1,) * (energy_error.ndim - 1) |
| natoms = batch.natoms.to(energy_error).reshape(natoms_shape) |
| losses["energy"] = (energy_error / natoms).square().mean() |
| if force_weight: |
| force_target = transforms.normalize_target( |
| "forces", batch.forces, pred["forces"], batch |
| ) |
| losses["forces"] = (pred["forces"] - force_target).square().mean() |
| if stress_weight and hasattr(batch, "stress") and "stress" in pred: |
| stress_target = transforms.normalize_target( |
| "stress", batch.stress, pred["stress"], batch |
| ) |
| losses["stress"] = (pred["stress"] - stress_target).square().mean() |
| total = energy_weight * losses.get("energy", 0.0) |
| total = total + force_weight * losses.get("forces", 0.0) |
| total = total + stress_weight * losses.get("stress", 0.0) |
| return total, {key: float(value.detach()) for key, value in losses.items()} |
|
|
|
|
| def _run_epoch( |
| model, |
| loader, |
| device, |
| optimizer, |
| weights, |
| transforms: ESENCheckpointTransforms, |
| context: DistributedContext, |
| ): |
| training = optimizer is not None |
| model.train(training) |
| total = 0.0 |
| batches = 0 |
| metric_names = tuple( |
| name |
| for name, weight in zip(("energy", "forces", "stress"), weights) |
| if weight |
| ) |
| metrics = {name: 0.0 for name in metric_names} |
| for batch in loader: |
| batch = batch.to(device) |
| if training: |
| optimizer.zero_grad(set_to_none=True) |
| prediction = model(batch) |
| loss, batch_metrics = _loss(prediction, batch, *weights, transforms) |
| if training: |
| loss.backward() |
| optimizer.step() |
| total += float(loss.detach()) |
| batches += 1 |
| for key, value in batch_metrics.items(): |
| metrics[key] = metrics.get(key, 0.0) + value |
| if batches == 0: |
| raise RuntimeError("The dataset contains no samples.") |
| values = torch.tensor([total, *metrics.values(), float(batches)], dtype=torch.float64, device=device) |
| if context.enabled: |
| torch.distributed.all_reduce(values, op=torch.distributed.ReduceOp.SUM) |
| global_batches = values[-1].item() |
| return { |
| "loss": values[0].item() / global_batches, |
| **{ |
| key: values[index].item() / global_batches |
| for index, key in enumerate(metrics, start=1) |
| }, |
| } |
|
|
|
|
| def main() -> None: |
| parser = argparse.ArgumentParser(description=__doc__) |
| parser.add_argument("--config", help="YAML configuration path") |
| parser.add_argument("--checkpoint") |
| parser.add_argument("--train", help="ASE DB or ASE-LMDB training path") |
| parser.add_argument("--val", help="ASE DB or ASE-LMDB validation path") |
| parser.add_argument("--output") |
| parser.add_argument("--device") |
| parser.add_argument("--epochs", type=int) |
| parser.add_argument("--batch-size", type=int) |
| parser.add_argument("--workers", type=int) |
| parser.add_argument("--lr", type=float) |
| parser.add_argument("--energy-weight", type=float) |
| parser.add_argument("--force-weight", type=float) |
| parser.add_argument("--stress-weight", type=float) |
| parser.add_argument("--max-train-samples", type=int) |
| parser.add_argument("--max-val-samples", type=int) |
| parser.add_argument("--backend", help="torch.distributed backend for torchrun") |
| parser.add_argument("--seed", type=int) |
| parser.add_argument( |
| "--fit-element-references", |
| action=argparse.BooleanOptionalAction, |
| default=None, |
| help="fit energy element references on the training data", |
| ) |
| args = parser.parse_args() |
|
|
| if not args.config: |
| parser.error("--config is required; use a YAML file from demo/configs") |
| config_path = args.config |
| with Path(config_path).expanduser().open() as handle: |
| config = yaml.safe_load(handle) or {} |
| for key, value in config.items(): |
| if getattr(args, key.replace("-", "_"), None) is None: |
| setattr(args, key.replace("-", "_"), value) |
| for key in ("checkpoint", "train", "val", "output"): |
| value = getattr(args, key) |
| if value is not None: |
| if isinstance(value, list): |
| value = [ |
| os.path.expandvars(os.path.expanduser(str(item))) |
| for item in value |
| ] |
| else: |
| value = os.path.expandvars(os.path.expanduser(str(value))) |
| setattr(args, key, value) |
| required = ("checkpoint", "train", "val", "output") |
| missing = [key for key in required if not getattr(args, key)] |
| if missing: |
| parser.error("missing required config fields: " + ", ".join(missing)) |
| args.backend = args.backend or "nccl" |
| args.seed = 0 if args.seed is None else args.seed |
| args.fit_element_references = bool(args.fit_element_references) |
| if not any((args.energy_weight, args.force_weight, args.stress_weight)): |
| parser.error("at least one of energy_weight, force_weight, or stress_weight must be nonzero") |
|
|
| if args.device.startswith("cuda") and not torch.cuda.is_available(): |
| raise RuntimeError("CUDA/DCU was requested but torch.cuda.is_available() is false.") |
| context = _init_distributed(args.device, args.backend) |
| try: |
| if args.device.startswith("cuda"): |
| device = torch.device(f"cuda:{context.local_rank}") |
| else: |
| device = torch.device(args.device) |
| torch.manual_seed(args.seed + context.rank) |
|
|
| |
| import onescience.models.esen |
|
|
| model = load_model_and_weights_from_checkpoint(args.checkpoint).to(device) |
| transforms = ESENCheckpointTransforms.from_checkpoint(args.checkpoint) |
| if args.fit_element_references: |
| reference_dataset = _loader( |
| args.train, args.batch_size, args.workers |
| ).dataset |
| fitted_references = fit_linear_references( |
| targets=["energy"], |
| dataset=reference_dataset, |
| batch_size=args.batch_size, |
| num_workers=args.workers, |
| log_metrics=False, |
| shuffle=False, |
| ) |
| transforms.elementrefs["energy"] = fitted_references["energy"] |
| if context.is_main: |
| print("fitted energy element references from training data", flush=True) |
| transforms = transforms.to(device) |
| if context.enabled: |
| model = DistributedDataParallel( |
| model, |
| device_ids=[context.local_rank] if device.type == "cuda" else None, |
| output_device=context.local_rank if device.type == "cuda" else None, |
| ) |
| optimizer = torch.optim.AdamW(model.parameters(), lr=args.lr) |
| train_loader = _loader( |
| args.train, |
| args.batch_size, |
| args.workers, |
| args.max_train_samples, |
| context=context, |
| train=True, |
| seed=args.seed, |
| ) |
| val_loader = _loader( |
| args.val, |
| args.batch_size, |
| args.workers, |
| args.max_val_samples, |
| context=context, |
| train=False, |
| seed=args.seed + 1, |
| ) |
| weights = (args.energy_weight, args.force_weight, args.stress_weight) |
|
|
| history = [] |
| for epoch in range(args.epochs): |
| if isinstance(train_loader.sampler, DistributedSampler): |
| train_loader.sampler.set_epoch(epoch) |
| train_metrics = _run_epoch( |
| model, train_loader, device, optimizer, weights, transforms, context |
| ) |
| |
| |
| val_metrics = _run_epoch( |
| model, val_loader, device, None, weights, transforms, context |
| ) |
| record = {"epoch": epoch, "train": train_metrics, "val": val_metrics} |
| if context.is_main: |
| history.append(record) |
| print(json.dumps(record, sort_keys=True), flush=True) |
|
|
| if context.is_main: |
| output = Path(args.output) |
| output.parent.mkdir(parents=True, exist_ok=True) |
| source = torch.load(args.checkpoint, map_location="cpu", weights_only=False) |
| checkpoint = copy.deepcopy(source) |
| base_model = model.module if context.enabled else model |
| checkpoint["state_dict"] = { |
| key: value.detach().cpu() for key, value in base_model.state_dict().items() |
| } |
| checkpoint["elementrefs"] = { |
| name: { |
| key: value.detach().cpu() |
| for key, value in elementref.state_dict().items() |
| } |
| for name, elementref in transforms.elementrefs.items() |
| } |
| checkpoint.setdefault("metadata", {}) |
| checkpoint["metadata"].update( |
| { |
| "onescience_esen_history": history, |
| "source_checkpoint": args.checkpoint, |
| "world_size": context.world_size, |
| "loss_space": "checkpoint_normalized", |
| "element_references": ( |
| "fitted_from_training_data" |
| if args.fit_element_references |
| else "source_checkpoint" |
| ), |
| } |
| ) |
| torch.save(checkpoint, output) |
| print(f"saved checkpoint: {output}", flush=True) |
| finally: |
| _close_distributed(context) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|