Losses#
Computing the loss - how well a model’s prediction lines up with its targets - is a central part of model training. In particular, how we design and execute the loss computation can play an extremely large role in how we design model architectures as it dictates what kind of signal propagates back to the weight updates. NVIDIA ALCHEMI Toolkit provides a flexible abstraction with these requirements in mind: users and developers can use existing loss functions or implement their own logic in a straightforward manner, and freely compose them with a modular scheduling system that allows curricula to be designed exactly for a wide range of training styles.
Tip
AI coding assistant? Load the nvalchemi-loss-api
agent skill for concise instructions on using,
composing, and implementing training losses.
This page covers:
the built-in leaf (or terms) losses and how to call them directly;
ComposedLossFunctionfor multi-task training and where per-loss coefficients live;loss-weight scheduling via the
LossWeightScheduleprotocol, applied at the composition level;how to write your own loss — first a pure tensor-to-tensor loss, then a metadata-aware one.
Built-in losses#
The choice of reduction shapes both gradient noise and interpretability.
MSE losses (EnergyMSELoss, ForceMSELoss, StressMSELoss) have smooth
gradients and are the default starting point for most training runs. Huber
variants (EnergyHuberLoss, ForceHuberLoss, StressHuberLoss) reduce
sensitivity to outlier labels — prefer them when your dataset has noisy
DFT references or a long tail of unusual configurations. MAE and L2-norm
reductions (EnergyMAELoss, ForceL2NormLoss) report in the same units
as the target and are easiest to interpret as validation metrics, though
their non-smooth gradients make them less common as the primary training
loss.
The built-in losses cover standard MLIP training targets and additional
MAE/L2 norm tensor reductions. Each is a torch.nn.Module with
configurable target_key / prediction_key attributes used by
composition. All built-in losses expose dtype_policy for optional
prediction/target dtype alignment before validation. The MSE-style losses
expose an opt-in ignore_nonfinite flag; the MAE/L2 norm losses expose
ignore_nonfinite and mask target NaN and inf values.
Class |
Target |
Key defaults |
Extra knobs |
|---|---|---|---|
Per-graph energy |
|
|
|
Per-graph energy |
|
MAE reduction, |
|
Per-graph energy |
|
Huber residual, |
|
Per-atom forces, dense |
|
|
|
Per-atom forces, dense |
|
Huber residual, |
|
Per-atom forces, dense |
|
Vector-L2 reduction, |
|
Per-graph stress |
|
|
|
Per-graph stress |
|
Huber residual, |
Calling a leaf loss directly#
A leaf loss is a plain nn.Module. For losses that do not require
graph metadata — EnergyMSELoss(per_atom=False) (the default), dense
ForceMSELoss(normalize_by_atom_count=False),
ForceHuberLoss(normalize_by_atom_count=False),
StressMSELoss, StressHuberLoss, EnergyMAELoss(per_atom=False),
and dense ForceL2NormLoss(normalize_by_atom_count=False) — call it
with (pred, target) and get a scalar back. Leaves carry no weight or
schedule of their own; a direct call returns the unweighted value:
import torch
from nvalchemi.training import EnergyMSELoss
loss_fn = EnergyMSELoss()
pred = torch.randn(4, 1, requires_grad=True)
target = torch.randn(4, 1)
loss = loss_fn(pred, target) # scalar Tensor
loss.backward()
ForceMSELoss() and ForceL2NormLoss() (default
normalize_by_atom_count=True), EnergyHuberLoss() (default
per_atom=True), and both energy losses with per_atom=True require
graph metadata and will raise ValueError on a bare (pred, target)
call. Either pass metadata kwargs (see
Passing graph metadata) or, for dense (V, 3)
forces, disable the per-graph normalization for a tensor-only call:
from nvalchemi.training import ForceL2NormLoss, ForceMSELoss
force_fn = ForceMSELoss(normalize_by_atom_count=False) # plain MSE over (V, 3)
force_pred = torch.randn(10, 3, requires_grad=True)
force_target = torch.randn(10, 3)
loss = force_fn(force_pred, force_target) # no metadata needed
l2_fn = ForceL2NormLoss(normalize_by_atom_count=False)
l2_loss = l2_fn(force_pred, force_target) # no metadata needed
Padded (B, V_max, 3) forces still require num_nodes_per_graph even
with normalize_by_atom_count=False, since padding rows must be
masked before reduction.
Expected shape layouts#
Built-in leaves call assert_same_shape(..., strict=True), so
prediction and target shapes must match exactly. The table below lists
the layouts these losses are designed for.
Loss |
|
|
|---|---|---|
|
|
|
|
|
exact same shape as |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Warning
(B, 1) versus (B,) is broadcast-compatible but rejected by the
built-ins. Keep the explicit trailing 1 on per-graph tensors unless
both prediction and target intentionally use the (B,) layout supported
by EnergyMAELoss.
Leaf losses do not receive schedule counters. step= and epoch=
belong to ComposedLossFunction, which
uses them to resolve schedule-driven weights before calling each leaf
(see Composition weights and schedules).
Data type alignment#
In some workflows, it is desireable to train models with a different
precision from the more common ML ones like bfloat16 and float32;
for long term integration, double (float64) may be required.
To serve this purpose, BaseLossFunction provides a mechanism for
defining the policy for casting:
By default, leaf losses use
dtype_policy="strict": prediction and target tensors must already have the same dtype, and mismatches raise before shape validation. This catches accidental mixed precision labels early.Set
dtype_policy="prediction_to_target"when labels define the desired loss dtype and model outputs should be cast to match them, ordtype_policy="target_to_prediction"when labels should follow the model output dtype. Casting happens before each leaf’s normal shape and dtype validation.
from nvalchemi.training import EnergyMSELoss
loss_fn = EnergyMSELoss(dtype_policy="prediction_to_target")
loss = loss_fn(predicted_energy, energy_labels)
For multi-component objectives, set the policy on the composition when every strict leaf should share the same behavior:
from nvalchemi.training import ComposedLossFunction, EnergyMSELoss, ForceMSELoss
loss_fn = ComposedLossFunction(
[EnergyMSELoss(), ForceMSELoss()],
dtype_policy="prediction_to_target",
)
When using operator sugar, set the same property after constructing the composed loss:
loss_fn = EnergyMSELoss() + ForceMSELoss()
loss_fn.dtype_policy = "prediction_to_target"
A composed-level policy is applied at call time only to leaves whose own
dtype_policy is still "strict"; an explicitly configured leaf keeps its own
policy. This lets you set a broad default without mutating reusable leaf loss
instances or overriding a component that needs different dtype handling.
Passing graph metadata#
Concrete losses may require graph metadata as keyword arguments. For
example, ForceMSELoss with the default graph-balanced normalization
needs batch_idx and num_graphs for dense (V, 3) forces:
from nvalchemi.training import ForceMSELoss
force_fn = ForceMSELoss() # normalize_by_atom_count=True
pred = torch.randn(10, 3, requires_grad=True)
target = torch.randn(10, 3)
batch_idx = torch.tensor([0, 0, 0, 1, 1, 1, 1, 2, 2, 2])
loss = force_fn(pred, target, batch_idx=batch_idx, num_graphs=3)
The same loss accepts a padded (B, V_max, 3) layout with per-graph
counts instead:
pred_padded = torch.randn(3, 4, 3, requires_grad=True)
target_padded = torch.randn(3, 4, 3)
counts = torch.tensor([3, 4, 3])
loss = force_fn(pred_padded, target_padded, num_nodes_per_graph=counts)
EnergyMSELoss,
EnergyMAELoss,
EnergyHuberLoss,
ForceMSELoss,
ForceHuberLoss, and
ForceL2NormLoss accept an optional
batch= keyword argument as a convenience source for metadata when the
selected reduction needs it. When batch= is provided, the loss pulls
batch_idx, num_graphs, and num_nodes_per_graph directly from it:
# Batch-derived metadata — shorter callsite
loss = force_fn(pred, target, batch=batch)
# Equivalent explicit call — fine-grained control
loss = force_fn(
pred, target,
batch_idx=batch.batch_idx,
num_graphs=batch.num_graphs,
)
Explicit kwargs always win when both are provided — useful if you want
to override num_graphs for a sub-batch without rebuilding a Batch.
A duck-typed batch that’s missing a required attribute still falls
through to the descriptive ValueError raised by the metadata
resolver, so you don’t have to pre-validate it.
Ignoring missing labels#
EnergyMSELoss, ForceMSELoss, and StressMSELoss have an ignore_nonfinite=False
flag. When True, target entries equal to NaN contribute zero to both
the loss value and the gradient — a “nanmean”-style reduction
implemented with branch-free tensor ops so it stays torch.compile-safe:
energy_loss = EnergyMSELoss(ignore_nonfinite=True)
target = torch.tensor([[1.0], [float("nan")], [3.0]])
pred = torch.zeros_like(target, requires_grad=True)
loss = energy_loss(pred, target)
loss.backward()
assert torch.isfinite(loss)
assert pred.grad[1].item() == 0.0 # masked row has zero gradient
NaN targets contribute zero loss and zero gradient; a graph whose
target is entirely NaN contributes exactly 0.0 because the numerator
and denominator both go to zero and the denominator is clamp-min’d to
1. The default (ignore_nonfinite=False) lets NaN propagate, which is
usually what you want during development when a label shouldn’t be
missing.
Warning
For these MSE-style losses, only target NaNs are treated as missing
labels. Prediction NaNs still propagate whenever the corresponding
target is finite; if the target is NaN, that position contributes zero
loss and zero gradient. Do not rely on ignore_nonfinite to hide model
explosions.
If you need a different exclusion strategy — for example, masking based on
per-graph quality flags or confidence scores rather than per-entry NaN checks —
override mask in a custom leaf. See Masking.
MAE and force-L2 reductions#
EnergyMAELoss and ForceL2NormLoss implement tensor reductions only.
They do not apply dataset normalization, target transforms,
element-reference corrections, or any other preprocessing; apply those
outside the loss before passing tensors in.
EnergyMAELoss computes absolute energy residuals and defaults to
per_atom=True: prediction and target are divided by
num_nodes_per_graph, then reduced with atom-count weights so that
larger graphs contribute in proportion to their size — matching the
reduction semantics of EnergyMSELoss(per_atom=True).
ForceL2NormLoss computes a per-atom vector norm before reduction:
per_atom = torch.linalg.vector_norm(predicted_forces - forces, ord=2, dim=-1)
With normalize_by_atom_count=True, dense forces use batch_idx and
num_graphs to compute a valid-atom mean per graph, then mean over
graphs; padded forces use num_nodes_per_graph counts or a node mask to
exclude padding before the same per-graph reduction. With
normalize_by_atom_count=False, the scalar is a global mean over valid
atom L2 norms.
Both MAE/L2 norm losses have ignore_nonfinite=True by default and use
torch.isfinite(target) (.all(dim=-1) for force vectors), excluding
target NaN and inf labels while preserving gradients through valid
prediction entries.
If the built-in reductions don’t match your objective — for instance, if you want
a per-graph median instead of a mean, or a reduction that accounts for
per-structure uncertainty weights — override reduce in a custom leaf. See
Reduction.
Shape and dtype validation#
Built-in leaves validate inputs via the
validate() hook, which calls
nvalchemi.training.losses.assert_same_shape() with strict=True. This
requires exact shape and dtype equality between pred and target, raising a
ValueError that embeds the component name and routing keys in the message for
easy diagnosis.
from nvalchemi.training.losses import assert_same_shape
assert_same_shape(
pred, target,
name="MyLoss",
prediction_key="predicted_energy",
target_key="energy",
)
assert_same_shape checks dtype equality first. With strict=False it uses
torch.broadcast_shapes to verify shape compatibility — so (B, 1) vs. (B,)
passes — while strict=True requires exact shape equality.
Some legitimate losses have pred.shape != target.shape by design, so validation
is opt-in per-leaf rather than enforced globally. When writing a custom loss,
validate is the place to enforce whatever shape invariant your loss expects — or
to skip the check entirely when the shapes are intentionally asymmetric. See
Shape and dtype validation in the custom
loss section for override examples. Note that assert_same_shape is exported from
nvalchemi.training.losses only — it is not re-exported from the top-level
nvalchemi.training.
Composition#
Real training objectives typically combine several targets. The idiomatic way is
to literally add leaves together and use the resulting
ComposedLossFunction:
from nvalchemi.training import EnergyMSELoss, ForceMSELoss, StressMSELoss
loss_fn = EnergyMSELoss() + ForceMSELoss() + StressMSELoss()
loss_fn is an nn.Module whose components sit in an
nn.ModuleList, so .to(device), .state_dict(), .modules(), and
the nested __repr__ work the way you’d expect. Adding a
ComposedLossFunction to another loss flattens transparently:
loss_fn_a = EnergyMSELoss() + ForceMSELoss()
loss_fn_b = loss_fn_a + StressMSELoss() # still 3 flat components
The subsections below first cover the call signature and return type, then weights and scheduling — how to control the relative importance of each term.
The call signature#
ComposedLossFunction takes keyed mappings rather than raw tensors. This is the
design that allows routing: each leaf reads its own prediction_key and
target_key attributes to pull the tensors it needs from the two mappings, so you
pass one unified set of predictions and targets and the composition handles the
dispatch automatically. You never have to manually split tensors and call each leaf
in turn.
def loss_fn(
predictions: Mapping[str, torch.Tensor],
targets: Mapping[str, torch.Tensor],
*,
step: int = 0,
epoch: int | None = None,
**kwargs,
) -> ComposedLossOutput: ...
Any extra **kwargs — graph metadata, batch index, atom counts — are forwarded
unchanged to every leaf. Each leaf consumes what it needs and ignores the rest, so
you pass metadata once at the call site rather than threading it through each loss
individually:
predictions = {
"predicted_energy": model_outputs["energy"],
"predicted_forces": model_outputs["forces"],
"predicted_stress": model_outputs["stress"],
}
targets = {
"energy": batch.energy,
"forces": batch.forces,
"stress": batch.stress,
}
out = loss_fn(
predictions, targets,
step=global_step, epoch=epoch,
batch_idx=batch.batch_idx,
num_graphs=batch.num_graphs,
num_nodes_per_graph=batch.num_nodes_per_graph,
)
out["total_loss"].backward()
Or equivalently loss_fn(predictions, targets, step=..., epoch=..., batch=batch); see Passing graph metadata.
The return type#
Calling .backward() on total_loss is all you need for training, but
ComposedLossFunction returns a richer
ComposedLossOutput — a
typing.TypedDict — specifically so you can observe the composition’s
internals without recomputing anything. The per-component fields are the primary
hook for logging and debugging: they tell you what each task is contributing to
the objective and what weight was actually applied at this step (which differs from
the raw weight when normalize_weights=True or a schedule is active).
Field |
Type |
Meaning |
|---|---|---|
|
|
Scalar sum of |
|
|
Raw per-component loss before applying the effective weight. Keyed by component class name with suffixes on duplicates. |
|
|
Effective (post-normalization) weights actually applied at this call. |
|
|
Raw (pre-normalization) weights, equal to |
|
|
Weighted, detached |
out = loss_fn(predictions, targets)
out["total_loss"].backward()
for name, value in out["per_component_unweighted"].items():
logger.log_scalar(f"loss/{name}", value.detach(), step=global_step)
for name, w in out["per_component_weight"].items():
logger.log_scalar(f"loss_weight/{name}", w, step=global_step)
per_component_weight is particularly useful when a schedule is active: logging it
alongside the raw loss tells you whether a sudden loss spike came from the model or
from a weight that jumped. Duplicate class names get numeric suffixes
(StressMSELoss_0, StressMSELoss_1, …) so keys remain unique.
Per-sample loss diagnostics#
The scalar total_loss is what the optimizer sees, but understanding which
structures are driving the loss is a separate concern — useful for identifying
hard samples, debugging dataset quality issues, or building curriculum strategies
that weight structures by difficulty. Every leaf exposes this through an optional
per_sample_loss: torch.Tensor | None attribute, populated as a detached (B,)
side effect of forward and cleared to None at the start of each call.
The built-in losses populate it when the residual shape makes a per-graph decomposition well-defined. Whether a given configuration populates it and any aggregation caveats are listed below:
Loss |
When populated |
Aggregation caveat |
|---|---|---|
|
Recognizable |
|
|
Supported |
|
|
Recognizable |
Same layout caveats as |
|
Always |
None; per-graph Frobenius MSE is already the scalar mean input. |
|
Always |
Same as |
|
Graph-balanced paths and padded global path |
Dense |
|
Same paths as |
Inherits |
|
Graph-balanced paths and padded global path |
Dense |
ComposedLossOutput["per_component_sample"] surfaces these per-graph tensors
weighted by the effective composition weight, making them ready to log or rank
directly. Components whose per_sample_loss was None are absent from the
dict, so check before reading:
out = loss(predictions, targets)
if "EnergyMSELoss" in out["per_component_sample"]:
per_graph_energy_loss = out["per_component_sample"]["EnergyMSELoss"]
# shape (B,), detached, weighted by the effective energy weight at this step
Note
For paths with an aggregation caveat, inspect individual components rather than
assuming per_sample_loss.mean() equals the scalar return.
Custom leaves control this field through self.per_sample_loss inside their
reduce override. See Reduction for the contract.
Routing errors#
Before computing anything, ComposedLossFunction validates that its inputs
satisfy the routing contract. Errors here almost always indicate a mismatch between
what training_fn returned and what the loss components expect — catching it at
the call site is faster than debugging a silent wrong result or a downstream
shape error.
A missing
prediction_keyortarget_keyin the input mappings raisesKeyError.A mapping entry that is not a
torch.TensorraisesTypeError.A component class without
prediction_key/target_keyattributes (e.g. a custom loss you forgot to configure) raisesAttributeError.A non-finite or non-strictly-positive sum of resolved weights (when
normalize_weights=True) raisesValueError— see Weight normalization for details.
Weights#
More often than not, it is desirable to assign weights to different terms/leaves of the composed loss, for example training on more numerically stable targets first before introducing more complex/difficult outputs that are hard to optimize.
The weighting values are stored within the composed loss, rather than on the
individual term/loss; this is so that dynamic schedules can be orchestrated.
By default, if no weight is provided, any given loss term/leaf is multiplied
by 1.0. Static weights are most easily and intuitively applied by multiplying
a term in the composition like shown below:
from nvalchemi.training import EnergyMSELoss, ForceMSELoss, StressMSELoss
loss_fn = 1.0 * EnergyMSELoss() + 10.0 * ForceMSELoss() + 0.1 * StressMSELoss()
While the leading 1.0 is not necessary, it is used to illustrate how it should
just resemble an equation where we train with a 10x weighting on the forces,
but only 10% emphasis on the stresses in a periodic system.
Alternatively, the weights can be specified as a list of values;
3.0 * EnergyMSELoss() produces a one-component
ComposedLossFunction([EnergyMSELoss()], weights=[3.0]); subsequent additions
flatten components and weights into a single composition.
By default, the values above are not applied literally: the default behavior,
when normalize_weights is set to True, has each weighting factor normalized
by the sum of the weights. This is desirable when trying to reason about the
relative weighting of each term and to keep things numerically stable, however
sometimes the actual desired behavior is to have each term scaled literally
because it has physical meaning or needs the amplification. In those cases,
set normalize_weights=False.
When normalize_weights=True, the resolved weight sum must be finite and
strictly positive at every call; a zero or NaN sum raises ValueError before
any gradient is computed.
Weight schedules#
Static weights fix the relative importance of each task for the entire run. When your training objective should change over time — warming up force weight over the first thousand steps, introducing stress loss at epoch 10, annealing energy weight as the model converges — use a schedule instead of a float.
Any entry in the weights list may be a
LossWeightSchedule. The composition evaluates it
at every call with the (step, epoch) you pass to forward:
from nvalchemi.training import (
ConstantWeight,
CosineWeight,
EnergyMSELoss,
ForceMSELoss,
LinearWeight,
PiecewiseWeight,
StressMSELoss,
)
energy_sched = ConstantWeight(value=1.0)
force_sched = LinearWeight(start=0.0, end=1.0, num_steps=1000)
stress_sched = PiecewiseWeight(
boundaries=(0, 10, 20),
values=(0.0, 0.5, 1.0, 1.0),
per_epoch=True,
)
loss_fn = (
energy_sched * EnergyMSELoss()
+ force_sched * ForceMSELoss()
+ stress_sched * StressMSELoss()
)
out = loss_fn(predictions, targets, step=500, epoch=7, batch=batch)
Schedule |
Shape |
Typical use |
|---|---|---|
Flat |
Static task weight |
|
|
Curriculum warm-up |
|
Half-cosine |
Smooth curriculum |
|
Step function over boundaries |
Phase changes |
Every schedule has a per_epoch: bool field. When False (the default), the
schedule advances by the step argument; when True, by epoch. Mixing the two
lets most schedules advance per batch while keeping others — such as a
stress-weight curriculum — aligned with learning-rate epochs. A per_epoch=True
schedule called with epoch=None raises ValueError.
Tip
Operator constraints — a few non-obvious restrictions worth knowing:
composition + compositionrequires both sides to share the samenormalize_weightsflag. Mismatch raisesValueError; construct the combined composition explicitly to choose.schedule * compositionis rejected withTypeError. Scale each component individually and compose the results, or multiply the composition by a plain float.bool * lossis rejected to preventTruesilently coercing to1.0. Pass1.0explicitly.
Bring your own schedule#
When none of the built-in schedules fit — reciprocal-step decay, exponential decay
with a floor, cyclic oscillation, or a schedule driven by external state —
implement LossWeightSchedule directly.
The protocol is runtime_checkable, so any object with a per_epoch attribute
and a __call__(step: int, epoch: int) -> float method plugs into a composition
without subclassing anything:
class CappedInverse:
"""Return min(1.0, 1.0 / max(step, 1)) — reciprocal step decay."""
per_epoch = False
def __call__(self, step: int, epoch: int) -> float:
return min(1.0, 1.0 / max(step, 1))
loss_fn = CappedInverse() * ForceMSELoss() + EnergyMSELoss()
When a custom schedule is part of a TrainingStrategy, it must also be
serializable into the strategy checkpoint spec. Add to_spec() to meet the
full protocol:
from nvalchemi.training import create_model_spec
class CappedInverse:
per_epoch = False
def __init__(self, cap: float = 1.0) -> None:
self.cap = float(cap)
def __call__(self, step: int, epoch: int) -> float:
return min(self.cap, 1.0 / max(step, 1))
def to_spec(self):
return create_model_spec(type(self), cap=self.cap)
Subclass the internal _BaseWeightSchedule (from
nvalchemi.training.losses.base) when you want Pydantic validation and a
default to_spec() implementation backed by model_dump().
Scheduling controls when each objective matters; the next section covers how to change what is computed inside a leaf — residuals, normalization, masking, and reduction — by writing your own loss.
Writing your own loss#
BaseLossFunction is a template-method class:
its forward orchestrates five hooks in a fixed order, each with a default
implementation you can selectively override. This design lets you implement only
the logic your loss actually requires — from a single residual formula to a fully
custom reduction pipeline — without rewriting the parts the base class handles
correctly.
Four conventions apply to every custom loss:
Define
target_keyandprediction_key. These class-level attributes tellComposedLossFunctionwhich slots in the prediction/target mappings to wire into your loss. Without them, your loss works standalone but cannot participate in a composition.Accept
**kwargsin hooks that receive them.ComposedLossFunctionforwards extra metadata kwargs to every component. Swallowing the ones you don’t use keeps your loss composable with any other loss in the mix.Keep hooks tensor-first. See Passing graph metadata for the kwarg contract.
Weight scheduling lives on
ComposedLossFunction. Your hooks return unweighted values. Overrideforwarddirectly to bypass the template when a loss has a fundamentally non-standard signature — but doing so means the composition hook structure no longer applies.
Residuals (compute_residual)#
compute_residual is the one hook every leaf must implement — it has no default
and the base class raises if you omit it. It receives the (optionally normalized)
pred and target tensors and the boolean valid mask produced by mask, and
returns an element-wise residual tensor of the same shape. Because normalize and
mask have already run, compute_residual can focus entirely on the residual
formula and safely zero invalid positions with torch.where.
The minimum viable leaf overrides nothing but compute_residual. The base class
provides validation, an all-valid mask, and a validity-weighted mean reduction —
you supply only the residual formula:
import torch
from nvalchemi.training import BaseLossFunction
class EnergyMSELoss(BaseLossFunction):
target_key = "energy"
prediction_key = "predicted_energy"
def compute_residual(
self,
pred: torch.Tensor,
target: torch.Tensor,
valid: torch.Tensor,
) -> torch.Tensor:
residual = torch.where(valid, pred - target, torch.zeros_like(pred))
return residual.pow(2)
Expose target_key and prediction_key via __init__ when callers should be
able to override routing keys or configure extra fields — for example, delta on
EnergyHuberLoss.
Normalization (normalize)#
The default normalize is a pass-through: pred and target flow unchanged into
compute_residual, and an empty ReductionContext
is forwarded to the downstream hooks. Override it when the residual should be
computed on transformed inputs — the most common case being per-atom normalization,
where both energy tensors are divided by atom count before the squared-error is
taken. The ReductionContext is a dict-like
container that flows from normalize through mask and into reduce; storing
normalization factors as ctx["weights"] instructs the base reduce to apply a
correspondingly weighted mean automatically.
Override normalize to return a (pred, target, ctx) triple. Here, each energy
prediction and target is divided by atom count, and the counts are stored in the
context so the final reduction is also atom-count-weighted:
from typing import Any
import torch
from nvalchemi.training import BaseLossFunction, ReductionContext
class PerAtomEnergyMSELoss(BaseLossFunction):
target_key = "energy"
prediction_key = "predicted_energy"
def normalize(
self,
pred: torch.Tensor,
target: torch.Tensor,
**kwargs: Any,
) -> tuple[torch.Tensor, torch.Tensor, ReductionContext]:
ctx = ReductionContext()
counts = kwargs.get("num_nodes_per_graph")
if counts is None:
raise ValueError(
"PerAtomEnergyMSELoss requires num_nodes_per_graph=... metadata."
)
counts = counts.to(dtype=pred.dtype).unsqueeze(-1).clamp_min(1.0)
ctx["weights"] = counts
return pred / counts, target / counts, ctx
def compute_residual(
self,
pred: torch.Tensor,
target: torch.Tensor,
valid: torch.Tensor,
) -> torch.Tensor:
residual = torch.where(valid, pred - target, torch.zeros_like(pred))
return residual.pow(2)
ctx["weights"] here is a (B, 1) atom-count tensor. The base reduce
multiplies per-graph residuals by these weights when computing the mean, giving
larger graphs proportionally more influence — the same semantics as
EnergyMSELoss(per_atom=True).
Masking (mask)#
The default mask returns an all-True tensor — every position is valid, with no
padding exclusion and no NaN handling. The mask flows to compute_residual as the
valid argument, and the base reduce excludes False positions from both the
numerator and denominator. Override it when entries should be excluded from the
loss entirely. Two cases come up most often: missing labels, where NaN or inf
targets should contribute zero loss and zero gradient; and padded batches, where
padding rows occupy valid memory but should not contribute to the loss. You can
also combine both, or define completely custom validity criteria.
Override mask to return a boolean tensor broadcast-compatible with pred and
target. For missing labels, torch.isfinite(target) is usually sufficient:
def mask(
self,
pred: torch.Tensor,
target: torch.Tensor,
ctx: ReductionContext,
**kwargs: Any,
) -> torch.Tensor:
if self.ignore_nonfinite:
return torch.isfinite(target)
return torch.ones_like(target, dtype=torch.bool)
For padded tensor layouts, also exclude padding rows. The built-in force losses
combine a node-validity mask with an optional isfinite check:
def mask(self, pred, target, ctx, **kwargs):
num_nodes_per_graph = kwargs.get("num_nodes_per_graph")
# Build a (B, V_max) node mask from counts, expand to (B, V_max, 3)
node_mask = _padded_node_mask(num_nodes_per_graph, pred, pred.shape[1])
valid = node_mask.unsqueeze(-1).expand_as(pred)
if self.ignore_nonfinite:
valid = valid & torch.isfinite(target)
return valid
The key contract: mask returns a boolean tensor received by compute_residual
as valid. Use torch.where(valid, ..., torch.zeros_like(...)) in
compute_residual to zero invalid entries; the base reduce handles the
denominator.
Reduction (reduce)#
The default reduce computes a validity-weighted global mean: valid entries
contribute to both the numerator and the denominator, with ctx["weights"]
applied if normalize set them. Override it when the default aggregation would
bias your training signal. The most common case is a heterogeneous batch — one
very large graph and many small ones — where a global atom-position mean gives
disproportionate gradient signal to the large graph. A graph-balanced reduction
computes a per-graph mean first, then means over graphs, giving equal weight to
each structure regardless of size. This matters most for force losses, where atom
counts vary significantly across structures.
reduce receives the element-wise residual and boolean valid tensors, along
with any graph metadata forwarded as kwargs. The
nvalchemi.training.losses.reductions module provides scatter-based helpers for
building graph-level aggregations:
import torch
from nvalchemi.training import BaseLossFunction, ReductionContext
from nvalchemi.training.losses.reductions import per_graph_sum
class GraphBalancedForceMSE(BaseLossFunction):
target_key = "forces"
prediction_key = "predicted_forces"
def compute_residual(
self,
pred: torch.Tensor,
target: torch.Tensor,
valid: torch.Tensor,
) -> torch.Tensor:
residual = torch.where(valid, pred - target, torch.zeros_like(pred))
return residual.pow(2)
def reduce(
self,
residual: torch.Tensor,
valid: torch.Tensor,
ctx: ReductionContext,
**kwargs,
) -> torch.Tensor:
batch_idx = kwargs["batch_idx"]
num_graphs = kwargs["num_graphs"]
valid_f = valid.to(dtype=residual.dtype)
per_atom_se = residual.sum(dim=-1)
per_atom_valid = valid_f.sum(dim=-1)
per_graph_num = per_graph_sum(per_atom_se, batch_idx, num_graphs)
per_graph_den = per_graph_sum(per_atom_valid, batch_idx, num_graphs)
per_sample = per_graph_num / per_graph_den.clamp_min(1.0)
self.per_sample_loss = per_sample.detach()
return per_sample.mean()
Populate self.per_sample_loss with a detached (B,) tensor to expose
per-graph diagnostics through ComposedLossOutput["per_component_sample"].
See Per-sample loss diagnostics for the full
contract; leave it None when a per-graph decomposition is unavailable.
Shape and dtype validation (validate)#
By default, validate calls
nvalchemi.training.losses.assert_same_shape() with strict=True,
requiring exact shape and dtype equality between pred and target before any
residual computation runs. Override it when your loss has pred.shape != target.shape by design — a dipole loss derived from per-atom charges might produce
(V,) charge predictions and compare them against (B, 3) dipole targets, which
assert_same_shape would reject even though the asymmetry is intentional.
Replace validate with whatever check is meaningful for your shapes, or skip it
entirely:
def validate(self, pred, target, **kwargs):
# pred is (V,) charges, target is (B, 3) dipoles — shapes are intentional
if pred.dtype != target.dtype:
raise ValueError(
f"dtype mismatch: pred {pred.dtype} vs target {target.dtype}"
)
When pred and target should always match exactly, keep the default.
Layout dispatch with plum (advanced)#
Note
This section covers an advanced pattern used by the built-in force losses.
You do not need plum-dispatch to write a custom loss; most custom losses
branch on pred.ndim directly or accept a single layout. Read on only if
you need a loss that cleanly handles both dense (V, 3) and padded
(B, V_max, 3) inputs with separate, testable code paths per layout.
The built-in force losses (ForceMSELoss, ForceHuberLoss, ForceL2NormLoss)
accept both dense (V, 3) and padded (B, V_max, 3) inputs. Rather than
branching on pred.ndim inside each hook, they use
plum-dispatch to route to
type-annotated overloads. For example, ForceMSELoss._valid_force_components
has two @overload implementations — one for Forces (dense, 2-D) and
one for _PaddedForces (padded, 3-D) — plus a @dispatch fallback:
from plum import dispatch, overload
class ForceMSELoss(BaseLossFunction):
# ...
@overload
def _valid_force_components(self, pred: Forces, target: Forces, ...):
"""Dense (V, 3) path — no padding mask needed."""
...
@overload
def _valid_force_components(self, pred: _PaddedForces, target: _PaddedForces, ...):
"""Padded (B, V_max, 3) path — build node mask from counts."""
...
@dispatch
def _valid_force_components(self, pred, target, num_nodes_per_graph):
pass # plum routes to the matching overload at runtime
The mask and reduce hooks delegate to these dispatched helpers,
keeping each layout’s logic in a focused, testable overload. If you are
writing a loss that handles multiple tensor layouts, the ForceMSELoss
and ForceL2NormLoss implementations in
nvalchemi/training/losses/terms.py are the reference patterns to
follow.
Testing a custom loss#
Two checks usually suffice:
A direct call returns a scalar of the expected dtype and gradient flows back to
pred.If
ignore_nonfinitesemantics matter for your loss, assert that aNaN-filled target row contributes zero topred.grad.
import torch
from nvalchemi.training import EnergyMSELoss
loss_fn = EnergyMSELoss()
pred = torch.randn(4, 1, requires_grad=True)
target = torch.randn(4, 1)
value = loss_fn(pred, target)
assert value.ndim == 0
value.backward()
assert pred.grad is not None
For composed losses, assert total_loss equals the expected weighted
sum of per-component values on a tiny batch — inspect
out["per_component_unweighted"] and out["per_component_weight"] to see
exactly what the composition applied.
See also#
API: Losses — Training Terms for the full class and schedule reference.
Reductions: the
nvalchemi.training.losses.reductionsmodule for scatter-based per-graph helpers usable in custom losses.Models: the models guide covers the model-side of the contract (how
predictionsmappings are produced).Hooks: the hooks guide covers the
HookContextfields a training loop makes available, includingctx.loss.