Sellar¶
The Sellar problem is a classic problem with one coupled discipline:
subject to:
and with
We’ll look a bit on how Cottax would handle this internally, but first let’s just look at what you’d do in practice:
import jax
jax.config.update('jax_enable_x64', True)
import jax.numpy as jnp
import cottax.assemblies.jax as cx
from cottax.assemblies.jax import Start
flat = cx.flat
# Declaring
@flat.function("y_1")
def D1(x_1, z_1, z_2, y_2):
return z_1**2 + z_2 + x_1 - 0.2 * y_2
@flat.function("y_2")
def D2(y_1, z_1, z_2):
return jnp.sqrt(y_1) + z_1 + z_2
@flat.function("f")
def obj(x_1, z_2, y_1, y_2):
return x_1**2 + z_2 + y_1 + jnp.exp(-y_2)
@flat.inequality_constraint("g_1")
def C1(y_1):
return 3.6 - y_1
@flat.inequality_constraint("g_2")
def C2(y_2):
return y_2 - 24
opt = flat.optimise(design = {"z_1" : (-10 ,10), "z_2": (0, 10), "x_1": (0, 10)}, minimise = obj , subject_to = (C1, C2))
declared = flat.declare(D1, D2, obj, C1, C2, opt)
# Running
drivers = (cx.SLSQP(), cx.Newton(), cx.Picard())
mdf = declared + flat.mdf(opt) + flat.drive(*drivers)
start_val = {Start: dict(z_1 = 5., z_2 = 2., x_1 = 1., y_2 = 0.4)}
out = flat.run(mdf, read_from = start_val)
for name in ("z_1", "z_2", "x_1", "f", "g_1", "g_2"):
print(f'{name:<3}', out[name])
z_1 2.097366596103213
z_2 3.935152493842969e-14
x_1 0.0
f 3.6184123583244787
g_1 -8.322231792590173e-12
g_2 -20.005266807793525
cx.render_html(mdf, blocks=True, formatter = cx.LatexFormatter())
Breaking it down¶
The cottax graph itself doesn’t use any implicit conversions and is totally unambiguous. This enables the mathematics to work without having to continuously convert meanings or even have two meanings silently be the same.
Our goal is then to provide the surface interface to the core, which does have these implicit conversions. These surfaces can be swapped if another conversion is desired without affecting any of the core functionality.
This similarly holds for the output side: a visualization in most cases requires a formatter to make the cottax native types legible or transform their representation to nicely-looking latex.
The full representation can be shown below:
show_full(mdf.graph)
This is clearly not easy to write by hand! The surfaces and assemblies that can be imported make a much more convenient way to write this. In fact, the spelling itself is usually shortened a lot to keep it more readable.
A quick explanation of the above:
VarPathis the variable PyTree,Node-Path. These are different: a node label is not a variable place unless you explicitly convert!DictKeyis a dictionary key in the datastructure.^indicates a name that is created (NOT in the global datastructure by definition!)relations are comparisons between left-hand-side and right-hand-sides: None means zero.
inputsare reads of a function,outputsthe outputs of a function.unknownsare the variables a condition variable owns andconditionis the condition it needs to satisfy.parametersare variables that affect the answer but are not conditions: data is driver data that is needed for the driver but does not participate in what makes an answer correct (an initial condition!)a
contractis the driver contract which adds the edges for the initial value (the driver data)a
implementationrefers to the function or some implementation of the driver.
Given values¶
Also, our philosophy is that we don’t really want any actual values in the graph itself: it must be the structure only. Then, the graph gives a concrete view on what has to be provided to run it: data hidden in the graph obfuscates this. Furthermore, the graph defines the program (through e.g. a JIT cache key), and if values are changing, the compilation has to be redone. [1]
This does lead to some user-friendlyness problems: if you declare an optimizer, you usually give it bounds, and you don’t want to index into some place to give them. The declaration and surface consumers help here. They contain both the graph and a parallel datastructure with those given values and their names in the graph. Additional tags can be used as well (which also have a parallel datastructure).
The MDF Declared object has the following given:
print(mdf.given)
{^lower['z_1']: -10, ^upper['z_1']: 10, ^upper['z_2']: 10, ^upper['x_1']: 10}
And we can automatically do that with function reads as well:
@flat.function("k", reads = {"b" :3})
def f3(a, b ,c):
...
f3.given
{^given['k']['b']: 3}
These are given a special place: this wouldn’t be necessary in the flat space, but in hierarchical spaces there may not be a value in the datastructure to express it easily and it is convenient to give.
Then, the above problem requires a set of start values: they are not the same as the variables output (a graph cannot overwrite boundary inputs by definition) and have therefore to be given as well. Again, this is not so convenient in general, because they might have these minted ^ names.
mdf.graph.graph.boundary_inputs
(VarPath(^lower['z_1']),
VarPath(^upper['z_1']),
VarPath(^upper['z_2']),
VarPath(^upper['x_1']),
VarPath(^start['z_1']),
VarPath(^start['z_2']),
VarPath(^start['x_1']),
VarPath(^start^copy['y_2']))
The assembly run can extract these ^start values and their copies (^start^copy[‘y_2’]) from a datastructure:
flat.valued(mdf, read_from={Start : dict(z_1 = 5., z_2 = 2., x_1 = 1., y_2 = 0.4)}).values
{^lower['z_1']: -10, ^upper['z_1']: 10, ^upper['z_2']: 10, ^upper['x_1']: 10, ^start['z_1']: 5.0, ^start['z_2']: 2.0, ^start['x_1']: 1.0, ^start^copy['y_2']: 0.4}