Fitting Workflows¶
UFP has two complementary fitting paths:
direct least squares for models that are linear in their fitted coefficients;
gradient training for general differentiable models.
This page keeps the detailed fitting examples in one place. For a shorter map of coefficient selection, coefficient copying, projection, freezing, staged workflows, checkpoints, and prepared-geometry caches, start with coefficient workflows and workflow management.
Direct Least Squares¶
For fixed geometry, one-body and spline coefficient blocks are linear in their
parameters. UFP assembles weighted design blocks and target vectors from
FitSample objects. In the simplest energy-only case,
where \(\mathbf c\) is the flattened coefficient vector and \(\mathbf y\) contains target energies. Force fitting adds rows corresponding to
which are still linear in spline coefficients because the basis derivatives are known once geometry is fixed.
The least-squares objective is conceptually
where \(\mathbf W\) contains target weights, \(\lambda\) is ridge regularization, and \(\Omega\) can include block-specific shape penalties.
Regularization¶
LinearFitter supports global ridge values and term-family-specific ridge
values for one-body, two-body, and three-body blocks. Two-body spline shape
penalties can discourage rough or unphysical coefficient curves by penalizing
third finite differences, early positive slopes, initial curvature, or low first
coefficients.
These penalties operate on coefficients, not on sampled energies. They are therefore cheap and stable, but they are priors on the representation rather than direct physical constraints at every distance.
When a selected two-body fit covers only part of a pair channel or knot range, third-difference shape penalties use boundary-aware partial semantics. Rows that touch both selected and fixed coefficients still contribute selected-coefficient terms to the compact solve. The fixed neighboring coefficients are moved to the regularization right-hand side, so refitting a middle knot range with nonzero outside knots preserves the same physical prior as the corresponding full-block penalty.
Data-Scaled Ridge Guesses¶
For ridge values, raw neighbor, pair, or triplet counts are only indirect proxies. They affect the scale and conditioning of the design matrix, but the quantity that matters for a coefficient group is the weighted block curvature:
where \(\mathbf A_g\) is the weighted least-squares design block for group \(g\) and
\(n_g\) is that group’s parameter count. ufp.workflows exposes this as a
semi-automated starting point:
from ufp.workflows import estimate_linear_ridge_scales
estimate = estimate_linear_ridge_scales(
model,
training_samples,
fitter_kwargs={
"fit_energy": True,
"fit_forces": True,
"solver": "normal_equation_direct",
"dtype": dtype,
},
fit_kwargs={"batch_size": 64},
subset_size=128,
alpha=1.0e-6,
)
ridge_kwargs = estimate.candidate().as_fitter_kwargs()
For larger studies, use progressive regularization tuning. It estimates the same data-scaled seed, evaluates log-spaced candidates on deterministic training and validation subsets, then rechecks the best candidates on the full validation split:
from ufp.workflows import RegularizationSearchConfig, tune_linear_regularization
search = tune_linear_regularization(
make_model,
dataset,
config=RegularizationSearchConfig(
stage_subset_sizes=(64, 256),
top_k_per_stage=5,
refit_full=True,
),
fitter_kwargs={
"fit_energy": True,
"fit_forces": True,
"solver": "cg",
"dtype": dtype,
},
fit_kwargs={"batch_size": 64},
)
best_model = search.final_model
best_ridges = search.best_candidate.as_fitter_kwargs()
The tuner only adjusts coefficient ridge weights in v1. It preserves any
explicit two-body shape penalty passed through fitter_kwargs, but does not tune
that deprecated penalty family.
Selective Coefficient Fitting¶
LinearFitter can fit a subset of coefficients while preserving the current
values of all other fittable coefficients. Existing string selectors still
select whole blocks or body-order groups, for example fit_blocks=["threebody"]
or freeze_blocks=["twobody"]. For finer control, use CoefficientSelector:
from ufp.leastsquares import (
CoefficientSelector,
LinearFitter,
ParameterLayout,
resolve_coefficient_selection_summary,
)
# Fit only three-body coefficients.
LinearFitter(model, fit_blocks=["threebody"])
# Keep an existing two-body term fixed and fit the remaining coefficients as a
# correction. Explicit freeze selectors take precedence over fit selectors, and
# fixed coefficients are subtracted from the least-squares target internally.
LinearFitter(model, freeze_blocks=["twobody"])
# Fit one pair channel inside a categorized two-body term. Pair selectors are
# canonicalized with the term's symmetry convention.
LinearFitter(
model,
fit_blocks=[CoefficientSelector(block="twobody", channel=(1, 8))],
)
# Fit one source-distinguished three-body triplet channel.
LinearFitter(
model,
fit_blocks=[CoefficientSelector(block="threebody", channel=(8, 1, 1))],
)
# Refit only the first four knots for one pair channel.
LinearFitter(
model,
fit_blocks=[
CoefficientSelector(
block="twobody",
channel=(1, 8),
coeff_slice=slice(0, 4),
)
],
)
# Inspect how selectors resolve before starting a fit.
layout = ParameterLayout.from_model(model, include_frozen=True)
summary = resolve_coefficient_selection_summary(
layout,
fit_blocks=[
CoefficientSelector(
block="twobody",
channel=(1, 8),
coeff_slice=slice(0, 4),
)
],
)
summary.selected_block_labels
summary.fit_entries[0].original_indices
summary.fit_entries[0].compact_slice
Pair and two-body selectors use pair channels (Z_i, Z_j). Three-body and
2D-triplet selectors use source-distinguished triplet channels
(Z_center, Z_neighbor_1, Z_neighbor_2), with the two neighbor entries
canonicalized into the term’s symmetric order. Coefficient slices address the
trailing spline dimensions: one dimension for pair terms, three dimensions for
SplineThreeBodyTerm, and two dimensions for SplineTriplet2DTerm.
Partial refits preserve non-selected coefficients exactly and subtract their
current linear contribution from the target internally. With the default
assembly_contract="term", terms that implement selected-column assembly build
only the requested compact columns. Unsupported blocks and
assembly_contract="block" keep the full assembly plus slicing path as the
compatibility fallback, so missing selected assemblers are a performance
limitation rather than a correctness gap.
For example, this refits four middle pair knots while keeping nonzero boundary neighbors fixed. Legacy two-body shape penalties, when present in older scripts, continue to use boundary-aware partial semantics, but new workflows should start with ridge values and validation-driven tuning:
from ufp.leastsquares import CoefficientSelector, LinearFitter
fitter = LinearFitter(
model,
fit_blocks=[CoefficientSelector(block="pair", coeff_slice=slice(2, 6))],
pair_ridge=1.0e-8,
)
fitter.fit(samples, batch_size=32)
Large partial three-body refits can use the same selector syntax. The default term assembly contract will use selected assembly when the term provides it and will otherwise fall back to full assembly plus slicing:
fitter = LinearFitter(
model,
fit_blocks=[
CoefficientSelector(
block="threebody",
channel=(74, 74, 74),
coeff_slice=(slice(0, 8), slice(0, 8), slice(0, 8)),
)
],
solver="cg",
matrix_storage="column_chunked",
)
fitter.fit(samples, batch_size=8, cache_directory="cache/partial-w")
Coefficient Interchange¶
The ufp.coefficients namespace provides model-surgery helpers for compatible
physical coefficient channels. They build on CoefficientSelector, validate
channel identity, spline family, grid, cutoff, dtype, device, active masks,
symmetry, and category ordering, then copy only compatible values.
A common staged workflow is to fit elemental pair models first, then initialize the matching channels of a mixed model while leaving cross terms at their template values:
from ufp.coefficients import (
clone_model_with_zeroed_coefficients,
copy_matching_coefficients,
)
mixed = clone_model_with_zeroed_coefficients(mixed_template)
hh_report = copy_matching_coefficients(hh_pair_model, mixed)
oo_report = copy_matching_coefficients(oo_pair_model, mixed)
hh_report.copied_count
oo_report.copied_count
Use copy_coefficient_channel() when the source and target selectors differ or
when a workflow should fail unless one specific channel is compatible.
Offline Projection¶
The ufp.projection namespace contains offline helpers for turning prior
functions or existing spline channels into coefficient blocks. Projection is a
model-construction step: it writes ordinary spline coefficients and returns
diagnostics, but it does not add projection logic to runtime term evaluation.
Use project_radial_function() to sample a callable pair prior and solve for a
1D spline row. Use project_pair_prior() when the source is one of the
analytic pair priors from ufp.terms; it calls the prior’s
radial_values(pair, distances) method and attaches the prior’s
projection_metadata() payload to result.metadata. Both results store
detailed sampled values on the result object and shared ProjectionDiagnostics
under result.diagnostics:
import torch
from ufp import SplinePairTerm, SplineTwoBodyTerm, UFPModel
from ufp.coefficients import copy_matching_coefficients
from ufp.leastsquares import CoefficientSelector, LinearFitter
from ufp.projection import project_radial_function
hh_prior = project_radial_function(
lambda r: 0.2 * r.square() + 0.05 * r + 0.3,
coeff_size=8,
full_support_start=0.0,
cutoff=2.0,
dtype=torch.float64,
channel_label="pair[(1, 1)]",
)
hh_model = UFPModel(
pair_terms=[
SplinePairTerm(
cutoff=2.0,
pair=(1, 1),
coeffs=hh_prior.coeffs.clone(),
full_support_start=0.0,
)
],
atomic_types=[1],
)
mixed_model = UFPModel(
pair_terms=[
SplineTwoBodyTerm(
cutoff=2.0,
atomic_types=[1, 8],
coeffs_by_pair=torch.zeros((3, 8), dtype=torch.float64),
full_support_start=0.0,
)
],
atomic_types=[1, 8],
)
copy_matching_coefficients(hh_model, mixed_model)
LinearFitter(
mixed_model,
fit_blocks=[
CoefficientSelector(
block="twobody",
channel=(1, 1),
coeff_slice=slice(2, 6),
)
],
fit_forces=False,
solver="normal_equation_direct",
ridge=1.0e-8,
).fit(samples, batch_size=32)
hh_prior.diagnostics.summary.max_value_rmse
Spline-to-spline helpers such as project_pair_to_twobody(),
project_triplet2d_channel(), and project_threebody_channel() support
compatible channel projection between existing terms. Their diagnostics use the
same channel-level objects and include derivative-error summaries when the
projection includes derivative rows.
Analytic Priors and Residualized Corrections¶
Stage-wise workflows can combine nonlinear physical priors with linear spline corrections. Keep the three mechanisms distinct:
project_pair_prior()turns an analytic pair prior into ordinary spline coefficients. After projection, least squares sees only a spline block.HybridLinearFittersubtracts frozen nonlinear terms on the fly, then delegates the remaining selected coefficient solve toLinearFitter.materialize_residual_dataset()writes residual energy, force, or stress labels into anASEAtomsDatasetfor longer training runs.
The public analytic prior names are
InversePowerPairPrior, DampedInversePowerPairPrior,
ExponentialRepulsionPairPrior, MorsePairPrior, and ScaledZBLPairPrior.
CutoffEnvelope and the helper functions cutoff_envelope_values(),
cutoff_envelope_derivatives(), normalize_cutoff_envelope(), and
apply_cutoff_envelope() live under ufp.terms.
Project an analytic prior into a pair spline, then fine-tune only selected knots:
import torch
from ufp import ExponentialRepulsionPairPrior, SplinePairTerm, UFPModel
from ufp.leastsquares import CoefficientSelector, LinearFitter
from ufp.projection import project_pair_prior
prior = ExponentialRepulsionPairPrior(
cutoff=2.0,
atomic_types=[1],
amplitudes_by_pair={(1, 1): 0.9},
decay_rates_by_pair={(1, 1): 1.4},
cutoff_envelope="none",
trainable=False,
dtype=torch.float64,
)
projection = project_pair_prior(
prior,
(1, 1),
coeff_size=10,
full_support_start=0.2,
)
model = UFPModel(
pair_terms=[
SplinePairTerm(
cutoff=projection.cutoff,
pair=(1, 1),
coeffs=projection.coeffs.clone(),
spline=projection.spline,
full_support_start=projection.full_support_start,
)
],
atomic_types=[1],
)
LinearFitter(
model,
fit_blocks=[CoefficientSelector(block="pair", coeff_slice=slice(2, 6))],
fit_forces=False,
).fit(samples, batch_size=32)
projection.metadata["prior"]["parameter_state_hash"]
Use HybridLinearFitter when the prior should stay nonlinear and frozen while
a selected spline correction is solved. This is not the same as
LinearFitter(freeze_blocks=...): freeze selectors only subtract fixed linear
coefficient blocks already represented in the least-squares layout.
import torch
from ufp import ExponentialRepulsionPairPrior, SplineThreeBodyTerm, UFPModel
from ufp.leastsquares import CoefficientSelector, HybridLinearFitter
prior = ExponentialRepulsionPairPrior(
cutoff=2.0,
atomic_types=[1],
amplitudes_by_pair={(1, 1): 0.4},
decay_rates_by_pair={(1, 1): 0.8},
cutoff_envelope="none",
trainable=False,
dtype=torch.float64,
)
threebody = SplineThreeBodyTerm(
cutoff=2.0,
atomic_types=[1],
coeffs_by_triplet=torch.zeros((1, 8, 8, 8), dtype=torch.float64),
full_support_start_xy=0.0,
full_support_start_z=0.0,
)
model = UFPModel(
pair_terms=[prior],
threebody_terms=[threebody],
atomic_types=[1],
)
HybridLinearFitter(
model,
frozen_terms=[prior],
fit_blocks=[
CoefficientSelector(
block="threebody",
channel=(1, 1, 1),
coeff_slice=(slice(0, 4), slice(0, 4), slice(0, 4)),
)
],
fit_forces=False,
).fit(samples, batch_size=16, cache_directory="cache/hybrid")
For longer optimizer runs, materialize residual labels once and carry strict metadata with every sample:
from ufp.workflows import materialize_residual_dataset
residual = materialize_residual_dataset(
model,
dataset,
selectors=("pair",),
targets=("energy", "forces"),
target_weights={"energy": 1.0, "forces": 0.1},
metadata_key="frozen_prior_residuals",
)
residual.dataset[0].metadata["frozen_prior_residuals"]["metadata_hash"]
residual.metadata["frozen_terms_state_hash"]
Residual metadata hashes include the selected frozen term states, selectors,
target keys, target weights, units, and optional analytic prior projection
metadata. Hybrid cache directories use the same frozen-term state hash under
HybridLinearFitter.frozen_terms_state_hash.
Optimizer-Time Freezing¶
Gradient training can freeze the same coefficient selections by building a mask-and-restore wrapper around a PyTorch optimizer:
import torch
from ufp.leastsquares import CoefficientSelector
from ufp.training import freeze_model_coefficients
freeze_state = freeze_model_coefficients(
model,
[
CoefficientSelector(
block="twobody",
channel=(1, 8),
coeff_slice=slice(0, 4),
)
],
)
freeze_state.affected_parameter_names
freeze_state.frozen_counts
freeze_state.selector_metadata
optimizer = torch.optim.AdamW(model.parameters(), lr=1.0e-3, weight_decay=1.0e-2)
freeze_state.wrap_optimizer(optimizer)
The wrapper masks gradients, restores frozen entries after every optimizer step,
and clears optimizer state entries for frozen coefficients. This prevents AdamW
weight decay and stale moment buffers from drifting frozen coefficients. Calling
wrap_optimizer() with the same CoefficientFreezeState is idempotent; applying
a different freeze state to an already wrapped optimizer raises a clear error.
Workflow Stages and Checkpoints¶
The ufp.workflows namespace includes small stage objects for explicit
staged fitting scripts. They are wrappers around existing primitives, not a
hidden workflow runner: users still build a sequence, run each stage, and own
the context mapping between stages.
Available stage objects are ProjectStage, LinearFitStage,
ResidualizeStage, TrainStage, and ValidateStage. Each declares required
context keys, produced context keys, and checkpoint-ready metadata. Use
workflow_stage_metadata() to collect those stage records, then pass the result
to save_workflow_checkpoint():
import torch
from ufp.leastsquares import CoefficientSelector
from ufp.projection import project_radial_function
from ufp.training import LossWeights
from ufp.workflows import (
LinearFitStage,
ProjectStage,
TrainStage,
ValidateStage,
save_workflow_checkpoint,
workflow_stage_metadata,
)
fit_selector = CoefficientSelector(block="pair", coeff_slice=slice(2, 6))
freeze_selector = CoefficientSelector(block="pair", coeff_slice=slice(0, 2))
context = {"model": model, "fit_samples": samples}
stages = [
ProjectStage(projector=project_radial_function, projector_kwargs=projection_args),
LinearFitStage(
fitter_kwargs={
"fit_blocks": [fit_selector],
"fit_forces": False,
"solver": "normal_equation_direct",
},
fit_kwargs={"batch_size": 16},
),
TrainStage(
freeze_selectors=[freeze_selector],
fit_kwargs={
"epochs": 5,
"loss_weights": LossWeights(energy=1.0, forces=0.0),
"dtype": torch.float64,
},
),
ValidateStage(evaluate_kwargs={"dtype": torch.float64}),
]
results = []
for stage in stages:
result = stage.run(context)
result.update_context(context)
results.append(result)
save_workflow_checkpoint(
"workflow.pt",
model,
fit_blocks=[fit_selector],
freeze_blocks=[freeze_selector],
stage_metadata=workflow_stage_metadata(results, name="pair-refit"),
projection_diagnostics=context["projection_result"].diagnostics,
validation_metrics=context["validation_metrics"],
)
Workflow checkpoints store the package version, model and term metadata,
coefficient layout, selector metadata, fixed-coefficient hashes, stage metadata,
projection diagnostics, validation metrics, user metadata, and the model
state_dict. load_workflow_checkpoint() validates the schema and can reject
model-layout mismatches before loading parameters.
ufp.workflows.prepared is an advanced prepared-geometry helper for
workflow-level cache reuse. It can materialize tensorized geometry, neighbor
lists, pair categories, optional triplet cache metadata, and strict source
signatures from UFPInput, ASE batches, training batches, or least-squares
prepared batches. It is intentionally not exported from top-level
ufp.workflows and is not a runtime input path for model evaluation; import it
directly only for workflows that need reusable prepared geometry.
Caching¶
Large three-body fits can spend significant time assembling design blocks. UFP can write assembled batches, normal-equation components, and dense three-body feature caches to disk. Cache manifests include enough metadata to reject incompatible requests when samples, targets, dtype, backend settings, or coefficient bases change. Assembled least-squares caches use semantic coefficient-channel metadata for built-in one-body, pair/two-body spline, and three-body spline terms, so a full non-alchemical cache can be streamed into a compatible alchemical fit without writing a second adapted cache. This cache format is intentionally not compatible with assembled caches written by older UFP versions.
Cache directories are settings-addressed. The library derives child directory
names from a stable SHA-256 digest of the cache identity metadata, and each
written cache also contains a cache_settings.json summary next to the manifest.
That file is intended for humans: it records the cache kind, digest, manifest
name, schema version, and the invalidating settings used to derive the folder.
Example and workflow code can use the public ufp.cache.CacheIdentity API, or
the top-level ufp.suggest_cache_directory() helper, when it needs a
deterministic parent folder name from notebook or CLI parameters.
Training dense three-body feature caches use a separate V2 manifest with input
signatures, dtype and batch size, atomic and triplet category order, active
triplet indices, spline support/grid metadata, coefficient shape, and row
semantics. feature_cache_mode="read" requires an exact cache or a compatible
active-triplet superset and fails when the cache is missing or stale.
feature_cache_mode="refresh" intentionally rebuilds the cache. The Li-P-S
training examples use read by default, so first-time runs require
--refresh-cache.
For selective fits, cache and CG checkpoint compatibility metadata includes the
stable keys coefficient_selection, fixed_coefficients_signature, and
regularization_semantics.
coefficient_selection records the compact selected layout for each block, and
fixed_coefficients_signature changes when preserved coefficient values outside
the solve layout change. regularization_semantics changes when the meaning of
partial shape regularization changes. CG checkpoints also store the concrete
regularization weights in a regularization payload. Selected assembly does not
need separate correctness metadata because selected-column hooks and full
assembly plus slicing are required to produce the same compact problem.
Use disk-backed caches when repeated solves or checkpointed experiments reuse the same fixed geometries. Prefer ordinary in-memory assembly for small models and early debugging.
Gradient Training¶
The training stack keeps ASE structures in ASEAtomsDataset, collates them into
ASEAtomsBatch, prepares UFPInput, and optimizes model parameters with
PyTorch. Energy, forces, and stress can contribute to the loss when labels are
available.
Training is more general than linear fitting because it can optimize nonlinear parameters and alchemical mixing weights directly. Least squares is often faster and more deterministic when the coefficient-linear assumption applies.