Neural Network Embedding#
This tutorial shows how to embed a trained neural network as algebraic constraints in a discopt optimization model, enabling optimization over ML surrogates with global optimality guarantees.
The approach follows the ideas from OMLT [Ceccon et al., 2022] and the
strong MIP formulations of [Anderson et al., 2020], adapted to discopt’s
expression DAG, the POUNCE AD tape in the Rust core, and McCormick
relaxations for spatial branch-and-bound. (JAX is not on the default solve
path; a plain solve() imports no jax module.)
Overview#
The discopt.ml module provides:
NetworkDefinition: An intermediate representation for sequential feedforward networks.NNFormulation: A builder that embeds the network into adiscopt.Modelas variables and constraints.Three formulation strategies:
full_space— Explicit pre/post-activation variables with smooth activation constraints (sigmoid, tanh, softplus).relu_bigm— Big-M MILP formulation for ReLU networks using binary variables.reduced_space— Nested expressions with no intermediate variables (small networks).
load_onnx: Load trained models from ONNX format (covers scikit-learn, PyTorch, Keras exports).
Example: Optimize over a trained neural network#
We’ll train a small neural network to approximate a function, then find the input that minimizes the network’s output subject to constraints.
import discopt.modeling as dm
import numpy as np
from discopt.ml import (
Activation,
DenseLayer,
NetworkDefinition,
NNFormulation,
)
Step 1: Define a trained network#
Here we manually specify a small 2-layer ReLU network.
In practice, you would train this with scikit-learn, PyTorch, etc.
and load via load_onnx().
# A small 2-input, 4-hidden, 1-output ReLU network
np.random.seed(0)
W1 = np.random.randn(2, 4) * 0.5
b1 = np.random.randn(4) * 0.1
W2 = np.random.randn(4, 1) * 0.5
b2 = np.random.randn(1) * 0.1
net = NetworkDefinition(
layers=[
DenseLayer(W1, b1, Activation.RELU),
DenseLayer(W2, b2, Activation.LINEAR),
],
input_bounds=(np.array([-2.0, -2.0]), np.array([2.0, 2.0])),
)
print(f"Network: {net.input_size} -> {net.layers[0].n_outputs} -> {net.output_size}")
print(f"Forward pass at [0, 0]: {net.forward(np.array([0.0, 0.0]))}")
Network: 2 -> 4 -> 1
Forward pass at [0, 0]: [0.17936536]
Step 2: Embed in a discopt model with big-M formulation#
The relu_bigm strategy introduces binary variables for each ReLU neuron
where the pre-activation value can be positive or negative.
Interval arithmetic bound propagation computes tight big-M constants.
m = dm.Model("nn_optimization")
nn = NNFormulation(m, net, strategy="relu_bigm", prefix="surrogate")
nn.formulate()
# Minimize the network output
m.minimize(nn.outputs[0])
# Add constraints on the inputs
m.subject_to(nn.inputs[0] + nn.inputs[1] >= -1.0, name="sum_lb")
print(m.summary())
Model: nn_optimization
Variables: 15 (11 continuous, 4 integer/binary)
Constraints: 19
Objective: minimize surrogate_zhat_1[0]
Parameters: 0
result = m.solve(time_limit=60)
print(f"Status: {result.status}")
print(f"Optimal objective: {result.objective:.6f}")
print(f"Optimal inputs: {result.value(nn.inputs)}")
print(f"Network output: {result.value(nn.outputs)}")
# Verify against numpy forward pass
x_opt = result.value(nn.inputs)
nn_check = net.forward(x_opt)
print(f"NumPy forward check: {nn_check}")
# ...and against an independent dense grid over the feasible input box, so
# "optimal" is checked rather than taken on trust.
g = np.linspace(-2, 2, 1201)
a, b = np.meshgrid(g, g, indexing="ij")
pts = np.stack([a.ravel(), b.ravel()], axis=1)
pts = pts[pts[:, 0] + pts[:, 1] >= -1.0] # the sum_lb constraint
grid_min = float((np.maximum(pts @ W1 + b1, 0) @ W2 + b2).min())
print(f"Grid reference (1201x1201): {grid_min:.6f}")
assert abs(result.objective - grid_min) < 1e-5, (
f"{result.objective} != grid reference {grid_min}"
)
Status: optimal
Optimal objective: 0.149408
Optimal inputs: [-0.1276488 0.03176181]
Network output: [0.14940791]
NumPy forward check: [0.14940791]
Grid reference (1201x1201): 0.149408
Step 3: Compare with smooth formulation#
For networks with smooth activations (sigmoid, tanh, softplus),
the full_space strategy creates nonlinear constraints that get McCormick
relaxations, differentiated by the POUNCE AD tape.
Note
The full_space route on a scalar-output network exercises an axis reduction:
a width-1 affine layer is emitted as dm.sum(W.T * prev, axis=1) with a single
row. Until #1364 that
reduction was mishandled in two places — the Rust FBBT read a reduction’s
enclosure as its operand’s hull (narrow rather than conservative, so the
tightening could cut the optimum out of the box) and the scalarizer could not
expand it at all (so the per-node LP came back status="error") — and this
cell returned -0.230589 as optimal with gap_certified=True against a true
minimum of -0.579405. Both are fixed, so the cell below now asserts
against an independent dense grid instead of merely printing the gap.
# Same network structure but with tanh activation
net_smooth = NetworkDefinition(
layers=[
DenseLayer(W1, b1, Activation.TANH),
DenseLayer(W2, b2, Activation.LINEAR),
],
input_bounds=(np.array([-2.0, -2.0]), np.array([2.0, 2.0])),
)
m2 = dm.Model("smooth_nn")
nn2 = NNFormulation(m2, net_smooth, strategy="full_space")
nn2.formulate()
m2.minimize(nn2.outputs[0])
result2 = m2.solve(time_limit=60)
print(f"Status: {result2.status} (gap_certified={result2.gap_certified})")
print(f"Reported objective: {result2.objective:.6f} bound: {result2.bound:.6f}")
print(f"Reported inputs: {result2.value(nn2.inputs)}")
# Independent dense grid over the same input box (no constraints on this model).
g2 = np.linspace(-2, 2, 1201)
a2, b2_ = np.meshgrid(g2, g2, indexing="ij")
pts2 = np.stack([a2.ravel(), b2_.ravel()], axis=1)
vals2 = np.tanh(pts2 @ W1 + b1) @ W2 + b2
grid_min2 = float(vals2.min())
print(f"Grid reference: {grid_min2:.6f} at {pts2[vals2.argmin()]}")
assert abs(result2.objective - grid_min2) < 1e-4, (
f"{result2.objective} != grid reference {grid_min2}"
)
# The certificate itself: a valid dual bound never rises above the true optimum.
assert result2.bound <= grid_min2 + 1e-6, (
f"bound {result2.bound} is ABOVE the true optimum {grid_min2}"
)
Status: optimal (gap_certified=True)
Reported objective: -0.579405 bound: -0.579412
Reported inputs: [-1.99999985 -0.90869492]
Grid reference: -0.579405 at [-2. -0.91]
Formulation strategies#
Strategy |
Activations |
Variables |
Method |
|---|---|---|---|
|
sigmoid, tanh, softplus |
Explicit per neuron |
NLP with McCormick relaxations |
|
ReLU (+ smooth) |
Binary per ReLU neuron |
MILP big-M constraints |
|
All |
None (nested expressions) |
NLP, best for small nets |
Choose relu_bigm for ReLU networks (most common in practice).
Choose full_space for smooth networks when you want tight McCormick
relaxations for global optimization. Choose reduced_space for very
small networks where compilation overhead matters.