Embedding Models in MILPs (Pyomo and discopt)#
A common reason to fit a surrogate is to optimize over it: pick the operating conditions that minimize cost, or embed a unit model inside a larger design problem. JAXSR models are a weighted sum of basis terms, so they can be written directly into an optimization model. If every term is piecewise linear, that embedding is an exact mixed-integer linear program (MILP), which commercial and open-source solvers (HiGHS, Gurobi, CPLEX, discopt) solve to proven global optimality.
The workflow has two halves. First you fit with a basis library whose terms are
MILP-representable, most usefully hinge terms max(0, x - k) that give a
MARS-style piecewise-linear fit. Then you hand the fitted model to to_pyomo or
to_discopt, which writes the variables and constraints for you.
For a worked example with plots, a refine-and-resolve loop and a design problem solved in both backends, see the notebook Optimizing Over Surrogates with MILP.
Install the optional dependencies with pip install jaxsr[mip] (Pyomo and the
HiGHS solver). For discopt, pip install discopt.
Which terms are MILP-representable?#
Term |
Builder |
Formulation |
|---|---|---|
|
|
written directly |
|
|
1 continuous + 1 binary, big-M |
|
|
1 continuous + 1 binary, big-M |
|
|
one-hot binary |
|
|
exact McCormick product |
Everything else (polynomials, interactions, log, exp, ratios, power laws) is
nonlinear. You can still embed those with allow_nonlinear=True, which makes
the problem an MIQCP or MINLP that needs a nonlinear solver such as discopt,
BARON, SCIP or Gurobi’s nonconvex mode.
Step 1: fit a piecewise-linear model#
add_hinges places knots for each continuous feature. Knots come from one of
three sources, in order of priority: an explicit knots dict, quantiles of the
training data X, or evenly spaced points inside feature_bounds. By default
each knot produces the mirrored pair max(0, x - k) and max(0, k - x).
import numpy as np
from jaxsr import BasisLibrary, SymbolicRegressor
rng = np.random.default_rng(0)
X = rng.uniform(0, 4, size=(300, 2))
cost = np.abs(X[:, 0] - 1.5) + 0.8 * np.maximum(0, 2.5 - X[:, 1]) + 0.3 * X[:, 1]
library = (
BasisLibrary(n_features=2, feature_names=["T", "P"], feature_bounds=[(0, 4), (0, 4)])
.add_constant()
.add_linear()
.add_hinges(X=X, n_knots=7)
.add_transcendental(["abs"])
)
model = SymbolicRegressor(basis_library=library, max_terms=6, information_criterion="bic")
model.fit(X, cost)
print(model.expression_)
print(model.metrics_["r2"])
The knots in the term names are rounded to 6 significant digits so they survive
to_callable(), to_sympy() and save()/load() unchanged. Check that the
selected terms embed as a pure MILP before going further:
from jaxsr import milp_incompatible_terms
print(milp_incompatible_terms(model)) # [] means a pure MILP
Knot placement matters more than knot count. The fit can only bend where a knot
exists, so if you know where the response changes behavior (a phase boundary,
a saturation point), pass those locations explicitly with
knots={"T": [1.5], "P": [2.5]}. Otherwise quantiles of the data are a good
default, and the information criterion keeps only the hinges that pay for
themselves.
Step 2a: embed in Pyomo#
to_pyomo populates a Pyomo Block (or returns a new ConcreteModel if you
pass none). Continuing from above:
import pyomo.environ as pyo
m = pyo.ConcreteModel()
m.sr = pyo.Block()
model.to_pyomo(m.sr) # same as jaxsr.to_pyomo(model, m.sr)
m.obj = pyo.Objective(expr=m.sr.jaxsr_output, sense=pyo.minimize)
pyo.SolverFactory("appsi_highs").solve(m)
T_opt = pyo.value(m.sr.jaxsr_inputs["T"])
P_opt = pyo.value(m.sr.jaxsr_inputs["P"])
print(T_opt, P_opt, pyo.value(m.obj))
print(model.predict(np.array([[T_opt, P_opt]]))) # agrees with the objective
The block has these Pyomo components: aux (continuous variables, including
the inputs aux["in_T"] and the output aux["out"]), binaries, integers
and cons. The Python attributes jaxsr_inputs, jaxsr_output and
jaxsr_onehot are shortcuts to the variables you usually need.
Connecting to your own variables#
In a real design problem the inputs and output are already variables in your
model. Pass them through inputs and output and the block links them with
equality constraints:
m = pyo.ConcreteModel()
m.T = pyo.Var(bounds=(0, 4))
m.P = pyo.Var(bounds=(0, 4))
m.cost = pyo.Var()
m.sr = pyo.Block()
model.to_pyomo(m.sr, inputs={"T": m.T, "P": m.P}, output=m.cost)
m.spec = pyo.Constraint(expr=m.T + m.P >= 5) # your own constraints
m.obj = pyo.Objective(expr=m.cost)
pyo.SolverFactory("appsi_highs").solve(m)
print(pyo.value(m.T), pyo.value(m.P), pyo.value(m.cost))
Step 2b: embed in discopt#
to_discopt adds variables and constraints to a discopt.Model and returns a
DiscoptEmbedding with inputs, output, onehot and binaries. Every
variable name gets a prefix (default "jaxsr"), so to embed two models in one
problem give them different prefixes. Continuing from Step 1:
import discopt
dm_model = discopt.Model("design")
emb = model.to_discopt(dm_model) # same as jaxsr.to_discopt(model, dm_model)
dm_model.subject_to(emb.inputs["T"] + emb.inputs["P"] >= 5)
dm_model.minimize(emb.output)
result = dm_model.solve()
print(result.status, result.objective)
to_discopt accepts the same inputs, output, bounds, output_sense and
allow_nonlinear arguments as to_pyomo.
Bounds are required and enforced#
The big-M constants in the hinge and abs formulations come from the input
bounds, so every continuous input must have finite bounds. They are taken from,
in order: the bounds argument ({"T": (0, 4)} or a list in feature order),
the library’s feature_bounds, or the min and max of the training data.
The embedding enforces those bounds on its inputs. This is deliberate: a surrogate is only trustworthy where it was fitted, and an optimizer will happily exploit extrapolation. If your problem needs a wider range, refit with data that covers it rather than widening the bounds. Tight bounds also give smaller big-M constants and faster solves.
Making the MILP smaller with output_sense#
The exact formulation of each hinge or abs term needs one binary. When a
piece is convex in the direction you optimize, the binary is unnecessary: if
the output is only ever minimized, a term with a positive coefficient on
max(0, x - k) is already tight with just h >= 0 and h >= x - k.
blk = model.to_pyomo(output_sense="min") # drop binaries for convex pieces
Only use output_sense when the output appears only in a minimized objective
(for "min") or only in a maximized one (for "max"). If the output also
appears in a constraint like cost >= 2, the relaxed pieces can float upward
and the answer is wrong. The default, output_sense=None, is exact in every
setting.
Categorical inputs#
Categorical features become one-hot binaries, plus an integer input variable
that uses JAXSR’s 0..K-1 category encoding. jaxsr_onehot[("catalyst", "Pd")]
(Pyomo) or emb.onehot[("catalyst", "Pd")] (discopt) gives the binary for one
category, which is the variable you usually want to constrain or cost.
import numpy as np
import pyomo.environ as pyo
from jaxsr import BasisLibrary, SymbolicRegressor
rng = np.random.default_rng(1)
cat = rng.integers(0, 3, size=200)
T = rng.uniform(300, 400, size=200)
y = 0.01 * T + 2.0 * (cat == 1) - 0.004 * T * (cat == 2)
X = np.column_stack([cat, T]).astype(float)
library = (
BasisLibrary(
n_features=2,
feature_names=["catalyst", "T"],
feature_types=["categorical", "continuous"],
categories={0: ["Pt", "Pd", "Ni"]},
)
.add_constant()
.add_linear()
.add_categorical_indicators()
.add_categorical_interactions()
)
model = SymbolicRegressor(basis_library=library, max_terms=5).fit(X, y)
blk = model.to_pyomo()
blk.obj = pyo.Objective(expr=blk.jaxsr_output, sense=pyo.maximize)
pyo.SolverFactory("appsi_highs").solve(blk)
print({c: pyo.value(b) for (_, c), b in blk.jaxsr_onehot.items()}, pyo.value(blk.obj))
Nonlinear terms#
If the best model needs a quadratic or a log, you can still embed it with
allow_nonlinear=True. The terms are written as Pyomo or discopt expressions
and the hinge, abs and categorical terms keep their MILP formulations. The
problem is then an MINLP, so use a solver that handles it; discopt solves these
to global optimality.
import discopt
import numpy as np
from jaxsr import BasisLibrary, SymbolicRegressor
rng = np.random.default_rng(2)
X = rng.uniform(-1, 1, size=(100, 1))
y = (X[:, 0] - 0.3) ** 2 + 0.5 * np.maximum(0, X[:, 0])
library = (
BasisLibrary(n_features=1, feature_names=["x"])
.add_constant()
.add_linear()
.add_polynomials(max_degree=2)
.add_hinges(knots={"x": [0.0]})
)
model = SymbolicRegressor(basis_library=library, max_terms=4).fit(X, y)
dm_model = discopt.Model("minlp")
emb = model.to_discopt(dm_model, allow_nonlinear=True)
dm_model.minimize(emb.output)
print(dm_model.solve().objective)
Limitations#
Models with parametric (add_parametric) or custom (add_custom) basis
functions cannot be embedded, because JAXSR cannot see inside them. Rational
forms, compositions and SISSO-style features are also not supported. The
embedding is built once from the fitted coefficients; if you refit, rebuild
the block. MultiOutputSymbolicRegressor is not supported directly, but you
can embed each model.estimators_[i] separately, giving each its own block
(Pyomo) or prefix (discopt).