Repository navigation
Add Gradient Capability - #268
cjkunselman18 wants to merge 45 commits into
Conversation
…d be zero vector of parameters, not an empty list)
|
some changes I want to make and I think it's about ready to merge:
|
Revert the parameters-as-conditions machinery: fitted parameters are no longer IndependentPotential conditions set on pycalphad.variables and popped from the Database, and get_likelihood returns only the log-likelihood again. Gradients will return as an opt-in code path. Keep the SciPyOptimizer improvements: predict returns -lnL so that minimize maximizes the likelihood, data_weights is passed through, and the minimize result is kept. The default method is Powell again.
setup_context no longer deepcopies the Database or unwraps the Piecewise fitted symbols. The unwrap wrote through the symbols dict that Database.__deepcopy__ shares with the original, and it is unnecessary: models receive fitted symbols as parameters and never read their values. OptimizerBase's copies get their own symbols dict, so fit updates only the returned Database.
ResidualFunction gains get_likelihood_and_gradient, which raises NotImplementedError unless a residual implements it. Every residual implements it with a compute_gradient flag on its per-datum functions, off by default, so get_likelihood and get_residuals are unchanged. The equilibrium sensitivities come from espei._parameter_sensitivities, a stub here that raises NotImplementedError until a backend provides it. ZPF driving forces use the envelope theorem at isolated-phase minima and Danskin's theorem at sampled maxima, and their hyperplane gradients use the same NaN mask as the values. SciPyOptimizer.fit(..., jac=True) minimizes with the gradient.
calculate_ returns read-only arrays, which CompositionSet.update rejects.
Every residual's likelihood gradient is checked against central differences in Cu-Mg and in an invented A-B-C-D system, and each ZPF vertex type alone. Exact derivatives of an analytic binary, regular solution closed forms and PR PhasesResearchLab#268's parameters-as-conditions route are oracles. Tests that need gradients skip until a backend is installed. tests/gradient_data/generate.py writes the A-B-C and A-B-C-D datasets from abcd.tdb with pycalphad, independent of ESPEI's residual code, and asserts each region's stable phases. Their residuals vanish at the true parameters, which checks the value path in multicomponent systems.
The tangent of each converged equilibrium solves the equilibrium matrix once for every parameter's column: -dG/dtheta in the stable-phase rows, and the c_theta = -E dG2/dy dtheta terms in the mole-fraction and N rows, assembled as fill_equilibrium_system does for a free state variable. The solver state is rebuilt from the Workspace's results and the per-phase arrays are read from the solver classes' __getstate__ tuples, with a layout check. Parameter kernels are compiled lazily and cached by expression. No PyCalphad changes are needed.
A pure-A liquid reference state has right-hand sides at rounding level for parameters of the absent constituents, which an infeasibility check relative to the right-hand side alone mistakes for infeasible ones.
A right-hand side at rounding level, such as a parameter of a constituent absent from a dilute reference state, leaves a residual of rounding error that is not small relative to the right-hand side itself. Columns are now infeasible only when the residual also exceeds rounding error. The least-squares cutoff matches PyCalphad's own lstsq.
Matching names to positions with list.index for every call was quadratic in the number of parameters: a quarter of the ZPF gradient's cost at 200 parameters.
PR PhasesResearchLab#268 passed every fitted parameter as a condition to each ZPF Workspace, whose phase records know only the parameters in the data's subsystem, so binary ZPF data in a ternary fit raised "Conditions not yet supported". With parameters as symbols, such a parameter has no effect on the likelihood and exactly zero gradient.
For _FORM fixed-configuration data, get_thermochemical_data called shift_reference_state before zeroing the excluded contributions. shift_reference_state builds HMR, SMR and CPMR from the model's contributions at that moment, so the excluded contributions stayed in the phase's property and only the reference states excluded them. The contributions are now zeroed first.
ZPFResidual and EquilibriumPropertyResidual passed the parameter vector straight to code that expects it in sorted-name order, the order of the phase records' parameters, so with an unsorted symbols_to_fit they gave the values to the wrong symbols. Activity and fixed-configuration residuals already read the vector in the order of symbols_to_fit. All four now do, and their gradients follow the same order. setup_context sorts symbols_to_fit, so ESPEI's own fits were not affected.
|
I have been thinking on this awhile and made several prototypes. I think I finally have something worth sharing. I've pushed a bunch of changes. The original intent of being able to compute gradients, of course, stays. ChangesWhat I've done is to:
How to use itCurrently there's no YAML API. You need to use the Python API. Here's an example to run: # Fit the Cu-Mg tutorial's generated parameters with L-BFGS-B and likelihood gradients.
# Run from ESPEI's docs/tutorials/cu-mg-example directory. Needs PyCalphad develop .
import time
import timeit
import numpy as np
from pycalphad import Database, binplot, variables as v
from espei.datasets import apply_tags, load_datasets, recursive_glob
from espei.error_functions.context import setup_context
from espei.optimizers.opt_scipy import SciPyOptimizer
from espei.plot import dataplot
from espei.utils import database_symbols_to_fit, unpack_piecewise
# The tutorial's datasets and tags (run_mcmc.yaml) and generated parameters
datasets = load_datasets(recursive_glob("input-data"))
apply_tags(datasets, {"dft": {"excluded_model_contributions": ["idmix", "mag"]},
"estimated-entropy": {"excluded_model_contributions": ["idmix", "mag"]}})
dbf = Database("Cu-Mg-generated.tdb")
symbols = database_symbols_to_fit(dbf)
# Time each residual's likelihood with and without its gradient
ctx = setup_context(dbf, datasets, symbols)
x = np.array([unpack_piecewise(dbf.symbols[s]) for s in symbols])
for residual in ctx["residual_objs"]:
residual.get_likelihood_and_gradient(x) # the first call compiles the parameter derivatives
t_likelihood = min(timeit.repeat(lambda: residual.get_likelihood(x), number=1, repeat=3))
t_gradient = min(timeit.repeat(lambda: residual.get_likelihood_and_gradient(x), number=1, repeat=3))
print(f"{type(residual).__name__}: likelihood {t_likelihood:.3f} s, with gradient {t_gradient:.3f} s")
# Maximize the likelihood with its gradient
opt = SciPyOptimizer(dbf)
t0 = time.perf_counter()
fit_dbf = opt.fit(symbols, datasets, method="L-BFGS-B", jac=True, maxiter=50)
print(f"L-BFGS-B: {time.perf_counter() - t0:.1f} s, {opt.minimize_result.nfev} evaluations, lnL {-opt.minimize_result.fun:.1f}")
fit_dbf.to_file("Cu-Mg-gradient.tdb", if_exists="overwrite")
comps = ["CU", "MG", "VA"]
phases = list(fit_dbf.phases.keys())
conds = {v.P: 101325, v.T: (500, 1500, 10), v.X("MG"): (0, 1, 0.02)}
ax = binplot(fit_dbf, comps, phases, conds)
dataplot(comps, phases, conds, datasets, ax=ax)
ax.figure.savefig("Cu-Mg-gradient.png", bbox_inches="tight")However, it's worth noting that we have had better results in our papers [1,2] if we scale the parameters to order unity: # Fit the Cu-Mg tutorial's generated parameters with L-BFGS-B in parameters scaled to order unity.
# Run from ESPEI's docs/tutorials/cu-mg-example directory. Needs PyCalphad develop
import time
import numpy as np
from scipy.optimize import minimize
from pycalphad import Database, binplot, variables as v
from espei.datasets import apply_tags, load_datasets, recursive_glob
from espei.error_functions.context import setup_context
from espei.plot import dataplot
from espei.utils import database_symbols_to_fit, unpack_piecewise
# The tutorial's datasets and tags (run_mcmc.yaml) and generated parameters
datasets = load_datasets(recursive_glob("input-data"))
apply_tags(datasets, {"dft": {"excluded_model_contributions": ["idmix", "mag"]},
"estimated-entropy": {"excluded_model_contributions": ["idmix", "mag"]}})
dbf = Database("Cu-Mg-generated.tdb")
ctx = setup_context(dbf, datasets, database_symbols_to_fit(dbf))
symbols = ctx["symbols_to_fit"]
# Optimize z = theta / |theta_0|, so every variable starts at +-1
theta0 = np.array([unpack_piecewise(dbf.symbols[s]) for s in symbols])
scale = np.where(theta0 != 0, np.abs(theta0), 1.0)
def objective(z):
"""Negative log-likelihood and its gradient with respect to the scaled parameters"""
lnlike, gradient = 0.0, np.zeros(z.size)
for residual in ctx["residual_objs"]:
likelihood, residual_gradient = residual.get_likelihood_and_gradient(z * scale)
lnlike += likelihood
gradient += residual_gradient
return -lnlike, -gradient * scale # dlnL/dz = |theta_0| dlnL/dtheta
t0 = time.perf_counter()
result = minimize(objective, theta0 / scale, jac=True, method="L-BFGS-B", options={"maxiter": 50})
print(f"L-BFGS-B: {time.perf_counter() - t0:.1f} s, {result.nfev} evaluations, lnL {-result.fun:.1f}")
# Fitted symbols are parameters to the residuals, so updating the Database afterwards is safe
dbf.symbols.update(dict(zip(symbols, result.x * scale)))
dbf.to_file("Cu-Mg-gradient-scaled.tdb", if_exists="overwrite")
comps = ["CU", "MG", "VA"]
phases = list(dbf.phases.keys())
conds = {v.P: 101325, v.T: (500, 1500, 10), v.X("MG"): (0, 1, 0.02)}
ax = binplot(dbf, comps, phases, conds)
dataplot(comps, phases, conds, datasets, ax=ax)
ax.figure.savefig("Cu-Mg-gradient-scaled.png", bbox_inches="tight")it takes about a minute to produce: [1] C. Kunselman, B. Bocklund, R. Otis, R. Arróyave, Gradient-informed Hamiltonian Monte Carlo for multicomponent CALPHAD model optimization and uncertainty quantification, Acta Materialia (2026) 122412. https://doi.org/10.1016/j.actamat.2026.122412. PerformanceTimes are per evaluation, medians, and the central-difference column is 2·N_θ likelihoods:
Per equilibrium, the tangent costs 0.2–0.5 ms at 10–200 parameters, against a 10–14 ms solve. What's left to do:I might also try to take a pass at simplifying the NumPy backend. Ultimately, we could move this tangent approach back into PyCalphad and it would be a bit faster, but
These require the latest PyCalphad |
The generated A-B-C-D datasets, their generator and the central difference harness are replaced by hardcoded values: exact derivatives of the analytic binary, closed forms on the regular solution, and Cu-Mg ZPF gradients checked against central differences of each vertex's driving force. The dilute reference state test uses a B-C L1 term in A_B_C_TWO_PHASE_TDB, which gives the same rounding-level right-hand sides. The symbols_to_fit order and subsystem tests are merged into one that checks both likelihoods and gradients. Coverage of the added lines is unchanged.
One fit of a regular solution parameter, with Powell and with L-BFGS-B and the gradient, checks that predict and predict_with_gradient return the negative log-likelihood and that neither setup_context nor the optimizer modifies the caller's Database.
It only let the tests skip without a parameter-sensitivity backend, from when two backends were prototyped.

This pull request uses Jansson derivatives to calculate and output the gradient of each contribution to the likelihood function for use in gradient-based optimization.