nvalchemi-model-wrapping

작성자: nvidia

How to wrap an arbitrary MLIP (Machine Learning Interatomic Potential) using the BaseModelMixin interface to standardize inputs, outputs, and embeddings. Use…

npx skills add https://github.com/nvidia/nvalchemi-toolkit --skill nvalchemi-model-wrapping

nvalchemi Model Wrapping

Overview

To use an arbitrary MLIP (Machine Learning Interatomic Potential) within nvalchemi, wrap it using the BaseModelMixin interface. This standardizes how models receive AtomicData/Batch inputs and produce ModelOutputs.

from nvalchemi.models.base import BaseModelMixin, ModelCard, ModelConfig
from nvalchemi.data import AtomicData, Batch

Architecture

A wrapped model uses multiple inheritance: your PyTorch model class + BaseModelMixin.

┌──────────────────────┐    ┌──────────────────┐
│  YourModel(nn.Module)│    │  BaseModelMixin   │
│  - forward()         │    │  - model_card     │
│  - your layers       │    │  - adapt_input()  │
└──────┬───────────────┘    │  - adapt_output() │
       │                    └────────┬─────────┘
       └──────────┬─────────────────┘
                  │
       ┌──────────▼──────────┐
       │  YourModelWrapper   │
       │  (YourModel,        │
       │   BaseModelMixin)   │
       └─────────────────────┘

Step-by-step guide

1. Define ModelCard (capabilities & requirements)

ModelCard declares what your model can compute and what inputs it needs.

@property
def model_card(self) -> ModelCard:
    return ModelCard(
        # Capabilities
        forces_via_autograd=True,   # forces via autograd (not direct prediction)
        supports_energies=True,
        supports_forces=True,
        supports_stresses=False,
        supports_hessians=False,
        supports_dipoles=False,
        supports_non_batch=True,        # handles single AtomicData (not just Batch)
        supports_pbc=False,             # handles periodic boundary conditions
        supports_node_embeddings=False,
        supports_edge_embeddings=False,
        supports_graph_embeddings=False,
        # Requirements
        needs_neighborlist=False,       # expects neighbor_list in input
        needs_pbc=False,                # requires cell/pbc in input
        needs_node_charges=False,       # requires charges
        needs_system_charges=False,     # requires charge
    )

2. Define embedding_shapes

@property
def embedding_shapes(self) -> dict[str, tuple[int, ...]]:
    return {
        "node_embeddings": (self.hidden_dim,),
        "graph_embedding": (self.hidden_dim,),
    }

3. Implement adapt_input

Converts AtomicData/Batch to a dict of keyword arguments for the underlying model's forward().

Always call super().adapt_input() first — it enables gradients on required tensors (e.g. positions when computing forces) and validates that required input keys are present.

def adapt_input(self, data: AtomicData | Batch, **kwargs: Any) -> dict[str, Any]:
    model_inputs = super().adapt_input(data, **kwargs)

    # Extract tensors in the format your model expects
    model_inputs["atomic_numbers"] = data.atomic_numbers
    model_inputs["positions"] = data.positions.to(self.dtype)

    # Handle batched vs single input
    if isinstance(data, Batch):
        model_inputs["batch_indices"] = data.batch_idx
    else:
        model_inputs["batch_indices"] = None

    # Pass config flags to control model behavior
    model_inputs["compute_forces"] = self.model_config.compute_forces
    return model_inputs

4. Implement adapt_output

Converts the model's raw output to ModelOutputs (an OrderedDict[str, Tensor | None]).

Always call super().adapt_output() first — it creates an OrderedDict pre-filled with expected keys (set to None) and auto-maps matching key names.

def adapt_output(self, model_output: Any, data: AtomicData | Batch) -> ModelOutputs:
    output = super().adapt_output(model_output, data)

    # Map model outputs to standardized keys
    energy = model_output["energy"]
    if isinstance(data, AtomicData) and energy.ndim == 1:
        energy = energy.unsqueeze(-1)   # must be [B, 1]
    output["energy"] = energy

    if self.model_config.compute_forces:
        output["forces"] = model_output["forces"]

    return output

Standard output keys and shapes:

KeyShapeNotes
energy[B, 1]Per-graph energy (eV)
forces[V, 3]Per-node forces
stress[B, 3, 3]Per-graph stress tensor
hessians[V, 3, 3]Energy Hessian
dipole[B, 3]Dipole moment
charges[V, 1]Partial charges

5. Implement compute_embeddings

Extract intermediate representations from the model. Writes embeddings to the data structure in-place.

def compute_embeddings(self, data: AtomicData | Batch, **kwargs: Any) -> AtomicData | Batch:
    model_inputs = self.adapt_input(data, **kwargs)

    # Run model layers to get intermediate representations
    atom_z = self.embedding(model_inputs["atomic_numbers"])
    coord_z = self.coord_embedding(model_inputs["positions"])
    embedding = self.joint_mlp(torch.cat([atom_z, coord_z], dim=-1))

    # Aggregate to graph level
    if isinstance(data, Batch):
        batch_indices = data.batch_idx
        num_graphs = data.batch_size
    else:
        batch_indices = torch.zeros_like(model_inputs["atomic_numbers"])
        num_graphs = 1

    graph_embedding = torch.zeros(
        (num_graphs, *self.embedding_shapes["graph_embedding"]),
        device=embedding.device, dtype=embedding.dtype,
    )
    graph_embedding.scatter_add_(0, batch_indices.unsqueeze(-1), embedding)

    # Write to data structure in-place
    data.node_embeddings = embedding
    data.graph_embeddings = graph_embedding
    return data

6. Implement forward

The main entry point. Adapts input, calls the underlying model, adapts output.

def forward(self, data: AtomicData | Batch, **kwargs: Any) -> ModelOutputs:
    model_inputs = self.adapt_input(data, **kwargs)
    model_outputs = super().forward(**model_inputs)   # calls YourModel.forward()
    return self.adapt_output(model_outputs, data)

7. (Optional) Implement export_model

Export the model without the BaseModelMixin interface (e.g. for use with ASE calculators).

def export_model(self, path: Path, as_state_dict: bool = False) -> None:
    base_cls = self.__class__.__mro__[1]  # get the original model class
    base_model = base_cls()
    for name, module in self.named_children():
        setattr(base_model, name, module)
    if as_state_dict:
        torch.save(base_model.state_dict(), path)
    else:
        torch.save(base_model, path)

ModelConfig (runtime computation control)

ModelConfig controls what to compute on each forward pass. It is set as the model_config attribute on the wrapper instance.

from nvalchemi.models.base import ModelConfig

model = MyModelWrapper()
model.model_config = ModelConfig(
    compute_energies=True,      # default: True
    compute_forces=True,        # default: True
    compute_stresses=False,     # default: False
    compute_hessians=False,     # default: False
    compute_dipoles=False,      # default: False
    compute_charges=False,      # default: False
    compute_embeddings=False,   # default: False
    gradient_keys=set(),        # auto-populated (e.g. "positions" for forces)
)

Use _verify_request() to check if a computation is both requested and supported:

if self._verify_request(self.model_config, self.model_card, "stresses"):
    output["stress"] = compute_stress(...)

Helper methods

MethodReturnsDescription
input_data()set[str]Required input keys based on model_card
output_data()set[str]Expected output keys based on model_config & model_card
_verify_request(config, card, key)boolTrue if computation is requested AND supported
add_output_head(prefix)NoneAdd an MLP output head (override for custom models)

Complete example

import torch
from torch import nn
from pathlib import Path
from typing import Any
from collections import OrderedDict

from nvalchemi.data import AtomicData, Batch
from nvalchemi.models.base import BaseModelMixin, ModelCard, ModelConfig
from nvalchemi._typing import ModelOutputs


class MyPotential(nn.Module):
    """Your existing PyTorch MLIP model."""

    def __init__(self, hidden_dim: int = 128):
        super().__init__()
        self.hidden_dim = hidden_dim
        self.encoder = nn.Linear(3, hidden_dim)
        self.energy_head = nn.Linear(hidden_dim, 1)

    def forward(self, positions, batch_indices=None):
        h = self.encoder(positions)
        node_energy = self.energy_head(h)
        if batch_indices is not None:
            num_graphs = batch_indices.max() + 1
            energy = torch.zeros(num_graphs, 1, device=h.device, dtype=h.dtype)
            energy.scatter_add_(0, batch_indices.unsqueeze(-1), node_energy)
        else:
            energy = node_energy.sum(dim=0, keepdim=True)
        return {"energy": energy}


class MyPotentialWrapper(MyPotential, BaseModelMixin):
    """Wrapped version for use in nvalchemi."""

    @property
    def model_card(self) -> ModelCard:
        return ModelCard(
            forces_via_autograd=True,
            supports_energies=True,
            supports_forces=True,
            supports_non_batch=True,
            needs_neighborlist=False,
            needs_pbc=False,
        )

    @property
    def embedding_shapes(self) -> dict[str, tuple[int, ...]]:
        return {"node_embeddings": (self.hidden_dim,)}

    def adapt_input(self, data: AtomicData | Batch, **kwargs: Any) -> dict[str, Any]:
        model_inputs = super().adapt_input(data, **kwargs)
        model_inputs["positions"] = data.positions
        if isinstance(data, Batch):
            model_inputs["batch_indices"] = data.batch_idx
        else:
            model_inputs["batch_indices"] = None
        return model_inputs

    def adapt_output(self, model_output: Any, data: AtomicData | Batch) -> ModelOutputs:
        output = super().adapt_output(model_output, data)
        output["energy"] = model_output["energy"]
        if self.model_config.compute_forces:
            output["forces"] = -torch.autograd.grad(
                model_output["energy"],
                data.positions,
                grad_outputs=torch.ones_like(model_output["energy"]),
                create_graph=self.training,
            )[0]
        return output

    def compute_embeddings(self, data: AtomicData | Batch, **kwargs) -> AtomicData | Batch:
        model_inputs = self.adapt_input(data, **kwargs)
        data.node_embeddings = self.encoder(model_inputs["positions"])
        return data

    def forward(self, data: AtomicData | Batch, **kwargs: Any) -> ModelOutputs:
        model_inputs = self.adapt_input(data, **kwargs)
        model_outputs = super().forward(**model_inputs)
        return self.adapt_output(model_outputs, data)


# Usage
model = MyPotentialWrapper(hidden_dim=128)
model.model_config = ModelConfig(compute_forces=True)

data = AtomicData(
    positions=torch.randn(5, 3),
    atomic_numbers=torch.tensor([6, 6, 8, 1, 1], dtype=torch.long),
)
batch = Batch.from_data_list([data])
outputs = model(batch)
# outputs["energy"] shape: [1, 1]
# outputs["forces"] shape: [5, 3]

nvidia의 다른 스킬

compileiq-debug
nvidia
Use when something is wrong: Search() hangs, all evaluations return INVALID_SCORE, scores aren't improving, every config returns the same number, ptxas errors…
official
create-github-pr
nvidia
gh CLI를 사용하여 GitHub 풀 리퀘스트를 생성합니다. 사용자가 새 PR을 만들거나, 코드 리뷰를 제출하거나, 풀 리퀘스트를 열고자 할 때 사용합니다. 트리거 키워드 -…
official
diagnose-perf
nvidia
First-responder performance triage for Isaac Sim and Isaac Lab. Identifies bottleneck category (GPU-bound, CPU-bound, VRAM, loading) using nvidia-smi and…
official
eagle3-review-logs
nvidia
Review EAGLE3 pipeline experiment logs from the launcher's experiments/ directory. Summarizes pass/fail status for all 4 tasks, diagnoses failures with root…
official
nemoclaw-maintainer-cross-issue-sweep
nvidia
다른 열린 이슈들을 스캔하여 주어진 PR이 함께 수정하거나 실수로 망가뜨릴 수 있는 이슈를 찾습니다. 인접 수정 기회와 모순 위험을 file:line…과 함께 출력합니다.
official
karpathy-guidelines
nvidia
일반적인 LLM 코딩 실수를 줄이기 위한 행동 지침입니다. 코드 작성, 검토 또는 리팩토링 시 과도한 복잡성을 피하고 정밀한 변경을 위해 사용하세요.
official
fhir-basics
nvidia
에이전트에게 FHIR R4 API의 작동 방식, 사용 가능한 리소스, 검색 매개변수를 사용한 쿼리 방법, 모든 응답 형식을 올바르게 파싱하는 방법을 가르칩니다…
official
underdeclared-agent
nvidia
A helpful assistant agent
official