Modeling Guide: Expressive vs. Fast Construction#
discopt provides two complementary APIs for building optimization models, and the expressive one has two idioms whose cost differs by an order of magnitude. All three appear below, because choosing between them matters more than choosing between discopt and another modelling layer.
Expressive, array-valued |
Expressive, per-element |
Fast API |
|
|---|---|---|---|
Syntax |
|
|
Matrix/vector ( |
Objects built |
O(constraint families) |
O(rows) |
none — one Rust call |
Best for |
anything at scale, linear or not |
small models, irregular rows |
large-scale LP/QP/MILP/MIQP |
Nonlinear support |
Full ( |
Full |
Linear and quadratic only |
Hybrid |
Yes — mix all three in one model |
Yes |
Yes |
If you take one thing from this guide: write the body over the whole array.
m.subject_to(dm.exp(x) + y <= b) with x of shape (n,) is one Python
object describing n rows; the same constraint written as a loop over i is
n objects. Measured across four model families at 100,000 rows, the two
idioms differ by 6.9–11.1x in build-and-write time and 55–77x in memory
— a larger spread than between discopt and Pyomo. The array-valued idiom puts
discopt at parity with a compiled Rust modelling layer; the per-element idiom
does not. See §4 below, and docs/dev/performance-plan.md §48/§50 for the
measurements.
The per-element idiom is not deprecated — it is what AMPL-style indexed
notation [Fourer et al., 2003] and Pyomo’s Constraint(index, rule=...)
[Hart et al., 2017] express, it is often the clearest way to write an irregular
family, and at small sizes the difference is invisible. It is simply not the
one to reach for at scale.
Both APIs feed into the same solver pipeline: automatic problem classification, algebraic coefficient extraction, POUNCE (a pure-Rust Ipopt port) for the NLP solves, the in-house Rust simplex for LP/MILP, and Branch & Bound.
This notebook covers:
The Expressive API — syntax, functions, examples, and its two idioms
The Fast API — bulk construction for large models
Hybrid mode — combining both in one model
Performance comparison — when each one matters
Decision guide — which to choose
import time
import discopt.modeling as dm
import numpy as np
import scipy.sparse as sp
Part 1: The Expressive API#
The expressive API uses Python operator overloading to build an expression DAG (directed acyclic graph). Every arithmetic operation creates a node; the DAG is later lowered to Rust for structure detection and for the evaluation tape the solver differentiates against.
Pattern#
m = dm.Model("name")
x = m.continuous("x", lb=0, ub=10) # 1. Variables
m.minimize(x**2 + 1) # 2. Objective
m.subject_to(x >= 0.5) # 3. Constraints
result = m.solve() # 4. Solve
1.1 Variables#
Three variable types are available:
Method |
Domain |
Example |
|---|---|---|
|
\([\text{lb}, \text{ub}]\) |
Flow rates, temperatures |
|
\(\{0, 1\}\) |
On/off decisions |
|
\(\{\text{lb}, \ldots, \text{ub}\}\) |
Batch counts |
m = dm.Model("variable_demo")
# Scalar variables
T = m.continuous("T", lb=300, ub=600) # temperature (K)
on = m.binary("on") # reactor on/off
n_batches = m.integer("n_batches", lb=0, ub=20)
# Array variables (shape tuple)
x = m.continuous("x", shape=(5,), lb=0, ub=1) # 5-element vector
y = m.binary("y", shape=(3, 4)) # 3x4 binary matrix
print(f"Scalar T: shape={T.shape}, bounds=[{T.lb}, {T.ub}]")
print(f"Array x: shape={x.shape}, {x.size} elements")
print(f"Matrix y: shape={y.shape}, {y.size} elements")
Scalar T: shape=(), bounds=[300.0, 600.0]
Array x: shape=(5,), 5 elements
Matrix y: shape=(3, 4), 12 elements
1.2 Building Expressions#
Arithmetic on variables creates expression nodes in the DAG.
All standard Python operators are supported, plus mathematical
functions from the dm namespace.
m = dm.Model("expression_demo")
x = m.continuous("x", shape=(3,), lb=0, ub=5)
# Arithmetic operators
linear = 2 * x[0] + 3 * x[1] - x[2] # linear combination
quadratic = x[0] ** 2 + x[1] * x[2] # quadratic / bilinear
rational = x[0] / (1 + x[1]) # rational expression
# Mathematical functions
nonlinear = dm.exp(-x[0]) + dm.log(1 + x[1]) + dm.sqrt(x[2])
trig = dm.sin(x[0]) * dm.cos(x[1])
# Aggregations
total = dm.sum(x) # sum of array
indexed = dm.sum(lambda i: x[i] ** 2, over=range(3)) # indexed sum
print(f"linear: {linear}")
print(f"quadratic: {quadratic}")
print(f"nonlinear: {nonlinear}")
linear: (((2 * x[3][0]) + (3 * x[3][1])) - x[3][2])
quadratic: ((x[3][0] ** 2) + (x[3][1] * x[3][2]))
nonlinear: ((exp(neg(x[3][0])) + log((1 + x[3][1]))) + sqrt(x[3][2]))
1.3 Constraints#
Comparisons on expressions produce Constraint objects:
m.subject_to(expr1 <= expr2) # inequality
m.subject_to(expr1 >= expr2) # inequality
m.subject_to(expr1 == expr2) # equality
Constraints can be named for diagnostics:
m = dm.Model("constraint_demo")
x = m.continuous("x", shape=(3,), lb=0, ub=10)
m.minimize(dm.sum(x))
# Named constraints
m.subject_to(x[0] + x[1] <= 5, name="capacity")
m.subject_to(x[0] * x[1] >= 1, name="quality")
m.subject_to(dm.sum(x) == 7, name="balance")
# Loop-based constraints
for i in range(3):
m.subject_to(x[i] >= 0.5, name=f"min_flow_{i}")
print(m.summary())
Model: constraint_demo
Variables: 3 (3 continuous, 0 integer/binary)
Constraints: 6
Objective: minimize sum(x[3])
Parameters: 0
1.4 Example: Geometric Programming (via log transform)#
Minimize the surface area of a box with fixed volume \(V = 1\):
The optimal solution is a cube: \(w^* = d^* = h^* = 1\), surface area \(= 6\).
m = dm.Model("box")
w = m.continuous("w", lb=0.1, ub=10)
d = m.continuous("d", lb=0.1, ub=10)
h = m.continuous("h", lb=0.1, ub=10)
m.minimize(2 * (w * d + w * h + d * h))
m.subject_to(w * d * h == 1) # volume constraint
result = m.solve()
print(f"Status: {result.status}")
print(f"Surface area: {result.objective:.4f} (expected: 6.0)")
print(f"w={result.x['w']:.4f}, d={result.x['d']:.4f}, h={result.x['h']:.4f}")
Status: optimal
Surface area: 6.0000 (expected: 6.0)
w=1.0000, d=1.0000, h=1.0000
1.5 Example: Reactor Design (nonlinear MINLP)#
Select a reactor configuration and optimize temperature to maximize yield. The reaction rate follows Arrhenius kinetics:
where \(y\) is an on/off indicator for the reactor.
k0 = 1e6 # pre-exponential factor
Ea = 5000.0 # activation energy / R (in K)
m = dm.Model("reactor")
T = m.continuous("T", lb=300, ub=600)
y = m.binary("y") # reactor on/off
# Maximize rate (= minimize negative rate)
rate = k0 * dm.exp(-Ea / T)
m.minimize(-(y * rate))
# Reactor must be on for temperature to matter
m.subject_to(T <= 300 + 300 * y) # T = 300 when y=0
result = m.solve()
print(f"Status: {result.status}")
print(f"Reactor on: {result.x['y']:.0f}")
print(f"Temperature: {result.x['T']:.1f} K")
print(f"Rate: {-result.objective:.2f}")
Status: optimal
Reactor on: 1
Temperature: 600.0 K
Rate: 240.37
1.6 Example: Portfolio Optimization (QP via expressions)#
Minimize portfolio variance subject to a target return:
n_assets = 4
mu = np.array([0.12, 0.10, 0.07, 0.03]) # expected returns
# Covariance matrix
Sigma = np.array(
[
[0.04, 0.006, 0.002, 0.0],
[0.006, 0.025, 0.004, 0.001],
[0.002, 0.004, 0.01, 0.002],
[0.0, 0.001, 0.002, 0.005],
]
)
r_min = 0.08
m = dm.Model("portfolio")
w = m.continuous("w", shape=(n_assets,), lb=0, ub=1)
# Variance = w' Sigma w (expressed element-wise)
variance = dm.sum(
lambda i: dm.sum(lambda j: Sigma[i, j] * w[i] * w[j], over=range(n_assets)),
over=range(n_assets),
)
m.minimize(variance)
m.subject_to(dm.sum(lambda i: mu[i] * w[i], over=range(n_assets)) >= r_min)
m.subject_to(dm.sum(w) == 1)
result = m.solve()
print(f"Status: {result.status}")
print(f"Variance: {result.objective:.6f}")
print(f"Weights: {np.round(result.x['w'], 4)}")
print(f"Return: {np.dot(mu, result.x['w']):.4f} (target >= {r_min})")
Status: optimal
Variance: 0.006602
Weights: [0.2065 0.2187 0.4027 0.1721]
Return: 0.0800 (target >= 0.08)
1.7 Array-valued bodies — the idiom for scale#
Everything in §1.1–§1.6 works elementwise over an array. If x has shape
(n,), then dm.exp(x) + y <= b is a single expression describing n rows,
and m.subject_to registers it as one Constraint object rather than n.
The two forms below build the identical model — the .nl files they write are
byte-for-byte the same, and they solve to the same objective. They differ only in
how many Python objects exist along the way.
# The SAME nonlinear model, written both ways.
n = 4
rhs = 3.0 + np.arange(n) * 0.5
# (a) per-element: one Constraint object per row
m_elem = dm.Model("per_element")
x = m_elem.continuous("x", shape=(n,), lb=0.5, ub=4.0)
y = m_elem.continuous("y", shape=(n,), lb=0.5, ub=4.0)
for i in range(n):
m_elem.subject_to(dm.exp(x[i]) * 0.1 + y[i] <= float(rhs[i]), name=f"c{i}")
m_elem.subject_to(x[i] * y[i] >= 1.0, name=f"d{i}")
m_elem.minimize(dm.sum(x) + dm.sum(y))
# (b) array-valued: one Constraint object per FAMILY
m_vec = dm.Model("vectorised")
x = m_vec.continuous("x", shape=(n,), lb=0.5, ub=4.0)
y = m_vec.continuous("y", shape=(n,), lb=0.5, ub=4.0)
m_vec.subject_to(dm.exp(x) * 0.1 + y <= rhs, name="c") # n rows, one object
m_vec.subject_to(x * y >= 1.0, name="d") # n rows, one object
m_vec.minimize(dm.sum(x) + dm.sum(y))
print(f"per-element: {len(m_elem._constraints):>2d} Constraint objects")
print(f"array-valued:{len(m_vec._constraints):>2d} Constraint objects")
r_elem, r_vec = m_elem.solve(), m_vec.solve()
print(f"\nper-element {r_elem.status} objective = {r_elem.objective:.9f}")
print(f"array-valued {r_vec.status} objective = {r_vec.objective:.9f}")
print(f"same answer: {abs(r_elem.objective - r_vec.objective) < 1e-9}")
per-element: 8 Constraint objects
array-valued: 2 Constraint objects
per-element optimal objective = 8.000000036
array-valued optimal objective = 8.000000036
same answer: True
Two objects instead of eight, for a 4-row model; at 100,000 rows it is two
instead of 200,000. That is the whole difference, and it is worth 6.9–11.1x
in time and 55–77x in memory end to end (docs/dev/performance-plan.md
§48, §50).
Practical notes:
The right-hand side can be a numpy array (one bound per row) or a scalar (broadcast to every row).
name="c"names the constraint family; the model holds oneConstraintcalledc, and the writers expand it to rowsc_0,c_1, … (visible in LP/MPS/GAMS output;.nlcarries no names).Reductions work too:
dm.sum(x) <= 9.0is one row, anddm.sum(X, axis=1) <= 9.0reduces a 2-D family along one axis.Matrix products work:
A @ x <= bwith a numpyA.Integrality is unaffected —
m.binary("z", shape=(n,))participates in an array body like any other variable.
Part 2: The Fast Construction API#
For large-scale LP and QP models, the expressive API becomes a bottleneck: creating thousands of Python expression objects is slow. The fast API bypasses expression objects entirely, building constraints directly into the Rust expression arena via a single PyO3 call.
Methods#
Method |
Purpose |
|---|---|
|
Bulk linear constraints: \(Ax \leq b\) |
|
Linear objective: \(\min\; c^\top x\) |
|
Quadratic objective: \(\min\; \tfrac{1}{2} x^\top Q x + c^\top x\) |
Key points:
Acan be a scipy sparse matrix (any format) or dense numpy array — automatically converted to CSRsenseis"<=","==", or">="bcan be a vector or a scalar (broadcast to all rows)Qmust be symmetric; uses the convention \(f = \tfrac{1}{2} x^\top Q x + c^\top x\)
2.1 Example: Large LP (Transportation)#
Ship goods from 50 warehouses to 100 customers. This creates 150 constraints that we add in a single call.
n_wh, n_cust = 50, 100
n_vars = n_wh * n_cust # 5000 decision variables
np.random.seed(42)
supply = np.random.uniform(50, 200, n_wh)
demand = np.random.uniform(10, 50, n_cust)
# Scale demand to match total supply
demand *= supply.sum() / demand.sum()
cost = np.random.uniform(1, 20, (n_wh, n_cust))
m = dm.Model("transport_fast")
x = m.continuous("x", shape=(n_vars,), lb=0)
# --- Build constraint matrix ---
# Supply: sum_j x[i,j] <= supply[i] for each warehouse i
# Demand: sum_i x[i,j] >= demand[j] for each customer j
rows_supply, cols_supply, data_supply = [], [], []
for i in range(n_wh):
for j in range(n_cust):
rows_supply.append(i)
cols_supply.append(i * n_cust + j)
data_supply.append(1.0)
A_supply = sp.csr_matrix((data_supply, (rows_supply, cols_supply)), shape=(n_wh, n_vars))
rows_demand, cols_demand, data_demand = [], [], []
for j in range(n_cust):
for i in range(n_wh):
rows_demand.append(j)
cols_demand.append(i * n_cust + j)
data_demand.append(1.0)
A_demand = sp.csr_matrix((data_demand, (rows_demand, cols_demand)), shape=(n_cust, n_vars))
# Add all constraints in two bulk calls
t0 = time.perf_counter()
m.add_linear_constraints(A_supply, x, "<=", supply, name="supply")
m.add_linear_constraints(A_demand, x, ">=", demand, name="demand")
m.add_linear_objective(cost.flatten(), x)
t_build = time.perf_counter() - t0
print(f"Model: {n_vars} variables, {n_wh + n_cust} constraints")
print(f"Build time: {t_build * 1000:.1f} ms")
result = m.solve()
print(f"Status: {result.status}")
print(f"Total cost: {result.objective:.2f}")
print(f"Solve time: {result.wall_time:.3f}s")
Model: 5000 variables, 150 constraints
Build time: 0.6 ms
Status: optimal
Total cost: 8761.51
Solve time: 0.221s
/Users/jkitchin/projects/discopt/python/discopt/debug/__init__.py:98: UserWarning: Variables with very large or infinite declared bounds: x[0] (lb=0, ub=9.999e+19 [default]), x[1] (lb=0, ub=9.999e+19 [default]), x[2] (lb=0, ub=9.999e+19 [default]), x[3] (lb=0, ub=9.999e+19 [default]), x[4] (lb=0, ub=9.999e+19 [default]). A bound marked [default] is the box applied to a column you declared with no bounds: 9.999e+19, which is FINITE (it sits just below the 1e+20 infinity sentinel), so the solve can return a certified 'optimal' sitting on that corner rather than 'unbounded'. NLP solvers may fail (NaN, iteration_limit) when bounds exceed ~1e15. Add tighter explicit bounds, e.g. m.continuous('x', lb=0, ub=1000).
return fn(*args, **kwargs)
2.2 Example: Ridge Regression as QP#
Given data matrix \(X \in \mathbb{R}^{m \times n}\) and targets \(y \in \mathbb{R}^m\), ridge regression solves:
This is a QP with \(Q = X^\top X + \lambda I\) and \(c = -X^\top y\).
# Generate synthetic regression data
np.random.seed(0)
m_samples, n_features = 200, 20
X_data = np.random.randn(m_samples, n_features)
beta_true = np.random.randn(n_features)
y_data = X_data @ beta_true + 0.5 * np.random.randn(m_samples)
lam = 1.0 # regularization strength
# QP coefficients
Q = sp.csr_matrix(X_data.T @ X_data + lam * np.eye(n_features))
c_vec = -(X_data.T @ y_data)
# Build and solve
mod = dm.Model("ridge")
beta = mod.continuous("beta", shape=(n_features,), lb=-100, ub=100)
mod.add_quadratic_objective(Q, c_vec, beta)
result = mod.solve()
# Compare with closed-form solution
beta_closed = np.linalg.solve(X_data.T @ X_data + lam * np.eye(n_features), X_data.T @ y_data)
print(f"Status: {result.status}")
print(f"Max |discopt - closed_form|: {np.max(np.abs(result.x['beta'] - beta_closed)):.2e}")
print(f"Solve time: {result.wall_time:.3f}s")
Status: optimal
Max |discopt - closed_form|: 2.95e-14
Solve time: 0.003s
2.3 Example: Facility Location (MILP)#
Choose \(p\) facility locations from \(n\) candidates to minimize total weighted distance to \(m\) customers.
n_facilities = 10
n_customers = 30
p = 3 # number to open
np.random.seed(7)
# Random locations
fac_loc = np.random.rand(n_facilities, 2) * 100
cust_loc = np.random.rand(n_customers, 2) * 100
dist = np.sqrt(((cust_loc[:, None, :] - fac_loc[None, :, :]) ** 2).sum(axis=2))
n_x = n_customers * n_facilities # assignment variables
n_y = n_facilities # open/close variables
n_total = n_x + n_y
mod = dm.Model("facility")
# x_ij: fraction of customer i served by facility j (continuous)
x = mod.continuous("x", shape=(n_x,), lb=0, ub=1)
# y_j: whether facility j is open (binary)
y = mod.binary("y", shape=(n_y,))
# Objective: minimize total distance (linear in x)
mod.add_linear_objective(dist.flatten(), x)
# Assignment constraints: sum_j x_ij = 1 for each customer
rows_a, cols_a, data_a = [], [], []
for i in range(n_customers):
for j in range(n_facilities):
rows_a.append(i)
cols_a.append(i * n_facilities + j)
data_a.append(1.0)
A_assign = sp.csr_matrix((data_a, (rows_a, cols_a)), shape=(n_customers, n_x))
mod.add_linear_constraints(A_assign, x, "==", 1.0, name="assign")
# Linking: x_ij <= y_j (via expressions, since they involve two variable groups)
for i in range(n_customers):
for j in range(n_facilities):
mod.subject_to(x[i * n_facilities + j] <= y[j])
# Cardinality: sum_j y_j = p
mod.subject_to(dm.sum(y) == p, name="cardinality")
result = mod.solve(time_limit=30)
print(f"Status: {result.status}")
print(f"Total distance: {result.objective:.2f}")
print(f"Nodes: {result.node_count}, Time: {result.wall_time:.2f}s")
opened = [j for j in range(n_facilities) if result.x["y"][j] > 0.5]
print(f"Opened facilities: {opened}")
Status: optimal
Total distance: 708.58
Nodes: 1, Time: 0.04s
Opened facilities: [1, 2, 4]
Part 3: Hybrid Mode#
The real power of discopt is that you can mix both APIs in a single model. Use the fast API for the bulk linear/quadratic structure, and the expressive API for the few nonlinear constraints.
Example: Blending with Nonlinear Quality#
A refinery has a large block of linear flow-balance constraints (perfect for the fast API) plus a few nonlinear quality blending constraints (need expressions). Note in the summary below that the model reports all the rows — the bulk linear rows are materialized onto the nonlinear solve path rather than being visible only to the matrix engines.
n_streams = 50
mod = dm.Model("hybrid_blend")
flow = mod.continuous("flow", shape=(n_streams,), lb=0, ub=1000)
# --- Bulk linear constraints: flow balance ---
# Random sparse flow network.
np.random.seed(99)
A_balance = sp.random(40, n_streams, density=0.15, format="csr", random_state=99)
# Size the balance capacities around a reference operating point that already
# satisfies the two nonlinear constraints below (stream 1 carries the blend,
# stream 2 exceeds the conversion threshold). Drawing the right-hand side
# independently of A would leave the instance infeasible: a single row with a
# large coefficient on stream 2 is enough to contradict flow[2] >= 100*ln(2).
flow_ref = np.zeros(n_streams)
flow_ref[1] = 5.0
flow_ref[2] = 80.0
b_balance = A_balance @ flow_ref + np.random.uniform(10, 100, 40)
mod.add_linear_constraints(A_balance, flow, "<=", b_balance, name="balance")
# Linear objective
cost_vec = np.random.uniform(1, 10, n_streams)
mod.add_linear_objective(cost_vec, flow)
# --- A few nonlinear constraints via expressions ---
# Nonlinear blending: quality is a ratio
mod.subject_to(
(3.0 * flow[0] + 5.0 * flow[1]) / (flow[0] + flow[1] + 0.01) >= 4.0,
name="quality_blend",
)
# Exponential decay constraint
mod.subject_to(
dm.exp(-flow[2] / 100) <= 0.5,
name="conversion",
)
print(mod.summary())
result = mod.solve(time_limit=10)
print(f"\nStatus: {result.status}")
print(f"Objective: {result.objective:.4f}")
print(f"Solve time: {result.wall_time:.3f}s")
Model: hybrid_blend
Variables: 50 (50 continuous, 0 integer/binary)
Constraints: 42
Objective: minimize 0
Parameters: 0
Status: optimal
Objective: 0.0000
Solve time: 0.041s
Part 4: Performance Comparison#
All three ways of writing a model produce the same model, and it solves at the same speed. What differs — by two to three orders of magnitude on large instances — is what it costs to build the model object in the first place.
The comparison below is a linear model, so all three can express it. Note that
the array-valued expressive form is not a compromise between the other two: it
is the cheapest of the three and it is the only one of the cheap two that
would still work if the body were dm.exp(x) instead.
sizes = [100, 500, 1000, 5000]
results_table = []
for n_con in sizes:
n_var = 50
np.random.seed(42)
A = sp.random(n_con, n_var, density=0.1, format="csr", random_state=42)
b = np.ones(n_con)
c = np.ones(n_var)
A_dense = A.toarray()
# --- Fast API ---
t0 = time.perf_counter()
m_fast = dm.Model("fast")
x_fast = m_fast.continuous("x", shape=(n_var,), lb=0, ub=100)
m_fast.add_linear_constraints(A, x_fast, "<=", b)
m_fast.add_linear_objective(c, x_fast)
t_fast = time.perf_counter() - t0
# --- Expressive, array-valued body (one Constraint object for all rows) ---
t0 = time.perf_counter()
m_vec = dm.Model("vec")
x_vec = m_vec.continuous("x", shape=(n_var,), lb=0, ub=100)
m_vec.subject_to(A_dense @ x_vec <= b)
m_vec.minimize(dm.sum(c * x_vec))
t_vec = time.perf_counter() - t0
# --- Expressive, per-element (one Constraint object per row) ---
t0 = time.perf_counter()
m_expr = dm.Model("expr")
x_expr = m_expr.continuous("x", shape=(n_var,), lb=0, ub=100)
for row in range(n_con):
lhs = dm.sum(lambda j: A_dense[row, j] * x_expr[j], over=range(n_var))
m_expr.subject_to(lhs <= b[row])
m_expr.minimize(dm.sum(lambda j: c[j] * x_expr[j], over=range(n_var)))
t_expr = time.perf_counter() - t0
results_table.append((n_con, t_fast, t_vec, t_expr))
hdr = f"{'Constraints':>12s} {'Fast (ms)':>10s} {'Array (ms)':>11s} {'Per-elem (ms)':>14s}"
print(hdr + f" {'per-elem / array':>17s}")
print("-" * len(hdr + " " * 18))
for n_con, t_fast, t_vec, t_expr in results_table:
print(
f"{n_con:>12d} {t_fast * 1000:>10.1f} {t_vec * 1000:>11.1f} "
f"{t_expr * 1000:>14.1f} {t_expr / t_vec:>16.0f}x"
)
Constraints Fast (ms) Array (ms) Per-elem (ms) per-elem / array
--------------------------------------------------------------------
100 0.1 0.1 5.7 103x
500 0.1 0.0 79.2 2066x
1000 0.2 0.1 107.8 2057x
5000 0.7 0.2 504.6 2090x
Two things to read off that table.
The array-valued body is the cheapest arm, including against the matrix
API — and unlike the matrix API it accepts exp, log, bilinear products
and integrality. It is not a fallback for when the fast API will not do; it is
the default worth reaching for.
The per-element loop is the expensive one, and it gets worse with size — 73x the array-valued form at 100 rows, ~600x at 5,000. That gap is the cost of one Python object per row, and no amount of Rust behind it can undo it: the objects have to exist before anything downstream sees them.
For small models (< 100 constraints) any of the three is fine — use whichever reads best. The distinction starts to matter in the hundreds and dominates in the thousands.
Part 5: Decision Guide#
Default: the Expressive API with array-valued bodies#
Any model with more than a few hundred rows, linear or nonlinear
Nonlinear functions (
exp,log,sin, bilinear products) at any sizeData already in numpy arrays — the right-hand side can be an array
You want one line per constraint family, not per row
Use the per-element loop when:#
The model is small (< a few hundred rows) and the loop reads better
Rows are irregular — each one a different shape, not a family
You are porting AMPL/Pyomo-style indexed code [Fourer et al., 2003, Hart et al., 2017] and want a line-for-line translation first
Use the Fast API when:#
The model is large-scale LP/QP/MILP/MIQP and already in matrix form (
A,b,c,Q) — network flows, regression, scipy sparse pipelinesNo nonlinear terms are needed (it supports linear and quadratic only)
Use Hybrid mode when:#
Most constraints are linear (fast API) but a few are nonlinear (expressions)
You want the best of both: construction speed + modeling flexibility
Quick reference#
Task |
Array-valued |
Per-element |
Fast |
|---|---|---|---|
|
|
loop |
|
|
|
loop + |
|
|
|
loop |
Not supported |
|
|
loop |
Not supported |
GDP / indicator |
— |
|
Not supported |
API Reference Summary#
Model creation#
import discopt.modeling as dm
m = dm.Model("name")
Variables#
x = m.continuous("x", shape=(n,), lb=0, ub=100)
y = m.binary("y", shape=(k,))
z = m.integer("z", lb=0, ub=10)
Expressive objective and constraints#
m.minimize(expr) # or m.maximize(expr)
m.subject_to(expr <= rhs, name="label")
Array-valued constraint bodies (the idiom for scale)#
x = m.continuous("x", shape=(n,), lb=0.5, ub=4.0)
b = np.arange(n) + 1.0
m.subject_to(x + 2 * y <= b, name="c") # n rows, ONE object
m.subject_to(dm.exp(x) + y <= 9.0, name="d") # scalar rhs broadcasts
m.subject_to(x * y >= 1.0, name="e") # bilinear, elementwise
m.subject_to(A @ x <= b, name="f") # matmul with a numpy A
m.subject_to(dm.sum(X, axis=1) <= 9.0, name="g") # reduce a 2-D family
Bodies may be any shape a variable can be declared with (shape=(2, 3) works;
Variable has no .reshape, so declare the shape you want). Every math
function in the list above is elementwise over an array body.
Mathematical functions#
dm.exp(x), dm.log(x), dm.sqrt(x)
dm.sin(x), dm.cos(x), dm.tan(x)
dm.abs(x), dm.log2(x), dm.log10(x)
dm.sum(x), dm.sum(lambda i: ..., over=range(n))
All of these are also importable from the top level (from discopt import tanh),
including the hyperbolic, inverse-trig and erf/sigmoid/softplus families,
matching the intrinsic set Pyomo exports from pyomo.environ [Hart et al., 2017].
NumPy ufuncs are deliberately refused on expressions
(np.tanh(x) raises), so the discopt spelling is the one to reach for.
Reductions on a shaped expression#
xs.sum() # one n-ary node -- NOT the builtin sum()'s left-deep chain
X.sum(axis=1) # one row sum per row of a 2-D family
xs.mean(), xs.prod()
np.sum(xs) # dispatches to .sum(); np.mean / np.prod likewise
xs.min(), xs.max() # smallest / largest element (a bottleneck, a makespan)
np.min(xs), np.max(xs) # dispatch to .min() / .max() likewise
dm.minimum(a, b, c, d) # ELEMENT-WISE min of several operands
dm.maximum(*X[i, :]) # ... which is how you reduce one row
Prefer xs.sum() over the builtin sum(xs): the builtin folds left, so it
builds n-1 binary nodes nested n deep, and every later traversal pays that
depth. Variable still has no .reshape – declare the shape you want.
min/max are nonsmooth, so unlike sum they are a fold of binary nodes
rather than one n-ary node – a balanced fold, ceil(log2(n)) deep instead of
n-1 (docs/dev/performance-plan.md §64 records why the n-ary node buys no
bound and would risk one). Two consequences to know about: .min(axis=k) is
refused, because an axis reduction would have to stay array-valued and there is
no array-valued min node to carry it (reduce a slice at a time, as above); and
the .nl writer refuses a min/max node, so a model carrying one cannot
be exported that way (AMPL calls such a model DNLP); the .nl reader does
accept them, folding o11/o12 minlist/maxlist into binary nodes.
Fast construction#
m.add_linear_constraints(A, x, "<=", b, name="prefix")
m.add_linear_objective(c, x, constant=0.0, sense="minimize")
m.add_quadratic_objective(Q, c, x, constant=0.0, sense="minimize")
Solving#
result = m.solve(
time_limit=3600,
max_nodes=100000,
gap_tolerance=1e-4,
nlp_solver="pounce", # "pounce" (default), "ipopt", or "simplex"
)
print(result.status, result.objective, result.x)