Derivatives and sparsity¶
Ipopt needs the gradient of the objective, the Jacobian of the constraints and the Hessian of the Lagrangian. The last two are matrices, and for most large problems they are mostly zeros. Ipopt stores and factorizes only their nonzero entries, so a problem solves fast when xipopt tells it where those are.
What gets compiled¶
xipopt compiles five programs from the functions of a problem.
| Ipopt callback | Program |
|---|---|
eval_f |
The objective |
eval_grad_f |
The gradient of the objective |
eval_g |
All constraints, concatenated |
eval_jac_g |
The Jacobian, at its nonzero entries |
eval_h |
The Hessian of the Lagrangian, at the nonzero entries of its lower triangle |
The Lagrangian is factor * objective + multipliers @ constraints, with the factor and
the multipliers as inputs of the program. The entries of the Jacobian and the Hessian
come out in a fixed order that the program and the structure given to Ipopt share.
Detection¶
With the default derivatives=Derivatives.AUTOMATIC, xipopt uses
asdex when it is installed. asdex traces a function
and finds which outputs depend on which inputs, colors the columns of the Jacobian so
that columns without a shared row can be computed together, and evaluates a compressed
Jacobian with few forward or reverse passes. It does the same for the Hessian with
symmetric coloring and Hessian-vector products.
import jax.numpy as jnp
import numpy as np
import xipopt as xip
SIZE = 8
spacing = 1.0 / (SIZE + 1)
def objective(x, params):
return spacing * jnp.sum((x["u"] - 0.1) ** 2 + 0.01 * x["c"] ** 2)
def equation(x, params):
# The second difference of u, a tridiagonal operator, minus a control and a source.
padded = jnp.concatenate([jnp.zeros(1), x["u"], jnp.zeros(1)])
laplacian = (2.0 * x["u"] - padded[:-2] - padded[2:]) / spacing**2
return laplacian - jnp.exp(x["u"]) - x["c"]
problem = xip.Problem(
variables={"u": xip.Variable(shape=(SIZE,)), "c": xip.Variable(shape=(SIZE,))},
objective=objective,
constraints=(xip.Constraint(equation, lower=0.0, upper=0.0),),
)
compiled = xip.compile(problem)
print(compiled.structure.jacobian_rows.size, compiled.structure.hessian_rows.size)
assert compiled.structure.jacobian_rows.size == 4 * SIZE - 2
assert compiled.structure.hessian_rows.size == 2 * SIZE
The Jacobian of this problem has three entries per row for the state and one for the
control, and the Hessian is diagonal. A dense Jacobian would have 8 * 16 entries.
Dictionaries flatten in the order of their keys, so the control c comes before the
state u in the columns of the Jacobian. Patterns use the order of the flattened
variables, and the rows are the entries of the constraints in order.
Given patterns¶
Detection traces the functions with abstract values, so it cannot see through
everything, and it costs time on a large problem. A problem can give the patterns itself
as jacobian_pattern and hessian_pattern, in any of four forms.
- A boolean matrix.
- A pair of integer vectors
(rows, columns). - An
asdex.SparsityPattern. - A
jax.experimental.sparse.BCOOmatrix, whose stored entries count as nonzero.
A Hessian pattern is square, and it may list one triangle or both. xipopt uses the lower triangle, which is the part Ipopt asks for.
rows, columns = [], []
for row in range(SIZE):
rows.append(row)
columns.append(row)
for neighbor in (row - 1, row, row + 1):
if 0 <= neighbor < SIZE:
rows.append(row)
columns.append(SIZE + neighbor)
given = problem._replace(
jacobian_pattern=(np.array(rows), np.array(columns)),
hessian_pattern=(np.arange(2 * SIZE), np.arange(2 * SIZE)),
)
solution = xip.solve(given)
A pattern has to include every entry that can be nonzero. An entry that is left out is not computed, and the solve then runs on a wrong derivative without any error. Extra entries only cost time.
Failed detection¶
Some functions are beyond what asdex can trace. A linear solve is one. In that case
compiling raises a SparsityDetectionError that names
the ways out, and xipopt does not pick one for you:
- Give the patterns of the problem, as above.
- Use
CompileOptions(derivatives=Derivatives.DENSE), which differentiates densely with JAX and keeps all entries, or only the entries of a given pattern. - Use
CompileOptions(hessian=Hessian.LIMITED_MEMORY), which lets Ipopt approximate the Hessian from gradients. No Hessian program is compiled, and the solve takes more iterations.
The third choice changes the algorithm and its convergence, which is why it is not made silently. A dense pattern is not an error. It only means that the problem is as costly as a dense one.
Without asdex¶
asdex is a dependency, so it is normally there. When someone has uninstalled it,
Derivatives.AUTOMATIC computes dense derivatives and compiling warns.
Derivatives.SPARSE raises an ImportError instead, and Derivatives.DENSE is the
explicit choice. Dense derivatives still use the entries of a given pattern, so a
problem that gives its patterns gets sparse structure for Ipopt. Only the evaluation is
dense, which suits problems of a few hundred variables.