Training & Design Optimization ==================================== Both parameter training (fit distribution/model parameters to data) and design optimization (choose a design variable, e.g. a position or an abatement fraction, to minimize/maximize an objective) reduce to the same operation: gradient descent through the compiled graph via `Optax `_. The only difference is what plays the role of "the parameter being optimized" and what plays "the objective." Three training entry points, from narrowest to widest scope: .. list-table:: :header-rows: 1 * - Function - Scope * - :func:`~prophys.engine.fit_distribution` - One standalone distribution (univariate **or multivariate**), no model needed * - :func:`~prophys.engine.calibrate` - A compiled model, against observations of one attribute * - :func:`~prophys.engine.finetune` - The **entire model jointly**, against observations of several attributes at once Calibration (MLE / MAP) --------------------------- .. code-block:: python import prophys as prp compiled = model.compile() result = prp.calibrate( compiled, "acceptance_drop", # attribute name observations, # array of observed values n_steps=300, learning_rate=0.05, ) print(result.params) # fitted parameters, constrained space print(result.final_loss) Internally, :func:`~prophys.engine.calibrate` builds the negative log-likelihood (optionally plus a prior term for MAP) as a function of the model's **unconstrained** parameters — the same space :meth:`Param.forward ` maps back from — so Adam never has to worry about box constraints or positivity; those are enforced structurally by the parameterization itself. .. image:: /_static/generated/gas_dispersion_calibration.png :width: 60% :align: center Freezing and selecting parameters -------------------------------------- Both :func:`~prophys.engine.calibrate` and :func:`~prophys.engine.finetune` accept ``trainable=[...]`` (whitelist) or ``frozen=[...]`` (blacklist) of parameter names. Frozen parameters keep their current values but still participate in the forward computation — the standard workflow for staged fitting (fit the physics first, then the noise model, then release everything for a final joint pass). .. code-block:: python prp.calibrate(compiled, "complaints", records, trainable=["dose_k", "dose_x0"]) prp.finetune(compiled, all_observations, frozen=["emission_rate"]) Finetuning the entire model --------------------------------- :func:`~prophys.engine.finetune` maximizes the **joint** likelihood over several attributes at once. Because attributes typically share upstream parameters (a common emission rate, a shared monitoring sigma, a copula correlation), joint fitting pools every observation's information into every shared parameter — with per-attribute ``weights`` if some data sources are more trustworthy than others: .. code-block:: python result = prp.finetune( compiled, {"exposure_res0": exposure_obs, "complaint_res0": complaint_records}, weights={"complaint_res0": 2.0}, ) Learning distributions directly ------------------------------------ :func:`~prophys.engine.fit_distribution` trains the ``Param`` leaves of a standalone distribution — no model required, and multivariate data works the same way as univariate (the leading axis indexes observations): .. code-block:: python # Univariate: recover Weibull parameters from samples w = prp.Weibull(scale=prp.Param("scale", init=5.0, transform="softplus"), concentration=prp.Param("k", init=1.0, transform="softplus")) prp.fit_distribution(w, wind_measurements) # Multivariate: learn a full covariance via its Cholesky factor mvn = prp.MultivariateGaussian( mean=prp.Param("mu", shape=(2,), init=jnp.zeros(2)), cholesky=prp.Param("L", shape=(2, 2), init=jnp.eye(2)), ) prp.fit_distribution(mvn, paired_measurements) # Mixtures: learn component weights (softmax-normalized logits) mix = prp.Mixture([prp.Gaussian(0.0, 1.0), prp.Gaussian(10.0, 1.0)], logits=prp.Param("logits", shape=(2,), init=jnp.zeros(2))) prp.fit_distribution(mix, samples) The Cholesky and copula parameterizations are chosen so *any* unconstrained gradient step yields a valid covariance/correlation — no projections, no clipping, no failed updates. Design optimization ------------------------ .. code-block:: python result = prp.optimize( objective, # a scalar Expr to minimize wrt=[turbine_positions], constraints=[min_spacing_violation], # each must evaluate <= 0 n_steps=200, learning_rate=1.0, ) print(result.params["pos"]) Constraints are enforced via a quadratic penalty; per-parameter bounds don't need a constraint at all, since a bounded :class:`~prophys.symbolic.Param` already can't leave its valid range under any gradient step. If the objective references a :class:`~prophys.domain.RandomVariable` (e.g. optimizing for a specific wind condition rather than marginalizing over it), bind it via ``extra_env``: .. code-block:: python result = prp.optimize( objective, wrt=[abatement_param], extra_env={"wind_speed": 6.0, "wind_direction": jnp.deg2rad(35.0)}, ) Using a compiled model in external JAX solvers --------------------------------------------------- A :class:`~prophys.engine.CompiledModel` is a bundle of pure JAX functions — nothing about it is tied to prophys' own Optax loops. Any quantity it produces (an expectation, a log-likelihood, a CVaR) can be closed over in an ordinary Python function of a parameter pytree and handed to *any* JAX-gradient-based solver; gradients flow through the native kernels' ``custom_vjp`` rules exactly as they do internally: .. code-block:: python import jax def objective(raw_params): # any scalar the model can produce; raw_params is unconstrained space return -compiled.log_prob("exposure", observations, params=raw_params).sum() value_and_grad = jax.jit(jax.value_and_grad(objective)) # jaxopt import jaxopt result = jaxopt.LBFGS(fun=objective).run(compiled.default_params()) # optimistix import optimistix as optx sol = optx.minimise(lambda p, _: objective(p), optx.BFGS(rtol=1e-6, atol=1e-6), compiled.default_params()) # scipy, via JAX-computed gradients from scipy.optimize import minimize import numpy as np flat0 = np.asarray(compiled.default_params()["sigma"]) # ... flatten/unflatten with jax.flatten_util.ravel_pytree as usual The same applies to symbolic expressions directly: ``expr.evaluate(env)`` is a pure function of ``env``, so ``jax.grad(lambda v: expr.evaluate({"x": v}))`` works with any downstream solver, inside or outside ``jit``. Diagnostics -------------- :func:`~prophys.engine.finite_difference_check` compares ``jax.grad`` against central finite differences — useful whenever a :class:`~prophys.symbolic.Surrogate` wraps hand-written physics, to catch a silent mismatch between the analytic and autodiff gradient. :func:`~prophys.engine.check_identifiability` is a cheap heuristic flag for "the loss was still improving meaningfully when optimization stopped." .. autofunction:: prophys.engine.calibrate .. autofunction:: prophys.engine.finetune .. autofunction:: prophys.engine.fit_distribution .. autofunction:: prophys.engine.optimize .. autofunction:: prophys.engine.finite_difference_check .. autofunction:: prophys.engine.check_identifiability .. autoclass:: prophys.engine.CompiledModel :members: